0cf3756b09
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>
419 lines
15 KiB
Python
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
|
|
|