"""币安只读 REST:USDT 永续 (fapi) + 欧洲期权 (eapi)。含节流与 429/418 退避。""" from __future__ import annotations import json import logging import threading import time from pathlib import Path from typing import Any import httpx from ..types import BookLevel from .parse import rows_to_option_contracts, safe_float logger = logging.getLogger(__name__) _BOOK_TTL_SEC = 3.0 _CONTRACTS_TTL_SEC = 180.0 _MIN_EAPI_INTERVAL_SEC = 0.25 _DEFAULT_429_COOLDOWN_SEC = 15.0 _DEFAULT_418_COOLDOWN_SEC = 120.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, cache_dir: str | Path | 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]]]] = {} root = Path(cache_dir) if cache_dir else Path(__file__).resolve().parents[3] / "data" root.mkdir(parents=True, exist_ok=True) self._exchange_info_path = root / "binance_eapi_exchangeInfo.json" 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_ban(self, status_code: int, retry_after: float | None = None) -> None: if status_code == 418: wait = _DEFAULT_418_COOLDOWN_SEC else: 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 HTTP %s, cooldown %.0fs", status_code, wait) def _throttle_eapi(self) -> None: 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 in (418, 429): if is_eapi: ra = safe_float(r.headers.get("Retry-After")) self._mark_eapi_ban(r.status_code, ra) r.raise_for_status() r.raise_for_status() return r.json() def _load_exchange_info_disk(self) -> dict[str, Any] | None: try: if not self._exchange_info_path.is_file(): return None body = json.loads(self._exchange_info_path.read_text(encoding="utf-8")) return body if isinstance(body, dict) else None except Exception: return None def _save_exchange_info_disk(self, body: dict[str, Any]) -> None: try: self._exchange_info_path.write_text( json.dumps(body, ensure_ascii=False), encoding="utf-8" ) except Exception as e: logger.debug("save exchangeInfo cache failed: %s", e) def fetch_option_exchange_info(self) -> dict[str, Any]: if self._exchange_info is not None: return self._exchange_info try: body = self._get_json(self._eapi, "/eapi/v1/exchangeInfo", is_eapi=True) if isinstance(body, dict) and body: self._exchange_info = body self._save_exchange_info_disk(body) return body except Exception as e: logger.warning("binance exchangeInfo fetch failed: %s", e) disk = self._load_exchange_info_disk() if disk: logger.warning("binance exchangeInfo using disk cache") self._exchange_info = disk return disk self._exchange_info = {} 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() 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() 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)) if contracts: 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): px = safe_float(body.get("indexPrice") or body.get("price")) if px: return px 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: try: body = self._get_json( self._eapi, "/eapi/v1/mark", {"symbol": symbol}, is_eapi=True ) except Exception: return None 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: try: body = self._get_json( self._eapi, "/eapi/v1/ticker", {"symbol": symbol}, is_eapi=True ) except Exception: return None 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 limit = 10 if int(sz) < 10 else min(int(sz), 100) 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, ) if 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 cached: return cached[0], cached[1], cached[2] # 冷却中不要再打 ticker if self._eapi_cooling(): return [], [], None if (not bids or not asks) and not self._eapi_cooling(): 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)] if bids or asks: 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