867deba1bb
Co-authored-by: Cursor <cursoragent@cursor.com>
167 lines
5.9 KiB
Python
167 lines
5.9 KiB
Python
"""币安交易所适配器: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)
|