"""行情网关: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__) # 现价偏离当前行权价超过该点数则重选 ATM(ETH 期权常见步进 5) _ATM_DRIFT_POINTS = 5.0 def _has_open_position() -> bool: try: from ..models.db import get_db row = get_db().fetchone("SELECT status FROM positions WHERE id=1") return bool(row and row["status"] == "open") except Exception: return False 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 _mark_for_atm(self) -> float | None: snap = self.snapshot() if snap.perp and snap.perp.mark_px: return float(snap.perp.mark_px) if snap.index_px: return float(snap.index_px) if snap.perp and snap.perp.bid and snap.perp.ask: return (float(snap.perp.bid) + float(snap.perp.ask)) / 2 return None def atm_needs_realign(self, mark_px: float | None = None) -> bool: """到期日变了,或现价已偏离当前行权价超过阈值。""" if self._pair is None: return True want = next_session_expiry_ymd() if self._pair.expiry_ymd != want: return True mark = mark_px if mark_px is not None else self._mark_for_atm() if mark is None or mark <= 0: return False return abs(float(self._pair.strike) - float(mark)) >= _ATM_DRIFT_POINTS async def ensure_atm_async(self, *, force: bool = False) -> OptionPair | None: """空仓时按现价对齐 ATM。有持仓时不切换,避免盯市合约被换掉。""" if _has_open_position(): return self._pair if force or self.atm_needs_realign(): logger.info( "ATM realign force=%s old_strike=%s", force, self._pair.strike if self._pair else None, ) return await self.realign_async() return self._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: """周期性刷新指数价;空仓时按到期/ATM 偏离重对齐。""" 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) mark = await asyncio.to_thread( self.rest.fetch_mark_price, self.settings.perp_inst_id ) if mark: self.cache.set_mark_px(self.settings.perp_inst_id, mark) await self.ensure_atm_async(force=False) 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