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:
@@ -1,10 +1,12 @@
|
|||||||
"""币安只读 REST:USDT 永续 (fapi) + 欧洲期权 (eapi)。含节流与 429 退避。"""
|
"""币安只读 REST:USDT 永续 (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:
|
||||||
|
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 = float(retry_after) if retry_after and retry_after > 0 else _DEFAULT_429_COOLDOWN_SEC
|
||||||
wait = max(wait, _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,6 +180,7 @@ 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))
|
||||||
|
if contracts:
|
||||||
self._contracts_ttl[key] = (now, contracts)
|
self._contracts_ttl[key] = (now, contracts)
|
||||||
return contracts
|
return contracts
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
try:
|
||||||
body = self._get_json(
|
body = self._get_json(
|
||||||
self._eapi, "/eapi/v1/mark", {"symbol": symbol}, is_eapi=True
|
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:
|
||||||
|
try:
|
||||||
body = self._get_json(
|
body = self._get_json(
|
||||||
self._eapi, "/eapi/v1/ticker", {"symbol": symbol}, is_eapi=True
|
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,20 +273,20 @@ 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"))
|
||||||
@@ -256,10 +299,8 @@ class BinanceRestClient:
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -112,7 +112,11 @@ class StrategySession:
|
|||||||
return
|
return
|
||||||
self._started = True
|
self._started = True
|
||||||
await self.ex.start()
|
await self.ex.start()
|
||||||
|
try:
|
||||||
await asyncio.to_thread(self.align_instruments)
|
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,6 +357,9 @@ 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)
|
||||||
|
if self._pair is None:
|
||||||
|
await self.ensure_atm_async(force=True)
|
||||||
|
else:
|
||||||
await self.ensure_atm_async(force=False)
|
await self.ensure_atm_async(force=False)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
|||||||
Reference in New Issue
Block a user