onv-cex 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- onv_cex/__init__.py +7 -0
- onv_cex/base.py +178 -0
- onv_cex/binance/__init__.py +6 -0
- onv_cex/binance/base.py +489 -0
- onv_cex/binance/futures.py +316 -0
- onv_cex/binance/spot.py +222 -0
- onv_cex/demo.py +179 -0
- onv_cex/rate_limit.py +222 -0
- onv_cex/spot_sizing.py +363 -0
- onv_cex/symbols.py +50 -0
- onv_cex-0.1.0.dist-info/METADATA +158 -0
- onv_cex-0.1.0.dist-info/RECORD +16 -0
- onv_cex-0.1.0.dist-info/WHEEL +5 -0
- onv_cex-0.1.0.dist-info/licenses/LICENSE +202 -0
- onv_cex-0.1.0.dist-info/licenses/NOTICE +17 -0
- onv_cex-0.1.0.dist-info/top_level.txt +1 -0
onv_cex/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""ONV exchange adapter interface with a Binance reference implementation."""
|
|
2
|
+
|
|
3
|
+
from onv_cex.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
|
4
|
+
|
|
5
|
+
__all__ = ["BaseRestClient", "LiveOrderResult", "LiveTradingError", "__version__"]
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
onv_cex/base.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 sftgroup
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Base REST client helpers for direct exchange connections.
|
|
6
|
+
|
|
7
|
+
Notes:
|
|
8
|
+
- Keep this minimal and dependency-light (requests only).
|
|
9
|
+
- All secrets must be excluded from logs.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Dict, Optional, Tuple, Union
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Cached SSL verify setting for all live-trading REST calls (requests + SOCKS proxy).
|
|
26
|
+
_requests_verify_value: Optional[Union[bool, str]] = None
|
|
27
|
+
_ssl_verify_disabled_logged = False
|
|
28
|
+
|
|
29
|
+
# OS CA bundles (Docker / slim images: install ``ca-certificates``; corporate roots often added here too).
|
|
30
|
+
_SYSTEM_CA_BUNDLE_CANDIDATES: Tuple[str, ...] = (
|
|
31
|
+
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
|
|
32
|
+
"/etc/ssl/cert.pem", # Alpine, some slim images
|
|
33
|
+
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/Fedora
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_requests_verify() -> Union[bool, str]:
|
|
38
|
+
"""
|
|
39
|
+
Resolve ``verify`` for ``requests`` when calling exchanges through proxies (e.g. PROXY_URL=socks5h://...).
|
|
40
|
+
|
|
41
|
+
- LIVE_TRADING_SSL_VERIFY=0|false|no|off: disable verification (insecure; mitm risk).
|
|
42
|
+
- LIVE_TRADING_CA_BUNDLE / REQUESTS_CA_BUNDLE / SSL_CERT_FILE / CURL_CA_BUNDLE: path to a PEM CA bundle
|
|
43
|
+
(needed for corporate TLS inspection or custom roots).
|
|
44
|
+
- Else a non-empty OS CA file if present (helps Gate/HTX/hbdm etc. in minimal images).
|
|
45
|
+
- Otherwise certifi's bundle when available.
|
|
46
|
+
"""
|
|
47
|
+
global _requests_verify_value, _ssl_verify_disabled_logged
|
|
48
|
+
if _requests_verify_value is not None:
|
|
49
|
+
return _requests_verify_value
|
|
50
|
+
|
|
51
|
+
flag = (os.environ.get("LIVE_TRADING_SSL_VERIFY") or "").strip().lower()
|
|
52
|
+
if flag in ("0", "false", "no", "off"):
|
|
53
|
+
if not _ssl_verify_disabled_logged:
|
|
54
|
+
logger.warning(
|
|
55
|
+
"LIVE_TRADING_SSL_VERIFY is disabled: HTTPS certificate verification is OFF for live trading "
|
|
56
|
+
"requests (MITM risk). Fix CA trust or set LIVE_TRADING_CA_BUNDLE instead for production."
|
|
57
|
+
)
|
|
58
|
+
_ssl_verify_disabled_logged = True
|
|
59
|
+
_requests_verify_value = False
|
|
60
|
+
return _requests_verify_value
|
|
61
|
+
|
|
62
|
+
for key in ("LIVE_TRADING_CA_BUNDLE", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE"):
|
|
63
|
+
path = (os.environ.get(key) or "").strip()
|
|
64
|
+
if path and os.path.isfile(path):
|
|
65
|
+
_requests_verify_value = path
|
|
66
|
+
return _requests_verify_value
|
|
67
|
+
|
|
68
|
+
for path in _SYSTEM_CA_BUNDLE_CANDIDATES:
|
|
69
|
+
try:
|
|
70
|
+
if path and os.path.isfile(path) and os.path.getsize(path) >= 256:
|
|
71
|
+
_requests_verify_value = path
|
|
72
|
+
return _requests_verify_value
|
|
73
|
+
except OSError:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
import certifi
|
|
78
|
+
|
|
79
|
+
_requests_verify_value = certifi.where()
|
|
80
|
+
except ImportError:
|
|
81
|
+
_requests_verify_value = True
|
|
82
|
+
return _requests_verify_value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class LiveOrderResult:
|
|
87
|
+
"""交易所下单结果(CEX 与券商适配器共用的统一形状)。
|
|
88
|
+
|
|
89
|
+
全部字段带默认值:券商路径(IBKR / MT5 / Alpaca)转换为本类型时不带
|
|
90
|
+
``exchange_id``,而调用方(``券商路径``)
|
|
91
|
+
会读 ``success`` —— 缺字段会在运行期直接抛 ``TypeError`` / ``AttributeError``。
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
exchange_id: str = ""
|
|
95
|
+
exchange_order_id: str = ""
|
|
96
|
+
filled: float = 0.0
|
|
97
|
+
avg_price: float = 0.0
|
|
98
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
99
|
+
success: bool = True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class LiveTradingError(Exception):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class BaseRestClient:
|
|
107
|
+
def __init__(self, base_url: str, timeout_sec: float = 15.0):
|
|
108
|
+
self.base_url = (base_url or "").rstrip("/")
|
|
109
|
+
self.timeout_sec = float(timeout_sec)
|
|
110
|
+
|
|
111
|
+
def _url(self, path: str) -> str:
|
|
112
|
+
p = str(path or "")
|
|
113
|
+
if not p.startswith("/"):
|
|
114
|
+
p = "/" + p
|
|
115
|
+
return f"{self.base_url}{p}"
|
|
116
|
+
|
|
117
|
+
def _request(
|
|
118
|
+
self,
|
|
119
|
+
method: str,
|
|
120
|
+
path: str,
|
|
121
|
+
*,
|
|
122
|
+
params: Optional[Dict[str, Any]] = None,
|
|
123
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
124
|
+
headers: Optional[Dict[str, str]] = None,
|
|
125
|
+
data: Optional[Any] = None,
|
|
126
|
+
) -> Tuple[int, Dict[str, Any], str]:
|
|
127
|
+
url = self._url(path)
|
|
128
|
+
try:
|
|
129
|
+
resp = requests.request(
|
|
130
|
+
method=str(method or "GET").upper(),
|
|
131
|
+
url=url,
|
|
132
|
+
params=params or None,
|
|
133
|
+
json=json_body if json_body is not None else None,
|
|
134
|
+
data=data,
|
|
135
|
+
headers=headers or None,
|
|
136
|
+
timeout=self.timeout_sec,
|
|
137
|
+
verify=_get_requests_verify(),
|
|
138
|
+
)
|
|
139
|
+
except UnicodeEncodeError as e:
|
|
140
|
+
# requests/http.client requires header values to be latin-1 encodable.
|
|
141
|
+
# This usually means user pasted API keys/passphrases containing non-ASCII characters,
|
|
142
|
+
# or included invisible whitespace/newlines.
|
|
143
|
+
raise LiveTradingError(
|
|
144
|
+
"Auth failed: request headers contain non-ASCII characters and cannot be encoded. "
|
|
145
|
+
"Please re-enter API key/secret/passphrase (ASCII only; no Chinese characters, smart quotes, "
|
|
146
|
+
"full-width symbols, newlines, or trailing spaces). "
|
|
147
|
+
f"Original error: {e}"
|
|
148
|
+
)
|
|
149
|
+
except requests.exceptions.SSLError as e:
|
|
150
|
+
logger.warning(
|
|
151
|
+
"Exchange HTTPS TLS verify failed (%s). Same setting applies to all REST exchanges (Gate, HTX/hbdm, etc.). "
|
|
152
|
+
"Behind PROXY_URL/SOCKS or TLS inspection: set LIVE_TRADING_CA_BUNDLE to a PEM bundle (or REQUESTS_CA_BUNDLE), "
|
|
153
|
+
"ensure ca-certificates in the image, or dev-only LIVE_TRADING_SSL_VERIFY=false. %s",
|
|
154
|
+
url,
|
|
155
|
+
e,
|
|
156
|
+
)
|
|
157
|
+
raise
|
|
158
|
+
text = resp.text or ""
|
|
159
|
+
parsed: Dict[str, Any] = {}
|
|
160
|
+
try:
|
|
161
|
+
parsed = resp.json() if text else {}
|
|
162
|
+
except Exception:
|
|
163
|
+
parsed = {"raw_text": text[:2000]}
|
|
164
|
+
return int(resp.status_code), parsed, text
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def _now_ms() -> int:
|
|
168
|
+
return int(time.time() * 1000)
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _json_dumps(obj: Any) -> str:
|
|
172
|
+
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
|
173
|
+
|
|
174
|
+
def get_fee_rate(self, symbol: str, market_type: str = "swap") -> Optional[Dict[str, float]]:
|
|
175
|
+
"""Query account fee rate from exchange. Returns {"maker": 0.0002, "taker": 0.0005} or None."""
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
onv_cex/binance/base.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 sftgroup
|
|
3
|
+
|
|
4
|
+
"""Binance 现货 / 合约共用内核(ccxt 建连、签名、精度、订单参数)。
|
|
5
|
+
|
|
6
|
+
为什么用 ccxt 做内核:
|
|
7
|
+
- 币安私有接口签名(HMAC-SHA256 + timestamp/recvWindow)与主网/演示网双域名
|
|
8
|
+
由 ccxt 维护,避免自研签名随币安改版失效。
|
|
9
|
+
- 精度(LOT_SIZE / NOTIONAL / tickSize)与 positionSide、reduceOnly、
|
|
10
|
+
quoteOrderQty、newClientOrderId 等参数构造已在生产镜像实测走通。
|
|
11
|
+
|
|
12
|
+
本模块只放「现货与合约都一样」的部分;差异部分分别在
|
|
13
|
+
``binance/futures.py``(USDⓈ-M 合约)与 ``binance/spot.py``(现货)。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import math
|
|
20
|
+
import re
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from collections import OrderedDict
|
|
24
|
+
from typing import Any, Dict, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
from onv_cex.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
|
27
|
+
from onv_cex.demo import enable_demo_mode
|
|
28
|
+
from onv_cex.symbols import _split_base_quote, to_binance_futures_symbol
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# 币安“设置无变化”类错误码(-4046: No need to change xxx)。
|
|
33
|
+
# 必须吞掉:执行 worker 把 set_leverage 抛错当成拒单理由。
|
|
34
|
+
_NOOP_SETUP_CODES = (-4046,)
|
|
35
|
+
# 持仓模式(单向/双向)查询缓存时长;模式是账户级设置,不会频繁变化。
|
|
36
|
+
_POSITION_SIDE_TTL_SEC = 300.0
|
|
37
|
+
# place → wait_for_fill 之间用于回查 symbol 的订单缓存条数
|
|
38
|
+
_RECENT_ORDER_CACHE = 64
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _to_float(value: Any, default: float = 0.0) -> float:
|
|
42
|
+
if value is None:
|
|
43
|
+
return default
|
|
44
|
+
try:
|
|
45
|
+
return float(value)
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
try:
|
|
48
|
+
return float(str(value).replace(",", "").strip())
|
|
49
|
+
except (TypeError, ValueError):
|
|
50
|
+
return default
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _as_bool(value: Any) -> bool:
|
|
54
|
+
if isinstance(value, bool):
|
|
55
|
+
return value
|
|
56
|
+
if isinstance(value, (int, float)):
|
|
57
|
+
return int(value) == 1
|
|
58
|
+
return str(value or "").strip().lower() in ("true", "1", "yes", "on")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _error_code(err: BaseException) -> int:
|
|
62
|
+
"""从 ccxt 异常里取出币安错误码(ccxt 只把原始报文塞进 message)。"""
|
|
63
|
+
for arg in getattr(err, "args", ()) or ():
|
|
64
|
+
if isinstance(arg, dict):
|
|
65
|
+
try:
|
|
66
|
+
return int(arg.get("code"))
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
continue
|
|
69
|
+
m = re.search(r'"code"\s*:\s*(-?\d+)', str(err))
|
|
70
|
+
if m:
|
|
71
|
+
try:
|
|
72
|
+
return int(m.group(1))
|
|
73
|
+
except ValueError:
|
|
74
|
+
return 0
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_noop_setup_error(err: BaseException) -> bool:
|
|
79
|
+
if _error_code(err) in _NOOP_SETUP_CODES:
|
|
80
|
+
return True
|
|
81
|
+
return "No need to change" in str(err)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _step_decimals(step: float) -> int:
|
|
85
|
+
s = f"{float(step):.12f}".rstrip("0").rstrip(".")
|
|
86
|
+
return len(s.split(".", 1)[1]) if "." in s else 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _floor_step(value: float, step: float) -> float:
|
|
90
|
+
v = float(value or 0)
|
|
91
|
+
if v <= 0:
|
|
92
|
+
return 0.0
|
|
93
|
+
s = float(step or 0)
|
|
94
|
+
if s <= 0:
|
|
95
|
+
return v
|
|
96
|
+
out = math.floor(v / s + 1e-9) * s
|
|
97
|
+
return float(f"{out:.{_step_decimals(s)}f}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _precision_step(value: Any) -> float:
|
|
101
|
+
"""ccxt precision 语义(int=小数位,float=步长)→ 统一返回步长。"""
|
|
102
|
+
if value is None or isinstance(value, bool):
|
|
103
|
+
return 0.0
|
|
104
|
+
if isinstance(value, int):
|
|
105
|
+
return float(10 ** (-int(value))) if value >= 0 else 0.0
|
|
106
|
+
if isinstance(value, float):
|
|
107
|
+
return float(value) if value > 0 else 0.0
|
|
108
|
+
try:
|
|
109
|
+
return float(str(value).strip())
|
|
110
|
+
except (TypeError, ValueError):
|
|
111
|
+
return 0.0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _BinanceCcxtBase(BaseRestClient):
|
|
115
|
+
"""币安现货/合约共用部分:ccxt 建连、精度、订单参数、成交风控查询。"""
|
|
116
|
+
|
|
117
|
+
_EXCHANGE_ID = "binance"
|
|
118
|
+
_DEFAULT_TYPE = "spot"
|
|
119
|
+
_DEFAULT_BASE_URL = "https://api.binance.com"
|
|
120
|
+
_IS_SWAP = False
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
api_key: str = "",
|
|
126
|
+
secret_key: str = "",
|
|
127
|
+
base_url: str = "",
|
|
128
|
+
enable_demo_trading: bool = False,
|
|
129
|
+
broker_id: str = "",
|
|
130
|
+
timeout_sec: float = 15.0,
|
|
131
|
+
default_type: str = "",
|
|
132
|
+
**_ignored: Any,
|
|
133
|
+
):
|
|
134
|
+
url = str(base_url or "").strip() or self._DEFAULT_BASE_URL
|
|
135
|
+
super().__init__(url, timeout_sec=float(timeout_sec or 15.0))
|
|
136
|
+
self.api_key = str(api_key or "").strip()
|
|
137
|
+
self.secret_key = str(secret_key or "").strip()
|
|
138
|
+
self._demo = _as_bool(enable_demo_trading)
|
|
139
|
+
self._broker_id = str(broker_id or "").strip()
|
|
140
|
+
if self._broker_id:
|
|
141
|
+
logger.warning(
|
|
142
|
+
"binance client ignores broker_id=%s: ccxt (the signing kernel used here) does not "
|
|
143
|
+
"implement Binance broker rebate attribution, so orders are not attributed to a broker.",
|
|
144
|
+
self._broker_id,
|
|
145
|
+
)
|
|
146
|
+
self._default_type = str(default_type or self._DEFAULT_TYPE).strip() or "spot"
|
|
147
|
+
self._ccxt: Any = None
|
|
148
|
+
self._ccxt_lock = threading.Lock()
|
|
149
|
+
self._markets: Optional[Dict[str, Any]] = None
|
|
150
|
+
self._markets_ts = 0.0
|
|
151
|
+
self._markets_ttl = 600.0
|
|
152
|
+
self._recent_order_symbols: "OrderedDict[str, str]" = OrderedDict()
|
|
153
|
+
# 注意:不能命名为 ``_position_side_dual``,否则会遮蔽同名方法
|
|
154
|
+
self._position_side_dual_cache: Optional[bool] = None
|
|
155
|
+
self._position_side_ts = 0.0
|
|
156
|
+
|
|
157
|
+
# ────────────────────────── ccxt plumbing ──────────────────────────
|
|
158
|
+
|
|
159
|
+
def _ex(self) -> Any:
|
|
160
|
+
if self._ccxt is not None:
|
|
161
|
+
return self._ccxt
|
|
162
|
+
with self._ccxt_lock:
|
|
163
|
+
if self._ccxt is not None:
|
|
164
|
+
return self._ccxt
|
|
165
|
+
try:
|
|
166
|
+
import ccxt
|
|
167
|
+
except ImportError as e: # pragma: no cover
|
|
168
|
+
raise LiveTradingError("binance channel requires ccxt>=4.0.0") from e
|
|
169
|
+
client = ccxt.binance(
|
|
170
|
+
{
|
|
171
|
+
"apiKey": self.api_key,
|
|
172
|
+
"secret": self.secret_key,
|
|
173
|
+
"enableRateLimit": True,
|
|
174
|
+
"timeout": int(float(self.timeout_sec) * 1000),
|
|
175
|
+
"options": {"defaultType": self._default_type},
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
if self._demo:
|
|
179
|
+
# ccxt 原生 demo 开关优先(一次把现货/合约/COIN-M 全指向官方演示域);
|
|
180
|
+
# sandbox 域名已废弃(合约侧 ccxt 直接抛 NotSupported),故不能只用
|
|
181
|
+
# set_sandbox_mode。策略选择见 demo。
|
|
182
|
+
strategy = enable_demo_mode(client, self._EXCHANGE_ID)
|
|
183
|
+
logger.debug("binance demo mode via %s", strategy)
|
|
184
|
+
self._ccxt = client
|
|
185
|
+
self._warn_if_base_url_unused(client)
|
|
186
|
+
return self._ccxt
|
|
187
|
+
|
|
188
|
+
def _warn_if_base_url_unused(self, client: Any) -> None:
|
|
189
|
+
"""显式 base_url 若不是 ccxt 已知域名,请求不会走它 —— 记一条便于排障。
|
|
190
|
+
|
|
191
|
+
(``base_url`` 属性本身必须保留:``平台符号工具``
|
|
192
|
+
依赖它含 "binance" 来拼公开行情兜底 URL。)
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
api = client.urls.get("api") if isinstance(getattr(client, "urls", None), dict) else {}
|
|
196
|
+
hosts = {
|
|
197
|
+
str(v).split("/")[2]
|
|
198
|
+
for v in (api or {}).values()
|
|
199
|
+
if isinstance(v, str) and "://" in v
|
|
200
|
+
}
|
|
201
|
+
host = str(self.base_url).split("/")[2] if "://" in str(self.base_url) else ""
|
|
202
|
+
if host and hosts and host not in hosts:
|
|
203
|
+
logger.warning(
|
|
204
|
+
"binance base_url=%s is not a ccxt-known endpoint %s; requests use the "
|
|
205
|
+
"ccxt-resolved host instead.",
|
|
206
|
+
self.base_url,
|
|
207
|
+
sorted(hosts),
|
|
208
|
+
)
|
|
209
|
+
except Exception: # pragma: no cover
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
def _load_markets(self) -> Dict[str, Any]:
|
|
213
|
+
now = time.time()
|
|
214
|
+
if self._markets is None or now - self._markets_ts > self._markets_ttl:
|
|
215
|
+
self._markets = self._ex().load_markets()
|
|
216
|
+
self._markets_ts = now
|
|
217
|
+
return self._markets or {}
|
|
218
|
+
|
|
219
|
+
def _public_time(self) -> Any:
|
|
220
|
+
return self._ex().publicGetTime()
|
|
221
|
+
|
|
222
|
+
def ping(self) -> bool:
|
|
223
|
+
try:
|
|
224
|
+
self._public_time()
|
|
225
|
+
return True
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.warning("binance ping failed: %s", e)
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
# ────────────────────────── symbol / precision ──────────────────────────
|
|
231
|
+
|
|
232
|
+
def _ccxt_symbol(self, symbol: str) -> str:
|
|
233
|
+
"""平台符号(BTC/USDT、BTC/USDT:USDT、BTCUSDT)→ ccxt 统一符号。"""
|
|
234
|
+
base, quote = _split_base_quote(str(symbol or ""))
|
|
235
|
+
if not base:
|
|
236
|
+
raise LiveTradingError(f"Invalid symbol: {symbol}")
|
|
237
|
+
q = quote or "USDT"
|
|
238
|
+
return f"{base}/{q}:{q}" if self._IS_SWAP else f"{base}/{q}"
|
|
239
|
+
|
|
240
|
+
def _native_symbol(self, symbol: str) -> str:
|
|
241
|
+
"""平台符号 → 币安原生 symbol(BTCUSDT),用于 /fapi、/api 原始接口。"""
|
|
242
|
+
return to_binance_futures_symbol(str(symbol or ""))
|
|
243
|
+
|
|
244
|
+
def _market(self, symbol: str) -> Dict[str, Any]:
|
|
245
|
+
sym = self._ccxt_symbol(symbol)
|
|
246
|
+
m = self._load_markets().get(sym)
|
|
247
|
+
if not isinstance(m, dict):
|
|
248
|
+
# 可能是新上币:强制刷新一次 markets 后重试
|
|
249
|
+
self._markets = None
|
|
250
|
+
m = (self._load_markets() or {}).get(sym)
|
|
251
|
+
if not isinstance(m, dict):
|
|
252
|
+
raise LiveTradingError(f"Unsupported symbol: {symbol} -> {sym}")
|
|
253
|
+
return m
|
|
254
|
+
|
|
255
|
+
def _remember_order(self, order: Any, ccxt_symbol: str) -> None:
|
|
256
|
+
o = order if isinstance(order, dict) else {}
|
|
257
|
+
oid = str(o.get("id") or "").strip()
|
|
258
|
+
if not oid:
|
|
259
|
+
return
|
|
260
|
+
self._recent_order_symbols[oid] = ccxt_symbol
|
|
261
|
+
while len(self._recent_order_symbols) > _RECENT_ORDER_CACHE:
|
|
262
|
+
self._recent_order_symbols.popitem(last=False)
|
|
263
|
+
|
|
264
|
+
def _resolve_ccxt_symbol(self, symbol: str, order_id: str) -> str:
|
|
265
|
+
"""wait_for_fill 的 symbol 解析:显式传入优先,否则查 place 时缓存。"""
|
|
266
|
+
s = str(symbol or "").strip()
|
|
267
|
+
if s:
|
|
268
|
+
try:
|
|
269
|
+
return self._ccxt_symbol(s)
|
|
270
|
+
except LiveTradingError:
|
|
271
|
+
return ""
|
|
272
|
+
return self._recent_order_symbols.get(str(order_id or "").strip(), "")
|
|
273
|
+
|
|
274
|
+
# ────────────────────────── public market data ──────────────────────────
|
|
275
|
+
|
|
276
|
+
def get_ticker(self, symbol: str) -> Dict[str, Any]:
|
|
277
|
+
"""平台 get_ticker 契约 → {'last': price}(USDT→base 换算用)。"""
|
|
278
|
+
native = self._native_symbol(symbol)
|
|
279
|
+
ex = self._ex()
|
|
280
|
+
try:
|
|
281
|
+
row = self._ticker_price(native)
|
|
282
|
+
px = _to_float((row or {}).get("price"))
|
|
283
|
+
if px > 0:
|
|
284
|
+
return {"last": px}
|
|
285
|
+
except Exception as e:
|
|
286
|
+
logger.warning("binance ticker price failed for %s: %s", native, e)
|
|
287
|
+
# 兜底走 ccxt 统一接口(行情端点偶发限频时仍能取到价)
|
|
288
|
+
try:
|
|
289
|
+
tick = ex.fetch_ticker(self._ccxt_symbol(symbol))
|
|
290
|
+
last = _to_float((tick or {}).get("last"))
|
|
291
|
+
if last > 0:
|
|
292
|
+
return {"last": last}
|
|
293
|
+
except Exception as e:
|
|
294
|
+
logger.warning("binance fetch_ticker failed for %s: %s", symbol, e)
|
|
295
|
+
raise LiveTradingError(f"Could not fetch Binance price for {symbol}")
|
|
296
|
+
|
|
297
|
+
def _ticker_price(self, native_symbol: str) -> Dict[str, Any]:
|
|
298
|
+
return self._ex().publicGetTickerPrice({"symbol": native_symbol}) or {}
|
|
299
|
+
|
|
300
|
+
# ────────────────────────── precision hooks ──────────────────────────
|
|
301
|
+
|
|
302
|
+
def _normalize_quantity(
|
|
303
|
+
self, symbol: str, quantity: float, for_market: bool = True
|
|
304
|
+
) -> Tuple[float, float]:
|
|
305
|
+
"""按交易所 LOT_SIZE 向下取整。返回 (数量, 步长)。"""
|
|
306
|
+
qty = _to_float(quantity)
|
|
307
|
+
if qty <= 0:
|
|
308
|
+
return 0.0, 0.0
|
|
309
|
+
try:
|
|
310
|
+
m = self._market(symbol)
|
|
311
|
+
dec = _to_float(self._ex().amount_to_precision(m["symbol"], qty))
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.warning("binance amount precision failed (%s): %s", symbol, e)
|
|
314
|
+
return 0.0, 0.0
|
|
315
|
+
step = _precision_step((m.get("precision") or {}).get("amount"))
|
|
316
|
+
if dec <= 0:
|
|
317
|
+
return 0.0, step
|
|
318
|
+
return dec, step
|
|
319
|
+
|
|
320
|
+
def _normalize_quote_size(self, symbol: str, quote_size: float) -> Tuple[float, float]:
|
|
321
|
+
"""现货市价买入的计价额取整(quoteOrderQty);低于最小名义额返回 0。
|
|
322
|
+
|
|
323
|
+
返回 0 是有意义的:调用方 会据此报
|
|
324
|
+
「订单金额低于交易所最小下单要求,请增大 USDT 金额」。
|
|
325
|
+
"""
|
|
326
|
+
amt = _to_float(quote_size)
|
|
327
|
+
if amt <= 0:
|
|
328
|
+
return 0.0, 0.0
|
|
329
|
+
try:
|
|
330
|
+
m = self._market(symbol)
|
|
331
|
+
except Exception as e:
|
|
332
|
+
logger.warning("binance quote precision failed (%s): %s", symbol, e)
|
|
333
|
+
return 0.0, 0.0
|
|
334
|
+
step = _precision_step((m.get("precision") or {}).get("quote"))
|
|
335
|
+
dec = _floor_step(amt, step) if step > 0 else float(f"{amt:.8f}")
|
|
336
|
+
limits = (m.get("limits") or {}).get("cost") or {}
|
|
337
|
+
min_notional = _to_float(limits.get("min"))
|
|
338
|
+
if dec <= 0 or (min_notional > 0 and dec < min_notional):
|
|
339
|
+
logger.info(
|
|
340
|
+
"binance quote amount %s below min notional %s for %s", dec, min_notional, symbol
|
|
341
|
+
)
|
|
342
|
+
return 0.0, step
|
|
343
|
+
return dec, step
|
|
344
|
+
|
|
345
|
+
# ────────────────────────── order helpers ──────────────────────────
|
|
346
|
+
|
|
347
|
+
def _order_result(self, order: Any) -> LiveOrderResult:
|
|
348
|
+
o = order if isinstance(order, dict) else {}
|
|
349
|
+
filled = _to_float(o.get("filled"))
|
|
350
|
+
avg = _to_float(o.get("average") or o.get("price"))
|
|
351
|
+
raw = o.get("info") if isinstance(o.get("info"), (dict, list)) else o
|
|
352
|
+
return LiveOrderResult(
|
|
353
|
+
exchange_id=self._EXCHANGE_ID,
|
|
354
|
+
exchange_order_id=str(o.get("id") or ""),
|
|
355
|
+
filled=filled,
|
|
356
|
+
avg_price=avg,
|
|
357
|
+
raw=raw if isinstance(raw, dict) else {"raw": raw},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def _client_order_params(self, client_order_id: Optional[str]) -> Dict[str, Any]:
|
|
361
|
+
cid = str(client_order_id or "").strip()
|
|
362
|
+
return {"newClientOrderId": cid} if cid else {}
|
|
363
|
+
|
|
364
|
+
def cancel_order(
|
|
365
|
+
self,
|
|
366
|
+
symbol: str = "",
|
|
367
|
+
order_id: str = "",
|
|
368
|
+
client_order_id: str = "",
|
|
369
|
+
**_ignored: Any,
|
|
370
|
+
) -> Dict[str, Any]:
|
|
371
|
+
native = self._native_symbol(symbol) if symbol else ""
|
|
372
|
+
oid = str(order_id or "").strip()
|
|
373
|
+
cid = str(client_order_id or "").strip()
|
|
374
|
+
if not native or (not oid and not cid):
|
|
375
|
+
raise LiveTradingError("binance cancel_order requires symbol and order_id/client_order_id")
|
|
376
|
+
params: Dict[str, Any] = {"symbol": native}
|
|
377
|
+
if oid:
|
|
378
|
+
params["orderId"] = int(oid) if oid.isdigit() else oid
|
|
379
|
+
else:
|
|
380
|
+
params["origClientOrderId"] = cid
|
|
381
|
+
return self._cancel_raw(params)
|
|
382
|
+
|
|
383
|
+
# ────────────────────────── fill / fee queries ──────────────────────────
|
|
384
|
+
|
|
385
|
+
def wait_for_fill(
|
|
386
|
+
self,
|
|
387
|
+
order_id: str = "",
|
|
388
|
+
symbol: str = "",
|
|
389
|
+
client_order_id: str = "",
|
|
390
|
+
max_wait_sec: float = 8.0,
|
|
391
|
+
) -> Dict[str, Any]:
|
|
392
|
+
"""轮询订单终态,返回 {'filled','avg_price','fee','fee_ccy'}。
|
|
393
|
+
|
|
394
|
+
调用方(worker / _try_enrich_fill)有两种调用姿势:
|
|
395
|
+
``wait_for_fill(symbol=…, order_id=…, client_order_id=…, max_wait_sec=…)``
|
|
396
|
+
与 ``wait_for_fill(order_id=…, max_wait_sec=…)`` —— 后者靠 place 时的
|
|
397
|
+
订单缓存补齐 symbol(币安查单接口 symbol 为必填)。
|
|
398
|
+
"""
|
|
399
|
+
out = {"filled": 0.0, "avg_price": 0.0, "fee": 0.0, "fee_ccy": ""}
|
|
400
|
+
oid = str(order_id or "").strip()
|
|
401
|
+
if not oid:
|
|
402
|
+
return out
|
|
403
|
+
ccxt_sym = self._resolve_ccxt_symbol(symbol, oid)
|
|
404
|
+
if not ccxt_sym:
|
|
405
|
+
logger.info("binance wait_for_fill: unknown symbol for order %s", oid)
|
|
406
|
+
return out
|
|
407
|
+
try:
|
|
408
|
+
native = self._native_symbol(ccxt_sym)
|
|
409
|
+
except LiveTradingError:
|
|
410
|
+
return out
|
|
411
|
+
deadline = time.time() + max(0.0, _to_float(max_wait_sec))
|
|
412
|
+
while True:
|
|
413
|
+
row: Dict[str, Any] = {}
|
|
414
|
+
try:
|
|
415
|
+
row = self._fetch_order_raw(native, oid, client_order_id=client_order_id)
|
|
416
|
+
except Exception as e:
|
|
417
|
+
logger.debug("binance fetch order %s failed: %s", oid, e)
|
|
418
|
+
if isinstance(row, dict) and row:
|
|
419
|
+
status = str(row.get("status") or "").strip().upper()
|
|
420
|
+
filled = _to_float(row.get("executedQty"))
|
|
421
|
+
if filled > 0 or status in ("FILLED", "CANCELED", "CANCELLED", "EXPIRED", "REJECTED"):
|
|
422
|
+
out["filled"] = filled
|
|
423
|
+
avg = _to_float(row.get("avgPrice"))
|
|
424
|
+
if avg <= 0 and filled > 0:
|
|
425
|
+
quote = _to_float(row.get("cumQuote") or row.get("cummulativeQuoteQty"))
|
|
426
|
+
if quote > 0:
|
|
427
|
+
avg = quote / filled
|
|
428
|
+
out["avg_price"] = avg
|
|
429
|
+
fee, ccy = self._fetch_fee(native, oid)
|
|
430
|
+
out["fee"] = fee
|
|
431
|
+
out["fee_ccy"] = ccy
|
|
432
|
+
return out
|
|
433
|
+
if time.time() >= deadline:
|
|
434
|
+
return out
|
|
435
|
+
time.sleep(0.5)
|
|
436
|
+
|
|
437
|
+
def _fetch_order_raw(
|
|
438
|
+
self, native_symbol: str, order_id: str, *, client_order_id: str = ""
|
|
439
|
+
) -> Dict[str, Any]:
|
|
440
|
+
params: Dict[str, Any] = {"symbol": native_symbol}
|
|
441
|
+
order_id = str(order_id or "").strip()
|
|
442
|
+
cid = str(client_order_id or "").strip()
|
|
443
|
+
if order_id:
|
|
444
|
+
params["orderId"] = int(order_id) if order_id.isdigit() else order_id
|
|
445
|
+
elif cid:
|
|
446
|
+
params["origClientOrderId"] = cid
|
|
447
|
+
else:
|
|
448
|
+
raise LiveTradingError("binance order query requires order_id/client_order_id")
|
|
449
|
+
try:
|
|
450
|
+
return self._order_query(params)
|
|
451
|
+
except Exception as e:
|
|
452
|
+
# 演示环境(demo-api)按 orderId 查单存在不可靠窗口:刚下的单会返
|
|
453
|
+
# -2013,同一时刻按 clientOrderId 查得到 ⇒ 有 clientOrderId 时退一步重试。
|
|
454
|
+
if cid and _error_code(e) == -2013:
|
|
455
|
+
logger.debug("binance order %s not found by orderId, retry by clientOrderId", order_id)
|
|
456
|
+
return self._order_query({"symbol": native_symbol, "origClientOrderId": cid})
|
|
457
|
+
raise
|
|
458
|
+
|
|
459
|
+
def _fee_from_rows(self, rows: Any, *, commission_key: str, asset_key: str) -> Tuple[float, str]:
|
|
460
|
+
totals: Dict[str, float] = {}
|
|
461
|
+
for row in rows if isinstance(rows, list) else []:
|
|
462
|
+
if not isinstance(row, dict):
|
|
463
|
+
continue
|
|
464
|
+
v = abs(_to_float(row.get(commission_key)))
|
|
465
|
+
if v <= 0:
|
|
466
|
+
continue
|
|
467
|
+
ccy = str(row.get(asset_key) or "")
|
|
468
|
+
totals[ccy] = totals.get(ccy, 0.0) + v
|
|
469
|
+
if not totals:
|
|
470
|
+
return 0.0, ""
|
|
471
|
+
ccy = max(totals, key=lambda k: totals[k])
|
|
472
|
+
return totals[ccy], ccy
|
|
473
|
+
|
|
474
|
+
# ────────── 子类必须实现 ──────────
|
|
475
|
+
|
|
476
|
+
def get_account(self) -> Dict[str, Any]:
|
|
477
|
+
raise NotImplementedError
|
|
478
|
+
|
|
479
|
+
def get_positions(self, symbol: str = "") -> Any:
|
|
480
|
+
raise NotImplementedError
|
|
481
|
+
|
|
482
|
+
def _cancel_raw(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
483
|
+
raise NotImplementedError
|
|
484
|
+
|
|
485
|
+
def _order_query(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
486
|
+
raise NotImplementedError
|
|
487
|
+
|
|
488
|
+
def _fetch_fee(self, native_symbol: str, order_id: str) -> Tuple[float, str]:
|
|
489
|
+
raise NotImplementedError
|