diff --git a/backend/app/config.py b/backend/app/config.py index af27e50..5162f45 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -37,6 +37,7 @@ class Settings(BaseSettings): binance_fapi_base: str = "https://fapi.binance.com" binance_eapi_base: str = "https://eapi.binance.com" binance_futures_ws: str = "wss://fstream.binance.com/stream" + # 官方默认根路径 /eoptions;组合流用 /eoptions/stream?streams= binance_options_ws: str = "wss://nbstream.binance.com/eoptions/stream" binance_http_proxy: str = "" diff --git a/backend/app/exchange/binance/adapter.py b/backend/app/exchange/binance/adapter.py index d262c69..e7d80bf 100644 --- a/backend/app/exchange/binance/adapter.py +++ b/backend/app/exchange/binance/adapter.py @@ -84,8 +84,11 @@ class BinanceExchange: ids = [i for i in inst_ids if i] for inst in ids: try: - bids, asks, ts = self.rest.fetch_books(inst, sz=5) - self.cache.upsert_book(inst, bids=bids, asks=asks, ts_ms=ts) + 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) try: @@ -111,7 +114,32 @@ class BinanceExchange: def quote(self, inst_id: str) -> Quote | None: return self.cache.get(inst_id) + def _refresh_quote_if_stale(self, inst_id: str) -> None: + if not inst_id: + return + q = self.cache.get(inst_id) + if q is not None and (q.bid is not None or q.ask is not None): + return + 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/ticker 补买卖一,避免 UI 一直是 - + self._refresh_quote_if_stale(perp_inst_id) + pair = None + try: + # BookCache 无公开 getter;从 snapshot 前先按 pair 刷新 + 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]: diff --git a/backend/app/exchange/binance/rest.py b/backend/app/exchange/binance/rest.py index 04fa457..cf99226 100644 --- a/backend/app/exchange/binance/rest.py +++ b/backend/app/exchange/binance/rest.py @@ -115,22 +115,59 @@ class BinanceRestClient: 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}) + 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 - limit = max(5, min(int(sz), 100)) if is_option_symbol(inst_id): - body = self._get_json( - self._eapi, "/eapi/v1/depth", {"symbol": inst_id, "limit": limit} - ) - else: - body = self._get_json( - self._fapi, - "/fapi/v1/depth", - {"symbol": inst_id.upper(), "limit": min(limit, 20)}, - ) + # 币安期权 depth 的 limit 仅支持 10/20/50/100 等,5 会失败 + limit = 10 if int(sz) < 10 else min(int(sz), 100) + try: + body = self._get_json( + self._eapi, "/eapi/v1/depth", {"symbol": inst_id, "limit": limit} + ) + except Exception: + body = None + bids: list[BookLevel] = [] + asks: list[BookLevel] = [] + ts_ms = None + 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 []) + # depth 空盘时回退 ticker 买卖一 + if not bids or not asks: + 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)] + 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}, + ) if not isinstance(body, dict): return [], [], None ts = safe_float(body.get("T") or body.get("E") or body.get("time")) diff --git a/scripts/probe_binance_options.py b/scripts/probe_binance_options.py new file mode 100644 index 0000000..5ac4101 --- /dev/null +++ b/scripts/probe_binance_options.py @@ -0,0 +1,56 @@ +"""服务器上探测币安期权盘口(只读)。用法: python scripts/probe_binance_options.py""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "backend")) + +import httpx # noqa: E402 + + +def main() -> None: + proxy = None + env = ROOT / ".env" + if env.exists(): + for line in env.read_text(encoding="utf-8").splitlines(): + if line.startswith("BINANCE_HTTP_PROXY=") or line.startswith("OKX_HTTP_PROXY="): + v = line.split("=", 1)[1].strip().strip('"').strip("'") + if v: + proxy = v + print("proxy=", proxy) + kw: dict = {"timeout": 30, "trust_env": False} + if proxy: + kw["proxy"] = proxy + with httpx.Client(**kw) as cli: + info = cli.get("https://eapi.binance.com/eapi/v1/exchangeInfo") + print("exchangeInfo", info.status_code) + opts = (info.json() or {}).get("optionSymbols") or [] + near = [ + x + for x in opts + if str(x.get("symbol", "")).startswith("ETH-260726-1850") + or str(x.get("symbol", "")).startswith("ETH-250726-1850") + ] + print("near1850", [x.get("symbol") for x in near[:6]]) + if not near: + near = [x for x in opts if str(x.get("symbol", "")).startswith("ETH-") ][:4] + print("fallback sample", [x.get("symbol") for x in near]) + for row in near[:4]: + sym = row["symbol"] + for limit in (5, 10, 20): + d = cli.get( + "https://eapi.binance.com/eapi/v1/depth", + params={"symbol": sym, "limit": limit}, + ) + print(f"depth limit={limit}", sym, d.status_code, d.text[:180]) + t = cli.get("https://eapi.binance.com/eapi/v1/ticker", params={"symbol": sym}) + print("ticker", sym, t.status_code, t.text[:240]) + print("---") + + +if __name__ == "__main__": + main()