640ecc9530
Co-authored-by: Cursor <cursoragent@cursor.com>
508 lines
18 KiB
Python
508 lines
18 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 place_option_ioc(
|
|
self,
|
|
*,
|
|
symbol: str,
|
|
side: str, # BUY|SELL
|
|
quantity: float,
|
|
price: float,
|
|
reduce_only: bool = False,
|
|
) -> LiveFill:
|
|
"""期权限价 IOC:按买一/卖一价吃单,未成交部分取消。"""
|
|
qty = str(int(round(quantity)))
|
|
if qty == "0":
|
|
qty = "1"
|
|
px = f"{float(price):.8f}".rstrip("0").rstrip(".")
|
|
if not px or px == "0":
|
|
raise RuntimeError("币安期权 IOC 价格无效")
|
|
params: dict[str, Any] = {
|
|
"symbol": symbol,
|
|
"side": side.upper(),
|
|
"type": "LIMIT",
|
|
"timeInForce": "IOC",
|
|
"quantity": qty,
|
|
"price": px,
|
|
}
|
|
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
|
|
|