Fix Binance option top-of-book via depth limit and ticker fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-25 12:08:33 +08:00
parent 78fd046fb6
commit d701d30c04
4 changed files with 134 additions and 12 deletions
+1
View File
@@ -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 = ""
+30 -2
View File
@@ -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]:
+47 -10
View File
@@ -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"))
+56
View File
@@ -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()