867deba1bb
Co-authored-by: Cursor <cursoragent@cursor.com>
295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""币安只读 REST:USDT 永续 (fapi) + 欧洲期权 (eapi)。含节流与 429 退避。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import threading
|
||
import time
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from ..types import BookLevel
|
||
from .parse import rows_to_option_contracts, safe_float
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# eapi 公共接口较严;UI 1.5s 轮询时必须节流
|
||
_BOOK_TTL_SEC = 2.5
|
||
_CONTRACTS_TTL_SEC = 120.0
|
||
_MIN_EAPI_INTERVAL_SEC = 0.12
|
||
_DEFAULT_429_COOLDOWN_SEC = 8.0
|
||
|
||
|
||
class EapiCooldownError(RuntimeError):
|
||
"""eapi 限流冷却中,调用方应使用缓存或跳过。"""
|
||
|
||
|
||
class BinanceRestClient:
|
||
def __init__(
|
||
self,
|
||
*,
|
||
fapi_base: str = "https://fapi.binance.com",
|
||
eapi_base: str = "https://eapi.binance.com",
|
||
timeout: float = 15.0,
|
||
proxy: str | None = None,
|
||
) -> None:
|
||
self.fapi_base = fapi_base.rstrip("/")
|
||
self.eapi_base = eapi_base.rstrip("/")
|
||
self.proxy = (proxy or "").strip() or None
|
||
headers = {"Accept": "application/json", "User-Agent": "eth-hedge-sim/0.3"}
|
||
self._fapi = httpx.Client(
|
||
base_url=self.fapi_base,
|
||
timeout=timeout,
|
||
proxy=self.proxy,
|
||
headers=headers,
|
||
trust_env=False,
|
||
)
|
||
self._eapi = httpx.Client(
|
||
base_url=self.eapi_base,
|
||
timeout=timeout,
|
||
proxy=self.proxy,
|
||
headers=headers,
|
||
trust_env=False,
|
||
)
|
||
self._exchange_info: dict[str, Any] | None = None
|
||
self._lock = threading.Lock()
|
||
self._eapi_cool_until = 0.0
|
||
self._eapi_last_at = 0.0
|
||
self._book_ttl: dict[str, tuple[list[BookLevel], list[BookLevel], int | None, float]] = {}
|
||
self._contracts_ttl: dict[str, tuple[float, list[dict[str, Any]]]] = {}
|
||
|
||
def close(self) -> None:
|
||
self._fapi.close()
|
||
self._eapi.close()
|
||
|
||
def _eapi_cooling(self) -> bool:
|
||
return time.monotonic() < self._eapi_cool_until
|
||
|
||
def _mark_eapi_429(self, retry_after: float | None = None) -> None:
|
||
wait = float(retry_after) if retry_after and retry_after > 0 else _DEFAULT_429_COOLDOWN_SEC
|
||
wait = max(wait, _DEFAULT_429_COOLDOWN_SEC)
|
||
self._eapi_cool_until = time.monotonic() + wait
|
||
logger.warning("binance eapi 429, cooldown %.1fs", wait)
|
||
|
||
def _throttle_eapi(self) -> None:
|
||
"""简单串行节流,避免并发 snapshot/选约打爆 eapi。"""
|
||
now = time.monotonic()
|
||
if now < self._eapi_cool_until:
|
||
raise EapiCooldownError(
|
||
f"eapi cooldown {self._eapi_cool_until - now:.1f}s left"
|
||
)
|
||
gap = now - self._eapi_last_at
|
||
if gap < _MIN_EAPI_INTERVAL_SEC:
|
||
time.sleep(_MIN_EAPI_INTERVAL_SEC - gap)
|
||
self._eapi_last_at = time.monotonic()
|
||
|
||
def _get_json(
|
||
self,
|
||
client: httpx.Client,
|
||
path: str,
|
||
params: dict[str, Any] | None = None,
|
||
*,
|
||
is_eapi: bool = False,
|
||
) -> Any:
|
||
with self._lock:
|
||
if is_eapi:
|
||
self._throttle_eapi()
|
||
r = client.get(path, params=params or {})
|
||
if r.status_code == 429:
|
||
if is_eapi:
|
||
ra = safe_float(r.headers.get("Retry-After"))
|
||
self._mark_eapi_429(ra)
|
||
r.raise_for_status()
|
||
r.raise_for_status()
|
||
return r.json()
|
||
|
||
def fetch_option_exchange_info(self) -> dict[str, Any]:
|
||
if self._exchange_info is None:
|
||
body = self._get_json(self._eapi, "/eapi/v1/exchangeInfo", is_eapi=True)
|
||
self._exchange_info = body if isinstance(body, dict) else {}
|
||
return self._exchange_info
|
||
|
||
def fetch_option_instruments(self, underlying: str) -> list[dict[str, Any]]:
|
||
"""underlying 如 ETH / ETHUSDT。误传 OKX family(ETH-USD_UM)时映射到 ETHUSDT。"""
|
||
info = self.fetch_option_exchange_info()
|
||
rows = info.get("optionSymbols") or info.get("symbols") or []
|
||
want = (underlying or "ETHUSDT").strip().upper()
|
||
# 兼容误用 OKX 期权族名
|
||
if "USD_UM" in want or want in ("ETH-USD", "ETH-USDT", "ETHUSD"):
|
||
want = "ETHUSDT"
|
||
eth_mode = want in ("ETH", "ETHUSDT") or (
|
||
want.startswith("ETH") and "-" not in want
|
||
)
|
||
out: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
u = str(row.get("underlying") or row.get("underlyingAsset") or "").upper()
|
||
sym = str(row.get("symbol") or "").upper()
|
||
# 只收币安格式 ETH-YYMMDD-STRIKE-C/P,避免脏符号进缓存
|
||
if eth_mode:
|
||
if sym.startswith("ETH-") and u.startswith("ETH"):
|
||
out.append(row)
|
||
continue
|
||
base = want.replace("USDT", "") if want.endswith("USDT") else want
|
||
if u == want or u == base or sym.startswith(f"{base}-"):
|
||
out.append(row)
|
||
return out
|
||
|
||
def list_option_contracts(self, family: str) -> list[dict[str, Any]]:
|
||
key = (family or "").strip().upper() or "ETHUSDT"
|
||
now = time.monotonic()
|
||
hit = self._contracts_ttl.get(key)
|
||
if hit and now - hit[0] < _CONTRACTS_TTL_SEC:
|
||
return hit[1]
|
||
contracts = rows_to_option_contracts(self.fetch_option_instruments(family))
|
||
self._contracts_ttl[key] = (now, contracts)
|
||
return contracts
|
||
|
||
def fetch_index(self, underlying: str) -> float | None:
|
||
"""期权指数:underlying=ETHUSDT。"""
|
||
u = (underlying or "ETHUSDT").strip().upper()
|
||
if not u.endswith("USDT") and u.isalpha():
|
||
u = f"{u}USDT"
|
||
try:
|
||
body = self._get_json(
|
||
self._eapi, "/eapi/v1/index", {"underlying": u}, is_eapi=True
|
||
)
|
||
if isinstance(body, dict):
|
||
return safe_float(body.get("indexPrice") or body.get("price"))
|
||
except Exception:
|
||
pass
|
||
# 回退永续标记
|
||
return self.fetch_mark_perp(u if u.endswith("USDT") else "ETHUSDT")
|
||
|
||
def fetch_mark_perp(self, symbol: str) -> float | None:
|
||
body = self._get_json(
|
||
self._fapi,
|
||
"/fapi/v1/premiumIndex",
|
||
{"symbol": (symbol or "ETHUSDT").upper()},
|
||
is_eapi=False,
|
||
)
|
||
if isinstance(body, dict):
|
||
return safe_float(body.get("markPrice")) or safe_float(body.get("indexPrice"))
|
||
return None
|
||
|
||
def fetch_mark_option(self, symbol: str) -> float | None:
|
||
body = self._get_json(
|
||
self._eapi, "/eapi/v1/mark", {"symbol": symbol}, is_eapi=True
|
||
)
|
||
if isinstance(body, list) and body:
|
||
return safe_float(body[0].get("markPrice"))
|
||
if isinstance(body, dict):
|
||
return safe_float(body.get("markPrice"))
|
||
return None
|
||
|
||
def fetch_mark(self, inst_id: str) -> float | None:
|
||
from .parse import is_option_symbol
|
||
|
||
if is_option_symbol(inst_id):
|
||
return self.fetch_mark_option(inst_id)
|
||
return self.fetch_mark_perp(inst_id)
|
||
|
||
def fetch_option_ticker(self, symbol: str) -> dict[str, Any] | None:
|
||
body = self._get_json(
|
||
self._eapi, "/eapi/v1/ticker", {"symbol": symbol}, is_eapi=True
|
||
)
|
||
if isinstance(body, list) and body:
|
||
row = body[0]
|
||
return row if isinstance(row, dict) else None
|
||
if isinstance(body, dict):
|
||
return body
|
||
return None
|
||
|
||
def fetch_books(
|
||
self, inst_id: str, sz: int = 5
|
||
) -> tuple[list[BookLevel], list[BookLevel], int | None]:
|
||
from .parse import is_option_symbol
|
||
|
||
if is_option_symbol(inst_id):
|
||
now = time.monotonic()
|
||
cached = self._book_ttl.get(inst_id)
|
||
if cached and now - cached[3] < _BOOK_TTL_SEC:
|
||
return cached[0], cached[1], cached[2]
|
||
if self._eapi_cooling():
|
||
if cached:
|
||
return cached[0], cached[1], cached[2]
|
||
return [], [], None
|
||
|
||
# 币安期权 depth 的 limit 仅支持 10/20/50/100 等,5 会失败
|
||
limit = 10 if int(sz) < 10 else min(int(sz), 100)
|
||
depth_ok = False
|
||
bids: list[BookLevel] = []
|
||
asks: list[BookLevel] = []
|
||
ts_ms = None
|
||
try:
|
||
body = self._get_json(
|
||
self._eapi,
|
||
"/eapi/v1/depth",
|
||
{"symbol": inst_id, "limit": limit},
|
||
is_eapi=True,
|
||
)
|
||
depth_ok = isinstance(body, dict)
|
||
if depth_ok and isinstance(body, dict):
|
||
ts = safe_float(body.get("T") or body.get("E") or body.get("time"))
|
||
ts_ms = int(ts) if ts is not None else None
|
||
bids = _levels(body.get("bids") or body.get("b") or [])
|
||
asks = _levels(body.get("asks") or body.get("a") or [])
|
||
except Exception as e:
|
||
logger.debug("binance option depth %s: %s", inst_id, e)
|
||
if self._eapi_cooling() and cached:
|
||
return cached[0], cached[1], cached[2]
|
||
|
||
# depth 缺买卖一时回退 ticker;冷却中不再打
|
||
if (not bids or not asks) and not self._eapi_cooling():
|
||
try:
|
||
tick = self.fetch_option_ticker(inst_id)
|
||
if tick:
|
||
bid = safe_float(tick.get("bidPrice") or tick.get("b"))
|
||
ask = safe_float(tick.get("askPrice") or tick.get("a"))
|
||
bid_sz = safe_float(tick.get("bidQty") or tick.get("B")) or 1.0
|
||
ask_sz = safe_float(tick.get("askQty") or tick.get("A")) or 1.0
|
||
ts = safe_float(tick.get("time") or tick.get("E") or tick.get("T"))
|
||
ts_ms = int(ts) if ts is not None else ts_ms
|
||
if bid is not None and bid > 0 and not bids:
|
||
bids = [BookLevel(px=bid, sz=bid_sz)]
|
||
if ask is not None and ask > 0 and not asks:
|
||
asks = [BookLevel(px=ask, sz=ask_sz)]
|
||
except Exception as e:
|
||
logger.debug("binance option ticker %s: %s", inst_id, e)
|
||
|
||
if bids or asks or depth_ok:
|
||
self._book_ttl[inst_id] = (bids, asks, ts_ms, time.monotonic())
|
||
return bids, asks, ts_ms
|
||
|
||
limit = max(5, min(int(sz), 20))
|
||
body = self._get_json(
|
||
self._fapi,
|
||
"/fapi/v1/depth",
|
||
{"symbol": inst_id.upper(), "limit": limit},
|
||
is_eapi=False,
|
||
)
|
||
if not isinstance(body, dict):
|
||
return [], [], None
|
||
ts = safe_float(body.get("T") or body.get("E") or body.get("time"))
|
||
ts_ms = int(ts) if ts is not None else None
|
||
return (
|
||
_levels(body.get("bids") or []),
|
||
_levels(body.get("asks") or []),
|
||
ts_ms,
|
||
)
|
||
|
||
|
||
def _levels(raw: list[Any]) -> list[BookLevel]:
|
||
out: list[BookLevel] = []
|
||
for item in raw:
|
||
if not isinstance(item, (list, tuple)) or len(item) < 2:
|
||
continue
|
||
px = safe_float(item[0])
|
||
sz = safe_float(item[1])
|
||
if px is None or sz is None or px <= 0 or sz <= 0:
|
||
continue
|
||
out.append(BookLevel(px=px, sz=sz))
|
||
return out
|