Throttle Binance eapi REST to avoid 429 on Plan snapshot polls.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import Any, Sequence
|
from typing import Any, Sequence
|
||||||
|
|
||||||
from ...config import Settings, get_settings
|
from ...config import Settings, get_settings
|
||||||
@@ -14,6 +15,9 @@ from .ws import BinancePublicWs
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# snapshot 轮询勿每 1.5s 打 REST;缺盘口时最少间隔再补
|
||||||
|
_REST_FILL_MIN_INTERVAL_SEC = 5.0
|
||||||
|
|
||||||
|
|
||||||
class BinanceExchange:
|
class BinanceExchange:
|
||||||
name = "binance"
|
name = "binance"
|
||||||
@@ -39,6 +43,7 @@ class BinanceExchange:
|
|||||||
)
|
)
|
||||||
self._started = False
|
self._started = False
|
||||||
self._ct_cache: dict[str, float] = {}
|
self._ct_cache: dict[str, float] = {}
|
||||||
|
self._rest_fill_at: dict[str, float] = {}
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
if self._started:
|
if self._started:
|
||||||
@@ -91,19 +96,23 @@ class BinanceExchange:
|
|||||||
logger.warning("binance warm book empty: %s", inst)
|
logger.warning("binance warm book empty: %s", inst)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("binance warm book %s failed: %s", inst, e)
|
logger.warning("binance warm book %s failed: %s", inst, e)
|
||||||
try:
|
# 期权 mark 易触发 eapi 限流;有盘口即可,永续再拉 mark
|
||||||
mp = self.rest.fetch_mark(inst)
|
from .parse import is_option_symbol
|
||||||
if mp:
|
|
||||||
self.cache.set_mark_px(inst, mp)
|
if not is_option_symbol(inst):
|
||||||
except Exception:
|
try:
|
||||||
pass
|
mp = self.rest.fetch_mark(inst)
|
||||||
# 指数
|
if mp:
|
||||||
|
self.cache.set_mark_px(inst, mp)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 指数(失败则跳过,勿连环打 eapi)
|
||||||
try:
|
try:
|
||||||
idx = self.rest.fetch_index(self.settings.index_inst_id)
|
idx = self.rest.fetch_index(self.settings.index_inst_id)
|
||||||
if idx:
|
if idx:
|
||||||
self.cache.set_index_px(idx)
|
self.cache.set_index_px(idx)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("binance index failed: %s", e)
|
logger.debug("binance index failed: %s", e)
|
||||||
keep = set(ids)
|
keep = set(ids)
|
||||||
self.cache.drop_except(keep)
|
self.cache.drop_except(keep)
|
||||||
self.ws.set_instruments(ids)
|
self.ws.set_instruments(ids)
|
||||||
@@ -115,11 +124,17 @@ class BinanceExchange:
|
|||||||
return self.cache.get(inst_id)
|
return self.cache.get(inst_id)
|
||||||
|
|
||||||
def _refresh_quote_if_stale(self, inst_id: str) -> None:
|
def _refresh_quote_if_stale(self, inst_id: str) -> None:
|
||||||
|
"""仅在买卖一都缺失时补 REST,且有最短间隔,避免 429。"""
|
||||||
if not inst_id:
|
if not inst_id:
|
||||||
return
|
return
|
||||||
q = self.cache.get(inst_id)
|
q = self.cache.get(inst_id)
|
||||||
if q is not None and (q.bid is not None or q.ask is not None):
|
if q is not None and q.bid is not None and q.ask is not None:
|
||||||
return
|
return
|
||||||
|
now = time.monotonic()
|
||||||
|
last = self._rest_fill_at.get(inst_id, 0.0)
|
||||||
|
if now - last < _REST_FILL_MIN_INTERVAL_SEC:
|
||||||
|
return
|
||||||
|
self._rest_fill_at[inst_id] = now
|
||||||
try:
|
try:
|
||||||
bids, asks, ts = self.rest.fetch_books(inst_id, sz=10)
|
bids, asks, ts = self.rest.fetch_books(inst_id, sz=10)
|
||||||
if bids or asks:
|
if bids or asks:
|
||||||
@@ -128,11 +143,10 @@ class BinanceExchange:
|
|||||||
logger.debug("binance refresh quote %s: %s", inst_id, e)
|
logger.debug("binance refresh quote %s: %s", inst_id, e)
|
||||||
|
|
||||||
def snapshot(self, perp_inst_id: str) -> MarketSnapshot:
|
def snapshot(self, perp_inst_id: str) -> MarketSnapshot:
|
||||||
# 期权 WS 偶发无推送时,用 REST/ticker 补买卖一,避免 UI 一直是 -
|
# 优先吃 WS 缓存;仅缺盘口时低频 REST 补齐
|
||||||
self._refresh_quote_if_stale(perp_inst_id)
|
self._refresh_quote_if_stale(perp_inst_id)
|
||||||
pair = None
|
pair = None
|
||||||
try:
|
try:
|
||||||
# BookCache 无公开 getter;从 snapshot 前先按 pair 刷新
|
|
||||||
with self.cache._lock: # noqa: SLF001
|
with self.cache._lock: # noqa: SLF001
|
||||||
pair = self.cache._pair
|
pair = self.cache._pair
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"""币安只读 REST:USDT 永续 (fapi) + 欧洲期权 (eapi)。"""
|
"""币安只读 REST:USDT 永续 (fapi) + 欧洲期权 (eapi)。含节流与 429 退避。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -9,6 +12,18 @@ import httpx
|
|||||||
from ..types import BookLevel
|
from ..types import BookLevel
|
||||||
from .parse import rows_to_option_contracts, safe_float
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class EapiCooldownError(RuntimeError):
|
||||||
|
"""eapi 限流冷却中,调用方应使用缓存或跳过。"""
|
||||||
|
|
||||||
|
|
||||||
class BinanceRestClient:
|
class BinanceRestClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -38,19 +53,60 @@ class BinanceRestClient:
|
|||||||
trust_env=False,
|
trust_env=False,
|
||||||
)
|
)
|
||||||
self._exchange_info: dict[str, Any] | None = None
|
self._exchange_info: dict[str, Any] | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._eapi_cool_until = 0.0
|
||||||
|
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]]]] = {}
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self._fapi.close()
|
self._fapi.close()
|
||||||
self._eapi.close()
|
self._eapi.close()
|
||||||
|
|
||||||
def _get_json(self, client: httpx.Client, path: str, params: dict[str, Any] | None = None) -> Any:
|
def _eapi_cooling(self) -> bool:
|
||||||
r = client.get(path, params=params or {})
|
return time.monotonic() < self._eapi_cool_until
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
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)
|
||||||
|
self._eapi_cool_until = time.monotonic() + wait
|
||||||
|
logger.warning("binance eapi 429, cooldown %.1fs", wait)
|
||||||
|
|
||||||
|
def _throttle_eapi(self) -> None:
|
||||||
|
"""简单串行节流,避免并发 snapshot/选约打爆 eapi。"""
|
||||||
|
now = time.monotonic()
|
||||||
|
if now < self._eapi_cool_until:
|
||||||
|
raise EapiCooldownError(
|
||||||
|
f"eapi cooldown {self._eapi_cool_until - now:.1f}s left"
|
||||||
|
)
|
||||||
|
gap = now - self._eapi_last_at
|
||||||
|
if gap < _MIN_EAPI_INTERVAL_SEC:
|
||||||
|
time.sleep(_MIN_EAPI_INTERVAL_SEC - gap)
|
||||||
|
self._eapi_last_at = time.monotonic()
|
||||||
|
|
||||||
|
def _get_json(
|
||||||
|
self,
|
||||||
|
client: httpx.Client,
|
||||||
|
path: str,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
is_eapi: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
with self._lock:
|
||||||
|
if is_eapi:
|
||||||
|
self._throttle_eapi()
|
||||||
|
r = client.get(path, params=params or {})
|
||||||
|
if r.status_code == 429:
|
||||||
|
if is_eapi:
|
||||||
|
ra = safe_float(r.headers.get("Retry-After"))
|
||||||
|
self._mark_eapi_429(ra)
|
||||||
|
r.raise_for_status()
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
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 None:
|
||||||
body = self._get_json(self._eapi, "/eapi/v1/exchangeInfo")
|
body = self._get_json(self._eapi, "/eapi/v1/exchangeInfo", is_eapi=True)
|
||||||
self._exchange_info = body if isinstance(body, dict) else {}
|
self._exchange_info = body if isinstance(body, dict) else {}
|
||||||
return self._exchange_info
|
return self._exchange_info
|
||||||
|
|
||||||
@@ -82,7 +138,14 @@ class BinanceRestClient:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
def list_option_contracts(self, family: str) -> list[dict[str, Any]]:
|
def list_option_contracts(self, family: str) -> list[dict[str, Any]]:
|
||||||
return rows_to_option_contracts(self.fetch_option_instruments(family))
|
key = (family or "").strip().upper() or "ETHUSDT"
|
||||||
|
now = time.monotonic()
|
||||||
|
hit = self._contracts_ttl.get(key)
|
||||||
|
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)
|
||||||
|
return contracts
|
||||||
|
|
||||||
def fetch_index(self, underlying: str) -> float | None:
|
def fetch_index(self, underlying: str) -> float | None:
|
||||||
"""期权指数:underlying=ETHUSDT。"""
|
"""期权指数:underlying=ETHUSDT。"""
|
||||||
@@ -90,7 +153,9 @@ class BinanceRestClient:
|
|||||||
if not u.endswith("USDT") and u.isalpha():
|
if not u.endswith("USDT") and u.isalpha():
|
||||||
u = f"{u}USDT"
|
u = f"{u}USDT"
|
||||||
try:
|
try:
|
||||||
body = self._get_json(self._eapi, "/eapi/v1/index", {"underlying": u})
|
body = self._get_json(
|
||||||
|
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"))
|
return safe_float(body.get("indexPrice") or body.get("price"))
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -100,14 +165,19 @@ class BinanceRestClient:
|
|||||||
|
|
||||||
def fetch_mark_perp(self, symbol: str) -> float | None:
|
def fetch_mark_perp(self, symbol: str) -> float | None:
|
||||||
body = self._get_json(
|
body = self._get_json(
|
||||||
self._fapi, "/fapi/v1/premiumIndex", {"symbol": (symbol or "ETHUSDT").upper()}
|
self._fapi,
|
||||||
|
"/fapi/v1/premiumIndex",
|
||||||
|
{"symbol": (symbol or "ETHUSDT").upper()},
|
||||||
|
is_eapi=False,
|
||||||
)
|
)
|
||||||
if isinstance(body, dict):
|
if isinstance(body, dict):
|
||||||
return safe_float(body.get("markPrice")) or safe_float(body.get("indexPrice"))
|
return safe_float(body.get("markPrice")) or safe_float(body.get("indexPrice"))
|
||||||
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(self._eapi, "/eapi/v1/mark", {"symbol": symbol})
|
body = self._get_json(
|
||||||
|
self._eapi, "/eapi/v1/mark", {"symbol": symbol}, is_eapi=True
|
||||||
|
)
|
||||||
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):
|
||||||
@@ -122,7 +192,9 @@ 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(self._eapi, "/eapi/v1/ticker", {"symbol": symbol})
|
body = self._get_json(
|
||||||
|
self._eapi, "/eapi/v1/ticker", {"symbol": symbol}, is_eapi=True
|
||||||
|
)
|
||||||
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
|
||||||
@@ -136,36 +208,59 @@ class BinanceRestClient:
|
|||||||
from .parse import is_option_symbol
|
from .parse import is_option_symbol
|
||||||
|
|
||||||
if is_option_symbol(inst_id):
|
if is_option_symbol(inst_id):
|
||||||
|
now = time.monotonic()
|
||||||
|
cached = self._book_ttl.get(inst_id)
|
||||||
|
if cached and now - cached[3] < _BOOK_TTL_SEC:
|
||||||
|
return cached[0], cached[1], cached[2]
|
||||||
|
if self._eapi_cooling():
|
||||||
|
if cached:
|
||||||
|
return cached[0], cached[1], cached[2]
|
||||||
|
return [], [], None
|
||||||
|
|
||||||
# 币安期权 depth 的 limit 仅支持 10/20/50/100 等,5 会失败
|
# 币安期权 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)
|
||||||
try:
|
depth_ok = False
|
||||||
body = self._get_json(
|
|
||||||
self._eapi, "/eapi/v1/depth", {"symbol": inst_id, "limit": limit}
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
body = None
|
|
||||||
bids: list[BookLevel] = []
|
bids: list[BookLevel] = []
|
||||||
asks: list[BookLevel] = []
|
asks: list[BookLevel] = []
|
||||||
ts_ms = None
|
ts_ms = None
|
||||||
if isinstance(body, dict):
|
try:
|
||||||
ts = safe_float(body.get("T") or body.get("E") or body.get("time"))
|
body = self._get_json(
|
||||||
ts_ms = int(ts) if ts is not None else None
|
self._eapi,
|
||||||
bids = _levels(body.get("bids") or body.get("b") or [])
|
"/eapi/v1/depth",
|
||||||
asks = _levels(body.get("asks") or body.get("a") or [])
|
{"symbol": inst_id, "limit": limit},
|
||||||
# depth 空盘时回退 ticker 买卖一
|
is_eapi=True,
|
||||||
if not bids or not asks:
|
)
|
||||||
tick = self.fetch_option_ticker(inst_id)
|
depth_ok = isinstance(body, dict)
|
||||||
if tick:
|
if depth_ok and isinstance(body, dict):
|
||||||
bid = safe_float(tick.get("bidPrice") or tick.get("b"))
|
ts = safe_float(body.get("T") or body.get("E") or body.get("time"))
|
||||||
ask = safe_float(tick.get("askPrice") or tick.get("a"))
|
ts_ms = int(ts) if ts is not None else None
|
||||||
bid_sz = safe_float(tick.get("bidQty") or tick.get("B")) or 1.0
|
bids = _levels(body.get("bids") or body.get("b") or [])
|
||||||
ask_sz = safe_float(tick.get("askQty") or tick.get("A")) or 1.0
|
asks = _levels(body.get("asks") or body.get("a") or [])
|
||||||
ts = safe_float(tick.get("time") or tick.get("E") or tick.get("T"))
|
except Exception as e:
|
||||||
ts_ms = int(ts) if ts is not None else ts_ms
|
logger.debug("binance option depth %s: %s", inst_id, e)
|
||||||
if bid is not None and bid > 0 and not bids:
|
if self._eapi_cooling() and cached:
|
||||||
bids = [BookLevel(px=bid, sz=bid_sz)]
|
return cached[0], cached[1], cached[2]
|
||||||
if ask is not None and ask > 0 and not asks:
|
|
||||||
asks = [BookLevel(px=ask, sz=ask_sz)]
|
# 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)
|
||||||
|
|
||||||
|
if bids or asks or depth_ok:
|
||||||
|
self._book_ttl[inst_id] = (bids, asks, ts_ms, time.monotonic())
|
||||||
return bids, asks, ts_ms
|
return bids, asks, ts_ms
|
||||||
|
|
||||||
limit = max(5, min(int(sz), 20))
|
limit = max(5, min(int(sz), 20))
|
||||||
@@ -173,6 +268,7 @@ class BinanceRestClient:
|
|||||||
self._fapi,
|
self._fapi,
|
||||||
"/fapi/v1/depth",
|
"/fapi/v1/depth",
|
||||||
{"symbol": inst_id.upper(), "limit": limit},
|
{"symbol": inst_id.upper(), "limit": limit},
|
||||||
|
is_eapi=False,
|
||||||
)
|
)
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
return [], [], None
|
return [], [], None
|
||||||
|
|||||||
Reference in New Issue
Block a user