Files
eth_hedge_sim/backend/app/live/binance_trade.py
T
dekun 0cf3756b09 Harden LIVE opens with slot claim and exchange reconcile.
Prevent duplicate opens by atomically claiming an opening slot, verifying exchange perp is flat before live orders, setting leverage from ledger, and preferring exchange position size when closing perps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 22:39:27 +08:00

419 lines
15 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"币安永续无成交均价: {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"币安期权无成交均价: {data}")
st_final = str(data.get("status") or "").upper()
if st_final and st_final != "FILLED":
raise RuntimeError(f"币安期权未完全成交 status={st_final} {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 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