Split exchange and strategy modules for future Binance support.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import time
|
||||
from typing import Any
|
||||
|
||||
from ..config import get_settings
|
||||
from ..market import get_gateway
|
||||
from .session import get_session
|
||||
from ..models.db import get_db
|
||||
from ..sim.ledger import Ledger
|
||||
from ..sim.matcher import Matcher
|
||||
@@ -131,7 +131,7 @@ class StrategyEngine:
|
||||
continue
|
||||
async with self._lock:
|
||||
try:
|
||||
await get_gateway().ensure_atm_async(force=False)
|
||||
await get_session().ensure_atm_async(force=False)
|
||||
except Exception as e:
|
||||
logger.warning("ATM ensure before tick failed: %s", e)
|
||||
await self._tick_async()
|
||||
@@ -191,8 +191,7 @@ class StrategyEngine:
|
||||
self._set_state(phase="idle")
|
||||
|
||||
self._set_state(phase="wait_signal")
|
||||
gw = get_gateway()
|
||||
pick = await gw.pick_for_open_async()
|
||||
pick = await get_session().pick_for_open_async()
|
||||
if pick is None:
|
||||
self._set_state(
|
||||
last_error="无合格期权:需剩余时长与杠杆倍数同时满足"
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""策略选约:剩余时长 + ATM 平值 + 期权杠杆(交易所无关)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from ..exchange.types import OptionPair
|
||||
|
||||
_SH = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def hours_until_ms(expiry_ms: int, now: datetime | None = None) -> float:
|
||||
n = (now or datetime.now(tz=_SH)).astimezone(_SH)
|
||||
return (int(expiry_ms) - int(n.timestamp() * 1000)) / 3_600_000.0
|
||||
|
||||
|
||||
def hours_until_expiry(
|
||||
ymd: str,
|
||||
now: datetime | None = None,
|
||||
*,
|
||||
expiry_ms: int | None = None,
|
||||
) -> float:
|
||||
if expiry_ms is not None:
|
||||
return hours_until_ms(expiry_ms, now)
|
||||
# 兼容测试:无 ms 时按 OKX 惯例(UTC 08:00)推算
|
||||
from ..exchange.okx.parse import expiry_ms_from_ymd
|
||||
|
||||
return hours_until_ms(expiry_ms_from_ymd(ymd), now)
|
||||
|
||||
|
||||
def next_session_expiry_ymd(now: datetime | None = None) -> str:
|
||||
now_sh = (now or datetime.now(tz=_SH)).astimezone(_SH)
|
||||
open_today = now_sh.replace(hour=16, minute=0, second=0, microsecond=0)
|
||||
if now_sh >= open_today:
|
||||
target = now_sh.date() + timedelta(days=1)
|
||||
else:
|
||||
target = now_sh.date()
|
||||
return target.strftime("%y%m%d")
|
||||
|
||||
|
||||
def pick_atm_strike(strikes: list[float], mark_px: float) -> float | None:
|
||||
if not strikes or mark_px <= 0:
|
||||
return None
|
||||
return min(strikes, key=lambda s: (abs(s - mark_px), s))
|
||||
|
||||
|
||||
def option_leverage(underlying_px: float, premium_ask: float) -> float | None:
|
||||
if underlying_px <= 0 or premium_ask is None or premium_ask <= 0:
|
||||
return None
|
||||
return float(underlying_px) / float(premium_ask)
|
||||
|
||||
|
||||
def _complete_by_expiry(
|
||||
contracts: list[dict[str, Any]],
|
||||
) -> dict[str, tuple[int, dict[float, dict[str, str]]]]:
|
||||
"""ymd -> (expiry_ms, strike -> {C|P: instId})"""
|
||||
by_exp: dict[str, dict[float, dict[str, str]]] = {}
|
||||
ms_map: dict[str, int] = {}
|
||||
for c in contracts:
|
||||
y = str(c.get("expiry_ymd") or "")
|
||||
stk = c.get("strike")
|
||||
opt = str(c.get("side") or "").upper()
|
||||
inst_id = str(c.get("inst_id") or "")
|
||||
if not y or stk is None or opt not in ("C", "P") or not inst_id:
|
||||
continue
|
||||
by_exp.setdefault(y, {}).setdefault(float(stk), {})[opt] = inst_id
|
||||
if c.get("expiry_ms") is not None:
|
||||
ms_map[y] = int(c["expiry_ms"])
|
||||
out: dict[str, tuple[int, dict[float, dict[str, str]]]] = {}
|
||||
for ymd, strikes in by_exp.items():
|
||||
complete = {s: v for s, v in strikes.items() if "C" in v and "P" in v}
|
||||
if not complete:
|
||||
continue
|
||||
if ymd in ms_map:
|
||||
ems = ms_map[ymd]
|
||||
else:
|
||||
from ..exchange.okx.parse import expiry_ms_from_ymd
|
||||
|
||||
ems = expiry_ms_from_ymd(ymd)
|
||||
out[ymd] = (ems, complete)
|
||||
return out
|
||||
|
||||
|
||||
def list_eligible_expiry_ymds(
|
||||
contracts: list[dict[str, Any]],
|
||||
*,
|
||||
min_hours: float,
|
||||
now: datetime | None = None,
|
||||
) -> list[str]:
|
||||
complete = _complete_by_expiry(contracts)
|
||||
eligible = [
|
||||
ymd
|
||||
for ymd, (ems, _) in complete.items()
|
||||
if hours_until_ms(ems, now) + 1e-9 >= float(min_hours)
|
||||
]
|
||||
return sorted(eligible, key=lambda y: complete[y][0])
|
||||
|
||||
|
||||
def select_option_pair(
|
||||
contracts: list[dict[str, Any]],
|
||||
*,
|
||||
mark_px: float,
|
||||
expiry_ymd: str | None = None,
|
||||
min_hours: float | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> OptionPair | None:
|
||||
complete = _complete_by_expiry(contracts)
|
||||
if not complete:
|
||||
return None
|
||||
|
||||
if expiry_ymd:
|
||||
ymd = expiry_ymd
|
||||
if ymd not in complete:
|
||||
return None
|
||||
elif min_hours is not None:
|
||||
eligible = list_eligible_expiry_ymds(contracts, min_hours=min_hours, now=now)
|
||||
if not eligible:
|
||||
return None
|
||||
ymd = eligible[0]
|
||||
else:
|
||||
ymd = next_session_expiry_ymd(now)
|
||||
if ymd not in complete:
|
||||
eligible = list_eligible_expiry_ymds(contracts, min_hours=0, now=now)
|
||||
if not eligible:
|
||||
return None
|
||||
ymd = eligible[0]
|
||||
|
||||
ems, strikes_map = complete[ymd]
|
||||
atm = pick_atm_strike(list(strikes_map.keys()), mark_px)
|
||||
if atm is None:
|
||||
return None
|
||||
legs = strikes_map[atm]
|
||||
return OptionPair(
|
||||
expiry_ymd=ymd,
|
||||
expiry_ms=ems,
|
||||
strike=atm,
|
||||
call_inst_id=legs["C"],
|
||||
put_inst_id=legs["P"],
|
||||
)
|
||||
|
||||
|
||||
def normalize_contracts(contracts_or_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""若已是中性结构则原样返回;否则按 OKX 原始行解析(测试兼容)。"""
|
||||
if not contracts_or_rows:
|
||||
return []
|
||||
sample = contracts_or_rows[0]
|
||||
if "inst_id" in sample and "expiry_ymd" in sample:
|
||||
return contracts_or_rows
|
||||
from ..exchange.okx.parse import rows_to_option_contracts
|
||||
|
||||
return rows_to_option_contracts(contracts_or_rows)
|
||||
@@ -0,0 +1,336 @@
|
||||
"""策略行情会话:在交易所适配器之上做 ATM 对齐与开仓选约。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ..config import Settings, get_settings
|
||||
from ..exchange import get_exchange, set_exchange, build_exchange
|
||||
from ..exchange.protocol import ExchangeMarket
|
||||
from ..exchange.types import MarketSnapshot, OptionPair
|
||||
from .selection import (
|
||||
hours_until_expiry,
|
||||
list_eligible_expiry_ymds,
|
||||
option_leverage,
|
||||
select_option_pair,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ATM_DRIFT_POINTS = 5.0
|
||||
_session: StrategySession | None = None
|
||||
|
||||
|
||||
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]:
|
||||
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 StrategySession:
|
||||
"""策略侧会话;交易所实现由 exchange 模块注入。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings | None = None,
|
||||
exchange: ExchangeMarket | None = None,
|
||||
) -> None:
|
||||
self.settings = settings or get_settings()
|
||||
self.ex = exchange or get_exchange()
|
||||
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 self.ex.start()
|
||||
await asyncio.to_thread(self.align_instruments)
|
||||
await self.ex.resubscribe(
|
||||
[
|
||||
self.settings.perp_inst_id,
|
||||
self._pair.call_inst_id if self._pair else "",
|
||||
self._pair.put_inst_id if self._pair else "",
|
||||
]
|
||||
)
|
||||
self._refresh_task = asyncio.create_task(self._refresh_loop(), name="strategy-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.ex.stop()
|
||||
|
||||
def _apply_pair(self, pair: OptionPair, *, mark: float, idx: float | None) -> OptionPair:
|
||||
s = self.settings
|
||||
self._pair = pair
|
||||
self.ex.set_pair(pair)
|
||||
if idx is not None:
|
||||
self.ex.set_index_px(idx)
|
||||
ids = [s.perp_inst_id, pair.call_inst_id, pair.put_inst_id]
|
||||
self.ex.warm_and_subscribe(ids)
|
||||
logger.info(
|
||||
"aligned pair exchange=%s expiry=%s strike=%s mark=%.2f hours=%.1f",
|
||||
getattr(self.ex, "name", "?"),
|
||||
pair.expiry_ymd,
|
||||
pair.strike,
|
||||
mark,
|
||||
hours_until_expiry(pair.expiry_ymd, expiry_ms=pair.expiry_ms),
|
||||
)
|
||||
return pair
|
||||
|
||||
def align_instruments(self) -> OptionPair | None:
|
||||
s = self.settings
|
||||
idx = self.ex.fetch_index(s.index_inst_id)
|
||||
mark = self.ex.fetch_mark(s.perp_inst_id) or idx
|
||||
if mark is None or mark <= 0:
|
||||
raise RuntimeError("无法获取标的标记/指数价格,无法选 ATM")
|
||||
min_hours, _ = _strategy_floats()
|
||||
contracts = self.ex.list_option_contracts(s.option_inst_family)
|
||||
pair = select_option_pair(contracts, 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:
|
||||
from .signal import decide
|
||||
|
||||
s = self.settings
|
||||
min_hours, min_lev = _strategy_floats()
|
||||
idx = self.ex.fetch_index(s.index_inst_id)
|
||||
mark = self.ex.fetch_mark(s.perp_inst_id) or idx
|
||||
if mark is None or mark <= 0:
|
||||
return None
|
||||
underlying = float(mark)
|
||||
contracts = self.ex.list_option_contracts(s.option_inst_family)
|
||||
eligible = list_eligible_expiry_ymds(contracts, min_hours=min_hours)
|
||||
if not eligible:
|
||||
logger.info("no expiry with hours>=%.1f", min_hours)
|
||||
return None
|
||||
|
||||
for ymd in eligible:
|
||||
pair = select_option_pair(contracts, mark_px=underlying, expiry_ymd=ymd)
|
||||
if pair is None:
|
||||
continue
|
||||
call_bids, call_asks, _ = self.ex.fetch_book(pair.call_inst_id, depth=5)
|
||||
put_bids, put_asks, _ = self.ex.fetch_book(pair.put_inst_id, depth=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, expiry_ms=pair.expiry_ms)
|
||||
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)
|
||||
# warm_and_subscribe 已写盘口;再覆盖刚拉的 ask 侧
|
||||
from ..exchange.book_cache import BookCache
|
||||
|
||||
# 直接通过 exchange quote path:再 upsert
|
||||
if hasattr(self.ex, "cache"):
|
||||
cache: BookCache = self.ex.cache # type: ignore[attr-defined]
|
||||
cache.upsert_book(pair.call_inst_id, bids=call_bids, asks=call_asks)
|
||||
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.ex.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.ex.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, expiry_ms=self._pair.expiry_ms)
|
||||
+ 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:
|
||||
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.ex.snapshot(self.settings.perp_inst_id)
|
||||
|
||||
def snapshot_dict(self) -> dict[str, Any]:
|
||||
return self.ex.snapshot_dict(self.settings.perp_inst_id)
|
||||
|
||||
async def _refresh_loop(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
try:
|
||||
idx = await asyncio.to_thread(
|
||||
self.ex.fetch_index, self.settings.index_inst_id
|
||||
)
|
||||
self.ex.set_index_px(idx)
|
||||
mark = await asyncio.to_thread(
|
||||
self.ex.fetch_mark, self.settings.perp_inst_id
|
||||
)
|
||||
if mark:
|
||||
self.ex.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("strategy align refresh failed: %s", e)
|
||||
|
||||
|
||||
def get_session() -> StrategySession:
|
||||
global _session
|
||||
if _session is None:
|
||||
_session = StrategySession()
|
||||
return _session
|
||||
|
||||
|
||||
def set_session(s: StrategySession | None) -> None:
|
||||
global _session
|
||||
_session = s
|
||||
|
||||
|
||||
# 兼容旧名
|
||||
MarketGateway = StrategySession
|
||||
get_gateway = get_session
|
||||
set_gateway = set_session
|
||||
|
||||
|
||||
def bootstrap_session(settings: Settings | None = None) -> StrategySession:
|
||||
"""main 启动:创建交易所 + 策略会话。"""
|
||||
s = settings or get_settings()
|
||||
ex = build_exchange(s)
|
||||
set_exchange(ex)
|
||||
sess = StrategySession(s, ex)
|
||||
set_session(sess)
|
||||
return sess
|
||||
Reference in New Issue
Block a user