Add Binance SIM market adapter and exchange switch in settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-25 12:02:36 +08:00
parent 5dcec0fde0
commit 78fd046fb6
25 changed files with 879 additions and 45 deletions
+78 -16
View File
@@ -1,11 +1,18 @@
"""币安交易所适配器占位:后期接入,接口与 OKX 对齐"""
"""币安交易所适配器:USDT 永续 + 欧洲期权公共行情(SIM 只读)"""
from __future__ import annotations
import logging
from typing import Any, Sequence
from ...config import Settings, get_settings
from ..book_cache import BookCache
from ..types import BookLevel, MarketSnapshot, OptionPair, Quote
from .parse import safe_float
from .rest import BinanceRestClient
from .ws import BinancePublicWs
logger = logging.getLogger(__name__)
class BinanceExchange:
@@ -13,50 +20,105 @@ class BinanceExchange:
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or get_settings()
self.cache = BookCache()
proxy = (
self.settings.binance_http_proxy
or self.settings.okx_http_proxy
or None
)
self.rest = BinanceRestClient(
fapi_base=self.settings.binance_fapi_base,
eapi_base=self.settings.binance_eapi_base,
proxy=proxy,
)
self.ws = BinancePublicWs(
futures_ws_base=self.settings.binance_futures_ws,
options_ws_base=self.settings.binance_options_ws,
cache=self.cache,
proxy=proxy,
)
self._started = False
self._ct_cache: dict[str, float] = {}
async def start(self) -> None:
raise NotImplementedError("币安交易所模块尚未接入,请配置 EXCHANGE=okx")
if self._started:
return
self._started = True
await self.ws.start()
logger.info("Binance exchange started")
async def stop(self) -> None:
return
self._started = False
await self.ws.stop()
self.rest.close()
logger.info("Binance exchange stopped")
def list_option_contracts(self, family: str) -> list[dict[str, Any]]:
raise NotImplementedError("BinanceExchange.list_option_contracts")
contracts = self.rest.list_option_contracts(family)
for c in contracts:
if c.get("ct_mult"):
self._ct_cache[str(c["inst_id"])] = float(c["ct_mult"])
return contracts
def fetch_index(self, index_id: str) -> float | None:
raise NotImplementedError("BinanceExchange.fetch_index")
return self.rest.fetch_index(index_id)
def fetch_mark(self, inst_id: str) -> float | None:
raise NotImplementedError("BinanceExchange.fetch_mark")
return self.rest.fetch_mark(inst_id)
def fetch_book(
self, inst_id: str, depth: int = 5
) -> tuple[list[BookLevel], list[BookLevel], int | None]:
raise NotImplementedError("BinanceExchange.fetch_book")
return self.rest.fetch_books(inst_id, sz=depth)
def get_ct_mult(self, option_inst_id: str, family: str, default: float) -> float:
return float(default)
if option_inst_id in self._ct_cache:
return self._ct_cache[option_inst_id]
# 币安 ETH 期权 unit 常见为 1
return float(default if default > 0 else 1.0)
def set_pair(self, pair: OptionPair | None) -> None:
raise NotImplementedError("BinanceExchange.set_pair")
self.cache.set_pair(pair)
def warm_and_subscribe(self, inst_ids: Sequence[str]) -> None:
raise NotImplementedError("BinanceExchange.warm_and_subscribe")
ids = [i for i in inst_ids if i]
for inst in ids:
try:
bids, asks, ts = self.rest.fetch_books(inst, sz=5)
self.cache.upsert_book(inst, bids=bids, asks=asks, ts_ms=ts)
except Exception as e:
logger.warning("binance warm book %s failed: %s", inst, e)
try:
mp = self.rest.fetch_mark(inst)
if mp:
self.cache.set_mark_px(inst, mp)
except Exception:
pass
# 指数
try:
idx = self.rest.fetch_index(self.settings.index_inst_id)
if idx:
self.cache.set_index_px(idx)
except Exception as e:
logger.warning("binance index failed: %s", e)
keep = set(ids)
self.cache.drop_except(keep)
self.ws.set_instruments(ids)
async def resubscribe(self, inst_ids: Sequence[str]) -> None:
raise NotImplementedError("BinanceExchange.resubscribe")
await self.ws.resubscribe([i for i in inst_ids if i])
def quote(self, inst_id: str) -> Quote | None:
return None
return self.cache.get(inst_id)
def snapshot(self, perp_inst_id: str) -> MarketSnapshot:
raise NotImplementedError("BinanceExchange.snapshot")
return self.cache.snapshot(perp_inst_id)
def snapshot_dict(self, perp_inst_id: str) -> dict[str, Any]:
raise NotImplementedError("BinanceExchange.snapshot_dict")
return self.snapshot(perp_inst_id).to_dict()
def set_index_px(self, px: float | None) -> None:
return
self.cache.set_index_px(px)
def set_mark_px(self, inst_id: str, mark_px: float | None) -> None:
return
self.cache.set_mark_px(inst_id, mark_px)
+97
View File
@@ -0,0 +1,97 @@
"""币安期权 / 永续符号解析 → 中性合约行。"""
from __future__ import annotations
import re
from typing import Any
from ..expiry import expiry_ms_from_ymd, ymd_from_expiry_ms
_OPT_RE = re.compile(
r"^(?P<under>[A-Z0-9]+)-(?P<ymd>\d{6})-(?P<strike>\d+(?:\.\d+)?)-(?P<side>[CP])$",
re.IGNORECASE,
)
def safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def parse_option_symbol(symbol: str) -> tuple[str | None, float | None, str | None]:
"""ETH-250726-1860-C → (YYMMDD, strike, C|P)."""
m = _OPT_RE.match((symbol or "").strip())
if not m:
return None, None, None
ymd = m.group("ymd")
strike = safe_float(m.group("strike"))
side = m.group("side").upper()
return ymd, strike, side
def is_option_symbol(symbol: str) -> bool:
y, s, o = parse_option_symbol(symbol)
return y is not None and s is not None and o in ("C", "P")
def rows_to_option_contracts(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
归一化:
{inst_id, expiry_ymd, expiry_ms, strike, side, ct_mult}
"""
out: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
continue
status = str(row.get("status") or "TRADING").upper()
if status and status not in ("TRADING", "LIVE", ""):
continue
inst_id = str(row.get("symbol") or row.get("inst_id") or "")
y, stk, opt = parse_option_symbol(inst_id)
exp_ms = None
raw_exp = row.get("expiryDate") or row.get("expiration") or row.get("expiry_ms")
if raw_exp is not None:
try:
exp_ms = int(float(raw_exp))
if exp_ms < 10_000_000_000: # seconds
exp_ms *= 1000
except (TypeError, ValueError):
exp_ms = None
if y is None and exp_ms is not None:
y = ymd_from_expiry_ms(exp_ms)
if stk is None:
stk = safe_float(row.get("strikePrice") or row.get("strike"))
if opt is None:
side_raw = str(row.get("side") or row.get("optionSide") or "").upper()
if side_raw in ("CALL", "C"):
opt = "C"
elif side_raw in ("PUT", "P"):
opt = "P"
if not inst_id or not y or stk is None or opt not in ("C", "P"):
continue
if exp_ms is None:
try:
exp_ms = expiry_ms_from_ymd(y)
except ValueError:
continue
unit = safe_float(row.get("unit") or row.get("ct_mult"))
out.append(
{
"inst_id": inst_id,
"expiry_ymd": y,
"expiry_ms": int(exp_ms),
"strike": float(stk),
"side": opt,
"ct_mult": float(unit) if unit and unit > 0 else 1.0,
}
)
return out
+155
View File
@@ -0,0 +1,155 @@
"""币安只读 RESTUSDT 永续 (fapi) + 欧洲期权 (eapi)。"""
from __future__ import annotations
from typing import Any
import httpx
from ..types import BookLevel
from .parse import rows_to_option_contracts, safe_float
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
def close(self) -> None:
self._fapi.close()
self._eapi.close()
def _get_json(self, client: httpx.Client, path: str, params: dict[str, Any] | None = None) -> Any:
r = client.get(path, params=params or {})
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")
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。"""
info = self.fetch_option_exchange_info()
rows = info.get("optionSymbols") or info.get("symbols") or []
want = (underlying or "ETHUSDT").strip().upper()
eth_mode = want in ("ETH", "ETHUSDT") or want.startswith("ETH")
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-") or 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]]:
return rows_to_option_contracts(self.fetch_option_instruments(family))
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})
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()}
)
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})
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_books(
self, inst_id: str, sz: int = 5
) -> tuple[list[BookLevel], list[BookLevel], int | None]:
from .parse import is_option_symbol
limit = max(5, min(int(sz), 100))
if is_option_symbol(inst_id):
body = self._get_json(
self._eapi, "/eapi/v1/depth", {"symbol": inst_id, "limit": limit}
)
else:
body = self._get_json(
self._fapi,
"/fapi/v1/depth",
{"symbol": inst_id.upper(), "limit": min(limit, 20)},
)
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
+204
View File
@@ -0,0 +1,204 @@
"""币安公共 WebSocketUSDT 永续 bookTicker + 期权 bookTicker。只读。"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
from urllib.parse import urlparse
import websockets
from websockets.asyncio.client import ClientConnection
from ..book_cache import BookCache
from .parse import is_option_symbol, safe_float
logger = logging.getLogger(__name__)
class BinancePublicWs:
def __init__(
self,
*,
futures_ws_base: str,
options_ws_base: str,
cache: BookCache,
proxy: str | None = None,
ping_interval: float = 20.0,
) -> None:
self.futures_ws_base = futures_ws_base.rstrip("/")
self.options_ws_base = options_ws_base.rstrip("/")
self.cache = cache
self.proxy = (proxy or "").strip() or None
self.ping_interval = ping_interval
self._inst_ids: list[str] = []
self._tasks: list[asyncio.Task[None]] = []
self._stop = asyncio.Event()
def set_instruments(self, inst_ids: list[str]) -> None:
self._inst_ids = [i for i in inst_ids if i]
def _split(self) -> tuple[list[str], list[str]]:
perps: list[str] = []
opts: list[str] = []
for i in self._inst_ids:
if is_option_symbol(i):
opts.append(i)
else:
perps.append(i.upper())
return perps, opts
async def start(self) -> None:
if self._tasks and any(not t.done() for t in self._tasks):
return
self._stop.clear()
await self._spawn()
async def stop(self) -> None:
self._stop.set()
for t in self._tasks:
t.cancel()
for t in self._tasks:
try:
await t
except asyncio.CancelledError:
pass
self._tasks = []
self.cache.set_connected(False)
async def resubscribe(self, inst_ids: list[str]) -> None:
self.set_instruments(inst_ids)
await self.stop()
self._stop.clear()
await self._spawn()
async def _spawn(self) -> None:
perps, opts = self._split()
self._tasks = []
if perps:
url = self._combined_url(self.futures_ws_base, [f"{p.lower()}@bookTicker" for p in perps])
self._tasks.append(
asyncio.create_task(self._run_forever(url, kind="futures"), name="bn-fapi-ws")
)
if opts:
streams = [f"{s}@bookTicker" for s in opts]
url = self._combined_url(self.options_ws_base, streams)
self._tasks.append(
asyncio.create_task(self._run_forever(url, kind="options"), name="bn-eapi-ws")
)
if not self._tasks:
self.cache.set_connected(False)
@staticmethod
def _combined_url(base: str, streams: list[str]) -> str:
# base like wss://fstream.binance.com/stream or .../eoptions/stream
if "/stream" in base:
root = base
else:
root = base.rstrip("/") + "/stream"
return root + "?streams=" + "/".join(streams)
async def _open_connection(self, url: str) -> ClientConnection:
if not self.proxy:
return await websockets.connect(
url,
ping_interval=None,
max_size=2**22,
open_timeout=20,
)
from python_socks.async_.asyncio import Proxy
parsed = urlparse(url)
host = parsed.hostname or "fstream.binance.com"
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
sock = await Proxy.from_url(self.proxy).connect(dest_host=host, dest_port=port)
return await websockets.connect(
url,
sock=sock,
server_hostname=host,
ping_interval=None,
max_size=2**22,
open_timeout=20,
)
async def _run_forever(self, url: str, *, kind: str) -> None:
backoff = 1.0
while not self._stop.is_set():
try:
async with await self._open_connection(url) as ws:
self.cache.set_connected(True)
backoff = 1.0
logger.info("Binance %s WS connected: %s", kind, url[:120])
waiter = asyncio.create_task(self._stop.wait())
reader = asyncio.create_task(self._read_loop(ws))
pinger = asyncio.create_task(self._ping_loop(ws))
done, pending = await asyncio.wait(
{waiter, reader, pinger},
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
for t in done:
exc = t.exception()
if exc and not isinstance(exc, asyncio.CancelledError):
raise exc
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning("Binance %s WS disconnected: %s", kind, e)
self.cache.set_connected(False)
try:
await asyncio.wait_for(self._stop.wait(), timeout=backoff)
break
except asyncio.TimeoutError:
backoff = min(backoff * 2, 30.0)
self.cache.set_connected(False)
async def _ping_loop(self, ws: ClientConnection) -> None:
while True:
await asyncio.sleep(self.ping_interval)
try:
await ws.ping()
except Exception:
return
async def _read_loop(self, ws: ClientConnection) -> None:
try:
async for raw in ws:
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="ignore")
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
data = msg.get("data") if isinstance(msg, dict) and "stream" in msg else msg
if isinstance(data, dict):
self._handle_event(data)
except websockets.exceptions.ConnectionClosed:
return
def _handle_event(self, data: dict[str, Any]) -> None:
et = str(data.get("e") or "")
sym = str(data.get("s") or "")
if not sym:
return
ts = safe_float(data.get("E") or data.get("T"))
ts_ms = int(ts) if ts is not None else None
if et in ("bookTicker", "") or ("b" in data and "a" in data and "s" in data):
bid = safe_float(data.get("b"))
ask = safe_float(data.get("a"))
bid_sz = safe_float(data.get("B"))
ask_sz = safe_float(data.get("A"))
if bid is not None or ask is not None:
self.cache.upsert_top(
sym,
bid=bid,
ask=ask,
bid_sz=bid_sz,
ask_sz=ask_sz,
ts_ms=ts_ms,
)
# 永续可用中间价近似 mark
if not is_option_symbol(sym) and bid and ask:
self.cache.set_mark_px(sym, (bid + ask) / 2.0, ts_ms=ts_ms)