Files
eth_hedge_sim/backend/app/market/gateway.py
T
2026-07-25 07:52:37 +08:00

201 lines
7.0 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__)
# 现价偏离当前行权价超过该点数则重选 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