From 0659611d82605bcee287a876a647b145e2e1a201 Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 7 Aug 2026 16:30:29 +0800 Subject: [PATCH] Show OTM option quotes in option-option mode. Align session to amplitude Call/Put pair and replace perp/ATM market panels. Co-authored-by: Cursor --- backend/app/exchange/types.py | 8 ++ backend/app/strategy/session.py | 221 ++++++++++++++++++++++++++++++-- frontend/src/api/client.ts | 12 ++ frontend/src/pages/Plan.tsx | 217 ++++++++++++++++++++++--------- 4 files changed, 388 insertions(+), 70 deletions(-) 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 ? ( + <> +
Call 持仓 · 暂无
+
Put 持仓 · 暂无
+ ) : ( <>
永续持仓 · 暂无
@@ -861,43 +867,102 @@ export default function PlanPage() {
-
-

永续行情

-
- 买一 - {fmtExPx("perp", snap?.perp?.bid)} + {isOo ? ( +
+

指数 / 振幅

+
+ 指数 + {fmtExPx("index", snap?.index_px)} +
+
+ + 高点 + {snap?.oo_amplitude?.hours != null + ? `(${fmt(snap.oo_amplitude.hours, 0)}h)` + : ""} + + + {fmtExPx("index", snap?.oo_amplitude?.high)} + +
+
+ 低点 + + {fmtExPx("index", snap?.oo_amplitude?.low)} + +
+
+ 振幅 + + {snap?.oo_amplitude?.range_pct != null + ? `${fmt(snap.oo_amplitude.range_pct, 2)}%` + : "—"} + {snap?.oo_amplitude?.min_pct != null + ? ` · 门限≥${fmt(snap.oo_amplitude.min_pct, 1)}%` + : ""} + {snap?.oo_amplitude?.ok === false + ? " · 不足" + : snap?.oo_amplitude?.ok === true + ? " · 达标" + : ""} + +
-
- 卖一 - {fmtExPx("perp", snap?.perp?.ask)} + ) : ( +
+

永续行情

+
+ 买一 + {fmtExPx("perp", snap?.perp?.bid)} +
+
+ 卖一 + {fmtExPx("perp", snap?.perp?.ask)} +
+
+ 市价 + + {fmtExPx( + "perp", + snap?.perp?.mark_px ?? + (snap?.perp?.bid != null && snap?.perp?.ask != null + ? (snap.perp.bid + snap.perp.ask) / 2 + : null), + )} + +
+
+ 指数 + {fmtExPx("index", snap?.index_px)} +
-
- 市价 - - {fmtExPx( - "perp", - snap?.perp?.mark_px ?? - (snap?.perp?.bid != null && snap?.perp?.ask != null - ? (snap.perp.bid + snap.perp.ask) / 2 - : null), - )} - -
-
- 指数 - {fmtExPx("index", snap?.index_px)} -
-
+ )}

- {open ? "持仓期权" : "期权 ATM"} - {snap?.pair - ? ` @ ${ - snap.pair.strike != null - ? Math.round(Number(snap.pair.strike)) - : "—" - }` - : ""} + {isOo + ? open + ? "持仓虚值" + : "期期虚值" + : open + ? "持仓期权" + : "期权 ATM"} + {isOo && snap?.pair + ? ` · C@${Math.round( + Number( + snap.pair.call_strike ?? snap.pair.strike ?? 0, + ), + )} / P@${Math.round( + Number( + snap.pair.put_strike ?? snap.pair.strike ?? 0, + ), + )}` + : snap?.pair + ? ` @ ${ + snap.pair.strike != null + ? Math.round(Number(snap.pair.strike)) + : "—" + }` + : ""}

{(() => { const under = @@ -921,17 +986,29 @@ export default function PlanPage() { : open && heldSide === "put" ? putLev : null; + const callK = snap?.pair?.call_strike ?? snap?.pair?.strike; + const putK = snap?.pair?.put_strike ?? snap?.pair?.strike; return ( <>
- Call {sideLabel} + + Call {sideLabel} + {isOo && callK != null + ? ` @${Math.round(Number(callK))}` + : ""} + {fmtTopEx("option", callPx, callSz)} {!open ? ` · 杠杆 ${callLev}` : ""}
- Put {sideLabel} + + Put {sideLabel} + {isOo && putK != null + ? ` @${Math.round(Number(putK))}` + : ""} + {fmtTopEx("option", putPx, putSz)} {!open ? ` · 杠杆 ${putLev}` : ""} @@ -940,7 +1017,7 @@ export default function PlanPage() {
实际杠杆 - {open + {open && !isOo ? heldLev || (heldSide ? "—" @@ -951,29 +1028,51 @@ export default function PlanPage() { ); })()} -
- 距现价 - - {(() => { - const strike = snap?.pair?.strike; - const px = - snap?.index_px ?? - (snap?.perp?.bid != null && snap?.perp?.ask != null - ? (snap.perp.bid + snap.perp.ask) / 2 - : null); - if (strike == null || px == null) return "—"; - const abs = Math.abs(strike - px); - const sign = strike - px > 0 ? "+" : ""; - const delta = `${sign}${(strike - px).toFixed(0)}`; - if (!plan?.atm_open_offset_enabled) { - return `${delta} · 偏差限制关`; - } - const lim = plan?.max_atm_open_offset ?? 3; - const ok = abs <= lim + 1e-9; - return `${delta} · 开仓${ok ? "可" : "不可"}(|Δ|≤${lim})`; - })()} - -
+ {isOo ? ( +
+ 相对现价 + + {(() => { + const px = snap?.index_px; + const ck = snap?.pair?.call_strike ?? snap?.pair?.strike; + const pk = snap?.pair?.put_strike; + if (px == null || ck == null) return "—"; + const cOff = Number(ck) - Number(px); + const pOff = + pk != null ? Number(pk) - Number(px) : null; + const fmtOff = (n: number) => + `${n > 0 ? "+" : ""}${n.toFixed(0)}`; + return pOff != null + ? `C ${fmtOff(cOff)} · P ${fmtOff(pOff)}` + : `C ${fmtOff(cOff)}`; + })()} + +
+ ) : ( +
+ 距现价 + + {(() => { + const strike = snap?.pair?.strike; + const px = + snap?.index_px ?? + (snap?.perp?.bid != null && snap?.perp?.ask != null + ? (snap.perp.bid + snap.perp.ask) / 2 + : null); + if (strike == null || px == null) return "—"; + const abs = Math.abs(strike - px); + const sign = strike - px > 0 ? "+" : ""; + const delta = `${sign}${(strike - px).toFixed(0)}`; + if (!plan?.atm_open_offset_enabled) { + return `${delta} · 偏差限制关`; + } + const lim = plan?.max_atm_open_offset ?? 3; + const ok = abs <= lim + 1e-9; + return `${delta} · 开仓${ok ? "可" : "不可"}(|Δ|≤${lim})`; + })()} + +
+ )}
到期 {snap?.pair?.expiry_ymd || "—"}