Survive Binance eapi 418/429 with cooldown, soft start, and no ticker raise.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-25 12:49:14 +08:00
parent 867deba1bb
commit 21ca5cbfe0
3 changed files with 115 additions and 53 deletions
+90 -49
View File
@@ -1,10 +1,12 @@
"""币安只读 RESTUSDT 永续 (fapi) + 欧洲期权 (eapi)。含节流与 429 退避。""" """币安只读 RESTUSDT 永续 (fapi) + 欧洲期权 (eapi)。含节流与 429/418 退避。"""
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import threading import threading
import time import time
from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
@@ -14,15 +16,15 @@ from .parse import rows_to_option_contracts, safe_float
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# eapi 公共接口较严;UI 1.5s 轮询时必须节流 _BOOK_TTL_SEC = 3.0
_BOOK_TTL_SEC = 2.5 _CONTRACTS_TTL_SEC = 180.0
_CONTRACTS_TTL_SEC = 120.0 _MIN_EAPI_INTERVAL_SEC = 0.25
_MIN_EAPI_INTERVAL_SEC = 0.12 _DEFAULT_429_COOLDOWN_SEC = 15.0
_DEFAULT_429_COOLDOWN_SEC = 8.0 _DEFAULT_418_COOLDOWN_SEC = 120.0
class EapiCooldownError(RuntimeError): class EapiCooldownError(RuntimeError):
"""eapi 限流冷却中,调用方应使用缓存或跳过""" """eapi 限流/封禁冷却中。"""
class BinanceRestClient: class BinanceRestClient:
@@ -33,6 +35,7 @@ class BinanceRestClient:
eapi_base: str = "https://eapi.binance.com", eapi_base: str = "https://eapi.binance.com",
timeout: float = 15.0, timeout: float = 15.0,
proxy: str | None = None, proxy: str | None = None,
cache_dir: str | Path | None = None,
) -> None: ) -> None:
self.fapi_base = fapi_base.rstrip("/") self.fapi_base = fapi_base.rstrip("/")
self.eapi_base = eapi_base.rstrip("/") self.eapi_base = eapi_base.rstrip("/")
@@ -58,6 +61,9 @@ class BinanceRestClient:
self._eapi_last_at = 0.0 self._eapi_last_at = 0.0
self._book_ttl: dict[str, tuple[list[BookLevel], list[BookLevel], int | None, float]] = {} self._book_ttl: dict[str, tuple[list[BookLevel], list[BookLevel], int | None, float]] = {}
self._contracts_ttl: dict[str, tuple[float, list[dict[str, Any]]]] = {} 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: def close(self) -> None:
self._fapi.close() self._fapi.close()
@@ -66,14 +72,16 @@ class BinanceRestClient:
def _eapi_cooling(self) -> bool: def _eapi_cooling(self) -> bool:
return time.monotonic() < self._eapi_cool_until return time.monotonic() < self._eapi_cool_until
def _mark_eapi_429(self, retry_after: float | None = None) -> None: def _mark_eapi_ban(self, status_code: int, retry_after: float | None = None) -> None:
wait = float(retry_after) if retry_after and retry_after > 0 else _DEFAULT_429_COOLDOWN_SEC if status_code == 418:
wait = max(wait, _DEFAULT_429_COOLDOWN_SEC) 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 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: def _throttle_eapi(self) -> None:
"""简单串行节流,避免并发 snapshot/选约打爆 eapi。"""
now = time.monotonic() now = time.monotonic()
if now < self._eapi_cool_until: if now < self._eapi_cool_until:
raise EapiCooldownError( raise EapiCooldownError(
@@ -96,18 +104,48 @@ class BinanceRestClient:
if is_eapi: if is_eapi:
self._throttle_eapi() self._throttle_eapi()
r = client.get(path, params=params or {}) r = client.get(path, params=params or {})
if r.status_code == 429: if r.status_code in (418, 429):
if is_eapi: if is_eapi:
ra = safe_float(r.headers.get("Retry-After")) 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()
r.raise_for_status() r.raise_for_status()
return r.json() 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]: 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) 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 return self._exchange_info
def fetch_option_instruments(self, underlying: str) -> list[dict[str, Any]]: def fetch_option_instruments(self, underlying: str) -> list[dict[str, Any]]:
@@ -115,7 +153,6 @@ class BinanceRestClient:
info = self.fetch_option_exchange_info() info = self.fetch_option_exchange_info()
rows = info.get("optionSymbols") or info.get("symbols") or [] rows = info.get("optionSymbols") or info.get("symbols") or []
want = (underlying or "ETHUSDT").strip().upper() want = (underlying or "ETHUSDT").strip().upper()
# 兼容误用 OKX 期权族名
if "USD_UM" in want or want in ("ETH-USD", "ETH-USDT", "ETHUSD"): if "USD_UM" in want or want in ("ETH-USD", "ETH-USDT", "ETHUSD"):
want = "ETHUSDT" want = "ETHUSDT"
eth_mode = want in ("ETH", "ETHUSDT") or ( eth_mode = want in ("ETH", "ETHUSDT") or (
@@ -127,7 +164,6 @@ class BinanceRestClient:
continue continue
u = str(row.get("underlying") or row.get("underlyingAsset") or "").upper() u = str(row.get("underlying") or row.get("underlyingAsset") or "").upper()
sym = str(row.get("symbol") or "").upper() sym = str(row.get("symbol") or "").upper()
# 只收币安格式 ETH-YYMMDD-STRIKE-C/P,避免脏符号进缓存
if eth_mode: if eth_mode:
if sym.startswith("ETH-") and u.startswith("ETH"): if sym.startswith("ETH-") and u.startswith("ETH"):
out.append(row) out.append(row)
@@ -144,7 +180,8 @@ class BinanceRestClient:
if hit and now - hit[0] < _CONTRACTS_TTL_SEC: if hit and now - hit[0] < _CONTRACTS_TTL_SEC:
return hit[1] return hit[1]
contracts = rows_to_option_contracts(self.fetch_option_instruments(family)) 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 return contracts
def fetch_index(self, underlying: str) -> float | None: def fetch_index(self, underlying: str) -> float | None:
@@ -157,10 +194,11 @@ class BinanceRestClient:
self._eapi, "/eapi/v1/index", {"underlying": u}, is_eapi=True self._eapi, "/eapi/v1/index", {"underlying": u}, is_eapi=True
) )
if isinstance(body, dict): 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: except Exception:
pass pass
# 回退永续标记
return self.fetch_mark_perp(u if u.endswith("USDT") else "ETHUSDT") return self.fetch_mark_perp(u if u.endswith("USDT") else "ETHUSDT")
def fetch_mark_perp(self, symbol: str) -> float | None: def fetch_mark_perp(self, symbol: str) -> float | None:
@@ -175,9 +213,12 @@ class BinanceRestClient:
return None return None
def fetch_mark_option(self, symbol: str) -> float | None: def fetch_mark_option(self, symbol: str) -> float | None:
body = self._get_json( try:
self._eapi, "/eapi/v1/mark", {"symbol": symbol}, is_eapi=True body = self._get_json(
) self._eapi, "/eapi/v1/mark", {"symbol": symbol}, is_eapi=True
)
except Exception:
return None
if isinstance(body, list) and body: if isinstance(body, list) and body:
return safe_float(body[0].get("markPrice")) return safe_float(body[0].get("markPrice"))
if isinstance(body, dict): if isinstance(body, dict):
@@ -192,9 +233,12 @@ class BinanceRestClient:
return self.fetch_mark_perp(inst_id) return self.fetch_mark_perp(inst_id)
def fetch_option_ticker(self, symbol: str) -> dict[str, Any] | None: def fetch_option_ticker(self, symbol: str) -> dict[str, Any] | None:
body = self._get_json( try:
self._eapi, "/eapi/v1/ticker", {"symbol": symbol}, is_eapi=True body = self._get_json(
) self._eapi, "/eapi/v1/ticker", {"symbol": symbol}, is_eapi=True
)
except Exception:
return None
if isinstance(body, list) and body: if isinstance(body, list) and body:
row = body[0] row = body[0]
return row if isinstance(row, dict) else None return row if isinstance(row, dict) else None
@@ -205,6 +249,7 @@ class BinanceRestClient:
def fetch_books( def fetch_books(
self, inst_id: str, sz: int = 5 self, inst_id: str, sz: int = 5
) -> tuple[list[BookLevel], list[BookLevel], int | None]: ) -> tuple[list[BookLevel], list[BookLevel], int | None]:
"""期权路径永不向外抛限流异常;失败返回缓存或空盘。"""
from .parse import is_option_symbol from .parse import is_option_symbol
if is_option_symbol(inst_id): if is_option_symbol(inst_id):
@@ -217,9 +262,7 @@ class BinanceRestClient:
return cached[0], cached[1], cached[2] return cached[0], cached[1], cached[2]
return [], [], None return [], [], None
# 币安期权 depth 的 limit 仅支持 10/20/50/100 等,5 会失败
limit = 10 if int(sz) < 10 else min(int(sz), 100) limit = 10 if int(sz) < 10 else min(int(sz), 100)
depth_ok = False
bids: list[BookLevel] = [] bids: list[BookLevel] = []
asks: list[BookLevel] = [] asks: list[BookLevel] = []
ts_ms = None ts_ms = None
@@ -230,36 +273,34 @@ class BinanceRestClient:
{"symbol": inst_id, "limit": limit}, {"symbol": inst_id, "limit": limit},
is_eapi=True, is_eapi=True,
) )
depth_ok = isinstance(body, dict) if isinstance(body, dict):
if depth_ok and isinstance(body, dict):
ts = safe_float(body.get("T") or body.get("E") or body.get("time")) 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 ts_ms = int(ts) if ts is not None else None
bids = _levels(body.get("bids") or body.get("b") or []) bids = _levels(body.get("bids") or body.get("b") or [])
asks = _levels(body.get("asks") or body.get("a") or []) asks = _levels(body.get("asks") or body.get("a") or [])
except Exception as e: except Exception as e:
logger.debug("binance option depth %s: %s", inst_id, 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] 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(): if (not bids or not asks) and not self._eapi_cooling():
try: tick = self.fetch_option_ticker(inst_id)
tick = self.fetch_option_ticker(inst_id) if tick:
if tick: bid = safe_float(tick.get("bidPrice") or tick.get("b"))
bid = safe_float(tick.get("bidPrice") or tick.get("b")) ask = safe_float(tick.get("askPrice") or tick.get("a"))
ask = safe_float(tick.get("askPrice") or tick.get("a")) bid_sz = safe_float(tick.get("bidQty") or tick.get("B")) or 1.0
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
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 = 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
ts_ms = int(ts) if ts is not None else ts_ms if bid is not None and bid > 0 and not bids:
if bid is not None and bid > 0 and not bids: bids = [BookLevel(px=bid, sz=bid_sz)]
bids = [BookLevel(px=bid, sz=bid_sz)] if ask is not None and ask > 0 and not asks:
if ask is not None and ask > 0 and not asks: asks = [BookLevel(px=ask, sz=ask_sz)]
asks = [BookLevel(px=ask, sz=ask_sz)]
except Exception as e:
logger.debug("binance option ticker %s: %s", inst_id, e)
if bids or asks or depth_ok: if bids or asks:
self._book_ttl[inst_id] = (bids, asks, ts_ms, time.monotonic()) self._book_ttl[inst_id] = (bids, asks, ts_ms, time.monotonic())
return bids, asks, ts_ms return bids, asks, ts_ms
+8 -1
View File
@@ -230,8 +230,15 @@ class StrategyEngine:
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as e: 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") logger.exception("strategy tick failed")
self._set_state(last_error=str(e)) self._set_state(last_error=err)
await asyncio.sleep(1) await asyncio.sleep(1)
async def _tick_async(self) -> None: async def _tick_async(self) -> None:
+17 -3
View File
@@ -112,7 +112,11 @@ class StrategySession:
return return
self._started = True self._started = True
await self.ex.start() 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( await self.ex.resubscribe(
[ [
self.settings.perp_inst_id, 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) 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 call_ask = call_asks[0].px if call_asks else None
put_ask = put_asks[0].px if put_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( sig = decide(
call_ask, call_ask,
put_ask, put_ask,
@@ -335,7 +346,7 @@ class StrategySession:
async def _refresh_loop(self) -> None: async def _refresh_loop(self) -> None:
while True: while True:
await asyncio.sleep(30) await asyncio.sleep(30 if self._pair is not None else 10)
try: try:
idx = await asyncio.to_thread( idx = await asyncio.to_thread(
self.ex.fetch_index, self.settings.index_inst_id self.ex.fetch_index, self.settings.index_inst_id
@@ -346,7 +357,10 @@ class StrategySession:
) )
if mark: if mark:
self.ex.set_mark_px(self.settings.perp_inst_id, 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: except asyncio.CancelledError:
raise raise
except Exception as e: except Exception as e: