diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 19eb5a2..3d00ccd 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -978,9 +978,9 @@ class StrategyEngine: rk = 1.0 semi_d = check_semi_exits( net_pnl=float(upl.get("net_pnl") or 0), - entry_index=( - float(upl["entry_index_px"]) - if upl.get("entry_index_px") is not None + strike=( + float(upl["strike"]) + if upl.get("strike") is not None else None ), index_px=( @@ -992,6 +992,11 @@ class StrategyEngine: option_move_points=float(sp["option_move_points"]), perp_exit_unit=float(sp["perp_exit_unit"]), risk_k=rk, + entry_index=( + float(upl["entry_index_px"]) + if upl.get("entry_index_px") is not None + else None + ), ) # 复用 ExitDecision 形态 from .exits import ExitDecision diff --git a/backend/app/strategy/semi_auto.py b/backend/app/strategy/semi_auto.py index 081defc..c46f847 100644 --- a/backend/app/strategy/semi_auto.py +++ b/backend/app/strategy/semi_auto.py @@ -201,17 +201,18 @@ class SemiExitDecision: def check_semi_exits( *, net_pnl: float, - entry_index: float | None, + strike: float | None, index_px: float | None, view_side: str, option_move_points: float, perp_exit_unit: float, risk_k: float = 1.0, + entry_index: float | None = None, ) -> SemiExitDecision: """ - 顺方向:标的波动达到目标点 且 组合净利>0 → 全平。 - 逆方向兑现:组合净利 ≥ 永续出场基数×k → 全平。 - 流动性在 close_group 内再验;平仓顺序已是先期权后永续。 + 顺方向:指数到达「行权价 ± 波动点」且组合净利>0 → 全平。 + 多/Call:目标 = K + N;空/Put:目标 = K − N(N 为设置的波动点,不是现价±N)。 + 逆方向兑现(永续锁定净利):组合净利 ≥ 净利基数×k → 全平。 """ view = (view_side or "long").strip().lower() if view not in ("long", "short"): @@ -221,34 +222,39 @@ def check_semi_exits( net_tgt = max(0.0, float(perp_exit_unit)) * k net = float(net_pnl) - # 逆方向 / 对冲兑现:净利达标即可离场(不必等点位) + # 逆方向 / 永续净利锁定:达标即可离场(不必等点位) if net_tgt > 0 and net + 1e-9 >= net_tgt: return SemiExitDecision( True, REASON_PERP_NET, - f"半自动·净利≥{net_tgt:.2f}U(基数×k)", + f"半自动·永续净利锁定≥{net_tgt:.2f}U(基数×k)", net_target=net_tgt, ) - if entry_index is None or index_px is None: + if index_px is None: return SemiExitDecision(False, "", "缺指数") - entry = float(entry_index) idx = float(index_px) - if entry <= 0 or idx <= 0 or move <= 0: + # 锚定行权价;无 strike 时才回退开仓指数(兼容旧仓) + anchor = None + if strike is not None and float(strike) > 0: + anchor = float(strike) + elif entry_index is not None and float(entry_index) > 0: + anchor = float(entry_index) + if anchor is None or idx <= 0 or move <= 0: return SemiExitDecision(False, "", "点位无效") if view == "long": - target_idx = entry + move + target_idx = anchor + move hit = idx + 1e-9 >= target_idx else: - target_idx = entry - move + target_idx = anchor - move hit = idx - 1e-9 <= target_idx if hit and net > 0: return SemiExitDecision( True, REASON_POINTS, - f"半自动·标的到{target_idx:.2f}且组合净利>0", + f"半自动·指数到期权目标{target_idx:.2f}(K{anchor:g}±{move:g})且净利>0", target_index=target_idx, net_target=0.0, ) @@ -256,13 +262,13 @@ def check_semi_exits( return SemiExitDecision( False, "", - f"已到点位{target_idx:.2f}但组合净利≤0({net:.2f}),继续持有", + f"已到期权目标{target_idx:.2f}但组合净利≤0({net:.2f}),继续持有", target_index=target_idx, ) return SemiExitDecision( False, "", - f"未到点位(目标{target_idx:.2f})", + f"未到期权目标(K{anchor:g}→{target_idx:.2f})", target_index=target_idx, net_target=net_tgt, ) diff --git a/backend/tests/test_semi_auto.py b/backend/tests/test_semi_auto.py index 7f5f08f..68a0ace 100644 --- a/backend/tests/test_semi_auto.py +++ b/backend/tests/test_semi_auto.py @@ -12,10 +12,10 @@ from app.strategy.semi_auto import ( def test_semi_points_long_needs_net_positive() -> None: - # 到点但净利≤0 → 不平 + # 目标 = 行权价 1800 + 50 = 1850;到点但净利≤0 → 不平 d = check_semi_exits( net_pnl=-1.0, - entry_index=1800, + strike=1800, index_px=1850, view_side="long", option_move_points=50, @@ -27,7 +27,7 @@ def test_semi_points_long_needs_net_positive() -> None: d2 = check_semi_exits( net_pnl=1.0, - entry_index=1800, + strike=1800, index_px=1850, view_side="long", option_move_points=50, @@ -36,12 +36,41 @@ def test_semi_points_long_needs_net_positive() -> None: ) assert d2.should_close is True assert d2.reason == REASON_POINTS + assert d2.target_index == 1850.0 + + +def test_semi_points_uses_strike_not_spot() -> None: + # 现价 1915、K1930、+50 → 目标 1980;现价未到则不平 + d = check_semi_exits( + net_pnl=5.0, + strike=1930, + index_px=1915, + view_side="long", + option_move_points=50, + perp_exit_unit=5, + risk_k=1, + ) + assert d.should_close is False + assert d.target_index == 1980.0 + + d2 = check_semi_exits( + net_pnl=5.0, + strike=1930, + index_px=1980, + view_side="long", + option_move_points=50, + perp_exit_unit=5, + risk_k=1, + ) + assert d2.should_close is True + assert d2.reason == REASON_POINTS def test_semi_points_short() -> None: + # Put:目标 = K − 50 d = check_semi_exits( net_pnl=2.0, - entry_index=1800, + strike=1800, index_px=1750, view_side="short", option_move_points=50, @@ -50,13 +79,14 @@ def test_semi_points_short() -> None: ) assert d.should_close is True assert d.reason == REASON_POINTS + assert d.target_index == 1750.0 def test_semi_net_exit_with_k() -> None: - # 未到点,但净利 ≥ 5×2=10 + # 未到点,但净利 ≥ 5×2=10(永续锁定) d = check_semi_exits( net_pnl=10.0, - entry_index=1800, + strike=1800, index_px=1810, view_side="long", option_move_points=50, @@ -71,7 +101,7 @@ def test_semi_net_exit_with_k() -> None: def test_semi_not_yet() -> None: d = check_semi_exits( net_pnl=3.0, - entry_index=1800, + strike=1800, index_px=1820, view_side="long", option_move_points=50, diff --git a/docs/半自动说明.md b/docs/半自动说明.md index b8c368e..366cdee 100644 --- a/docs/半自动说明.md +++ b/docs/半自动说明.md @@ -59,15 +59,15 @@ | 规则 | 条件 | close_reason | |------|------|----------------| -| 顺方向 | 指数相对**开仓指数**达到目标点数,**且**组合净利 > 0 | `semi_target_points` | -| 逆方向 / 兑现 | 组合净利 ≥ `semi_perp_exit_unit × k`(k=以损倍数,手动仓视为 1) | `semi_perp_exit` | +| 顺方向(期权目标) | 指数到达 **行权价 ± 波动点**(多 Call:K+N;空 Put:K−N),**且**组合净利 > 0 | `semi_target_points` | +| 永续净利锁定 | 组合净利 ≥ `semi_perp_exit_unit × k`(k=以损倍数,手动仓视为 1) | `semi_perp_exit` | +- 波动点 N 相对**行权价**,不是现价/开仓价 ±N。 - 到点但净利 ≤ 0:继续持有(状态提示,不平)。 - 流动性不足:进入 `liquidity_wait`;回落未达标则取消挂起。 - 半自动平仓**禁止**远虚「只平永续、期权归档」;顺序仍为**先期权后永续**。 -首页预览「指数 → 到点」用**当前指数**示意;真实触发达标用**开仓指数**。 -「波动点」旁的**顺向预估**按选定行权价的内在价值变化 + 永续点位粗算(不含 IV/时间价值);与「净利基数×k」逆向兑现目标分开显示。 +首页预览「期权目标 K→K±N」+ 到点粗估盈亏;「永续锁定」为净利基数×k。 --- diff --git a/docs/更新说明.md b/docs/更新说明.md index 9eb1120..ce10a1c 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,15 @@ --- +## 2026-08-08 — 半自动出场锚定行权价 + 左右布局 + +### 变更 + +1. 顺向出场目标改为 **行权价 ± 波动点**(非现价±N);出场规则展示「期权目标 / 永续净利锁定」。 +2. 半自动单页两卡片改为左右并排(窄屏仍上下)。 + +--- + ## 2026-08-08 — 半自动波动点顺向预估盈利 ### 变更 diff --git a/frontend/src/labels.ts b/frontend/src/labels.ts index 58b79d0..75e6dbe 100644 --- a/frontend/src/labels.ts +++ b/frontend/src/labels.ts @@ -28,8 +28,8 @@ const CLOSE_REASON_ZH: Record = { premium_multiple: "权利金倍数达标·双腿全平", target_perp_only: "净盈利达标·只平永续(期权归档)", residual_premium_close: "残留期权·权利金回收中途平", - semi_target_points: "半自动·标的到点且组合净利>0·双腿全平", - semi_perp_exit: "半自动·净利基数达标·双腿全平", + semi_target_points: "半自动·指数到行权价±波动点且净利>0·双腿全平", + semi_perp_exit: "半自动·永续净利锁定达标·双腿全平", expiry: "到期结算", emergency: "紧急全平", manual: "手动平仓", diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 4b63248..e6a991c 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -226,8 +226,8 @@ function estimateSemiRiskQty(args: { } /** - * 标的顺向波动 move 点后的组合盈亏粗估(期权用内在价值变化,永续按点位)。 - * 多=Call+永续空;空=Put+永续多。不含权利金时间价值/IV。 + * 指数走到「行权价 ± 波动点」时的组合盈亏粗估。 + * 多=Call 目标 K+N;空=Put 目标 K−N。不含 IV/时间价值。 */ function estimateSemiMoveProfit(args: { view: "long" | "short"; @@ -263,16 +263,17 @@ function estimateSemiMoveProfit(args: { if (!(S > 0) || !(K > 0) || !(move > 0) || !(oq > 0) || !(pq > 0)) { return { ok: false, detail: "缺指数/行权价/数量" }; } - const tgt = view === "long" ? S + move : S - move; + const tgt = view === "long" ? K + move : K - move; + const dSpot = tgt - S; const callIntr = (px: number) => Math.max(0, px - K); const putIntr = (px: number) => Math.max(0, K - px); const dIntr = view === "long" ? callIntr(tgt) - callIntr(S) : putIntr(tgt) - putIntr(S); - // 多:空永续随涨亏;空:多永续随跌亏 → 均为 -move×qty const optionPnl = dIntr * oq; - const perpPnl = -move * pq; + // 多:空永续;空:多永续 → 指数变化 dSpot 时永续盈亏 = −dSpot×qty + const perpPnl = -dSpot * pq; const fees = feeRate > 0 ? S * feeRate * 3 * Math.max(oq, pq) /* 粗估开平 */ : 0; const net = optionPnl + perpPnl - fees; @@ -566,23 +567,50 @@ export default function PlanPage() { const movePct = pos?.move_pct ?? 0; const ooRatio = Number(plan?.oo_reward_ratio ?? 2); const ooBudget = plan?.risk_sizing_preview?.budget; - const exitRuleLabel = isOo - ? exitTarget != null - ? `${riskLocked ? "锁定 " : ""}净盈≥${fmt(exitTarget)} U(${ - ooBudget != null ? `${fmt(ooBudget)}×` : "预算×" - }${fmt(ooRatio, 1)})` - : `净盈≥预算×${fmt(ooRatio, 1)}(达标只平盈利腿)` - : exitMode === "premium_multiple" - ? riskBased - ? exitTarget != null - ? `${riskLocked ? "锁定 " : ""}净盈≥${fmt(exitTarget)} U(权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)})` - : `权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}(开仓后锁定)` - : `权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}` - : riskBased - ? exitTarget != null - ? `${riskLocked ? "锁定 " : "固定 "}${fmt(exitTarget)} U(基数${fmt(plan?.risk_exit_unit ?? 15)})` - : `待估算(基数${fmt(plan?.risk_exit_unit ?? 15)})` - : `固定 ${fmt(plan?.net_profit_target ?? 15)} U`; + const semiExitMove = Number(semiMove) || 50; + const semiExitView: "long" | "short" = + (semiDirty ? semiView : plan?.semi_view_side === "short" ? "short" : "long") === + "short" + ? "short" + : "long"; + const semiExitStrike = + open && pos?.strike != null && Number.isFinite(Number(pos.strike)) + ? Number(pos.strike) + : null; + const semiOptTarget = + semiExitStrike != null + ? semiExitView === "short" + ? semiExitStrike - semiExitMove + : semiExitStrike + semiExitMove + : null; + const semiNetLock = Number( + plan?.semi_net_exit_target ?? + (riskBased + ? Number(semiExitU) * + Number(plan?.risk_last_k ?? plan?.risk_sizing_preview?.k ?? 1) + : semiExitU), + ); + const exitRuleLabel = semiOn + ? semiOptTarget != null + ? `期权目标 K${Math.round(semiExitStrike!)}→${fmtExPx("index", semiOptTarget)} · 永续净利锁定≥${fmt(semiNetLock, 2)}U` + : `期权目标 行权价${semiExitView === "short" ? "−" : "+"}${fmt(semiExitMove, 0)} · 永续净利锁定≥${fmt(semiNetLock, 2)}U` + : isOo + ? exitTarget != null + ? `${riskLocked ? "锁定 " : ""}净盈≥${fmt(exitTarget)} U(${ + ooBudget != null ? `${fmt(ooBudget)}×` : "预算×" + }${fmt(ooRatio, 1)})` + : `净盈≥预算×${fmt(ooRatio, 1)}(达标只平盈利腿)` + : exitMode === "premium_multiple" + ? riskBased + ? exitTarget != null + ? `${riskLocked ? "锁定 " : ""}净盈≥${fmt(exitTarget)} U(权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)})` + : `权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}(开仓后锁定)` + : `权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}` + : riskBased + ? exitTarget != null + ? `${riskLocked ? "锁定 " : "固定 "}${fmt(exitTarget)} U(基数${fmt(plan?.risk_exit_unit ?? 15)})` + : `待估算(基数${fmt(plan?.risk_exit_unit ?? 15)})` + : `固定 ${fmt(plan?.net_profit_target ?? 15)} U`; const riskRatioLabel = isOo ? "预算平分 Call/Put" : `比例${Number(plan?.risk_perp_unit ?? 1)}:${Number(plan?.risk_option_unit ?? 2)}`; @@ -887,9 +915,9 @@ export default function PlanPage() {
  • 人工定方向、行权类型、永续:期权配比与出场 →「授权开下一单」后机器盯选约/开/平。
  • 平仓后停在等待授权,不自动连开;与全自动循环无关。
  • 虚值:偏离 ≤ 设定点数;杠杆门 ≥180(默认 200)。实值/平值用本单最低杠杆。
  • -
  • 波动点:标的相对当前价顺向波动 N 点(多涨/空跌);下方按行权价内在价值变化 + 永续点位粗估盈利。
  • -
  • 顺向出场:开仓指数 ± 波动点,且组合净利 > 0 → 双腿全平(先期权后永续)。
  • -
  • 逆向兑现:组合净利 ≥ 净利基数 × k → 全平。
  • +
  • 波动点 N:期权目标位 = 行权价 ± N(多 Call:K+N;空 Put:K−N),不是现价±N。
  • +
  • 顺向出场:指数到期权目标位且组合净利 > 0 → 双腿全平(先期权后永续)。
  • +
  • 永续锁定:组合净利 ≥ 净利基数 × k → 全平(不必等点位)。
  • 以损定仓时配比为单位再乘 k;手动仓按配比直接开。
  • @@ -981,7 +1009,7 @@ export default function PlanPage() { />
    - + - {fmtExPx("index", idx)}→{fmtExPx("index", prof.targetPx!)} · - K{Math.round(strike)} · 顺向预估≈{fmt(prof.netEst, 2)}U + 期权目标 K{Math.round(strike)}→ + {fmtExPx("index", prof.targetPx!)} · 现价 + {fmtExPx("index", idx)} · 到点预估≈{fmt(prof.netEst, 2)}U {" "} (期权{fmt(prof.optionPnl, 1)}/永续 - {fmt(prof.perpPnl, 1)} · 内在粗估) + {fmt(prof.perpPnl, 1)}) {" · "} - 逆向≥{fmt(reverseTgt, 2)}U + 永续锁定≥{fmt(reverseTgt, 2)}U ); })()} @@ -1437,7 +1466,8 @@ export default function PlanPage() { ? ` · 虚值≤${fmt(semiOtmOff, 0)}点` : ""} {" · "} - 顺向±{fmt(semiMove, 0)}点且净利>0 / 净利≥ + 期权目标K{semiExitView === "short" ? "−" : "+"} + {fmt(semiMove, 0)}且净利>0 / 永续锁定≥ {fmt(semiExitU, 1)}×k ) : isOo ? ( diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 7c39b4a..8535d88 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -474,10 +474,22 @@ input { } .plan-semi-tab { - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px; margin-bottom: 12px; + align-items: start; +} + +.plan-semi-tab > .card { + margin-bottom: 0; + min-width: 0; +} + +@media (max-width: 960px) { + .plan-semi-tab { + grid-template-columns: 1fr; + } } .plan-semi-compact {