Files
eth_hedge_sim/backend/app/live/binance_trade.py
T
dekun e2a19a1614 Recover stuck opening and harden LIVE open/close reconcile.
Stamp open intent, recover opening from exchange option/perp state, skip resell/reopen when already flat, and persist Binance margin mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 20:32:45 +08:00

479 lines
17 KiB
Python

"""币安私有交易: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