Files
eth_hedge_sim/backend/app/strategy/session.py
T
dekun f1e3d5527a Fix semi open stalls: one-leg asks, OTM realign, clearer pick errors.
Monitor no longer shows ATM Put bias while semi-auto is authorized for Call.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:43:00 +08:00

1313 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""策略行情会话:在交易所适配器之上做 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.option_ids import pair_from_option_inst
from ..exchange.protocol import ExchangeMarket
from ..exchange.types import MarketSnapshot, OptionPair
from .selection import (
atm_allows_open,
atm_open_offset,
hours_until_expiry,
is_itm_or_atm,
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
from ..sim.matcher import BLOCKING_STATUSES
row = get_db().fetchone("SELECT status, group_id, option_inst_id FROM positions WHERE id=1")
if not row:
return False
st = str(row["status"] or "")
if st not in BLOCKING_STATUSES:
return False
return bool(row["group_id"] or row["option_inst_id"])
except Exception:
return False
def _held_option_inst_id() -> str | None:
"""活跃持仓期权合约;无仓返回 None。"""
try:
from ..models.db import get_db
row = get_db().fetchone(
"SELECT status, option_inst_id FROM positions WHERE id=1"
)
if not row or row["status"] not in ("open", "half_open", "option_closed_perp_pending"):
return None
# 期权已平待平永续:不再钉期权盘口
if row["status"] == "option_closed_perp_pending":
return None
inst = str(row["option_inst_id"] or "").strip()
return inst or None
except Exception:
return None
def _held_option_legs() -> tuple[str | None, str | None]:
"""期期持仓:返回 (call_inst, put_inst);非期期或无仓则 put 为空。"""
try:
from ..models.db import get_db
row = get_db().fetchone(
"""SELECT status, hedge_mode, option_inst_id, option2_inst_id
FROM positions WHERE id=1"""
)
if not row or row["status"] not in ("open", "half_open"):
return None, None
call_id = str(row["option_inst_id"] or "").strip() or None
put_id = str(row["option2_inst_id"] or "").strip() or None
if str(row["hedge_mode"] or "").strip().lower() != "option_option":
return call_id, None
return call_id, put_id
except Exception:
return None, None
def _as_bool_setting(raw: str | None, default: bool) -> bool:
if raw is None or raw == "":
return default
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def _skip_expiry_ymds_for_next() -> set[str]:
"""
空仓选约/监控应跳过的到期日:
- 历史上已开过该到期(one_expiry_per_day,跨日)
- 仍有待结算残留期权的到期档(该档已「完成」开平,盯下一档)
"""
skip: set[str] = set()
try:
from ..models.db import get_db
from .clock import pending_residual_expiry_ymds, used_expiry_ymds
s = get_settings()
db = get_db()
one_exp_day = _as_bool_setting(
db.get_setting("one_expiry_per_day", str(s.one_expiry_per_day)),
s.one_expiry_per_day,
)
if one_exp_day:
skip |= used_expiry_ymds(db)
skip |= pending_residual_expiry_ymds(db)
except Exception:
logger.exception("skip-expiry lookup failed; continue without skip")
return skip
def _strategy_floats() -> tuple[float, float, float, bool]:
"""min_hours, min_leverage, max_atm_open_offset, atm_open_offset_enabled"""
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
)
atm_off = float(
db.get_setting("max_atm_open_offset", str(s.max_atm_open_offset))
or s.max_atm_open_offset
)
atm_on = _as_bool_setting(
db.get_setting("atm_open_offset_enabled", str(s.atm_open_offset_enabled)),
s.atm_open_offset_enabled,
)
return hours, lev, atm_off, atm_on
except Exception:
return (
s.min_option_hours,
s.min_option_leverage,
s.max_atm_open_offset,
s.atm_open_offset_enabled,
)
def _fixed_direction() -> tuple[bool, str]:
"""(enabled, perp_side long|short)。默认关。"""
s = get_settings()
try:
from ..models.db import get_db
db = get_db()
enabled = _as_bool_setting(
db.get_setting(
"fixed_direction_enabled", str(s.fixed_direction_enabled)
),
s.fixed_direction_enabled,
)
side = str(
db.get_setting("fixed_perp_side", s.fixed_perp_side) or s.fixed_perp_side
).strip().lower()
if side not in ("long", "short"):
side = "long"
return enabled, side
except Exception:
side = str(s.fixed_perp_side or "long").strip().lower()
if side not in ("long", "short"):
side = "long"
return bool(s.fixed_direction_enabled), side
def _option_side_for_perp(perp_side: str) -> str:
return "put" if (perp_side or "").strip().lower() == "long" else "call"
def _hedge_mode() -> str:
s = get_settings()
try:
from ..models.db import get_db
raw = str(
get_db().get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode
).strip().lower()
if raw in ("perp_option", "option_option"):
return raw
except Exception:
pass
return "perp_option"
def _oo_settings() -> tuple[float, float, float, float, float, bool]:
"""amplitude_pct, amplitude_hours, min_option_hours, min_leverage, strike_max_dev_pct, amp_filter_on"""
s = get_settings()
try:
from ..models.db import get_db
db = get_db()
filt_raw = db.get_setting(
"oo_amplitude_filter_enabled", str(s.oo_amplitude_filter_enabled)
)
filt_on = str(filt_raw or "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
if filt_raw is None or filt_raw == "":
filt_on = bool(s.oo_amplitude_filter_enabled)
return (
float(db.get_setting("oo_amplitude_pct", str(s.oo_amplitude_pct)) or s.oo_amplitude_pct),
float(
db.get_setting("oo_amplitude_hours", str(s.oo_amplitude_hours))
or s.oo_amplitude_hours
),
float(
db.get_setting("oo_min_option_hours", str(s.oo_min_option_hours))
or s.oo_min_option_hours
),
float(db.get_setting("oo_min_leverage", str(s.oo_min_leverage)) or s.oo_min_leverage),
float(
db.get_setting(
"oo_strike_max_dev_pct", str(s.oo_strike_max_dev_pct)
)
or s.oo_strike_max_dev_pct
),
filt_on,
)
except Exception:
return (
s.oo_amplitude_pct,
s.oo_amplitude_hours,
s.oo_min_option_hours,
s.oo_min_leverage,
s.oo_strike_max_dev_pct,
bool(s.oo_amplitude_filter_enabled),
)
@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
hedge_mode: str = "perp_option"
call_inst_id: str | None = None
put_inst_id: str | None = None
call_strike: float | None = None
put_strike: float | None = None
call_leverage: float | None = None
put_leverage: float | None = None
amplitude_high: float | None = None
amplitude_low: float | None = None
amplitude_range_pct: float | None = None
oo_detail: str | None = None
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._oo_amp: dict[str, Any] | None = None
self._last_pick_fail: str | None = None
self._refresh_task: asyncio.Task[None] | None = None
self._started = False
@property
def pair(self) -> OptionPair | None:
return self._pair
def _watch_ids(self, pair: OptionPair | None = None) -> list[str]:
"""永续 + 监控对 + 持仓腿(有仓时绝不能 drop 持仓盘口)。"""
s = self.settings
p = pair if pair is not None else self._pair
ids: list[str] = [s.perp_inst_id]
if p is not None:
ids.extend([p.call_inst_id, p.put_inst_id])
held, held2 = _held_option_legs()
if held:
ids.append(held)
if held2:
ids.append(held2)
# 去重保序
out: list[str] = []
seen: set[str] = set()
for i in ids:
if i and i not in seen:
seen.add(i)
out.append(i)
return out
async def start(self) -> None:
if self._started:
return
self._started = True
await self.ex.start()
try:
# 有持仓时必须钉在持仓行权价,禁止重启后漂到新 ATM
if _has_open_position():
await asyncio.to_thread(self.align_to_held_position)
else:
await asyncio.to_thread(self.align_instruments)
except Exception as e:
# eapi 418/429 时允许先起会话,后续 refresh 再对齐
logger.warning("initial ATM align failed (will retry): %s", e)
await self.ex.resubscribe(self._watch_ids())
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)
self.ex.warm_and_subscribe(self._watch_ids(pair))
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_to_held_position(self) -> OptionPair | None:
"""有活跃仓时:监控对锁定为持仓合约的到期/行权价。"""
self.refresh_oo_amplitude()
call_id, put_id = _held_option_legs()
held = call_id or _held_option_inst_id()
if not held:
return None
mark = self._mark_for_atm()
idx = None
try:
idx = self.ex.fetch_index(self.settings.index_inst_id)
except Exception:
pass
if put_id and call_id:
# 期期:双腿分别钉住
try:
from ..models.db import get_db
row = get_db().fetchone(
"SELECT strike2 FROM positions WHERE id=1"
)
except Exception:
row = None
cpair = pair_from_option_inst(call_id)
ppair = pair_from_option_inst(put_id)
if cpair is None:
logger.warning("cannot rebuild call pair from held %s", call_id)
return None
put_strike = None
if row and row["strike2"] is not None:
put_strike = float(row["strike2"])
elif ppair is not None:
put_strike = float(ppair.strike)
pair = OptionPair(
expiry_ymd=cpair.expiry_ymd,
expiry_ms=cpair.expiry_ms,
strike=float(cpair.strike),
call_inst_id=call_id,
put_inst_id=put_id,
put_strike=put_strike,
)
logger.info(
"pin OO watch call=%s put=%s C@%.0f P@%.0f",
call_id,
put_id,
pair.strike,
float(put_strike or pair.strike),
)
return self._apply_pair(
pair, mark=float(mark or pair.strike), idx=idx
)
pair = pair_from_option_inst(held)
if pair is None:
logger.warning("cannot rebuild pair from held option %s", held)
return None
mark = mark or float(pair.strike)
logger.info(
"pin watch to held option %s strike=%.0f expiry=%s",
held,
pair.strike,
pair.expiry_ymd,
)
return self._apply_pair(pair, mark=float(mark), idx=idx)
def align_instruments(self) -> OptionPair | None:
# 重启/刷新时若仍有仓,绝不切到新 ATM
if _has_open_position():
return self.align_to_held_position()
if _hedge_mode() == "option_option":
return self.align_oo_instruments()
# 永期:刷新振幅供 Plan(过滤关也展示;开仓门禁在 pick)
self.refresh_oo_amplitude()
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()
fixed_on, fixed_perp = _fixed_direction()
opt_side = _option_side_for_perp(fixed_perp) if fixed_on else None
contracts = self.ex.list_option_contracts(s.option_inst_family)
eligible = list_eligible_expiry_ymds(contracts, min_hours=min_hours)
skip = _skip_expiry_ymds_for_next()
pair = None
for ymd in eligible:
if ymd in skip:
logger.info(
"align skip expiry=%s (used today and/or residual pending)",
ymd,
)
continue
pair = select_option_pair(
contracts,
mark_px=float(mark),
expiry_ymd=ymd,
option_side=opt_side,
)
if pair is not None:
break
if pair is None and eligible:
# 候选都被跳过时仍回退最近合格档,避免盘口空白
pair = select_option_pair(
contracts,
mark_px=float(mark),
expiry_ymd=eligible[0],
option_side=opt_side,
)
if pair is None:
kind = f"实值/平值 {opt_side}" if opt_side else "ATM"
raise RuntimeError(
f"未找到剩余≥{min_hours}h 的 {kind} Call/Put (family={s.option_inst_family})"
)
return self._apply_pair(pair, mark=float(mark), idx=idx)
def refresh_oo_amplitude(self) -> dict[str, Any] | None:
"""刷新振幅高低(永期/期期;有仓/无仓都要,否则 UI 指数/振幅会空)。"""
try:
from ..exchange.candles import fetch_amplitude_hl_for_runtime
from .amplitude_gate import evaluate_amplitude_gate
amp_pct, amp_hours, _, _, _, amp_filt = _oo_settings()
amp = fetch_amplitude_hl_for_runtime(amp_hours)
gate = evaluate_amplitude_gate(
filter_enabled=bool(amp_filt),
amp=amp,
max_pct=float(amp_pct),
hours=float(amp_hours),
)
self._oo_amp = dict(gate["snapshot"])
return self._oo_amp
except Exception:
logger.exception("refresh_oo_amplitude failed")
return self._oo_amp
def _apply_amplitude_first_gate(self) -> bool:
"""
振幅过滤为开仓第一关。返回 True=可继续选约;False=拒开。
无论是否开启过滤都写入 _oo_amp 供 Plan 展示。
"""
from ..exchange.candles import fetch_amplitude_hl_for_runtime
from .amplitude_gate import evaluate_amplitude_gate
amp_pct, amp_hours, _, _, _, amp_filt = _oo_settings()
amp = fetch_amplitude_hl_for_runtime(amp_hours)
gate = evaluate_amplitude_gate(
filter_enabled=bool(amp_filt),
amp=amp,
max_pct=float(amp_pct),
hours=float(amp_hours),
)
self._oo_amp = dict(gate["snapshot"])
if gate["blocked"]:
logger.info("amplitude gate blocked: %s", gate.get("reason"))
return False
return True
def last_pick_fail_reason(self) -> str | None:
return self._last_pick_fail
def amplitude_gate_fail_reason(self) -> str | None:
"""若最近一次振幅快照显示过滤开启且未过关,返回文案。"""
amp = self._oo_amp
if not amp or not amp.get("filter_enabled"):
return None
if amp.get("ok") is True:
return None
hours = amp.get("hours")
range_pct = amp.get("range_pct")
max_pct = amp.get("max_pct")
if range_pct is None:
return f"振幅未过关:无法获取近 {hours:g}h K 线高低" if hours is not None else "振幅未过关"
try:
return (
f"振幅未过关:{float(hours):g}h 内 "
f"{float(range_pct):.2f}% > {float(max_pct):g}%"
)
except (TypeError, ValueError):
return "振幅未过关"
def align_oo_instruments(self) -> OptionPair | None:
"""期期监控:按振幅高低点选虚值 Call/Put(展示用;振幅超限仍对齐候选)。"""
from .oo_selection import select_oo_pair
# 有仓也刷新振幅(钉仓不再走选约,否则 _oo_amp 一直空)
self.refresh_oo_amplitude()
if _has_open_position():
return self.align_to_held_position()
s = self.settings
amp_pct, amp_hours, min_hours, _min_lev, max_dev, _amp_filt = _oo_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("无法获取标的标记/指数价格,无法选期期虚值")
underlying = float(mark)
if self._oo_amp is None:
raise RuntimeError("无法获取振幅 K 线高低点")
amp_high = float(self._oo_amp["high"])
amp_low = float(self._oo_amp["low"])
contracts = self.ex.list_option_contracts(s.option_inst_family)
skip = _skip_expiry_ymds_for_next()
picked = select_oo_pair(
contracts,
spot=underlying,
high=amp_high,
low=amp_low,
min_hours=float(min_hours),
skip_expiry_ymds=skip,
max_dev_pct=float(max_dev),
)
if picked is None:
raise RuntimeError(
f"未找到剩余≥{min_hours}h 且贴高低≤{max_dev:g}% 的虚值 Call/Put"
)
ymd, ems, ck, pk, call_inst, put_inst = picked
pair = OptionPair(
expiry_ymd=ymd,
expiry_ms=int(ems),
strike=float(ck),
call_inst_id=call_inst,
put_inst_id=put_inst,
put_strike=float(pk),
)
return self._apply_pair(pair, mark=underlying, idx=idx)
def pick_for_open(self) -> OpenPick | None:
if _hedge_mode() == "option_option":
return self._pick_for_open_oo()
return self._pick_for_open_perp()
def _pick_for_open_oo(self) -> OpenPick | None:
from ..exchange.candles import fetch_amplitude_hl_for_runtime
from .oo_selection import (
pick_otm_call_strike,
pick_otm_put_strike,
select_oo_pair,
)
from .selection import _complete_by_expiry, option_leverage
s = self.settings
amp_pct, amp_hours, min_hours, min_lev, max_dev, amp_filt = _oo_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:
return None
underlying = float(mark)
amp = fetch_amplitude_hl_for_runtime(amp_hours)
if amp is None:
logger.info("oo: amplitude candles unavailable")
return None
# 无论是否超限都写入,供「指数/振幅」面板展示
self._oo_amp = {
"high": float(amp.high),
"low": float(amp.low),
"mid": float(amp.mid),
"range_pct": float(amp.range_pct),
"hours": float(amp_hours),
"max_pct": float(amp_pct),
"filter_enabled": bool(amp_filt),
"ok": (
True
if not amp_filt
else float(amp.range_pct) <= float(amp_pct) + 1e-12
),
}
if amp_filt and float(amp.range_pct) > float(amp_pct) + 1e-12:
logger.info(
"oo: amplitude %.3f%% > max %.3f%% (H=%.2f L=%.2f)",
amp.range_pct,
amp_pct,
amp.high,
amp.low,
)
return None
contracts = self.ex.list_option_contracts(s.option_inst_family)
skip = _skip_expiry_ymds_for_next()
picked = select_oo_pair(
contracts,
spot=underlying,
high=float(amp.high),
low=float(amp.low),
min_hours=float(min_hours),
skip_expiry_ymds=skip,
max_dev_pct=float(max_dev),
)
if picked is None:
logger.info(
"oo: no OTM call/put within %.2f%% of amplitude HL",
max_dev,
)
return None
ymd, ems, ck, pk, call_inst, put_inst = picked
call_bids, call_asks, _ = self.ex.fetch_book(call_inst, depth=5)
put_bids, put_asks, _ = self.ex.fetch_book(put_inst, depth=5)
call_ask = call_asks[0].px if call_asks else None
put_ask = put_asks[0].px if put_asks else None
if call_ask is None:
cq = self.ex.quote(call_inst)
call_ask = cq.ask if cq else None
if put_ask is None:
pq = self.ex.quote(put_inst)
put_ask = pq.ask if pq else None
if call_ask is None or put_ask is None or call_ask <= 0 or put_ask <= 0:
logger.info("oo: missing ask call=%s put=%s", call_ask, put_ask)
return None
c_lev = option_leverage(underlying, float(call_ask))
p_lev = option_leverage(underlying, float(put_ask))
if (
c_lev is None
or p_lev is None
or c_lev + 1e-9 < min_lev
or p_lev + 1e-9 < min_lev
):
logger.info(
"oo: leverage too low call=%s put=%s need>=%.0f",
f"{c_lev:.1f}" if c_lev else "n/a",
f"{p_lev:.1f}" if p_lev else "n/a",
min_lev,
)
return None
# 监控用:Call/Put 不同行权价
pair = OptionPair(
expiry_ymd=ymd,
expiry_ms=int(ems),
strike=float(ck),
call_inst_id=call_inst,
put_inst_id=put_inst,
put_strike=float(pk),
)
self._oo_amp = {
"high": float(amp.high),
"low": float(amp.low),
"mid": float(amp.mid),
"range_pct": float(amp.range_pct),
"hours": float(amp_hours),
"max_pct": float(amp_pct),
"ok": True,
}
self._apply_pair(pair, mark=underlying, idx=idx)
if hasattr(self.ex, "cache"):
from ..exchange.book_cache import BookCache
cache: BookCache = self.ex.cache # type: ignore[attr-defined]
cache.upsert_book(call_inst, bids=call_bids, asks=call_asks)
cache.upsert_book(put_inst, bids=put_bids, asks=put_asks)
hours_left = hours_until_expiry(ymd, expiry_ms=ems)
return OpenPick(
pair=pair,
option_side="call",
perp_side="",
bias="option_option",
call_ask=float(call_ask),
put_ask=float(put_ask),
option_ask=float(call_ask),
option_leverage=float(min(c_lev, p_lev)),
hours_left=hours_left,
underlying_px=underlying,
hedge_mode="option_option",
call_inst_id=call_inst,
put_inst_id=put_inst,
call_strike=float(ck),
put_strike=float(pk),
call_leverage=float(c_lev),
put_leverage=float(p_lev),
amplitude_high=float(amp.high),
amplitude_low=float(amp.low),
amplitude_range_pct=float(amp.range_pct),
oo_detail=(
f"amp={amp.range_pct:.2f}% H={amp.high:.2f} L={amp.low:.2f} "
f"C@{ck:g} P@{pk:g}"
),
)
def _pick_for_open_perp(self) -> OpenPick | None:
from .signal import decide, decide_fixed
from .semi_auto import is_armed, is_semi_auto, read_semi_params
self._last_pick_fail = None
s = self.settings
min_hours, min_lev, max_atm_off, atm_off_on = _strategy_floats()
fixed_on, fixed_perp = _fixed_direction()
# 半自动:强制看法方向 + 行权类型(itm/atm/otm) + 半自动选约门槛(须已授权)
semi_on = is_semi_auto()
semi_mny: str | None = None
semi_otm_off: float | None = None
if semi_on:
if not is_armed():
self._last_pick_fail = "半自动未授权"
return None
sp = read_semi_params()
fixed_on = True
fixed_perp = str(sp["perp_side"])
min_hours = float(sp["min_option_hours"])
min_lev = float(sp["min_option_leverage"])
atm_off_on = False
semi_mny = str(sp.get("moneyness") or "otm")
semi_otm_off = float(sp.get("otm_max_offset") or 0)
opt_side_hint = _option_side_for_perp(fixed_perp) if fixed_on else None
# 第一关:振幅过滤(默认关;开启则回看窗振幅须 ≤ 最大%)
if not self._apply_amplitude_first_gate():
self._last_pick_fail = "振幅门未过"
return None
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:
self._last_pick_fail = "无标的价"
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)
self._last_pick_fail = f"无剩余≥{min_hours:g}h 的到期"
return None
skip_expiries = _skip_expiry_ymds_for_next()
last_skip = ""
for ymd in eligible:
if ymd in skip_expiries:
logger.info(
"skip expiry=%s: used today and/or residual pending",
ymd,
)
continue
pair = select_option_pair(
contracts,
mark_px=underlying,
expiry_ymd=ymd,
option_side=opt_side_hint,
moneyness=semi_mny if semi_on else None,
otm_max_offset=semi_otm_off if semi_on else None,
)
if pair is None:
if semi_on and semi_mny == "otm":
last_skip = (
f"{ymd}{opt_side_hint or '?'}虚值"
f"(偏离≤{float(semi_otm_off or 0):g}"
)
logger.info(
"skip expiry=%s no OTM within offset=%.1f for %s mark=%.2f",
ymd,
float(semi_otm_off or 0),
opt_side_hint,
underlying,
)
else:
last_skip = f"{ymd} 无合格行权价"
continue
if fixed_on:
from .selection import is_otm
side = opt_side_hint or ""
if semi_on and semi_mny == "otm":
if not is_otm(
option_side=side,
strike=pair.strike,
mark_px=underlying,
):
last_skip = f"{ymd} K{pair.strike:g} 非虚值"
continue
if (
atm_open_offset(pair.strike, underlying)
> float(semi_otm_off or 0) + 1e-9
):
last_skip = (
f"{ymd} K{pair.strike:g} 偏离>"
f"{float(semi_otm_off or 0):g}"
)
continue
elif semi_on and semi_mny == "atm":
# 平值:须为该到期最接近标的的档
pass
elif not is_itm_or_atm(
option_side=side,
strike=pair.strike,
mark_px=underlying,
):
last_skip = f"{ymd} K{pair.strike:g} 非实值/平值"
logger.info(
"skip expiry=%s strike=%.0f not ITM/ATM for %s mark=%.2f",
ymd,
pair.strike,
opt_side_hint,
underlying,
)
continue
else:
offset = atm_open_offset(pair.strike, underlying)
if not atm_allows_open(
pair.strike,
underlying,
max_offset=max_atm_off,
enabled=atm_off_on,
):
last_skip = f"{ymd} ATM偏离{offset:.1f}>{max_atm_off:g}"
logger.info(
"skip expiry=%s strike=%.0f atm_offset=%.1f > max=%.1f",
ymd,
pair.strike,
offset,
max_atm_off,
)
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
# REST 被限流时回退 WS/缓存盘口
if call_ask is None:
cq = self.ex.quote(pair.call_inst_id)
call_ask = cq.ask if cq else None
if put_ask is None:
pq = self.ex.quote(pair.put_inst_id)
put_ask = pq.ask if pq else None
if fixed_on:
sig = decide_fixed(call_ask, put_ask, perp_side=fixed_perp)
else:
sig = decide(
call_ask,
put_ask,
strike=pair.strike,
mark_px=underlying,
)
if sig is None:
need = "Call" if (opt_side_hint == "call") else (
"Put" if opt_side_hint == "put" else "Call/Put"
)
last_skip = f"{ymd} K{pair.strike:g}{need}卖一"
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:
last_skip = (
f"{ymd} {sig.option_side.upper()}@{pair.strike:g} "
f"杠杆{(f'{lev:.0f}x' if lev else 'n/a')}<{min_lev:g}x"
)
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)
self._last_pick_fail = None
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,
hedge_mode="perp_option",
)
if last_skip:
hint = ""
if semi_on:
hint = (
f"(半自动{opt_side_hint or '?'}·"
f"{semi_mny or '?'}·≥{min_lev:g}x·≥{min_hours:g}h"
)
self._last_pick_fail = f"最近跳过: {last_skip}{hint}"
else:
self._last_pick_fail = "合格到期均被跳过(一日一到期/残余等)"
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._watch_ids(pair))
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._watch_ids(pick.pair))
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 _hedge_mode() == "option_option":
return self.oo_needs_realign()
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
skip = _skip_expiry_ymds_for_next()
if str(self._pair.expiry_ymd or "") in skip:
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
fixed_on, fixed_perp = _fixed_direction()
# 半自动虚值/平值:监控对齐勿按「必须实值」强行重钉,否则 OTM 会一直 realign
try:
from .semi_auto import is_semi_auto, read_semi_params
from .selection import is_otm
if is_semi_auto():
sp = read_semi_params()
mny = str(sp.get("moneyness") or "otm")
opt = _option_side_for_perp(str(sp["perp_side"]))
if mny == "otm":
off = float(sp.get("otm_max_offset") or 0)
if not is_otm(
option_side=opt,
strike=float(self._pair.strike),
mark_px=float(mark),
):
return True
if atm_open_offset(self._pair.strike, mark) > off + 1e-9:
return True
return False
if mny == "atm":
return (
abs(float(self._pair.strike) - float(mark))
>= _ATM_DRIFT_POINTS
)
except Exception:
logger.debug("semi atm_needs_realign check failed", exc_info=True)
if fixed_on:
opt = _option_side_for_perp(fixed_perp)
if not is_itm_or_atm(
option_side=opt, strike=float(self._pair.strike), mark_px=float(mark)
):
return True
return abs(float(self._pair.strike) - float(mark)) >= _ATM_DRIFT_POINTS
def oo_needs_realign(self) -> bool:
if self._pair is None:
return True
_amp_pct, amp_hours, min_hours, _, max_dev, amp_filt = _oo_settings()
if (
hours_until_expiry(self._pair.expiry_ymd, expiry_ms=self._pair.expiry_ms)
+ 1e-9
< min_hours
):
return True
skip = _skip_expiry_ymds_for_next()
if str(self._pair.expiry_ymd or "") in skip:
return True
try:
from ..exchange.candles import fetch_amplitude_hl_for_runtime
from .oo_selection import select_oo_pair
mark = self._mark_for_atm()
if mark is None or mark <= 0:
return False
amp = fetch_amplitude_hl_for_runtime(amp_hours)
if amp is None:
return False
self._oo_amp = {
"high": float(amp.high),
"low": float(amp.low),
"mid": float(amp.mid),
"range_pct": float(amp.range_pct),
"hours": float(amp_hours),
"max_pct": float(_amp_pct),
"filter_enabled": bool(amp_filt),
"ok": (
True
if not amp_filt
else float(amp.range_pct) <= float(_amp_pct) + 1e-12
),
}
contracts = self.ex.list_option_contracts(self.settings.option_inst_family)
picked = select_oo_pair(
contracts,
spot=float(mark),
high=float(amp.high),
low=float(amp.low),
min_hours=float(min_hours),
skip_expiry_ymds=skip,
max_dev_pct=float(max_dev),
)
if picked is None:
return False
_ymd, _ems, _ck, _pk, call_inst, put_inst = picked
return (
call_inst != self._pair.call_inst_id
or put_inst != self._pair.put_inst_id
)
except Exception:
logger.exception("oo_needs_realign failed")
return False
async def ensure_atm_async(self, *, force: bool = False) -> OptionPair | None:
if _has_open_position():
# 持仓期间:钉住持仓行权价(禁止漂到新 ATM/虚值);仍刷新振幅供 UI
await asyncio.to_thread(self.refresh_oo_amplitude)
call_id, put_id = _held_option_legs()
held = call_id or _held_option_inst_id()
if held and (
self._pair is None
or held not in (self._pair.call_inst_id, self._pair.put_inst_id)
or (
put_id
and put_id
not in (self._pair.call_inst_id, self._pair.put_inst_id)
)
):
return await asyncio.to_thread(self.align_to_held_position)
return self._pair
if force or self.atm_needs_realign():
logger.info(
"%s realign force=%s old_strike=%s old_exp=%s",
"OO" if _hedge_mode() == "option_option" else "ATM",
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]:
d = self.ex.snapshot_dict(self.settings.perp_inst_id)
d["exchange"] = getattr(self.ex, "name", self.settings.exchange)
d["perp_inst_id"] = self.settings.perp_inst_id
hm = _hedge_mode()
d["hedge_mode"] = hm
if self._pair is not None:
pd = self._pair.to_dict()
d["pair"] = pd
if self._oo_amp is not None:
d["oo_amplitude"] = dict(self._oo_amp)
if hm == "option_option":
ac = dict(d.get("ask_compare") or {})
ac["bias"] = "option_option"
d["ask_compare"] = ac
return d
def option_ladder(
self,
*,
wings: int = 5,
side: str = "call",
min_hours: float = 30.0,
) -> dict[str, Any]:
"""
半自动单边报价:ATM 上下若干档。
到期:剩余时长 ≥ min_hours 的最近一档(与选约门槛一致)。
"""
s = self.settings
wings = max(1, min(12, int(wings)))
min_h = max(1.0, float(min_hours))
opt_side = "put" if str(side).strip().lower() == "put" else "call"
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 float(mark) <= 0:
return {
"ok": False,
"detail": "无标的价",
"rows": [],
"index_px": None,
"side": opt_side,
"min_hours": min_h,
}
underlying = float(mark)
contracts = self.ex.list_option_contracts(s.option_inst_family)
from .selection import _complete_by_expiry, pick_atm_strike
# 与半自动选约一致:只考虑剩余 ≥ min_hours 的到期,取最近一档
eligible = list_eligible_expiry_ymds(contracts, min_hours=min_h)
ymd = eligible[0] if eligible else None
if not ymd:
return {
"ok": False,
"detail": f"无剩余≥{min_h:g}h 的到期",
"rows": [],
"index_px": underlying,
"side": opt_side,
"min_hours": min_h,
}
complete = _complete_by_expiry(contracts)
if ymd not in complete:
return {
"ok": False,
"detail": f"到期 {ymd} 无完整对",
"rows": [],
"index_px": underlying,
"expiry_ymd": ymd,
"side": opt_side,
"min_hours": min_h,
}
_ems, strikes_map = complete[ymd]
hours_left = hours_until_expiry(ymd, expiry_ms=_ems)
strikes = sorted(float(k) for k in strikes_map.keys())
atm = pick_atm_strike(strikes, underlying)
if atm is None:
return {
"ok": False,
"detail": "无 ATM",
"rows": [],
"index_px": underlying,
"expiry_ymd": ymd,
"side": opt_side,
}
atm_i = min(range(len(strikes)), key=lambda i: abs(strikes[i] - float(atm)))
lo = max(0, atm_i - wings)
hi = min(len(strikes), atm_i + wings + 1)
def _quote_side(inst_id: str | None) -> tuple[float | None, float | None]:
if not inst_id:
return None, None
q = self.ex.quote(str(inst_id))
ask = float(q.ask) if q and q.ask is not None else None
ask_sz = float(q.ask_sz) if q and q.ask_sz is not None else None
if ask is not None:
return ask, ask_sz
try:
_bids, asks, _ = self.ex.fetch_book(str(inst_id), depth=1)
if asks:
return float(asks[0].px), (
float(asks[0].sz) if asks[0].sz is not None else None
)
except Exception:
logger.debug("ladder fetch_book failed inst=%s", inst_id, exc_info=True)
return None, None
rows: list[dict[str, Any]] = []
for k in strikes[lo:hi]:
legs = strikes_map[k]
inst = legs.get("C" if opt_side == "call" else "P")
ask, ask_sz = _quote_side(inst)
off = float(k) - underlying
if abs(float(k) - float(atm)) < 1e-9:
tag = "atm"
elif opt_side == "call":
tag = "itm" if float(k) < underlying - 1e-9 else "otm"
else:
tag = "itm" if float(k) > underlying + 1e-9 else "otm"
rows.append(
{
"strike": float(k),
"offset": round(off, 2),
"tag": tag,
"ask": ask,
"ask_sz": ask_sz,
"lev": option_leverage(underlying, ask) if ask else None,
"inst_id": inst,
}
)
# Call:高行权价在上(虚值在上);Put:低行权价在上(虚值在上)
if opt_side == "call":
rows.sort(key=lambda r: -float(r["strike"]))
else:
rows.sort(key=lambda r: float(r["strike"]))
return {
"ok": True,
"detail": "",
"side": opt_side,
"index_px": underlying,
"expiry_ymd": ymd,
"hours_left": round(float(hours_left), 1) if hours_left is not None else None,
"min_hours": min_h,
"atm_strike": float(atm),
"rows": rows,
}
async def _refresh_loop(self) -> None:
while True:
await asyncio.sleep(30 if self._pair is not None else 10)
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)
if self._pair is None:
await self.ensure_atm_async(force=True)
else:
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 启动:创建交易所 + 策略会话。始终以 DB 覆盖后的 runtime 为准。"""
from ..exchange.runtime import load_runtime_settings
# 忽略裸 get_settings():重启后必须跟 DB 里选的交易所一致
try:
s = load_runtime_settings()
except Exception:
s = settings or get_settings()
ex = build_exchange(s)
set_exchange(ex)
sess = StrategySession(s, ex)
set_session(sess)
return sess