diff --git a/backend/app/exchange/types.py b/backend/app/exchange/types.py index bb0c404..33e209f 100644 --- a/backend/app/exchange/types.py +++ b/backend/app/exchange/types.py @@ -43,12 +43,20 @@ class OptionPair: strike: float call_inst_id: str put_inst_id: str + put_strike: float | None = None # 期期:Put 行权价;None=与 strike 同(ATM) def to_dict(self) -> dict[str, Any]: + put_k = ( + float(self.put_strike) + if self.put_strike is not None + else float(self.strike) + ) return { "expiry_ymd": self.expiry_ymd, "expiry_ms": self.expiry_ms, "strike": self.strike, + "call_strike": float(self.strike), + "put_strike": put_k, "call_inst_id": self.call_inst_id, "put_inst_id": self.put_inst_id, } diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index ffd5a45..1bf45a8 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -63,6 +63,26 @@ def _held_option_inst_id() -> str | None: 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 @@ -236,6 +256,7 @@ class StrategySession: 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._refresh_task: asyncio.Task[None] | None = None self._started = False @@ -250,9 +271,11 @@ class StrategySession: ids: list[str] = [s.perp_inst_id] if p is not None: ids.extend([p.call_inst_id, p.put_inst_id]) - held = _held_option_inst_id() + held, held2 = _held_option_legs() if held: ids.append(held) + if held2: + ids.append(held2) # 去重保序 out: list[str] = [] seen: set[str] = set() @@ -309,19 +332,59 @@ class StrategySession: def align_to_held_position(self) -> OptionPair | None: """有活跃仓时:监控对锁定为持仓合约的到期/行权价。""" - held = _held_option_inst_id() + call_id, put_id = _held_option_legs() + held = call_id or _held_option_inst_id() if not held: return None - pair = pair_from_option_inst(held) - if pair is None: - logger.warning("cannot rebuild pair from held option %s", held) - return None - mark = self._mark_for_atm() or float(pair.strike) + 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, @@ -334,6 +397,8 @@ class StrategySession: # 重启/刷新时若仍有仓,绝不切到新 ATM if _has_open_position(): return self.align_to_held_position() + if _hedge_mode() == "option_option": + return self.align_oo_instruments() s = self.settings idx = self.ex.fetch_index(s.index_inst_id) mark = self.ex.fetch_mark(s.perp_inst_id) or idx @@ -376,6 +441,58 @@ class StrategySession: ) return self._apply_pair(pair, mark=float(mark), idx=idx) + def align_oo_instruments(self) -> OptionPair | None: + """期期监控:按振幅高低点选虚值 Call/Put(展示用;振幅不足仍对齐候选)。""" + from ..exchange.candles import fetch_amplitude_hl_for_runtime + from .oo_selection import select_oo_pair + + if _has_open_position(): + return self.align_to_held_position() + s = self.settings + amp_pct, amp_hours, min_hours, _min_lev = _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) + amp = fetch_amplitude_hl_for_runtime(amp_hours) + if amp is None: + self._oo_amp = None + raise RuntimeError("无法获取振幅 K 线高低点") + 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), + "min_pct": float(amp_pct), + "ok": float(amp.range_pct) + 1e-12 >= float(amp_pct), + } + 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, + ) + if picked is None: + raise RuntimeError( + f"未找到剩余≥{min_hours}h 的虚值 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() @@ -452,14 +569,24 @@ class StrategySession: min_lev, ) return None - # 监控用:用 Call 行权价构造假 pair(两腿不同 strike,call/put inst 正确) + # 监控用: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), + "min_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 @@ -655,6 +782,8 @@ class StrategySession: 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() @@ -679,19 +808,78 @@ class StrategySession: 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, _ = _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), + "min_pct": float(_amp_pct), + "ok": float(amp.range_pct) + 1e-12 >= float(_amp_pct), + } + 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, + ) + 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) - held = _held_option_inst_id() + # 持仓期间:钉住持仓行权价(禁止漂到新 ATM/虚值) + 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( - "ATM realign force=%s old_strike=%s old_exp=%s", + "%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, @@ -706,6 +894,17 @@ class StrategySession: 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 async def _refresh_loop(self) -> None: diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e2b6600..26fbe84 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -204,9 +204,21 @@ export type MarketSnapshot = { index_px: number | null; exchange?: string; perp_inst_id?: string; + hedge_mode?: "perp_option" | "option_option"; + oo_amplitude?: { + high?: number; + low?: number; + mid?: number; + range_pct?: number; + hours?: number; + min_pct?: number; + ok?: boolean; + } | null; pair: { expiry_ymd: string; strike: number; + call_strike?: number; + put_strike?: number; call_inst_id: string; put_inst_id: string; } | null; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 2f543c9..da70cad 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -187,6 +187,7 @@ export default function PlanPage() { const pos = plan?.position; const isOo = plan?.hedge_mode === "option_option" || + snap?.hedge_mode === "option_option" || pos?.hedge_mode === "option_option" || !!pos?.option2_inst_id; const open = !!pos?.has_position; @@ -851,6 +852,11 @@ export default function PlanPage() { > + ) : isOo ? ( + <> +