From 8ecc70a61cea1e4e2148b65740a9816068f21b6d Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 14 Jul 2026 14:32:06 +0800 Subject: [PATCH] Support dual breakout targets for options-options hedges. Replace single S* with up/down targets across UI, preview, persist, monitor, and alerts so ranging breakouts can close the winner either way. Co-authored-by: Cursor --- lib/common/static/hedge_plan.js | 44 +++++++++---- lib/common/static/instance_theme.css | 16 +++++ lib/hedge_plan/hedge_plan_calc_lib.py | 65 ++++++++++++++----- lib/hedge_plan/hedge_plan_db.py | 14 ++++ lib/hedge_plan/hedge_plan_monitor_lib.py | 40 +++++++++--- lib/hedge_plan/hedge_plan_notify_lib.py | 21 ++++-- lib/hedge_plan/hedge_plan_orders_lib.py | 16 ++++- lib/hedge_plan/hedge_plan_register.py | 35 ++++++++-- .../templates/hedge_plan_panel.html | 11 ++-- lib/instance/templates/embed_shell.html | 2 +- lib/instance/templates/index.html | 2 +- tests/test_hedge_plan_calc.py | 18 ++++- tests/test_hedge_plan_orders.py | 2 + 13 files changed, 233 insertions(+), 53 deletions(-) diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index 7918c65..a567855 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -324,8 +324,14 @@ fillExpSelect($("hp-oo-exp-select"), d); renderListStrikes(); renderTStrikes(); - if (d.index_px && $("hp-target") && !$("hp-target").value) { - $("hp-target").value = d.index_px; + if (d.index_px) { + const idx = Number(d.index_px); + if ($("hp-target-up") && !$("hp-target-up").value) { + $("hp-target-up").value = String(Math.round(idx * 1.03)); + } + if ($("hp-target-down") && !$("hp-target-down").value) { + $("hp-target-down").value = String(Math.round(idx * 0.97)); + } } } @@ -583,7 +589,8 @@ if ($("hp-contracts")) $("hp-contracts").value = ""; if ($("hp-tp")) $("hp-tp").value = ""; if ($("hp-sl")) $("hp-sl").value = ""; - if ($("hp-target")) $("hp-target").value = ""; + if ($("hp-target-up")) $("hp-target-up").value = ""; + if ($("hp-target-down")) $("hp-target-down").value = ""; if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; if ($("hp-premium-line")) $("hp-premium-line").textContent = ""; if ($("hp-oo-sheets-a")) { @@ -606,12 +613,16 @@ let body; if (isOo) { if (!state.legA || !state.legB) throw new Error("请选用两条期权腿"); - const target = Number(($("hp-target") && $("hp-target").value) || 0); - if (!target) throw new Error("请填写目标价"); + const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0); + const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0); + if (!up || !down) throw new Error("请填写上破与下破目标价"); + if (up <= down) throw new Error("上破目标价必须大于下破目标价"); body = { plan_type: "options_options", - target_price: target, - index_px: (state.chain && state.chain.index_px) || target, + target_price_up: up, + target_price_down: down, + target_price: up, + index_px: (state.chain && state.chain.index_px) || (up + down) / 2, leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), }; @@ -862,6 +873,8 @@ oo_expiry_loss: "期期到期亏损", oo_expiry_win: "期期到期盈利", target_win_leg: "期期平盈利腿", + target_up_win_leg: "期期上破·平盈利腿", + target_down_win_leg: "期期下破·平盈利腿", expiry: "到期", manual: "人工结束", partial_fail: "半腿失败", @@ -930,7 +943,12 @@ fmt(p.perp_size, 4) + ""; } else { - html += "
目标价 S* " + fmt(p.target_price) + "
"; + html += + "
目标价 上破 " + + fmt(p.target_price_up || p.target_price) + + " · 下破 " + + fmt(p.target_price_down || p.target_price) + + "
"; } html += "
权利金合计 " + @@ -1084,12 +1102,16 @@ let body; if (isOo) { if (!state.legA || !state.legB) throw new Error("请选用两条期权腿"); - const target = Number(($("hp-target") && $("hp-target").value) || 0); - if (!target) throw new Error("请填写目标价"); + const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0); + const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0); + if (!up || !down) throw new Error("请填写上破与下破目标价"); + if (up <= down) throw new Error("上破目标价必须大于下破目标价"); body = { plan_type: "options_options", underlying: state.underlying, - target_price: target, + target_price_up: up, + target_price_down: down, + target_price: up, leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), }; diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 8678072..2539613 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -3403,6 +3403,22 @@ html[data-theme="light"] .opt-be-dist-down { background: rgba(255, 255, 255, 0.03); font-size: 0.78rem; } +.hedge-plan-page-wrap .hp-target-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin: 6px 0; +} +.hedge-plan-page-wrap .hp-target-row label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; +} +.hedge-plan-page-wrap .hp-target-row input { + width: 110px; +} .hedge-plan-page-wrap .hp-contracts-cell { max-width: 220px; overflow: hidden; diff --git a/lib/hedge_plan/hedge_plan_calc_lib.py b/lib/hedge_plan/hedge_plan_calc_lib.py index 130cc21..a4fae7d 100644 --- a/lib/hedge_plan/hedge_plan_calc_lib.py +++ b/lib/hedge_plan/hedge_plan_calc_lib.py @@ -223,12 +223,14 @@ def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]: def build_options_options_preview( *, - target_price: float, + target_price: float | None = None, + target_price_up: float | None = None, + target_price_down: float | None = None, index_px: float, leg_a: dict[str, Any], leg_b: dict[str, Any], ) -> dict[str, Any]: - """期期情景:目标价 / 到期现价 / 到期两边.""" + """期期情景:上破/下破目标价 / 到期现价 / 最大保费损耗.""" def _leg_pnl(leg: dict[str, Any], spot: float) -> float: return option_expiry_pnl( @@ -240,11 +242,25 @@ def build_options_options_preview( premium_paid=float(leg.get("premium_paid") or 0), ) + # 兼容旧单目标:若未传上下目标则用 target_price 填两边 + up = target_price_up if target_price_up is not None else target_price + down = target_price_down if target_price_down is not None else target_price + if up is None or down is None: + raise ValueError("缺少上破/下破目标价") + up_f = float(up) + down_f = float(down) + prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0) - a_t = _leg_pnl(leg_a, target_price) - b_t = _leg_pnl(leg_b, target_price) - at_target = a_t + b_t - win_leg = "a" if a_t >= b_t else "b" + a_up = _leg_pnl(leg_a, up_f) + b_up = _leg_pnl(leg_b, up_f) + at_up = a_up + b_up + win_up = "a" if a_up >= b_up else "b" + + a_dn = _leg_pnl(leg_a, down_f) + b_dn = _leg_pnl(leg_b, down_f) + at_dn = a_dn + b_dn + win_dn = "a" if a_dn >= b_dn else "b" + a_flat = _leg_pnl(leg_a, index_px) b_flat = _leg_pnl(leg_b, index_px) flat_total = a_flat + b_flat @@ -253,17 +269,30 @@ def build_options_options_preview( return { "plan_type": "options_options", "premium_paid": round(prem, 6), - "target_price": target_price, - "winner_at_target": win_leg, + "target_price": up_f, # 兼容旧字段,取上破 + "target_price_up": up_f, + "target_price_down": down_f, + "winner_at_up": win_up, + "winner_at_down": win_dn, + "winner_at_target": win_up, "scenarios": [ { - "id": "target", - "label": "到达目标价", - "spot": target_price, - "leg_a_pnl": round(a_t, 4), - "leg_b_pnl": round(b_t, 4), - "total": round(at_target, 4), - "note": f"盈利方≈腿{win_leg.upper()}(可平);亏损方默认到期", + "id": "target_up", + "label": "上破目标", + "spot": up_f, + "leg_a_pnl": round(a_up, 4), + "leg_b_pnl": round(b_up, 4), + "total": round(at_up, 4), + "note": f"盈利方≈腿{win_up.upper()}(可平);亏损方默认到期", + }, + { + "id": "target_down", + "label": "下破目标", + "spot": down_f, + "leg_a_pnl": round(a_dn, 4), + "leg_b_pnl": round(b_dn, 4), + "total": round(at_dn, 4), + "note": f"盈利方≈腿{win_dn.upper()}(可平);亏损方默认到期", }, { "id": "expiry_flat", @@ -285,9 +314,11 @@ def build_options_options_preview( }, ], "summary": { - "at_target_total": round(at_target, 4), + "at_target_up_total": round(at_up, 4), + "at_target_down_total": round(at_dn, 4), + "at_target_total": round(at_up, 4), "expiry_flat_total": round(expiry_loss, 4), - "premium_paid": round(prem, 4), + "premium_paid": round(prem, 6), "expiry_is_loss": flat_total <= 0, }, } diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py index c8b1052..9ce5a0b 100644 --- a/lib/hedge_plan/hedge_plan_db.py +++ b/lib/hedge_plan/hedge_plan_db.py @@ -70,6 +70,20 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None: conn.execute( "CREATE INDEX IF NOT EXISTS idx_hedge_plan_legs_plan ON hedge_plan_legs(plan_id)" ) + _ensure_column(conn, "hedge_plans", "target_price_up", "REAL") + _ensure_column(conn, "hedge_plans", "target_price_down", "REAL") + + +def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + names: set[str] = set() + for r in rows: + try: + names.add(str(r["name"])) + except (TypeError, KeyError, IndexError): + names.add(str(r[1])) + if col not in names: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}") def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int: diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py index 4255473..1048ea8 100644 --- a/lib/hedge_plan/hedge_plan_monitor_lib.py +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -235,12 +235,29 @@ def _tick_po(cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[di def _tick_oo_target( cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] ) -> Optional[dict[str, Any]]: - target = _sf(plan.get("target_price")) + """期期:触及上破或下破目标价时平盈利腿.""" idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) - if target is None or idx is None: + if idx is None: return None - near = abs(idx - target) / max(abs(target), 1.0) <= 0.002 - if not near: + up = _sf(plan.get("target_price_up")) + down = _sf(plan.get("target_price_down")) + # 旧计划仅有单目标:两边都用它 + legacy = _sf(plan.get("target_price")) + if up is None and legacy is not None: + up = legacy + if down is None and legacy is not None: + down = legacy + if up is None and down is None: + return None + + hit_side: Optional[str] = None + # 上破:现价接近或超过上破目标 + if up is not None and idx >= up * 0.998: + hit_side = "up" + # 下破:现价接近或低于下破目标 + elif down is not None and idx <= down * 1.002: + hit_side = "down" + if not hit_side: return None if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True): return None @@ -270,16 +287,23 @@ def _tick_oo_target( ), ) return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r} + reason = "target_up_win_leg" if hit_side == "up" else "target_down_win_leg" conn.execute( "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", "target_win_leg", _now(), best_pnl, best["id"]), + ("closed", reason, _now(), best_pnl, best["id"]), ) - update_plan(conn, int(plan["id"]), close_reason="target_win_leg") + update_plan(conn, int(plan["id"]), close_reason=reason) mid = dict(plan) - mid["close_reason"] = "target_win_leg" + mid["close_reason"] = reason mid["status"] = "active" notify_plan_end(cfg, conn, mid) - return {"plan_id": plan["id"], "close_reason": "target_win_leg", "closed_leg": best.get("id")} + return { + "plan_id": plan["id"], + "close_reason": reason, + "hit_side": hit_side, + "closed_leg": best.get("id"), + "index": idx, + } def _tick_oo_expiry( diff --git a/lib/hedge_plan/hedge_plan_notify_lib.py b/lib/hedge_plan/hedge_plan_notify_lib.py index a5f8736..76a13a3 100644 --- a/lib/hedge_plan/hedge_plan_notify_lib.py +++ b/lib/hedge_plan/hedge_plan_notify_lib.py @@ -48,7 +48,8 @@ def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[ else: lines.extend( [ - f"🎯 目标价 S*:{_fmt(plan.get('target_price'))}", + f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}" + f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}", f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", ] ) @@ -78,6 +79,8 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str: "perp_tp": "永续止盈(期权默认不平)", "perp_sl": "永续止损(期权强制平)", "target_win_leg": "期期已平盈利腿(中间态)", + "target_up_win_leg": "期期上破·已平盈利腿", + "target_down_win_leg": "期期下破·已平盈利腿", "oo_expiry_loss": "期期到期无盈利·总亏损", "oo_expiry_win": "期期到期仍盈利", "expiry": "到期收口", @@ -143,13 +146,23 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo if int(plan.get("wechat_end_sent") or 0): return False # 中间态 target_win_leg 不算正式结束推送(用告警) - if (plan.get("close_reason") or "") == "target_win_leg" and (plan.get("status") or "") != "closed": + if (plan.get("close_reason") or "") in ( + "target_win_leg", + "target_up_win_leg", + "target_down_win_leg", + ) and (plan.get("status") or "") != "closed": + side = "上破" if "up" in str(plan.get("close_reason")) else ( + "下破" if "down" in str(plan.get("close_reason")) else "目标价" + ) notify_hedge( cfg, build_hedge_alert_message( - title="期期已平盈利腿,亏损腿继续持有至到期", + title=f"期期{side}已平盈利腿,亏损腿继续持有至到期", plan_id=plan.get("id"), - detail=f"目标价 {_fmt(plan.get('target_price'))}", + detail=( + f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}" + f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}" + ), ), ) return True diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index 9a335ea..e4635db 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -382,8 +382,20 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: b = body.get("leg_b") or {} if not a.get("inst_id") or not b.get("inst_id"): return "请选用两条期权腿" - if body.get("target_price") in (None, ""): - return "缺少目标价" + up = body.get("target_price_up") + down = body.get("target_price_down") + legacy = body.get("target_price") + if up in (None, "") and legacy not in (None, ""): + up = legacy + if down in (None, "") and legacy not in (None, ""): + down = legacy + if up in (None, "") or down in (None, ""): + return "请填写上破与下破目标价" + try: + if float(up) <= float(down): + return "上破目标价必须大于下破目标价" + except (TypeError, ValueError): + return "目标价无效" return None return "未知计划类型" diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index 617604d..0c17f83 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -269,7 +269,21 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any "plan_type": "options_options", "status": "active", "underlying": str(body.get("underlying") or "ETH").upper(), - "target_price": float(body.get("target_price") or 0), + "target_price": float( + body.get("target_price_up") + or body.get("target_price") + or 0 + ), + "target_price_up": float( + body.get("target_price_up") + or body.get("target_price") + or 0 + ), + "target_price_down": float( + body.get("target_price_down") + or body.get("target_price") + or 0 + ), "sizing_mode_at_open": load_position_sizing_mode(), "premium_total": premium, "opened_at": result.get("opened_at"), @@ -610,8 +624,20 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]: def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: - target = float(body["target_price"]) - index_px = float(body.get("index_px") or target) + up = body.get("target_price_up") + down = body.get("target_price_down") + legacy = body.get("target_price") + if up in (None, "") and legacy not in (None, ""): + up = legacy + if down in (None, "") and legacy not in (None, ""): + down = legacy + if up in (None, "") or down in (None, ""): + raise ValueError("请填写上破与下破目标价") + up_f = float(up) + down_f = float(down) + if up_f <= down_f: + raise ValueError("上破目标价必须大于下破目标价") + index_px = float(body.get("index_px") or ((up_f + down_f) / 2)) leg_a = body.get("leg_a") or {} leg_b = body.get("leg_b") or {} for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)): @@ -626,7 +652,8 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: if leg.get("premium_paid") is None: raise ValueError(f"缺少 {name} 权利金") return build_options_options_preview( - target_price=target, + target_price_up=up_f, + target_price_down=down_f, index_px=index_px, leg_a=leg_a, leg_b=leg_b, diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index a0393d8..bec5c75 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -118,11 +118,14 @@
- +
+
+ +
-

单位说明:目标价=指数价(USD) · 张数=期权张(整张) · 权利金结算币=USDC

+

震荡突破:设上下两个目标价(USD);触达任一侧重平盈利腿。张数=期权张 · 权利金=USDC

腿A: 尚未选用
@@ -186,7 +189,7 @@ - 选用两腿并填目标价后点计算 + 选用两腿并填上破/下破目标后点计算
@@ -230,4 +233,4 @@
- + diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index f83cf80..0791ce0 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -7,7 +7,7 @@ - + {{ exchange_display }} · 加密货币 | 交易监控复盘系统 diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 00ec504..3abdfb9 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -17,7 +17,7 @@ {{ exchange_display }} · 加密货币 | 交易监控复盘系统 - +