Files

336 lines
12 KiB
Python
Raw Permalink 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/418 退避。"""
from __future__ import annotations
import json
import logging
import threading
import time
from pathlib import Path
from typing import Any
import httpx
from ..types import BookLevel
from .parse import rows_to_option_contracts, safe_float
logger = logging.getLogger(__name__)
_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 限流/封禁冷却中。"""
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,
cache_dir: str | Path | 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]]]] = {}
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()
self._eapi.close()
def _eapi_cooling(self) -> bool:
return time.monotonic() < self._eapi_cool_until
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 HTTP %s, cooldown %.0fs", status_code, wait)
def _throttle_eapi(self) -> None:
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 in (418, 429):
if is_eapi:
ra = safe_float(r.headers.get("Retry-After"))
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 not None:
return self._exchange_info
try:
body = self._get_json(self._eapi, "/eapi/v1/exchangeInfo", is_eapi=True)
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]]:
"""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()
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()
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))
if contracts:
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):
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:
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:
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):
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:
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
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
limit = 10 if int(sz) < 10 else min(int(sz), 100)
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,
)
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 cached:
return cached[0], cached[1], cached[2]
# 冷却中不要再打 ticker
if self._eapi_cooling():
return [], [], None
if (not bids or not asks) and not self._eapi_cooling():
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:
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