3957a83761
Co-authored-by: Cursor <cursoragent@cursor.com>
331 lines
11 KiB
Python
331 lines
11 KiB
Python
"""行情网关: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
|