"""币安私有交易:USDT-M 永续 (fapi) + 欧洲期权 (eapi)。""" from __future__ import annotations import hashlib import hmac import logging import time from typing import Any from urllib.parse import urlencode import httpx from ..config import Settings, get_settings from ..exchange.okx.parse import safe_float from .okx_trade import LiveFill from .rate_limit import RateLimitError, get_throttle, parse_retry_after_header logger = logging.getLogger(__name__) class BinanceTradeClient: def __init__(self, settings: Settings | None = None) -> None: self.settings = settings or get_settings() proxy = (self.settings.binance_http_proxy or "").strip() or None headers = { "Accept": "application/json", "User-Agent": "eth-hedge-live/0.1", "X-MBX-APIKEY": self.settings.binance_api_key or "", } self._fapi = httpx.Client( base_url=self.settings.binance_fapi_base.rstrip("/"), timeout=20.0, proxy=proxy, headers=headers, trust_env=False, ) self._eapi = httpx.Client( base_url=self.settings.binance_eapi_base.rstrip("/"), timeout=20.0, proxy=proxy, headers=headers, trust_env=False, ) self._hedge: bool | None = None self._fapi_throttle = get_throttle("binance_fapi_trade", min_interval_sec=1.0) self._eapi_throttle = get_throttle( "binance_eapi_trade", min_interval_sec=1.0, cooldown_429_sec=20.0, cooldown_418_sec=120.0, ) def close(self) -> None: self._fapi.close() self._eapi.close() def _sign(self, params: dict[str, Any]) -> str: qs = urlencode(params, doseq=True) secret = (self.settings.binance_api_secret or "").encode("utf-8") return hmac.new(secret, qs.encode("utf-8"), hashlib.sha256).hexdigest() def _throttle_for(self, client: httpx.Client): if client is self._eapi: return self._eapi_throttle return self._fapi_throttle def _signed( self, client: httpx.Client, method: str, path: str, params: dict[str, Any] | None = None, ) -> Any: throttle = self._throttle_for(client) throttle.before_request() p = dict(params or {}) p["timestamp"] = int(time.time() * 1000) p["signature"] = self._sign(p) r = client.request(method.upper(), path, params=p) if r.status_code in (418, 429): ra = parse_retry_after_header(r.headers) throttle.mark_http(r.status_code, ra) raise RateLimitError( f"Binance {path} HTTP {r.status_code}: {r.text[:200]}", retry_after=throttle.remaining_cooldown(), ) if r.status_code >= 400: raise RuntimeError(f"Binance {path} HTTP {r.status_code}: {r.text[:400]}") data = r.json() if isinstance(data, dict) and "code" in data and "orderId" not in data: code = data.get("code") try: code_i = int(code) except (TypeError, ValueError): code_i = None msg = str(data.get("msg") or "") # -1003 too many requests; -1015 too many orders if code_i in (-1003, -1015) or "too many" in msg.lower(): throttle.mark_seconds(20.0) raise RateLimitError( f"Binance rate-limited code={code} msg={msg}", retry_after=throttle.remaining_cooldown(), ) if code_i is not None and code_i != 0: raise RuntimeError(f"Binance error code={code} msg={msg}") if code_i is None: raise RuntimeError(f"Binance error code={code} msg={msg}") return data def is_hedge_mode(self) -> bool: if self._hedge is not None: return self._hedge try: data = self._signed(self._fapi, "GET", "/fapi/v1/positionSide/dual") self._hedge = bool(data.get("dualSidePosition") in (True, "true", "True")) except Exception as e: logger.warning("binance hedge mode probe failed: %s; assume one-way", e) self._hedge = False return self._hedge def place_perp_market( self, *, symbol: str, side: str, # BUY|SELL qty_eth: float, position_side: str | None = None, # LONG|SHORT|None reduce_only: bool = False, ) -> LiveFill: # ETHUSDT 数量单位为 ETH qty = f"{float(qty_eth):.3f}".rstrip("0").rstrip(".") if not qty or qty == "0": qty = "0.001" params: dict[str, Any] = { "symbol": symbol, "side": side.upper(), "type": "MARKET", "quantity": qty, } hedge = self.is_hedge_mode() if hedge: ps = (position_side or ("LONG" if side.upper() == "BUY" else "SHORT")).upper() params["positionSide"] = ps elif reduce_only: params["reduceOnly"] = "true" data = self._signed(self._fapi, "POST", "/fapi/v1/order", params) return self._fill_from_fapi(symbol, data) def _fill_from_fapi(self, symbol: str, data: dict[str, Any]) -> LiveFill: ord_id = str(data.get("orderId") or "") avg = safe_float(data.get("avgPrice")) sz = safe_float(data.get("executedQty")) if (not avg or avg <= 0) and ord_id: q = self._signed( self._fapi, "GET", "/fapi/v1/order", {"symbol": symbol, "orderId": ord_id}, ) avg = safe_float(q.get("avgPrice")) or avg sz = safe_float(q.get("executedQty")) or sz data = q if not avg or avg <= 0: raise RuntimeError(f"币安永续无成交均价 orderId={ord_id} last={data}") from .money import abs_fee_usdt fee = abs(safe_float(data.get("cumCommission")) or 0.0) fee_asset = str(data.get("commissionAsset") or "USDT") if fee <= 0 and ord_id: fee, fee_asset = self.sum_perp_trade_fees(symbol, ord_id) return LiveFill( inst_id=symbol, side=str(data.get("side") or "").lower(), avg_px=float(avg), sz=float(sz or 0), fee=abs_fee_usdt(fee, fee_asset), ord_id=ord_id, raw=data if isinstance(data, dict) else {}, ) def place_option_market( self, *, symbol: str, side: str, # BUY|SELL quantity: float, reduce_only: bool = False, ) -> LiveFill: qty = str(int(round(quantity))) if qty == "0": qty = "1" params: dict[str, Any] = { "symbol": symbol, "side": side.upper(), "type": "MARKET", "quantity": qty, } if reduce_only: params["reduceOnly"] = "true" data = self._signed(self._eapi, "POST", "/eapi/v1/order", params) return self._fill_from_eapi(symbol, data) def _fill_from_eapi(self, symbol: str, data: dict[str, Any]) -> LiveFill: ord_id = str(data.get("orderId") or data.get("id") or "") avg = safe_float(data.get("avgPrice")) or safe_float(data.get("price")) sz = safe_float(data.get("executedQty")) or safe_float(data.get("quantity")) if (not avg or avg <= 0) and ord_id: # 轮询几轮 for _ in range(8): time.sleep(0.2) q = self._signed( self._eapi, "GET", "/eapi/v1/order", {"symbol": symbol, "orderId": ord_id}, ) avg = safe_float(q.get("avgPrice")) or safe_float(q.get("price")) sz = safe_float(q.get("executedQty")) or safe_float(q.get("quantity")) st = str(q.get("status") or "").upper() data = q if avg and avg > 0 and st == "FILLED": break if st in ("CANCELED", "REJECTED", "EXPIRED"): raise RuntimeError(f"币安期权订单失败 status={st} {q}") if st == "PARTIALLY_FILLED": continue if not avg or avg <= 0: raise RuntimeError(f"币安期权无成交均价 orderId={ord_id} last={data}") st_final = str(data.get("status") or "").upper() if st_final and st_final != "FILLED": raise RuntimeError( f"币安期权未完全成交 status={st_final} orderId={ord_id} last={data}" ) from .money import abs_fee_usdt fee = abs(safe_float(data.get("fee")) or 0.0) fee_asset = "USDT" if fee <= 0 and ord_id: fee, fee_asset = self.sum_option_trade_fees(symbol, ord_id) return LiveFill( inst_id=symbol, side=str(data.get("side") or "").lower(), avg_px=float(avg), sz=float(sz or 0), fee=abs_fee_usdt(fee, fee_asset), ord_id=ord_id, raw=data if isinstance(data, dict) else {}, ) def sum_perp_trade_fees(self, symbol: str, order_id: str) -> tuple[float, str]: try: rows = self._signed( self._fapi, "GET", "/fapi/v1/userTrades", {"symbol": symbol, "orderId": order_id}, ) except Exception as e: logger.warning("binance perp userTrades fee failed: %s", e) return 0.0, "USDT" if not isinstance(rows, list): rows = [rows] if isinstance(rows, dict) else [] total = 0.0 asset = "USDT" for row in rows: total += abs(safe_float(row.get("commission")) or 0.0) if row.get("commissionAsset"): asset = str(row.get("commissionAsset")) return total, asset def sum_option_trade_fees(self, symbol: str, order_id: str) -> tuple[float, str]: try: rows = self._signed( self._eapi, "GET", "/eapi/v1/userTrades", {"symbol": symbol, "orderId": order_id}, ) except Exception as e: logger.warning("binance option userTrades fee failed: %s", e) return 0.0, "USDT" if not isinstance(rows, list): rows = [rows] if isinstance(rows, dict) else [] total = 0.0 asset = "USDT" for row in rows: total += abs(safe_float(row.get("commission")) or safe_float(row.get("fee")) or 0.0) if row.get("commissionAsset") or row.get("feeAsset"): asset = str(row.get("commissionAsset") or row.get("feeAsset")) return total, asset def get_perp_upl_usdt(self, symbol: str, *, position_side: str | None = None) -> float | None: from .money import to_usdt try: rows = self._signed( self._fapi, "GET", "/fapi/v2/positionRisk", {"symbol": symbol} ) except Exception as e: logger.warning("binance positionRisk failed: %s", e) return None if isinstance(rows, dict): rows = [rows] want = (position_side or "").strip().upper() for row in rows: amt = safe_float(row.get("positionAmt")) or 0.0 if abs(amt) < 1e-12: continue ps = str(row.get("positionSide") or "").upper() if want and ps and ps not in ("BOTH",) and ps != want: continue upl = safe_float(row.get("unRealizedProfit")) if upl is None: continue return to_usdt(float(upl), "USDT") return 0.0 def get_perp_pos_sz(self, symbol: str, *, position_side: str | None = None) -> float | None: """当前永续绝对持仓(ETH)。""" try: rows = self._signed( self._fapi, "GET", "/fapi/v2/positionRisk", {"symbol": symbol} ) except Exception as e: logger.warning("binance get_perp_pos_sz failed: %s", e) return None if isinstance(rows, dict): rows = [rows] want = (position_side or "").strip().upper() for row in rows: amt = safe_float(row.get("positionAmt")) or 0.0 if abs(amt) < 1e-12: continue ps = str(row.get("positionSide") or "").upper() if want and ps and ps not in ("BOTH",) and ps != want: continue return abs(float(amt)) return 0.0 def get_option_pos_sz(self, symbol: str) -> float | None: """期权持仓绝对张数;查不到接口时返回 None。""" try: rows = self._signed(self._eapi, "GET", "/eapi/v1/position", {"symbol": symbol}) except Exception as e: logger.warning("binance get_option_pos_sz failed: %s", e) return None if isinstance(rows, dict): rows = [rows] total = 0.0 hit = False for row in rows: if not isinstance(row, dict): continue if str(row.get("symbol") or "") and str(row.get("symbol")) != symbol: continue qty = safe_float(row.get("quantity")) or safe_float(row.get("positionAmt")) or 0.0 hit = True total += abs(float(qty)) return total if hit else 0.0 def any_option_pos_abs(self) -> float | None: """账户任意期权绝对持仓合计(ETH 期权)。""" try: rows = self._signed(self._eapi, "GET", "/eapi/v1/position", {}) except Exception as e: logger.warning("binance any_option_pos_abs failed: %s", e) return None if isinstance(rows, dict): rows = [rows] total = 0.0 for row in rows: if not isinstance(row, dict): continue sym = str(row.get("symbol") or "") if sym and not sym.upper().startswith("ETH"): continue qty = safe_float(row.get("quantity")) or safe_float(row.get("positionAmt")) or 0.0 total += abs(float(qty)) return total def set_margin_type(self, symbol: str, margin_type: str) -> None: """ISOLATED | CROSSED。""" mt = "ISOLATED" if str(margin_type).lower() == "isolated" else "CROSSED" try: self._signed( self._fapi, "POST", "/fapi/v1/marginType", {"symbol": symbol, "marginType": mt}, ) except Exception as e: # 已是目标模式时币安常报错,忽略 msg = str(e).lower() if "no need to change" in msg or "-4046" in msg: return raise def set_leverage(self, symbol: str, leverage: int | float) -> None: lev = int(round(float(leverage))) if lev < 1: lev = 1 self._signed( self._fapi, "POST", "/fapi/v1/leverage", {"symbol": symbol, "leverage": lev}, ) def get_funding_usdt( self, symbol: str, *, begin_ms: int, end_ms: int | None = None ) -> float: from .money import to_usdt end = int(end_ms or int(time.time() * 1000)) try: rows = self._signed( self._fapi, "GET", "/fapi/v1/income", { "symbol": symbol, "incomeType": "FUNDING_FEE", "startTime": int(begin_ms), "endTime": end, "limit": 1000, }, ) except Exception as e: logger.warning("binance funding income failed: %s", e) return 0.0 if isinstance(rows, dict): rows = [rows] total = 0.0 for row in rows: raw = safe_float(row.get("income")) if raw is None: continue asset = str(row.get("asset") or "USDT") total += to_usdt(float(raw), asset) return total def get_closed_perp_pnl_usdt( self, symbol: str, *, begin_ms: int, end_ms: int | None = None ) -> float | None: """用 REALIZED_PNL income 近似已实现(含部分平仓);资金费另计。""" from .money import to_usdt end = int(end_ms or int(time.time() * 1000)) try: rows = self._signed( self._fapi, "GET", "/fapi/v1/income", { "symbol": symbol, "incomeType": "REALIZED_PNL", "startTime": int(begin_ms), "endTime": end, "limit": 1000, }, ) except Exception as e: logger.warning("binance realized income failed: %s", e) return None if isinstance(rows, dict): rows = [rows] if not rows: return None total = 0.0 for row in rows: raw = safe_float(row.get("income")) if raw is None: continue asset = str(row.get("asset") or "USDT") total += to_usdt(float(raw), asset) return total