Add Binance live trading, anti-stuck open/close recovery, and configurable rate limits.
OKX/Binance LIVE share half_open and option_closed_perp_pending repair paths; private REST throttles default to 1s and are tunable in settings. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
"""币安私有交易: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}")
|
||||
# 手续费:优先 cumCommission;否则用名义×费率估
|
||||
fee = abs(safe_float(data.get("cumCommission")) or 0.0)
|
||||
if fee <= 0:
|
||||
fee = float(avg) * float(sz or 0) * float(self.settings.fee_rate)
|
||||
return LiveFill(
|
||||
inst_id=symbol,
|
||||
side=str(data.get("side") or "").lower(),
|
||||
avg_px=float(avg),
|
||||
sz=float(sz or 0),
|
||||
fee=float(fee),
|
||||
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 in ("FILLED", "PARTIALLY_FILLED"):
|
||||
break
|
||||
if st in ("CANCELED", "REJECTED", "EXPIRED"):
|
||||
raise RuntimeError(f"币安期权订单失败 status={st} {q}")
|
||||
if not avg or avg <= 0:
|
||||
raise RuntimeError(f"币安期权无成交均价: {data}")
|
||||
fee = abs(safe_float(data.get("fee")) or 0.0)
|
||||
if fee <= 0:
|
||||
fee = float(avg) * float(sz or 0) * float(self.settings.fee_rate)
|
||||
return LiveFill(
|
||||
inst_id=symbol,
|
||||
side=str(data.get("side") or "").lower(),
|
||||
avg_px=float(avg),
|
||||
sz=float(sz or 0),
|
||||
fee=float(fee),
|
||||
ord_id=ord_id,
|
||||
raw=data if isinstance(data, dict) else {},
|
||||
)
|
||||
Reference in New Issue
Block a user