"""币安交易所适配器:USDT 永续 + 欧洲期权公共行情(SIM 只读)。""" from __future__ import annotations import logging import time from typing import Any, Sequence from ...config import Settings, get_settings from ..book_cache import BookCache from ..types import BookLevel, MarketSnapshot, OptionPair, Quote from .parse import safe_float from .rest import BinanceRestClient from .ws import BinancePublicWs logger = logging.getLogger(__name__) # snapshot 轮询勿每 1.5s 打 REST;缺盘口时最少间隔再补 _REST_FILL_MIN_INTERVAL_SEC = 5.0 class BinanceExchange: name = "binance" def __init__(self, settings: Settings | None = None) -> None: self.settings = settings or get_settings() self.cache = BookCache() proxy = ( self.settings.binance_http_proxy or self.settings.okx_http_proxy or None ) self.rest = BinanceRestClient( fapi_base=self.settings.binance_fapi_base, eapi_base=self.settings.binance_eapi_base, proxy=proxy, ) self.ws = BinancePublicWs( futures_ws_base=self.settings.binance_futures_ws, options_ws_base=self.settings.binance_options_ws, cache=self.cache, proxy=proxy, ) self._started = False self._ct_cache: dict[str, float] = {} self._rest_fill_at: dict[str, float] = {} async def start(self) -> None: if self._started: return self._started = True await self.ws.start() logger.info("Binance exchange started") async def stop(self) -> None: self._started = False await self.ws.stop() self.rest.close() logger.info("Binance exchange stopped") def list_option_contracts(self, family: str) -> list[dict[str, Any]]: contracts = self.rest.list_option_contracts(family) for c in contracts: if c.get("ct_mult"): self._ct_cache[str(c["inst_id"])] = float(c["ct_mult"]) return contracts def fetch_index(self, index_id: str) -> float | None: return self.rest.fetch_index(index_id) def fetch_mark(self, inst_id: str) -> float | None: return self.rest.fetch_mark(inst_id) def fetch_book( self, inst_id: str, depth: int = 5 ) -> tuple[list[BookLevel], list[BookLevel], int | None]: return self.rest.fetch_books(inst_id, sz=depth) def get_ct_mult(self, option_inst_id: str, family: str, default: float) -> float: if option_inst_id in self._ct_cache: return self._ct_cache[option_inst_id] # 币安 ETH 期权 unit 常见为 1 return float(default if default > 0 else 1.0) def set_pair(self, pair: OptionPair | None) -> None: self.cache.set_pair(pair) def warm_and_subscribe(self, inst_ids: Sequence[str]) -> None: ids = [i for i in inst_ids if i] for inst in ids: try: bids, asks, ts = self.rest.fetch_books(inst, sz=10) if bids or asks: self.cache.upsert_book(inst, bids=bids, asks=asks, ts_ms=ts) else: logger.warning("binance warm book empty: %s", inst) except Exception as e: logger.warning("binance warm book %s failed: %s", inst, e) # 期权 mark 易触发 eapi 限流;有盘口即可,永续再拉 mark from .parse import is_option_symbol if not is_option_symbol(inst): try: mp = self.rest.fetch_mark(inst) if mp: self.cache.set_mark_px(inst, mp) except Exception: pass # 指数(失败则跳过,勿连环打 eapi) try: idx = self.rest.fetch_index(self.settings.index_inst_id) if idx: self.cache.set_index_px(idx) except Exception as e: logger.debug("binance index failed: %s", e) keep = set(ids) self.cache.drop_except(keep) self.ws.set_instruments(ids) async def resubscribe(self, inst_ids: Sequence[str]) -> None: await self.ws.resubscribe([i for i in inst_ids if i]) def quote(self, inst_id: str) -> Quote | None: return self.cache.get(inst_id) def _refresh_quote_if_stale(self, inst_id: str) -> None: """仅在买卖一都缺失时补 REST,且有最短间隔,避免 429。""" if not inst_id: return q = self.cache.get(inst_id) if q is not None and q.bid is not None and q.ask is not None: return now = time.monotonic() last = self._rest_fill_at.get(inst_id, 0.0) if now - last < _REST_FILL_MIN_INTERVAL_SEC: return self._rest_fill_at[inst_id] = now try: bids, asks, ts = self.rest.fetch_books(inst_id, sz=10) if bids or asks: self.cache.upsert_book(inst_id, bids=bids, asks=asks, ts_ms=ts) except Exception as e: logger.debug("binance refresh quote %s: %s", inst_id, e) def snapshot(self, perp_inst_id: str) -> MarketSnapshot: # 优先吃 WS 缓存;仅缺盘口时低频 REST 补齐 self._refresh_quote_if_stale(perp_inst_id) pair = None try: with self.cache._lock: # noqa: SLF001 pair = self.cache._pair except Exception: pair = None if pair is not None: self._refresh_quote_if_stale(pair.call_inst_id) self._refresh_quote_if_stale(pair.put_inst_id) return self.cache.snapshot(perp_inst_id) def snapshot_dict(self, perp_inst_id: str) -> dict[str, Any]: return self.snapshot(perp_inst_id).to_dict() def set_index_px(self, px: float | None) -> None: self.cache.set_index_px(px) def set_mark_px(self, inst_id: str, mark_px: float | None) -> None: self.cache.set_mark_px(inst_id, mark_px)