Split exchange and strategy modules for future Binance support.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-25 08:43:09 +08:00
parent 3957a83761
commit e19bb452f9
27 changed files with 1504 additions and 1048 deletions
+16
View File
@@ -0,0 +1,16 @@
"""交易所模块:OKX 已接入,币安占位。策略不直接依赖具体交易所。"""
from .factory import build_exchange, get_exchange, set_exchange
from .protocol import ExchangeMarket
from .types import BookLevel, MarketSnapshot, OptionPair, Quote
__all__ = [
"BookLevel",
"ExchangeMarket",
"MarketSnapshot",
"OptionPair",
"Quote",
"build_exchange",
"get_exchange",
"set_exchange",
]
+3
View File
@@ -0,0 +1,3 @@
from .adapter import BinanceExchange
__all__ = ["BinanceExchange"]
+62
View File
@@ -0,0 +1,62 @@
"""币安交易所适配器占位:后期接入,接口与 OKX 对齐。"""
from __future__ import annotations
from typing import Any, Sequence
from ...config import Settings, get_settings
from ..types import BookLevel, MarketSnapshot, OptionPair, Quote
class BinanceExchange:
name = "binance"
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or get_settings()
async def start(self) -> None:
raise NotImplementedError("币安交易所模块尚未接入,请配置 EXCHANGE=okx")
async def stop(self) -> None:
return
def list_option_contracts(self, family: str) -> list[dict[str, Any]]:
raise NotImplementedError("BinanceExchange.list_option_contracts")
def fetch_index(self, index_id: str) -> float | None:
raise NotImplementedError("BinanceExchange.fetch_index")
def fetch_mark(self, inst_id: str) -> float | None:
raise NotImplementedError("BinanceExchange.fetch_mark")
def fetch_book(
self, inst_id: str, depth: int = 5
) -> tuple[list[BookLevel], list[BookLevel], int | None]:
raise NotImplementedError("BinanceExchange.fetch_book")
def get_ct_mult(self, option_inst_id: str, family: str, default: float) -> float:
return float(default)
def set_pair(self, pair: OptionPair | None) -> None:
raise NotImplementedError("BinanceExchange.set_pair")
def warm_and_subscribe(self, inst_ids: Sequence[str]) -> None:
raise NotImplementedError("BinanceExchange.warm_and_subscribe")
async def resubscribe(self, inst_ids: Sequence[str]) -> None:
raise NotImplementedError("BinanceExchange.resubscribe")
def quote(self, inst_id: str) -> Quote | None:
return None
def snapshot(self, perp_inst_id: str) -> MarketSnapshot:
raise NotImplementedError("BinanceExchange.snapshot")
def snapshot_dict(self, perp_inst_id: str) -> dict[str, Any]:
raise NotImplementedError("BinanceExchange.snapshot_dict")
def set_index_px(self, px: float | None) -> None:
return
def set_mark_px(self, inst_id: str, mark_px: float | None) -> None:
return
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
import threading
import time
from typing import Iterable
from .types import BookLevel, MarketSnapshot, OptionPair, Quote
class BookCache:
"""内存盘口缓存:永续 + Call/Put。线程安全。"""
def __init__(self) -> None:
self._lock = threading.RLock()
self._quotes: dict[str, Quote] = {}
self._index_px: float | None = None
self._pair: OptionPair | None = None
self._connected = False
self._updated_at_ms: int | None = None
def set_connected(self, ok: bool) -> None:
with self._lock:
self._connected = bool(ok)
def set_pair(self, pair: OptionPair | None) -> None:
with self._lock:
self._pair = pair
def set_index_px(self, px: float | None) -> None:
with self._lock:
if px is not None and px > 0:
self._index_px = float(px)
self._touch()
def upsert_book(
self,
inst_id: str,
*,
bids: list[BookLevel],
asks: list[BookLevel],
ts_ms: int | None = None,
) -> None:
with self._lock:
q = self._quotes.get(inst_id) or Quote(inst_id=inst_id)
q.bids = bids
q.asks = asks
q.bid = bids[0].px if bids else None
q.ask = asks[0].px if asks else None
q.bid_sz = bids[0].sz if bids else None
q.ask_sz = asks[0].sz if asks else None
if ts_ms is not None:
q.ts_ms = ts_ms
self._quotes[inst_id] = q
self._touch(ts_ms)
def upsert_top(
self,
inst_id: str,
*,
bid: float | None,
ask: float | None,
bid_sz: float | None = None,
ask_sz: float | None = None,
ts_ms: int | None = None,
) -> None:
with self._lock:
q = self._quotes.get(inst_id) or Quote(inst_id=inst_id)
if bid is not None:
q.bid = bid
if ask is not None:
q.ask = ask
if bid_sz is not None:
q.bid_sz = bid_sz
if ask_sz is not None:
q.ask_sz = ask_sz
if ts_ms is not None:
q.ts_ms = ts_ms
# 同步一层盘口,便于 snapshot 展示
if bid is not None and bid_sz is not None:
q.bids = [BookLevel(px=bid, sz=bid_sz)] + q.bids[1:]
if ask is not None and ask_sz is not None:
q.asks = [BookLevel(px=ask, sz=ask_sz)] + q.asks[1:]
self._quotes[inst_id] = q
self._touch(ts_ms)
def set_mark_px(self, inst_id: str, mark_px: float | None, ts_ms: int | None = None) -> None:
with self._lock:
if mark_px is None or mark_px <= 0:
return
q = self._quotes.get(inst_id) or Quote(inst_id=inst_id)
q.mark_px = float(mark_px)
if ts_ms is not None:
q.ts_ms = ts_ms
self._quotes[inst_id] = q
self._touch(ts_ms)
def get(self, inst_id: str) -> Quote | None:
with self._lock:
return self._quotes.get(inst_id)
def drop_except(self, keep: Iterable[str]) -> None:
keep_set = set(keep)
with self._lock:
for k in list(self._quotes):
if k not in keep_set:
del self._quotes[k]
def snapshot(self, perp_inst_id: str) -> MarketSnapshot:
with self._lock:
pair = self._pair
call = self._quotes.get(pair.call_inst_id) if pair else None
put = self._quotes.get(pair.put_inst_id) if pair else None
return MarketSnapshot(
perp=self._quotes.get(perp_inst_id),
call=call,
put=put,
index_px=self._index_px,
pair=pair,
connected=self._connected,
updated_at_ms=self._updated_at_ms,
)
def _touch(self, ts_ms: int | None = None) -> None:
self._updated_at_ms = int(ts_ms) if ts_ms is not None else int(time.time() * 1000)
+34
View File
@@ -0,0 +1,34 @@
"""按配置创建交易所实例。"""
from __future__ import annotations
from ..config import Settings, get_settings
from .protocol import ExchangeMarket
_exchange: ExchangeMarket | None = None
def build_exchange(settings: Settings | None = None) -> ExchangeMarket:
s = settings or get_settings()
name = (s.exchange or "okx").strip().lower()
if name == "okx":
from .okx.adapter import OkxExchange
return OkxExchange(s)
if name in ("binance", "bn"):
from .binance.adapter import BinanceExchange
return BinanceExchange(s)
raise ValueError(f"未知交易所 EXCHANGE={s.exchange!r},支持 okx / binance")
def get_exchange() -> ExchangeMarket:
global _exchange
if _exchange is None:
_exchange = build_exchange()
return _exchange
def set_exchange(ex: ExchangeMarket | None) -> None:
global _exchange
_exchange = ex
+3
View File
@@ -0,0 +1,3 @@
from .adapter import OkxExchange
__all__ = ["OkxExchange"]
+108
View File
@@ -0,0 +1,108 @@
"""OKX 交易所适配器:只负责行情与合约,不含策略选约。"""
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 rows_to_option_contracts, safe_float
from .rest import OkxRestClient
from .ws import OkxPublicWs
logger = logging.getLogger(__name__)
class OkxExchange:
name = "okx"
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or get_settings()
self.cache = BookCache()
proxy = self.settings.okx_http_proxy or None
self.rest = OkxRestClient(self.settings.okx_rest_base, proxy=proxy)
self.ws = OkxPublicWs(self.settings.okx_ws_public, self.cache, proxy=proxy)
self._started = False
self._ct_cache: dict[str, float] = {}
async def start(self) -> None:
if self._started:
return
self._started = True
await self.ws.start()
logger.info("OKX exchange started")
async def stop(self) -> None:
self._started = False
await self.ws.stop()
self.rest.close()
logger.info("OKX exchange stopped")
def list_option_contracts(self, family: str) -> list[dict[str, Any]]:
rows = self.rest.fetch_option_instruments(family)
contracts = rows_to_option_contracts(rows)
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:
return self.rest.fetch_index_ticker(index_id)
def fetch_mark(self, inst_id: str) -> float | None:
return self.rest.fetch_mark_price(inst_id)
def fetch_book(
self, inst_id: str, depth: int = 5
) -> tuple[list[BookLevel], list[BookLevel], int | None]:
return self.rest.fetch_books(inst_id, sz=depth)
def get_ct_mult(self, option_inst_id: str, family: str, default: float) -> float:
if option_inst_id in self._ct_cache:
return self._ct_cache[option_inst_id]
try:
rows = self.rest.fetch_instruments(inst_type="OPTION", inst_family=family)
for r in rows:
if str(r.get("instId")) == option_inst_id:
m = safe_float(r.get("ctMult"))
if m and m > 0:
self._ct_cache[option_inst_id] = float(m)
return float(m)
except Exception:
pass
return float(default)
def set_pair(self, pair: OptionPair | None) -> None:
self.cache.set_pair(pair)
def warm_and_subscribe(self, inst_ids: Sequence[str]) -> None:
ids = [i for i in inst_ids if i]
for inst in ids:
bids, asks, ts = self.rest.fetch_books(inst, sz=5)
self.cache.upsert_book(inst, bids=bids, asks=asks, ts_ms=ts)
mp = self.rest.fetch_mark_price(inst)
if mp:
self.cache.set_mark_px(inst, mp)
keep = set(ids)
self.cache.drop_except(keep)
self.ws.set_instruments(ids)
async def resubscribe(self, inst_ids: Sequence[str]) -> None:
await self.ws.resubscribe([i for i in inst_ids if i])
def quote(self, inst_id: str) -> Quote | None:
return self.cache.get(inst_id)
def snapshot(self, perp_inst_id: str) -> MarketSnapshot:
return self.cache.snapshot(perp_inst_id)
def snapshot_dict(self, perp_inst_id: str) -> dict[str, Any]:
return self.snapshot(perp_inst_id).to_dict()
def set_index_px(self, px: float | None) -> None:
self.cache.set_index_px(px)
def set_mark_px(self, inst_id: str, mark_px: float | None) -> None:
self.cache.set_mark_px(inst_id, mark_px)
+76
View File
@@ -0,0 +1,76 @@
"""OKX 合约 ID / 到期解析(交易所专属)。"""
from __future__ import annotations
import re
from datetime import datetime, timezone
from typing import Any
_DATE_RE = re.compile(r"^\d{6}$")
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_inst_id(inst_id: str) -> tuple[str | None, float | None, str | None]:
"""ETH-USD_UM-YYMMDD-STRIKE-C → (YYMMDD, strike, C|P)."""
parts = (inst_id or "").strip().split("-")
if len(parts) < 5:
return None, None, None
ymd = parts[-3]
strike = safe_float(parts[-2])
opt = parts[-1].upper()
if not _DATE_RE.fullmatch(ymd) or strike is None or opt not in ("C", "P"):
return None, None, None
return ymd, strike, opt
def expiry_ms_from_ymd(ymd: str) -> int:
"""OKX 期权到期:当日 08:00 UTC = 上海 16:00。"""
yy, mm, dd = int(ymd[0:2]), int(ymd[2:4]), int(ymd[4:6])
dt = datetime(2000 + yy, mm, dd, 8, 0, 0, tzinfo=timezone.utc)
return int(dt.timestamp() * 1000)
def rows_to_option_contracts(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
归一化为策略层可用的中性结构:
{inst_id, expiry_ymd, strike, side, ct_mult}
"""
out: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
continue
state = str(row.get("state") or "live").lower()
if state and state != "live":
continue
inst_id = str(row.get("instId") or "")
y, stk, opt = parse_option_inst_id(inst_id)
if y is None or stk is None or opt is None:
exp = safe_float(row.get("expTime"))
if exp:
ms = int(exp) if exp > 10_000_000_000 else int(exp * 1000)
y = datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%y%m%d")
stk = safe_float(row.get("stk"))
opt_raw = str(row.get("optType") or "").upper()
opt = opt_raw if opt_raw in ("C", "P") else None
if not inst_id or not y or stk is None or opt not in ("C", "P"):
continue
ct = safe_float(row.get("ctMult"))
out.append(
{
"inst_id": inst_id,
"expiry_ymd": y,
"expiry_ms": expiry_ms_from_ymd(y),
"strike": float(stk),
"side": opt,
"ct_mult": float(ct) if ct and ct > 0 else None,
}
)
return out
+99
View File
@@ -0,0 +1,99 @@
"""OKX REST 只读行情。不调用任何交易类接口。"""
from __future__ import annotations
from typing import Any
import httpx
from ..types import BookLevel
from .parse import safe_float
class OkxRestClient:
def __init__(
self,
base_url: str = "https://www.okx.com",
timeout: float = 15.0,
proxy: str | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.proxy = (proxy or "").strip() or None
self._client = httpx.Client(
base_url=self.base_url,
timeout=timeout,
proxy=self.proxy,
headers={"Accept": "application/json", "User-Agent": "eth-hedge-sim/0.1"},
)
def close(self) -> None:
self._client.close()
def __enter__(self) -> OkxRestClient:
return self
def __exit__(self, *args: object) -> None:
self.close()
def _get(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
r = self._client.get(path, params=params or {})
r.raise_for_status()
body = r.json()
if str(body.get("code")) != "0":
raise RuntimeError(f"OKX REST error code={body.get('code')} msg={body.get('msg')}")
data = body.get("data") or []
return [x for x in data if isinstance(x, dict)]
def fetch_instruments(self, *, inst_type: str, inst_family: str | None = None) -> list[dict[str, Any]]:
params: dict[str, Any] = {"instType": inst_type}
if inst_family:
params["instFamily"] = inst_family
return self._get("/api/v5/public/instruments", params)
def fetch_option_instruments(self, inst_family: str) -> list[dict[str, Any]]:
rows = self.fetch_instruments(inst_type="OPTION", inst_family=inst_family)
return [r for r in rows if str(r.get("state") or "").lower() == "live"]
def fetch_index_ticker(self, inst_id: str) -> float | None:
rows = self._get("/api/v5/market/index-tickers", {"instId": inst_id})
if not rows:
return None
return safe_float(rows[0].get("idxPx"))
def fetch_mark_price(self, inst_id: str) -> float | None:
rows = self._get("/api/v5/public/mark-price", {"instId": inst_id})
if not rows:
t = self._get("/api/v5/market/ticker", {"instId": inst_id})
if not t:
return None
return safe_float(t[0].get("markPx")) or safe_float(t[0].get("last"))
return safe_float(rows[0].get("markPx"))
def fetch_books(self, inst_id: str, sz: int = 5) -> tuple[list[BookLevel], list[BookLevel], int | None]:
rows = self._get(
"/api/v5/market/books",
{"instId": inst_id, "sz": str(max(1, min(int(sz), 400)))},
)
if not rows:
return [], [], None
row = rows[0]
ts = safe_float(row.get("ts"))
ts_ms = int(ts) if ts is not None else None
return (
_levels(row.get("bids") or []),
_levels(row.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
+207
View File
@@ -0,0 +1,207 @@
"""OKX 公共 WebSocket:永续 + 期权 books5 / mark-price。只读。"""
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 ..types import BookLevel
from .parse import safe_float
logger = logging.getLogger(__name__)
class OkxPublicWs:
def __init__(
self,
url: str,
cache: BookCache,
*,
proxy: str | None = None,
ping_interval: float = 20.0,
) -> None:
self.url = url
self.cache = cache
self.proxy = (proxy or "").strip() or None
self.ping_interval = ping_interval
self._inst_ids: list[str] = []
self._task: asyncio.Task[None] | None = None
self._stop = asyncio.Event()
self._subscribed: set[str] = set()
def set_instruments(self, inst_ids: list[str]) -> None:
self._inst_ids = [i for i in inst_ids if i]
async def start(self) -> None:
if self._task and not self._task.done():
return
self._stop.clear()
self._task = asyncio.create_task(self._run_forever(), name="okx-public-ws")
async def stop(self) -> None:
self._stop.set()
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
self.cache.set_connected(False)
async def resubscribe(self, inst_ids: list[str]) -> None:
self.set_instruments(inst_ids)
self._stop.set()
await asyncio.sleep(0)
self._stop.clear()
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = asyncio.create_task(self._run_forever(), name="okx-public-ws")
async def _open_connection(self) -> ClientConnection:
if not self.proxy:
return await websockets.connect(
self.url,
ping_interval=None,
max_size=2**22,
open_timeout=20,
)
from python_socks.async_.asyncio import Proxy
parsed = urlparse(self.url)
host = parsed.hostname or "ws.okx.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(
self.url,
sock=sock,
server_hostname=host,
ping_interval=None,
max_size=2**22,
open_timeout=20,
)
async def _run_forever(self) -> None:
backoff = 1.0
while not self._stop.is_set():
try:
async with await self._open_connection() as ws:
self.cache.set_connected(True)
backoff = 1.0
await self._subscribe(ws)
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("OKX WS disconnected: %s", 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 _subscribe(self, ws: ClientConnection) -> None:
args: list[dict[str, str]] = []
for inst in self._inst_ids:
args.append({"channel": "books5", "instId": inst})
args.append({"channel": "mark-price", "instId": inst})
if not args:
return
payload = {"op": "subscribe", "args": args}
await ws.send(json.dumps(payload))
self._subscribed = {a["instId"] for a in args}
logger.info("OKX WS subscribed: %s", sorted(self._subscribed))
async def _ping_loop(self, ws: ClientConnection) -> None:
while True:
await asyncio.sleep(self.ping_interval)
await ws.send("ping")
async def _read_loop(self, ws: ClientConnection) -> None:
try:
async for raw in ws:
if raw == "pong":
continue
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="ignore")
if raw == "ping":
await ws.send("pong")
continue
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
self._handle_message(msg)
except websockets.exceptions.ConnectionClosed:
return
def _handle_message(self, msg: dict[str, Any]) -> None:
if msg.get("event") in ("subscribe", "error", "channel-conn-count"):
if msg.get("event") == "error":
logger.error("OKX WS error: %s", msg)
return
arg = msg.get("arg") or {}
channel = str(arg.get("channel") or "")
inst_id = str(arg.get("instId") or "")
data = msg.get("data") or []
if not inst_id or not data:
return
row = data[0] if isinstance(data[0], dict) else None
if row is None:
return
if channel == "books5":
ts = safe_float(row.get("ts"))
self.cache.upsert_book(
inst_id,
bids=_levels(row.get("bids") or []),
asks=_levels(row.get("asks") or []),
ts_ms=int(ts) if ts is not None else None,
)
elif channel == "mark-price":
ts = safe_float(row.get("ts"))
self.cache.set_mark_px(
inst_id,
safe_float(row.get("markPx")),
ts_ms=int(ts) if ts is not None else None,
)
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
+48
View File
@@ -0,0 +1,48 @@
"""交易所行情适配器协议:策略/撮合只依赖此接口,不直接碰 OKX/币安。"""
from __future__ import annotations
from typing import Any, Protocol, Sequence, runtime_checkable
from .types import BookLevel, MarketSnapshot, OptionPair, Quote
@runtime_checkable
class ExchangeMarket(Protocol):
name: str
async def start(self) -> None: ...
async def stop(self) -> None: ...
def list_option_contracts(self, family: str) -> list[dict[str, Any]]:
"""中性期权合约列表:inst_id/expiry_ymd/strike/side/ct_mult。"""
...
def fetch_index(self, index_id: str) -> float | None: ...
def fetch_mark(self, inst_id: str) -> float | None: ...
def fetch_book(
self, inst_id: str, depth: int = 5
) -> tuple[list[BookLevel], list[BookLevel], int | None]: ...
def get_ct_mult(self, option_inst_id: str, family: str, default: float) -> float: ...
def set_pair(self, pair: OptionPair | None) -> None: ...
def warm_and_subscribe(self, inst_ids: Sequence[str]) -> None:
"""REST 预热盘口 + 设置 WS 订阅列表。"""
...
async def resubscribe(self, inst_ids: Sequence[str]) -> None: ...
def quote(self, inst_id: str) -> Quote | None: ...
def snapshot(self, perp_inst_id: str) -> MarketSnapshot: ...
def snapshot_dict(self, perp_inst_id: str) -> dict[str, Any]: ...
def set_index_px(self, px: float | None) -> None: ...
def set_mark_px(self, inst_id: str, mark_px: float | None) -> None: ...
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class BookLevel:
px: float
sz: float # OKX 张数 / 合约张数口径
@dataclass(slots=True)
class Quote:
inst_id: str
bid: float | None = None
ask: float | None = None
bid_sz: float | None = None
ask_sz: float | None = None
mark_px: float | None = None
ts_ms: int | None = None
bids: list[BookLevel] = field(default_factory=list)
asks: list[BookLevel] = field(default_factory=list)
def to_dict(self, *, depth: int = 5) -> dict[str, Any]:
return {
"inst_id": self.inst_id,
"bid": self.bid,
"ask": self.ask,
"bid_sz": self.bid_sz,
"ask_sz": self.ask_sz,
"mark_px": self.mark_px,
"ts_ms": self.ts_ms,
"bids": [{"px": x.px, "sz": x.sz} for x in self.bids[:depth]],
"asks": [{"px": x.px, "sz": x.sz} for x in self.asks[:depth]],
}
@dataclass(slots=True)
class OptionPair:
expiry_ymd: str # YYMMDD
expiry_ms: int
strike: float
call_inst_id: str
put_inst_id: str
def to_dict(self) -> dict[str, Any]:
return {
"expiry_ymd": self.expiry_ymd,
"expiry_ms": self.expiry_ms,
"strike": self.strike,
"call_inst_id": self.call_inst_id,
"put_inst_id": self.put_inst_id,
}
@dataclass(slots=True)
class MarketSnapshot:
perp: Quote | None
call: Quote | None
put: Quote | None
index_px: float | None
pair: OptionPair | None
connected: bool
updated_at_ms: int | None
def to_dict(self) -> dict[str, Any]:
return {
"connected": self.connected,
"updated_at_ms": self.updated_at_ms,
"index_px": self.index_px,
"pair": self.pair.to_dict() if self.pair else None,
"perp": self.perp.to_dict() if self.perp else None,
"call": self.call.to_dict() if self.call else None,
"put": self.put.to_dict() if self.put else None,
"ask_compare": {
"call_ask": self.call.ask if self.call else None,
"put_ask": self.put.ask if self.put else None,
"bias": _ask_bias(self.call, self.put),
},
}
def _ask_bias(call: Quote | None, put: Quote | None) -> str:
"""卖一比价仅用于选向展示;相等则 wait。"""
ca = call.ask if call else None
pa = put.ask if put else None
if ca is None or pa is None:
return "unknown"
if ca > pa:
return "call_ask_gt_put" # 永续多 + 期权空(腿待拍板)
if ca < pa:
return "put_ask_gt_call" # 永续空 + 期权多(腿待拍板)
return "equal"