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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-14 14:32:06 +08:00
parent f6ef06f659
commit 8ecc70a61c
13 changed files with 233 additions and 53 deletions
+33 -11
View File
@@ -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) +
"</div>";
} else {
html += "<div><span class=\"muted\">目标价 S*</span> " + fmt(p.target_price) + "</div>";
html +=
"<div><span class=\"muted\">目标价</span> 上破 " +
fmt(p.target_price_up || p.target_price) +
" · 下破 " +
fmt(p.target_price_down || p.target_price) +
"</div>";
}
html +=
"<div><span class=\"muted\">权利金合计</span> " +
@@ -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")),
};
+16
View File
@@ -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;
+48 -17
View File
@@ -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,
},
}
+14
View File
@@ -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:
+32 -8
View File
@@ -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(
+17 -4
View File
@@ -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
+14 -2
View File
@@ -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 "未知计划类型"
+31 -4
View File
@@ -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,
@@ -118,11 +118,14 @@
<div class="form-row hp-uly-row">
<button type="button" class="btn-secondary hp-uly-btn-oo active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
<label>目标价 S* <input type="number" step="any" id="hp-target" /></label>
</div>
<div class="form-row hp-target-row">
<label>上破目标 <input type="number" step="any" id="hp-target-up" placeholder="向上突破" /></label>
<label>下破目标 <input type="number" step="any" id="hp-target-down" placeholder="向下突破" /></label>
</div>
<div id="hp-oo-index" class="muted hp-quote-line"></div>
<div id="hp-oo-bal-line" class="muted hp-quote-line"></div>
<p class="muted hp-unit-hint">单位说明:目标价=指数价(USD) · 张数=<strong>期权张</strong>(整张) · 权利金结算币=USDC</p>
<p class="muted hp-unit-hint">震荡突破:设上下两个目标价(USD);触达任一侧重平盈利腿。张数=<strong>期权张</strong> · 权利金=USDC</p>
<div id="hp-oo-legs" class="hp-oo-legs">
<div class="hp-oo-leg-row" data-leg="a">
<div class="muted" id="hp-oo-leg-a-info">腿A: 尚未选用</div>
@@ -186,7 +189,7 @@
</tr>
</thead>
<tbody id="hp-result-tbody-oo">
<tr><td colspan="6" class="muted">选用两腿并填目标后点计算</td></tr>
<tr><td colspan="6" class="muted">选用两腿并填上破/下破目标后点计算</td></tr>
</tbody>
</table>
</div>
@@ -230,4 +233,4 @@
</div>
</div>
</div>
<script src="/static/hedge_plan.js?v=11"></script>
<script src="/static/hedge_plan.js?v=12"></script>
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=6">
<link rel="stylesheet" href="/static/instance_theme.css?v=89">
<link rel="stylesheet" href="/static/instance_theme.css?v=90">
<script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14">
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
+1 -1
View File
@@ -17,7 +17,7 @@
<link rel="manifest" href="/static/icons/manifest.webmanifest">
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
<link rel="stylesheet" href="/static/instance_page.css?v=3">
<link rel="stylesheet" href="/static/instance_theme.css?v=89">
<link rel="stylesheet" href="/static/instance_theme.css?v=90">
</head>
<body
+17 -1
View File
@@ -72,9 +72,25 @@ class TestHedgePlanCalc(unittest.TestCase):
def test_oo_expiry_loss_flag(self):
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
p = build_options_options_preview(target_price=3500, index_px=3200, leg_a=a, leg_b=b)
p = build_options_options_preview(
target_price_up=3500,
target_price_down=3000,
index_px=3200,
leg_a=a,
leg_b=b,
)
self.assertEqual(p["summary"]["premium_paid"], 10)
self.assertTrue(p["summary"]["expiry_is_loss"])
self.assertEqual(len(p["scenarios"]), 4)
self.assertEqual(p["scenarios"][0]["id"], "target_up")
self.assertEqual(p["scenarios"][1]["id"], "target_down")
def test_oo_legacy_single_target_still_works(self):
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
p = build_options_options_preview(target_price=3500, index_px=3200, leg_a=a, leg_b=b)
self.assertEqual(p["target_price_up"], 3500)
self.assertEqual(p["target_price_down"], 3500)
def test_perp_short_pnl(self):
self.assertEqual(
+2
View File
@@ -148,6 +148,8 @@ class TestHedgePlanOrderPath(unittest.TestCase):
}
body = {
"target_price": 1900,
"target_price_up": 1950,
"target_price_down": 1750,
"leg_a": {"inst_id": "A", "sheets": 1},
"leg_b": {"inst_id": "B", "sheets": 1},
}