From 21ca5cbfe0abb2e8b639585c7166aab9ce769a95 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 25 Jul 2026 12:49:14 +0800 Subject: [PATCH] Survive Binance eapi 418/429 with cooldown, soft start, and no ticker raise. Co-authored-by: Cursor --- backend/app/exchange/binance/rest.py | 139 +++++++++++++++++---------- backend/app/strategy/engine.py | 9 +- backend/app/strategy/session.py | 20 +++- 3 files changed, 115 insertions(+), 53 deletions(-) diff --git a/backend/app/exchange/binance/rest.py b/backend/app/exchange/binance/rest.py index eb93913..0624e66 100644 --- a/backend/app/exchange/binance/rest.py +++ b/backend/app/exchange/binance/rest.py @@ -1,10 +1,12 @@ -"""币安只读 REST:USDT 永续 (fapi) + 欧洲期权 (eapi)。含节流与 429 退避。""" +"""币安只读 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 @@ -14,15 +16,15 @@ from .parse import rows_to_option_contracts, safe_float logger = logging.getLogger(__name__) -# eapi 公共接口较严;UI 1.5s 轮询时必须节流 -_BOOK_TTL_SEC = 2.5 -_CONTRACTS_TTL_SEC = 120.0 -_MIN_EAPI_INTERVAL_SEC = 0.12 -_DEFAULT_429_COOLDOWN_SEC = 8.0 +_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 限流冷却中,调用方应使用缓存或跳过。""" + """eapi 限流/封禁冷却中。""" class BinanceRestClient: @@ -33,6 +35,7 @@ class BinanceRestClient: 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("/") @@ -58,6 +61,9 @@ class BinanceRestClient: 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() @@ -66,14 +72,16 @@ class BinanceRestClient: def _eapi_cooling(self) -> bool: return time.monotonic() < self._eapi_cool_until - def _mark_eapi_429(self, retry_after: float | None = None) -> None: - wait = float(retry_after) if retry_after and retry_after > 0 else _DEFAULT_429_COOLDOWN_SEC - wait = max(wait, _DEFAULT_429_COOLDOWN_SEC) + 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 429, cooldown %.1fs", wait) + logger.warning("binance eapi HTTP %s, cooldown %.0fs", status_code, wait) def _throttle_eapi(self) -> None: - """简单串行节流,避免并发 snapshot/选约打爆 eapi。""" now = time.monotonic() if now < self._eapi_cool_until: raise EapiCooldownError( @@ -96,18 +104,48 @@ class BinanceRestClient: if is_eapi: self._throttle_eapi() r = client.get(path, params=params or {}) - if r.status_code == 429: + if r.status_code in (418, 429): if is_eapi: ra = safe_float(r.headers.get("Retry-After")) - self._mark_eapi_429(ra) + 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 None: + if self._exchange_info is not None: + return self._exchange_info + try: body = self._get_json(self._eapi, "/eapi/v1/exchangeInfo", is_eapi=True) - self._exchange_info = body if isinstance(body, dict) else {} + 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]]: @@ -115,7 +153,6 @@ class BinanceRestClient: info = self.fetch_option_exchange_info() rows = info.get("optionSymbols") or info.get("symbols") or [] want = (underlying or "ETHUSDT").strip().upper() - # 兼容误用 OKX 期权族名 if "USD_UM" in want or want in ("ETH-USD", "ETH-USDT", "ETHUSD"): want = "ETHUSDT" eth_mode = want in ("ETH", "ETHUSDT") or ( @@ -127,7 +164,6 @@ class BinanceRestClient: continue u = str(row.get("underlying") or row.get("underlyingAsset") or "").upper() sym = str(row.get("symbol") or "").upper() - # 只收币安格式 ETH-YYMMDD-STRIKE-C/P,避免脏符号进缓存 if eth_mode: if sym.startswith("ETH-") and u.startswith("ETH"): out.append(row) @@ -144,7 +180,8 @@ class BinanceRestClient: if hit and now - hit[0] < _CONTRACTS_TTL_SEC: return hit[1] contracts = rows_to_option_contracts(self.fetch_option_instruments(family)) - self._contracts_ttl[key] = (now, contracts) + if contracts: + self._contracts_ttl[key] = (now, contracts) return contracts def fetch_index(self, underlying: str) -> float | None: @@ -157,10 +194,11 @@ class BinanceRestClient: self._eapi, "/eapi/v1/index", {"underlying": u}, is_eapi=True ) if isinstance(body, dict): - return safe_float(body.get("indexPrice") or body.get("price")) + 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: @@ -175,9 +213,12 @@ class BinanceRestClient: return None def fetch_mark_option(self, symbol: str) -> float | None: - body = self._get_json( - self._eapi, "/eapi/v1/mark", {"symbol": symbol}, is_eapi=True - ) + 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): @@ -192,9 +233,12 @@ class BinanceRestClient: 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}, is_eapi=True - ) + 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 @@ -205,6 +249,7 @@ class BinanceRestClient: 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): @@ -217,9 +262,7 @@ class BinanceRestClient: return cached[0], cached[1], cached[2] return [], [], None - # 币安期权 depth 的 limit 仅支持 10/20/50/100 等,5 会失败 limit = 10 if int(sz) < 10 else min(int(sz), 100) - depth_ok = False bids: list[BookLevel] = [] asks: list[BookLevel] = [] ts_ms = None @@ -230,36 +273,34 @@ class BinanceRestClient: {"symbol": inst_id, "limit": limit}, is_eapi=True, ) - depth_ok = isinstance(body, dict) - if depth_ok and isinstance(body, dict): + 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 self._eapi_cooling() and cached: + if cached: return cached[0], cached[1], cached[2] + # 冷却中不要再打 ticker + if self._eapi_cooling(): + return [], [], None - # depth 缺买卖一时回退 ticker;冷却中不再打 if (not bids or not asks) and not self._eapi_cooling(): - try: - 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)] - except Exception as e: - logger.debug("binance option ticker %s: %s", inst_id, e) + 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 or depth_ok: + if bids or asks: self._book_ttl[inst_id] = (bids, asks, ts_ms, time.monotonic()) return bids, asks, ts_ms diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 68fe085..96cb98b 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -230,8 +230,15 @@ class StrategyEngine: except asyncio.CancelledError: raise except Exception as e: + err = str(e) + # 限流时勿刷屏;拉长休眠给 eapi 冷却 + if "418" in err or "429" in err or "cooldown" in err.lower(): + logger.warning("strategy tick rate-limited: %s", err[:200]) + self._set_state(last_error="币安期权接口限流,稍后自动重试") + await asyncio.sleep(15) + continue logger.exception("strategy tick failed") - self._set_state(last_error=str(e)) + self._set_state(last_error=err) await asyncio.sleep(1) async def _tick_async(self) -> None: diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index b89e724..74a08e3 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -112,7 +112,11 @@ class StrategySession: return self._started = True await self.ex.start() - await asyncio.to_thread(self.align_instruments) + try: + await asyncio.to_thread(self.align_instruments) + except Exception as e: + # eapi 418/429 时允许先起会话,后续 refresh 再对齐 + logger.warning("initial ATM align failed (will retry): %s", e) await self.ex.resubscribe( [ self.settings.perp_inst_id, @@ -205,6 +209,13 @@ class StrategySession: put_bids, put_asks, _ = self.ex.fetch_book(pair.put_inst_id, depth=5) call_ask = call_asks[0].px if call_asks else None put_ask = put_asks[0].px if put_asks else None + # REST 被限流时回退 WS/缓存盘口 + if call_ask is None: + cq = self.ex.quote(pair.call_inst_id) + call_ask = cq.ask if cq else None + if put_ask is None: + pq = self.ex.quote(pair.put_inst_id) + put_ask = pq.ask if pq else None sig = decide( call_ask, put_ask, @@ -335,7 +346,7 @@ class StrategySession: async def _refresh_loop(self) -> None: while True: - await asyncio.sleep(30) + await asyncio.sleep(30 if self._pair is not None else 10) try: idx = await asyncio.to_thread( self.ex.fetch_index, self.settings.index_inst_id @@ -346,7 +357,10 @@ class StrategySession: ) if mark: self.ex.set_mark_px(self.settings.perp_inst_id, mark) - await self.ensure_atm_async(force=False) + if self._pair is None: + await self.ensure_atm_async(force=True) + else: + await self.ensure_atm_async(force=False) except asyncio.CancelledError: raise except Exception as e: