Signed-off-by: Dinger <quantdinger@gmail.com>
This commit is contained in:
Dinger
2026-04-06 23:19:41 +08:00
parent 3ca291a346
commit de3fd0945b
91 changed files with 1504 additions and 405 deletions
@@ -40,6 +40,10 @@ class BinanceFuturesClient(BaseRestClient):
self._dual_side_cache: Optional[Tuple[float, bool]] = None
self._dual_side_cache_ttl_sec = 60.0
# serverTime - local_ms; avoids Binance -1021 when the host clock is ahead of Binance.
self._time_offset_ms: int = 0
self._time_sync_monotonic: float = 0.0
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
@@ -163,6 +167,26 @@ class BinanceFuturesClient(BaseRestClient):
def _signed_headers(self) -> Dict[str, str]:
return {"X-MBX-APIKEY": self.api_key}
def _ensure_server_time(self, *, force: bool = False) -> None:
"""
Align signed request timestamps with Binance server time (GET /fapi/v1/time).
"""
now_m = time.monotonic()
if not force and (now_m - float(self._time_sync_monotonic or 0.0)) < 300.0:
return
try:
code, data, _ = self._request("GET", "/fapi/v1/time")
if code != 200 or not isinstance(data, dict):
return
server_ms = int(data.get("serverTime") or 0)
if server_ms <= 0:
return
local_ms = int(time.time() * 1000)
self._time_offset_ms = server_ms - local_ms
self._time_sync_monotonic = now_m
except Exception:
pass
def _format_client_order_id(self, client_order_id: Optional[str]) -> str:
raw = str(client_order_id or "").strip()
broker_id = str(self.broker_id or "").strip()
@@ -179,17 +203,34 @@ class BinanceFuturesClient(BaseRestClient):
return f"{prefix}{raw[:suffix_budget]}"
def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]:
p = dict(params or {})
# Use server-accepted timestamp in ms.
p["timestamp"] = int(time.time() * 1000)
qs = urlencode(p, doseq=True)
p["signature"] = self._sign(qs)
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
if code >= 400:
raise LiveTradingError(f"Binance HTTP {code}: {text[:500]}")
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
raise LiveTradingError(f"Binance error: {data}")
return data if isinstance(data, dict) else {"raw": data}
self._ensure_server_time()
last_err: Optional[LiveTradingError] = None
for attempt in range(2):
p = dict(params or {})
p["timestamp"] = int(time.time() * 1000) + int(self._time_offset_ms)
if "recvWindow" not in p:
p["recvWindow"] = 10000
qs = urlencode(p, doseq=True)
p["signature"] = self._sign(qs)
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
if code >= 400:
err = LiveTradingError(f"Binance HTTP {code}: {text[:500]}")
if attempt == 0 and ("-1021" in text or "1021" in text):
self._ensure_server_time(force=True)
last_err = err
continue
raise err
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
err = LiveTradingError(f"Binance error: {data}")
if attempt == 0 and int(data.get("code") or 0) == -1021:
self._ensure_server_time(force=True)
last_err = err
continue
raise err
return data if isinstance(data, dict) else {"raw": data}
if last_err:
raise last_err
raise LiveTradingError("Binance signed request failed")
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
@@ -870,12 +911,41 @@ class BinanceFuturesClient(BaseRestClient):
raise LiveTradingError("Binance cancel_order requires order_id or client_order_id")
return self._signed_request("DELETE", "/fapi/v1/order", params=params)
def get_positions(self) -> Any:
def set_margin_type(self, *, symbol: str, margin_mode: str) -> Dict[str, Any]:
"""
Return all futures positions (position risk endpoint).
Set symbol margin mode on USDT-M futures.
Endpoint: POST /fapi/v1/marginType
margin_mode: cross | crossed | isolated
"""
sym = to_binance_futures_symbol(symbol)
m = (margin_mode or "").strip().lower()
if m in ("cross", "crossed"):
mt = "CROSSED"
elif m in ("isolated", "iso"):
mt = "ISOLATED"
else:
raise LiveTradingError(f"Invalid margin_mode for Binance: {margin_mode}")
return self._signed_request("POST", "/fapi/v1/marginType", params={"symbol": sym, "marginType": mt})
def get_positions(self, *, symbol: str = "") -> Any:
"""
Futures positions (position risk). Optional ``symbol`` filters to one contract.
Endpoint: GET /fapi/v2/positionRisk
"""
return self._signed_request("GET", "/fapi/v2/positionRisk", params={})
raw = self._signed_request("GET", "/fapi/v2/positionRisk", params={})
rows: list
if isinstance(raw, list):
rows = raw
elif isinstance(raw, dict) and isinstance(raw.get("raw"), list):
rows = raw["raw"]
else:
rows = []
want = (symbol or "").strip()
if not want:
return rows
sym = to_binance_futures_symbol(want)
return [p for p in rows if isinstance(p, dict) and str(p.get("symbol") or "") == sym]
@@ -31,6 +31,9 @@ class BinanceSpotClient(BaseRestClient):
self._sym_filter_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
self._sym_filter_cache_ttl_sec = 300.0
self._time_offset_ms: int = 0
self._time_sync_monotonic: float = 0.0
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
@@ -158,6 +161,24 @@ class BinanceSpotClient(BaseRestClient):
def _signed_headers(self) -> Dict[str, str]:
return {"X-MBX-APIKEY": self.api_key}
def _ensure_server_time(self, *, force: bool = False) -> None:
"""Align signed request timestamps with Binance (GET /api/v3/time)."""
now_m = time.monotonic()
if not force and (now_m - float(self._time_sync_monotonic or 0.0)) < 300.0:
return
try:
code, data, _ = self._request("GET", "/api/v3/time")
if code != 200 or not isinstance(data, dict):
return
server_ms = int(data.get("serverTime") or 0)
if server_ms <= 0:
return
local_ms = int(time.time() * 1000)
self._time_offset_ms = server_ms - local_ms
self._time_sync_monotonic = now_m
except Exception:
pass
def _format_client_order_id(self, client_order_id: Optional[str]) -> str:
raw = str(client_order_id or "").strip()
broker_id = str(self.broker_id or "").strip()
@@ -174,16 +195,34 @@ class BinanceSpotClient(BaseRestClient):
return f"{prefix}{raw[:suffix_budget]}"
def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]:
p = dict(params or {})
p["timestamp"] = int(time.time() * 1000)
qs = urlencode(p, doseq=True)
p["signature"] = self._sign(qs)
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
if code >= 400:
raise LiveTradingError(f"BinanceSpot HTTP {code}: {text[:500]}")
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
raise LiveTradingError(f"BinanceSpot error: {data}")
return data if isinstance(data, dict) else {"raw": data}
self._ensure_server_time()
last_err: Optional[LiveTradingError] = None
for attempt in range(2):
p = dict(params or {})
p["timestamp"] = int(time.time() * 1000) + int(self._time_offset_ms)
if "recvWindow" not in p:
p["recvWindow"] = 10000
qs = urlencode(p, doseq=True)
p["signature"] = self._sign(qs)
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
if code >= 400:
err = LiveTradingError(f"BinanceSpot HTTP {code}: {text[:500]}")
if attempt == 0 and ("-1021" in text or "1021" in text):
self._ensure_server_time(force=True)
last_err = err
continue
raise err
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
err = LiveTradingError(f"BinanceSpot error: {data}")
if attempt == 0 and int(data.get("code") or 0) == -1021:
self._ensure_server_time(force=True)
last_err = err
continue
raise err
return data if isinstance(data, dict) else {"raw": data}
if last_err:
raise last_err
raise LiveTradingError("BinanceSpot signed request failed")
def ping(self) -> bool:
"""
@@ -61,6 +61,10 @@ class BitgetMixClient(BaseRestClient):
self._lev_cache: Dict[str, Tuple[float, bool]] = {}
self._lev_cache_ttl_sec = 60.0
# posMode from GET /api/v2/mix/account/account (hedge_mode vs one_way_mode), cached per contract.
self._pos_mode_cache: Dict[str, Tuple[float, str]] = {}
self._pos_mode_cache_ttl_sec = 60.0
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
@@ -242,6 +246,31 @@ class BitgetMixClient(BaseRestClient):
raise LiveTradingError(f"Bitget error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def _post_mix_place_order(
self,
body: Dict[str, Any],
*,
original_side: str,
reduce_only: bool,
) -> Dict[str, Any]:
"""
POST place-order; on 40774 (hedge vs one-way mismatch) retry with alternate position fields.
"""
sd = (original_side or "").lower()
try:
return self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
except LiveTradingError as e:
if "40774" not in str(e):
raise
b2: Dict[str, Any] = {k: v for k, v in body.items() if k not in ("side", "tradeSide", "reduceOnly")}
if "tradeSide" in body:
b2["side"] = sd
b2["reduceOnly"] = "YES" if reduce_only else "NO"
else:
b2["tradeSide"] = "close" if reduce_only else "open"
b2["side"] = ("sell" if sd == "buy" else "buy") if reduce_only else sd
return self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=b2)
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
if code >= 400:
@@ -252,6 +281,117 @@ class BitgetMixClient(BaseRestClient):
raise LiveTradingError(f"Bitget error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def get_ticker(self, *, symbol: str, **kwargs: Any) -> Dict[str, Any]:
"""
Public mix ticker (for USDT-notional -> base size conversion in quick trade).
Endpoint: GET /api/v2/mix/market/ticker
"""
sym = to_bitget_um_symbol(symbol)
pt = str(kwargs.get("product_type") or "USDT-FUTURES")
if not sym:
return {}
try:
raw = self._public_request(
"GET",
"/api/v2/mix/market/ticker",
params={"symbol": sym, "productType": pt},
)
except Exception:
return {}
data = raw.get("data") if isinstance(raw, dict) else None
if isinstance(data, list) and data:
data = data[0]
if not isinstance(data, dict):
return {}
try:
last = float(
data.get("lastPr")
or data.get("last")
or data.get("close")
or data.get("markPrice")
or data.get("indexPrice")
or 0
)
except Exception:
last = 0.0
if last <= 0:
return {}
return {"last": last, "price": last, "close": last}
def get_account_pos_mode(
self,
*,
symbol: str,
margin_coin: str = "USDT",
product_type: str = "USDT-FUTURES",
) -> str:
"""
Returns Bitget posMode for the contract account: 'hedge_mode', 'one_way_mode', or '' if unknown.
GET /api/v2/mix/account/account
"""
sym = to_bitget_um_symbol(symbol)
if not sym:
return ""
mc = (margin_coin or "USDT").strip().upper()
pt = str(product_type or "USDT-FUTURES")
key = f"{pt}:{sym}:{mc}"
now = time.time()
cached = self._pos_mode_cache.get(key)
if cached:
ts, mode = cached
if (now - float(ts or 0.0)) <= float(self._pos_mode_cache_ttl_sec or 60.0) and mode is not None:
return str(mode)
try:
resp = self._signed_request(
"GET",
"/api/v2/mix/account/account",
params={
"symbol": sym.lower(),
"productType": pt,
"marginCoin": mc.lower() or "usdt",
},
)
d = resp.get("data") if isinstance(resp, dict) else None
mode = ""
if isinstance(d, dict):
mode = str(d.get("posMode") or "").strip().lower()
self._pos_mode_cache[key] = (now, mode)
return mode
except Exception:
return ""
def _mix_order_position_fields(
self,
*,
symbol: str,
side: str,
reduce_only: bool,
margin_coin: str,
product_type: str,
) -> Dict[str, Any]:
"""
Bitget mix place-order: hedge_mode requires tradeSide open/close; one_way_mode requires reduceOnly YES/NO
and must not send tradeSide (see Bitget API doc + CCXT bitget.py).
"""
sd = (side or "").lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
pos_mode = self.get_account_pos_mode(
symbol=symbol, margin_coin=margin_coin, product_type=product_type
)
hedge = pos_mode == "hedge_mode"
if hedge:
# Mirror CCXT: hedge close flips side; hedge open keeps side + tradeSide open.
out: Dict[str, Any] = {
"tradeSide": "close" if reduce_only else "open",
"side": ("sell" if sd == "buy" else "buy") if reduce_only else sd,
}
return out
return {"side": sd, "reduceOnly": "YES" if reduce_only else "NO"}
def get_contract(self, *, symbol: str, product_type: str = "USDT-FUTURES") -> Dict[str, Any]:
"""
Fetch contract metadata (best-effort) from public endpoint.
@@ -354,13 +494,34 @@ class BitgetMixClient(BaseRestClient):
"""
return self._signed_request("GET", "/api/v2/mix/account/accounts", params={"productType": str(product_type or "USDT-FUTURES")})
def get_positions(self, *, product_type: str = "USDT-FUTURES") -> Dict[str, Any]:
def get_positions(self, *, product_type: str = "USDT-FUTURES", symbol: str = "") -> Dict[str, Any]:
"""
Get all positions (best-effort).
Get positions (best-effort).
Endpoint: GET /api/v2/mix/position/all-position
When ``symbol`` is set (e.g. ETH/USDT), filters the response list to that contract only.
"""
return self._signed_request("GET", "/api/v2/mix/position/all-position", params={"productType": str(product_type or "USDT-FUTURES")})
resp = self._signed_request(
"GET",
"/api/v2/mix/position/all-position",
params={"productType": str(product_type or "USDT-FUTURES")},
)
want = (symbol or "").strip()
if not want:
return resp
sym_key = to_bitget_um_symbol(want).upper()
if not isinstance(resp, dict):
return resp
data = resp.get("data")
if not isinstance(data, list):
return resp
filtered = [
p for p in data
if isinstance(p, dict) and str(p.get("symbol") or "").strip().upper() == sym_key
]
out = dict(resp)
out["data"] = filtered
return out
def set_leverage(
self,
@@ -444,16 +605,22 @@ class BitgetMixClient(BaseRestClient):
"productType": str(product_type or "USDT-FUTURES"),
"marginCoin": str(margin_coin or "USDT"),
"marginMode": self._normalize_margin_mode(margin_mode),
"side": sd,
"orderType": "market",
"size": self._dec_str(sz_dec, strict_precision=sz_precision),
}
if reduce_only:
body["reduceOnly"] = "YES"
body.update(
self._mix_order_position_fields(
symbol=symbol,
side=sd,
reduce_only=reduce_only,
margin_coin=str(margin_coin or "USDT"),
product_type=str(product_type or "USDT-FUTURES"),
)
)
if client_order_id:
body["clientOid"] = str(client_order_id)
raw = self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
raw = self._post_mix_place_order(body, original_side=sd, reduce_only=reduce_only)
data = raw.get("data") if isinstance(raw, dict) else None
exchange_order_id = ""
if isinstance(data, dict):
@@ -498,21 +665,27 @@ class BitgetMixClient(BaseRestClient):
"productType": str(product_type or "USDT-FUTURES"),
"marginCoin": str(margin_coin or "USDT"),
"marginMode": self._normalize_margin_mode(margin_mode),
"side": sd,
"orderType": "limit",
"price": str(px),
"size": self._dec_str(sz_dec, strict_precision=sz_precision),
}
body.update(
self._mix_order_position_fields(
symbol=symbol,
side=sd,
reduce_only=reduce_only,
margin_coin=str(margin_coin or "USDT"),
product_type=str(product_type or "USDT-FUTURES"),
)
)
# Force maker behavior when requested (avoid taker fills).
if post_only:
body["force"] = "post_only"
else:
body["force"] = "gtc"
if reduce_only:
body["reduceOnly"] = "YES"
if client_order_id:
body["clientOid"] = str(client_order_id)
raw = self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
raw = self._post_mix_place_order(body, original_side=sd, reduce_only=reduce_only)
data = raw.get("data") if isinstance(raw, dict) else None
exchange_order_id = str(data.get("orderId") or data.get("clientOid") or "") if isinstance(data, dict) else ""
return LiveOrderResult(exchange_id="bitget", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw)