"""行情网关:REST 对齐合约 + WS 推送盘口。""" from __future__ import annotations import asyncio import logging from dataclasses import dataclass from typing import Any from ..config import Settings, get_settings from .book_cache import BookCache from .instruments import ( hours_until_expiry, list_eligible_expiry_ymds, option_leverage, select_option_pair, ) from .okx_rest import OkxRestClient from .okx_ws import OkxPublicWs from .types import MarketSnapshot, OptionPair logger = logging.getLogger(__name__) # 展示用:现价偏离当前行权超过该点数则重选 ATM(空仓) _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 def _strategy_floats() -> tuple[float, float]: """(min_option_hours, min_option_leverage)""" s = get_settings() try: from ..models.db import get_db db = get_db() hours = float( db.get_setting("min_option_hours", str(s.min_option_hours)) or s.min_option_hours ) lev = float( db.get_setting("min_option_leverage", str(s.min_option_leverage)) or s.min_option_leverage ) return hours, lev except Exception: return s.min_option_hours, s.min_option_leverage @dataclass(slots=True) class OpenPick: pair: OptionPair option_side: str perp_side: str bias: str call_ask: float put_ask: float option_ask: float option_leverage: float hours_left: float underlying_px: float 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 _apply_pair(self, pair: OptionPair, *, mark: float, idx: float | None) -> OptionPair: s = self.settings self._pair = pair self.cache.set_pair(pair) if idx is not None: self.cache.set_index_px(idx) 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 hours=%.1f", pair.expiry_ymd, pair.strike, pair.call_inst_id, pair.put_inst_id, mark, hours_until_expiry(pair.expiry_ymd), ) return pair def align_instruments(self) -> OptionPair | None: """空仓展示:选剩余时长合格的最近到期 ATM(不校验期权杠杆)。""" 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") min_hours, _ = _strategy_floats() instruments = self.rest.fetch_option_instruments(s.option_inst_family) pair = select_option_pair( instruments, mark_px=float(mark), min_hours=min_hours ) if pair is None: raise RuntimeError( f"未找到剩余≥{min_hours}h 的 ATM Call/Put (family={s.option_inst_family})" ) return self._apply_pair(pair, mark=float(mark), idx=idx) def pick_for_open(self) -> OpenPick | None: """ 开仓选约: 1) 剩余时长 ≥ min_hours 的到期日(由近到远) 2) 该到期 ATM 平值 3) 卖一比价定方向后校验 现价/卖一 ≥ min_option_leverage """ s = self.settings min_hours, min_lev = _strategy_floats() 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: return None underlying = float(mark) instruments = self.rest.fetch_option_instruments(s.option_inst_family) eligible = list_eligible_expiry_ymds(instruments, min_hours=min_hours) if not eligible: logger.info("no expiry with hours>=%.1f", min_hours) return None from ..strategy.signal import decide for ymd in eligible: pair = select_option_pair( instruments, mark_px=underlying, expiry_ymd=ymd ) if pair is None: continue call_bids, call_asks, _ = self.rest.fetch_books(pair.call_inst_id, sz=5) put_bids, put_asks, _ = self.rest.fetch_books(pair.put_inst_id, sz=5) call_ask = call_asks[0].px if call_asks else None put_ask = put_asks[0].px if put_asks else None sig = decide(call_ask, put_ask) if sig is None: continue opt_ask = sig.call_ask if sig.option_side == "call" else sig.put_ask lev = option_leverage(underlying, opt_ask) hours_left = hours_until_expiry(ymd) if lev is None or lev + 1e-9 < min_lev: logger.info( "skip expiry=%s strike=%.0f side=%s lev=%s need>=%.0f hours=%.1f", ymd, pair.strike, sig.option_side, f"{lev:.1f}" if lev else "n/a", min_lev, hours_left, ) continue self._apply_pair(pair, mark=underlying, idx=idx) # 写入刚拉的盘口,避免 WS 尚未推送 self.cache.upsert_book(pair.call_inst_id, bids=call_bids, asks=call_asks) self.cache.upsert_book(pair.put_inst_id, bids=put_bids, asks=put_asks) return OpenPick( pair=pair, option_side=sig.option_side, perp_side=sig.perp_side, bias=sig.bias, call_ask=float(sig.call_ask), put_ask=float(sig.put_ask), option_ask=float(opt_ask), option_leverage=float(lev), hours_left=hours_left, underlying_px=underlying, ) return None 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 async def pick_for_open_async(self) -> OpenPick | None: old = self._pair pick = await asyncio.to_thread(self.pick_for_open) if pick and ( old is None or pick.pair.call_inst_id != old.call_inst_id or pick.pair.put_inst_id != old.put_inst_id ): await self.ws.resubscribe( [ self.settings.perp_inst_id, pick.pair.call_inst_id, pick.pair.put_inst_id, ] ) return pick 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 min_hours, _ = _strategy_floats() if hours_until_expiry(self._pair.expiry_ymd) + 1e-9 < min_hours: 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 old_exp=%s", force, self._pair.strike if self._pair else None, self._pair.expiry_ymd 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: 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) _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