Anchor semi exit to strike +/- move points; side-by-side semi cards.

Forward target is K+/-N not spot+/-N; UI shows option target and perp net lock.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-08 14:59:48 +08:00
parent 6f983ed2ab
commit 4f56eff40c
8 changed files with 162 additions and 70 deletions
+8 -3
View File
@@ -978,9 +978,9 @@ class StrategyEngine:
rk = 1.0 rk = 1.0
semi_d = check_semi_exits( semi_d = check_semi_exits(
net_pnl=float(upl.get("net_pnl") or 0), net_pnl=float(upl.get("net_pnl") or 0),
entry_index=( strike=(
float(upl["entry_index_px"]) float(upl["strike"])
if upl.get("entry_index_px") is not None if upl.get("strike") is not None
else None else None
), ),
index_px=( index_px=(
@@ -992,6 +992,11 @@ class StrategyEngine:
option_move_points=float(sp["option_move_points"]), option_move_points=float(sp["option_move_points"]),
perp_exit_unit=float(sp["perp_exit_unit"]), perp_exit_unit=float(sp["perp_exit_unit"]),
risk_k=rk, risk_k=rk,
entry_index=(
float(upl["entry_index_px"])
if upl.get("entry_index_px") is not None
else None
),
) )
# 复用 ExitDecision 形态 # 复用 ExitDecision 形态
from .exits import ExitDecision from .exits import ExitDecision
+20 -14
View File
@@ -201,17 +201,18 @@ class SemiExitDecision:
def check_semi_exits( def check_semi_exits(
*, *,
net_pnl: float, net_pnl: float,
entry_index: float | None, strike: float | None,
index_px: float | None, index_px: float | None,
view_side: str, view_side: str,
option_move_points: float, option_move_points: float,
perp_exit_unit: float, perp_exit_unit: float,
risk_k: float = 1.0, risk_k: float = 1.0,
entry_index: float | None = None,
) -> SemiExitDecision: ) -> SemiExitDecision:
""" """
顺方向:标的波动达到目标点 且 组合净利>0 → 全平。 顺方向:指数到达「行权价 ± 波动点」且组合净利>0 → 全平。
逆方向兑现:组合净利 ≥ 永续出场基数×k → 全平 多/Call:目标 = K + N;空/Put:目标 = K − N(N 为设置的波动点,不是现价±N)
流动性在 close_group 内再验;平仓顺序已是先期权后永续 逆方向兑现(永续锁定净利):组合净利 ≥ 净利基数×k → 全平
""" """
view = (view_side or "long").strip().lower() view = (view_side or "long").strip().lower()
if view not in ("long", "short"): 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_tgt = max(0.0, float(perp_exit_unit)) * k
net = float(net_pnl) net = float(net_pnl)
# 逆方向 / 对冲兑现:净利达标即可离场(不必等点位) # 逆方向 / 永续净利锁定:达标即可离场(不必等点位)
if net_tgt > 0 and net + 1e-9 >= net_tgt: if net_tgt > 0 and net + 1e-9 >= net_tgt:
return SemiExitDecision( return SemiExitDecision(
True, True,
REASON_PERP_NET, REASON_PERP_NET,
f"半自动·净利{net_tgt:.2f}U(基数×k", f"半自动·永续净利锁定{net_tgt:.2f}U(基数×k",
net_target=net_tgt, net_target=net_tgt,
) )
if entry_index is None or index_px is None: if index_px is None:
return SemiExitDecision(False, "", "缺指数") return SemiExitDecision(False, "", "缺指数")
entry = float(entry_index)
idx = float(index_px) 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, "", "点位无效") return SemiExitDecision(False, "", "点位无效")
if view == "long": if view == "long":
target_idx = entry + move target_idx = anchor + move
hit = idx + 1e-9 >= target_idx hit = idx + 1e-9 >= target_idx
else: else:
target_idx = entry - move target_idx = anchor - move
hit = idx - 1e-9 <= target_idx hit = idx - 1e-9 <= target_idx
if hit and net > 0: if hit and net > 0:
return SemiExitDecision( return SemiExitDecision(
True, True,
REASON_POINTS, REASON_POINTS,
f"半自动·标的到{target_idx:.2f}组合净利>0", f"半自动·指数到期权目{target_idx:.2f}K{anchor:g}±{move:g}且净利>0",
target_index=target_idx, target_index=target_idx,
net_target=0.0, net_target=0.0,
) )
@@ -256,13 +262,13 @@ def check_semi_exits(
return SemiExitDecision( return SemiExitDecision(
False, False,
"", "",
f"已到点位{target_idx:.2f}但组合净利≤0{net:.2f}),继续持有", f"已到期权目标{target_idx:.2f}但组合净利≤0{net:.2f}),继续持有",
target_index=target_idx, target_index=target_idx,
) )
return SemiExitDecision( return SemiExitDecision(
False, False,
"", "",
f"未到点位(目标{target_idx:.2f}", f"未到期权目标(K{anchor:g}{target_idx:.2f}",
target_index=target_idx, target_index=target_idx,
net_target=net_tgt, net_target=net_tgt,
) )
+37 -7
View File
@@ -12,10 +12,10 @@ from app.strategy.semi_auto import (
def test_semi_points_long_needs_net_positive() -> None: def test_semi_points_long_needs_net_positive() -> None:
# 到点但净利≤0 → 不平 # 目标 = 行权价 1800 + 50 = 1850到点但净利≤0 → 不平
d = check_semi_exits( d = check_semi_exits(
net_pnl=-1.0, net_pnl=-1.0,
entry_index=1800, strike=1800,
index_px=1850, index_px=1850,
view_side="long", view_side="long",
option_move_points=50, option_move_points=50,
@@ -27,7 +27,7 @@ def test_semi_points_long_needs_net_positive() -> None:
d2 = check_semi_exits( d2 = check_semi_exits(
net_pnl=1.0, net_pnl=1.0,
entry_index=1800, strike=1800,
index_px=1850, index_px=1850,
view_side="long", view_side="long",
option_move_points=50, 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.should_close is True
assert d2.reason == REASON_POINTS 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: def test_semi_points_short() -> None:
# Put:目标 = K 50
d = check_semi_exits( d = check_semi_exits(
net_pnl=2.0, net_pnl=2.0,
entry_index=1800, strike=1800,
index_px=1750, index_px=1750,
view_side="short", view_side="short",
option_move_points=50, option_move_points=50,
@@ -50,13 +79,14 @@ def test_semi_points_short() -> None:
) )
assert d.should_close is True assert d.should_close is True
assert d.reason == REASON_POINTS assert d.reason == REASON_POINTS
assert d.target_index == 1750.0
def test_semi_net_exit_with_k() -> None: def test_semi_net_exit_with_k() -> None:
# 未到点,但净利 ≥ 5×2=10 # 未到点,但净利 ≥ 5×2=10(永续锁定)
d = check_semi_exits( d = check_semi_exits(
net_pnl=10.0, net_pnl=10.0,
entry_index=1800, strike=1800,
index_px=1810, index_px=1810,
view_side="long", view_side="long",
option_move_points=50, option_move_points=50,
@@ -71,7 +101,7 @@ def test_semi_net_exit_with_k() -> None:
def test_semi_not_yet() -> None: def test_semi_not_yet() -> None:
d = check_semi_exits( d = check_semi_exits(
net_pnl=3.0, net_pnl=3.0,
entry_index=1800, strike=1800,
index_px=1820, index_px=1820,
view_side="long", view_side="long",
option_move_points=50, option_move_points=50,
+4 -4
View File
@@ -59,15 +59,15 @@
| 规则 | 条件 | close_reason | | 规则 | 条件 | close_reason |
|------|------|----------------| |------|------|----------------|
| 顺方向 | 指数相对**开仓指数**达到目标点数**且**组合净利 > 0 | `semi_target_points` | | 顺方向(期权目标) | 指数到达 **行权价 ± 波动点**(多 CallK+N;空 PutKN**且**组合净利 > 0 | `semi_target_points` |
| 逆方向 / 兑现 | 组合净利 ≥ `semi_perp_exit_unit × k`k=以损倍数,手动仓视为 1 | `semi_perp_exit` | | 永续净利锁定 | 组合净利 ≥ `semi_perp_exit_unit × k`k=以损倍数,手动仓视为 1 | `semi_perp_exit` |
- 波动点 N 相对**行权价**,不是现价/开仓价 ±N。
- 到点但净利 ≤ 0:继续持有(状态提示,不平)。 - 到点但净利 ≤ 0:继续持有(状态提示,不平)。
- 流动性不足:进入 `liquidity_wait`;回落未达标则取消挂起。 - 流动性不足:进入 `liquidity_wait`;回落未达标则取消挂起。
- 半自动平仓**禁止**远虚「只平永续、期权归档」;顺序仍为**先期权后永续**。 - 半自动平仓**禁止**远虚「只平永续、期权归档」;顺序仍为**先期权后永续**。
首页预览「指数 → 到点」用**当前指数**示意;真实触发达标用**开仓指数**。 首页预览「期权目标 K→K±N」+ 到点粗估盈亏;「永续锁定」为净利基数×k。
「波动点」旁的**顺向预估**按选定行权价的内在价值变化 + 永续点位粗算(不含 IV/时间价值);与「净利基数×k」逆向兑现目标分开显示。
--- ---
+9
View File
@@ -5,6 +5,15 @@
--- ---
## 2026-08-08 — 半自动出场锚定行权价 + 左右布局
### 变更
1. 顺向出场目标改为 **行权价 ± 波动点**(非现价±N);出场规则展示「期权目标 / 永续净利锁定」。
2. 半自动单页两卡片改为左右并排(窄屏仍上下)。
---
## 2026-08-08 — 半自动波动点顺向预估盈利 ## 2026-08-08 — 半自动波动点顺向预估盈利
### 变更 ### 变更
+2 -2
View File
@@ -28,8 +28,8 @@ const CLOSE_REASON_ZH: Record<string, string> = {
premium_multiple: "权利金倍数达标·双腿全平", premium_multiple: "权利金倍数达标·双腿全平",
target_perp_only: "净盈利达标·只平永续(期权归档)", target_perp_only: "净盈利达标·只平永续(期权归档)",
residual_premium_close: "残留期权·权利金回收中途平", residual_premium_close: "残留期权·权利金回收中途平",
semi_target_points: "半自动·标的到点且组合净利>0·双腿全平", semi_target_points: "半自动·指数到行权价±波动点且净利>0·双腿全平",
semi_perp_exit: "半自动·净利基数达标·双腿全平", semi_perp_exit: "半自动·永续净利锁定达标·双腿全平",
expiry: "到期结算", expiry: "到期结算",
emergency: "紧急全平", emergency: "紧急全平",
manual: "手动平仓", manual: "手动平仓",
+52 -22
View File
@@ -226,8 +226,8 @@ function estimateSemiRiskQty(args: {
} }
/** /**
* 标的顺向波动 move 点后的组合盈亏粗估(期权用内在价值变化,永续按点位) * 指数走到「行权价 ± 波动点」时的组合盈亏粗估
* 多=Call+永续空;空=Put+永续多。不含权利金时间价值/IV * 多=Call 目标 K+N;空=Put 目标 KN。不含 IV/时间价值。
*/ */
function estimateSemiMoveProfit(args: { function estimateSemiMoveProfit(args: {
view: "long" | "short"; view: "long" | "short";
@@ -263,16 +263,17 @@ function estimateSemiMoveProfit(args: {
if (!(S > 0) || !(K > 0) || !(move > 0) || !(oq > 0) || !(pq > 0)) { if (!(S > 0) || !(K > 0) || !(move > 0) || !(oq > 0) || !(pq > 0)) {
return { ok: false, detail: "缺指数/行权价/数量" }; 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 callIntr = (px: number) => Math.max(0, px - K);
const putIntr = (px: number) => Math.max(0, K - px); const putIntr = (px: number) => Math.max(0, K - px);
const dIntr = const dIntr =
view === "long" view === "long"
? callIntr(tgt) - callIntr(S) ? callIntr(tgt) - callIntr(S)
: putIntr(tgt) - putIntr(S); : putIntr(tgt) - putIntr(S);
// 多:空永续随涨亏;空:多永续随跌亏 → 均为 -move×qty
const optionPnl = dIntr * oq; const optionPnl = dIntr * oq;
const perpPnl = -move * pq; // 多:空永续;空:多永续 → 指数变化 dSpot 时永续盈亏 = dSpot×qty
const perpPnl = -dSpot * pq;
const fees = const fees =
feeRate > 0 ? S * feeRate * 3 * Math.max(oq, pq) /* 粗估开平 */ : 0; feeRate > 0 ? S * feeRate * 3 * Math.max(oq, pq) /* 粗估开平 */ : 0;
const net = optionPnl + perpPnl - fees; const net = optionPnl + perpPnl - fees;
@@ -566,7 +567,34 @@ export default function PlanPage() {
const movePct = pos?.move_pct ?? 0; const movePct = pos?.move_pct ?? 0;
const ooRatio = Number(plan?.oo_reward_ratio ?? 2); const ooRatio = Number(plan?.oo_reward_ratio ?? 2);
const ooBudget = plan?.risk_sizing_preview?.budget; const ooBudget = plan?.risk_sizing_preview?.budget;
const exitRuleLabel = isOo 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 ? exitTarget != null
? `${riskLocked ? "锁定 " : ""}净盈≥${fmt(exitTarget)} U${ ? `${riskLocked ? "锁定 " : ""}净盈≥${fmt(exitTarget)} U${
ooBudget != null ? `${fmt(ooBudget)}×` : "预算×" ooBudget != null ? `${fmt(ooBudget)}×` : "预算×"
@@ -887,9 +915,9 @@ export default function PlanPage() {
<li>永续:期权配比与出场 //</li> <li>永续:期权配比与出场 //</li>
<li></li> <li></li>
<li> 180 200/</li> <li> 180 200/</li>
<li> N / + </li> <li> N = ± N CallK+N PutKN±N</li>
<li> ± &gt; 0 </li> <li> &gt; 0 </li>
<li> × k </li> <li> × k </li>
<li> k</li> <li> k</li>
</ul> </ul>
</details> </details>
@@ -981,7 +1009,7 @@ export default function PlanPage() {
/> />
</div> </div>
<div className="field"> <div className="field">
<label htmlFor="semiMove">()</label> <label htmlFor="semiMove">(K)</label>
<input <input
id="semiMove" id="semiMove"
className="mono" className="mono"
@@ -1121,15 +1149,15 @@ export default function PlanPage() {
: semiExitU), : semiExitU),
); );
if (idx == null || !Number.isFinite(idx)) {
return `逆向兑现≥${fmt(reverseTgt, 2)}U`;
}
if (strike == null || !Number.isFinite(strike)) { if (strike == null || !Number.isFinite(strike)) {
return `期权目标 行权价${semiView === "short" ? "" : "+"}${fmt(Number(semiMove) || 50, 0)} · 永续锁定≥${fmt(reverseTgt, 2)}U`;
}
if (idx == null || !Number.isFinite(idx)) {
const tgt = const tgt =
semiView === "long" semiView === "long"
? idx + Number(semiMove) ? strike + Number(semiMove)
: idx - Number(semiMove); : strike - Number(semiMove);
return `${fmtExPx("index", idx)}${fmtExPx("index", tgt)} · 待行权价估盈利 · 逆向${fmt(reverseTgt, 2)}U`; return `期权目标 K${Math.round(strike)}${fmtExPx("index", tgt)} · 永续锁定${fmt(reverseTgt, 2)}U`;
} }
const prof = estimateSemiMoveProfit({ const prof = estimateSemiMoveProfit({
@@ -1139,22 +1167,23 @@ export default function PlanPage() {
movePoints: Number(semiMove) || 50, movePoints: Number(semiMove) || 50,
optionQty: optQty, optionQty: optQty,
perpQty: perpQty, perpQty: perpQty,
feeRate: 0, // 预估展示用内在+永续点位;手续费另计 feeRate: 0,
}); });
if (!prof.ok) { if (!prof.ok) {
return prof.detail; return prof.detail;
} }
return ( return (
<> <>
{fmtExPx("index", idx)}{fmtExPx("index", prof.targetPx!)} · K{Math.round(strike)}
K{Math.round(strike)} · {fmt(prof.netEst, 2)}U {fmtExPx("index", prof.targetPx!)} ·
{fmtExPx("index", idx)} · {fmt(prof.netEst, 2)}U
<span className="meta"> <span className="meta">
{" "} {" "}
({fmt(prof.optionPnl, 1)}/ ({fmt(prof.optionPnl, 1)}/
{fmt(prof.perpPnl, 1)} · ) {fmt(prof.perpPnl, 1)})
</span> </span>
{" · "} {" · "}
{fmt(reverseTgt, 2)}U {fmt(reverseTgt, 2)}U
</> </>
); );
})()} })()}
@@ -1437,7 +1466,8 @@ export default function PlanPage() {
? ` · 虚值≤${fmt(semiOtmOff, 0)}` ? ` · 虚值≤${fmt(semiOtmOff, 0)}`
: ""} : ""}
{" · "} {" · "}
±{fmt(semiMove, 0)}&gt;0 / K{semiExitView === "short" ? "" : "+"}
{fmt(semiMove, 0)}&gt;0 /
{fmt(semiExitU, 1)}×k {fmt(semiExitU, 1)}×k
</> </>
) : isOo ? ( ) : isOo ? (
+14 -2
View File
@@ -474,10 +474,22 @@ input {
} }
.plan-semi-tab { .plan-semi-tab {
display: flex; display: grid;
flex-direction: column; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px; gap: 10px;
margin-bottom: 12px; 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 { .plan-semi-compact {