"""行情网关:REST 对齐合约 + WS 推送盘口。""" from __future__ import annotations import asyncio import logging from typing import Any from ..config import Settings, get_settings from .book_cache import BookCache from .instruments import next_session_expiry_ymd, select_option_pair from .okx_rest import OkxRestClient from .okx_ws import OkxPublicWs from .types import MarketSnapshot, OptionPair logger = logging.getLogger(__name__) class MarketGateway: 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._pair: OptionPair | None = None self._refresh_task: asyncio.Task[None] | None = None self._started = False @property def pair(self) -> OptionPair | None: return self._pair async def start(self) -> None: if self._started: return self._started = True await asyncio.to_thread(self.align_instruments) await self.ws.start() self._refresh_task = asyncio.create_task(self._refresh_loop(), name="market-align") async def stop(self) -> None: self._started = False if self._refresh_task: self._refresh_task.cancel() try: await self._refresh_task except asyncio.CancelledError: pass self._refresh_task = None await self.ws.stop() self.rest.close() def align_instruments(self) -> OptionPair | None: """同步:拉期权列表,选次日到期 ATM Call/Put,REST 预热盘口,切换 WS 订阅。""" s = self.settings idx = self.rest.fetch_index_ticker(s.index_inst_id) mark = self.rest.fetch_mark_price(s.perp_inst_id) or idx if mark is None or mark <= 0: raise RuntimeError("无法获取 ETH 标记/指数价格,无法选 ATM") instruments = self.rest.fetch_option_instruments(s.option_inst_family) ymd = next_session_expiry_ymd() pair = select_option_pair(instruments, mark_px=float(mark), expiry_ymd=ymd) if pair is None: raise RuntimeError(f"未找到到期 {ymd} 的 ATM Call/Put 合约 pair (family={s.option_inst_family})") self._pair = pair self.cache.set_pair(pair) self.cache.set_index_px(idx) # REST 预热:永续 + Call + Put for inst in (s.perp_inst_id, pair.call_inst_id, pair.put_inst_id): 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 = {s.perp_inst_id, pair.call_inst_id, pair.put_inst_id} self.cache.drop_except(keep) self.ws.set_instruments([s.perp_inst_id, pair.call_inst_id, pair.put_inst_id]) logger.info( "aligned pair expiry=%s strike=%s call=%s put=%s mark=%.2f", pair.expiry_ymd, pair.strike, pair.call_inst_id, pair.put_inst_id, mark, ) return pair async def realign_async(self) -> OptionPair | None: old = self._pair pair = await asyncio.to_thread(self.align_instruments) if old is None or ( pair and ( pair.call_inst_id != old.call_inst_id or pair.put_inst_id != old.put_inst_id ) ): await self.ws.resubscribe( [ self.settings.perp_inst_id, pair.call_inst_id, pair.put_inst_id, ] ) return pair def snapshot(self) -> MarketSnapshot: return self.cache.snapshot(self.settings.perp_inst_id) def snapshot_dict(self) -> dict[str, Any]: return self.snapshot().to_dict() async def _refresh_loop(self) -> None: """周期性刷新指数价;跨日到期切换时重对齐。""" while True: await asyncio.sleep(30) try: idx = await asyncio.to_thread( self.rest.fetch_index_ticker, self.settings.index_inst_id ) self.cache.set_index_px(idx) want = next_session_expiry_ymd() if self._pair and self._pair.expiry_ymd != want: logger.info("expiry rollover %s -> %s", self._pair.expiry_ymd, want) await self.realign_async() except asyncio.CancelledError: raise except Exception as e: logger.warning("market refresh failed: %s", e) # 进程级单例(FastAPI lifespan 注入) _gateway: MarketGateway | None = None def get_gateway() -> MarketGateway: global _gateway if _gateway is None: _gateway = MarketGateway() return _gateway def set_gateway(gw: MarketGateway | None) -> None: global _gateway _gateway = gw