e51f357b48
Co-authored-by: Cursor <cursoragent@cursor.com>
151 lines
5.2 KiB
Python
151 lines
5.2 KiB
Python
"""行情网关: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
|