Files
eth_hedge_sim/backend/app/exchange/binance/rest.py
T
2026-07-25 12:44:41 +08:00

295 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""币安只读 RESTUSDT 永续 (fapi) + 欧洲期权 (eapi)。含节流与 429 退避。"""
from __future__ import annotations
import logging
import threading
import time
from typing import Any
import httpx
from ..types import BookLevel
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:
def __init__(
self,
*,
fapi_base: str = "https://fapi.binance.com",
eapi_base: str = "https://eapi.binance.com",
timeout: float = 15.0,
proxy: str | None = None,
) -> None:
self.fapi_base = fapi_base.rstrip("/")
self.eapi_base = eapi_base.rstrip("/")
self.proxy = (proxy or "").strip() or None
headers = {"Accept": "application/json", "User-Agent": "eth-hedge-sim/0.3"}
self._fapi = httpx.Client(
base_url=self.fapi_base,
timeout=timeout,
proxy=self.proxy,
headers=headers,
trust_env=False,
)
self._eapi = httpx.Client(
base_url=self.eapi_base,
timeout=timeout,
proxy=self.proxy,
headers=headers,
trust_env=False,
)
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:
self._fapi.close()
self._eapi.close()
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)
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]:
if self._exchange_info is None:
body = self._get_json(self._eapi, "/eapi/v1/exchangeInfo", is_eapi=True)
self._exchange_info = body if isinstance(body, dict) else {}
return self._exchange_info
def fetch_option_instruments(self, underlying: str) -> list[dict[str, Any]]:
"""underlying 如 ETH / ETHUSDT。误传 OKX familyETH-USD_UM)时映射到 ETHUSDT。"""
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 (
want.startswith("ETH") and "-" not in want
)
out: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
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)
continue
base = want.replace("USDT", "") if want.endswith("USDT") else want
if u == want or u == base or sym.startswith(f"{base}-"):
out.append(row)
return out
def list_option_contracts(self, family: str) -> list[dict[str, Any]]:
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:
"""期权指数:underlying=ETHUSDT。"""
u = (underlying or "ETHUSDT").strip().upper()
if not u.endswith("USDT") and u.isalpha():
u = f"{u}USDT"
try:
body = self._get_json(
self._eapi, "/eapi/v1/index", {"underlying": u}, is_eapi=True
)
if isinstance(body, dict):
return safe_float(body.get("indexPrice") or body.get("price"))
except Exception:
pass
# 回退永续标记
return self.fetch_mark_perp(u if u.endswith("USDT") else "ETHUSDT")
def fetch_mark_perp(self, symbol: str) -> float | None:
body = self._get_json(
self._fapi,
"/fapi/v1/premiumIndex",
{"symbol": (symbol or "ETHUSDT").upper()},
is_eapi=False,
)
if isinstance(body, dict):
return safe_float(body.get("markPrice")) or safe_float(body.get("indexPrice"))
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
)
if isinstance(body, list) and body:
return safe_float(body[0].get("markPrice"))
if isinstance(body, dict):
return safe_float(body.get("markPrice"))
return None
def fetch_mark(self, inst_id: str) -> float | None:
from .parse import is_option_symbol
if is_option_symbol(inst_id):
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}, is_eapi=True
)
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
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 会失败
limit = 10 if int(sz) < 10 else min(int(sz), 100)
depth_ok = False
bids: list[BookLevel] = []
asks: list[BookLevel] = []
ts_ms = None
try:
body = self._get_json(
self._eapi,
"/eapi/v1/depth",
{"symbol": inst_id, "limit": limit},
is_eapi=True,
)
depth_ok = 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_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:
return cached[0], cached[1], cached[2]
# 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
limit = max(5, min(int(sz), 20))
body = self._get_json(
self._fapi,
"/fapi/v1/depth",
{"symbol": inst_id.upper(), "limit": limit},
is_eapi=False,
)
if not isinstance(body, dict):
return [], [], None
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
return (
_levels(body.get("bids") or []),
_levels(body.get("asks") or []),
ts_ms,
)
def _levels(raw: list[Any]) -> list[BookLevel]:
out: list[BookLevel] = []
for item in raw:
if not isinstance(item, (list, tuple)) or len(item) < 2:
continue
px = safe_float(item[0])
sz = safe_float(item[1])
if px is None or sz is None or px <= 0 or sz <= 0:
continue
out.append(BookLevel(px=px, sz=sz))
return out