"""策略行情会话:在交易所适配器之上做 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(*, include_used: bool = True) -> 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() if include_used: 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 # 半自动:人工授权可重复开同到期,不受 one_expiry_per_day 挡住; # 仍跳过有残余腿的到期。全自动保持原「一日一到期」。 skip_expiries = _skip_expiry_ymds_for_next(include_used=not semi_on) first_skip = "" last_skip = "" def _note_skip(msg: str) -> None: nonlocal first_skip, last_skip last_skip = msg if not first_skip: first_skip = msg from .selection import _complete_by_expiry, list_otm_strikes complete = _complete_by_expiry(contracts) def _try_pair(pair: OptionPair) -> OpenPick | None: """验盘口/方向/杠杆;不合格记 skip 并返回 None(调用方换下一档)。""" 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, ): _note_skip(f"{ymd} K{pair.strike:g} 非虚值") return None if ( atm_open_offset(pair.strike, underlying) > float(semi_otm_off or 0) + 1e-9 ): _note_skip( f"{ymd} K{pair.strike:g} 偏离>" f"{float(semi_otm_off or 0):g}" ) return None elif semi_on and semi_mny == "atm": pass elif not is_itm_or_atm( option_side=side, strike=pair.strike, mark_px=underlying, ): _note_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, ) return None 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, ): _note_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, ) return None 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 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" ) _note_skip(f"{ymd} K{pair.strike:g} 缺{need}卖一") return None 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: _note_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, ) return None self._apply_pair(pair, mark=underlying, idx=idx) from ..exchange.book_cache import BookCache 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", ) for ymd in eligible: if ymd in skip_expiries: _note_skip(f"{ymd} 有残余期权(跳过该到期)") logger.info( "skip expiry=%s: residual pending (semi=%s)", ymd, semi_on, ) continue # 半自动虚值:同到期内由近到远试偏离内各档,近档杠杆不够再试 1930/1940 if semi_on and semi_mny == "otm" and opt_side_hint in ("call", "put"): if ymd not in complete: _note_skip(f"{ymd} 无完整对") continue ems, strikes_map = complete[ymd] otm_ks = list_otm_strikes( list(strikes_map.keys()), underlying, option_side=str(opt_side_hint), max_offset=float(semi_otm_off or 0), ) if not otm_ks: _note_skip( f"{ymd} 无{opt_side_hint}虚值" 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, ) continue for k in otm_ks: legs = strikes_map.get(float(k)) or strikes_map.get(k) if not legs or "C" not in legs or "P" not in legs: continue pair = OptionPair( expiry_ymd=ymd, expiry_ms=ems, strike=float(k), call_inst_id=legs["C"], put_inst_id=legs["P"], ) picked = _try_pair(pair) if picked is not None: return picked 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: _note_skip(f"{ymd} 无合格行权价") continue picked = _try_pair(pair) if picked is not None: return picked # 报最近到期(列表最前)的原因,避免只显示远月 261225 造成误会 why = first_skip or last_skip if why: 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"{why}{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