Estimate semi forward PnL from strike intrinsic on move points.

Replace misleading exit-unit label with option/perp point-move preview vs selected K.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-08 14:35:10 +08:00
parent b09d1b0886
commit 4db1e0c470
3 changed files with 186 additions and 13 deletions
+2 -1
View File
@@ -64,7 +64,8 @@
- 流动性不足:进入 `liquidity_wait`;回落未达标则取消挂起。
- 半自动平仓**禁止**远虚「只平永续、期权归档」;顺序仍为**先期权后永续**。
首页预览「指数 → 到点」用**当前指数**示意;真实触发达标用**开仓指数**。
首页预览「指数 → 到点」用**当前指数**示意;真实触发达标用**开仓指数**。
「波动点」旁的**顺向预估**按选定行权价的内在价值变化 + 永续点位粗算(不含 IV/时间价值);与「净利基数×k」逆向兑现目标分开显示。
---
+9
View File
@@ -5,6 +5,15 @@
---
## 2026-08-08 — 半自动波动点顺向预估盈利
### 变更
1. 「波动点」下方按标的顺向 N 点 + 行权价内在价值变化粗估组合盈利(期权腿/永续腿分列)。
2. 与「净利基数×k」逆向兑现目标分开显示,不再把基数目标误标为顺向净利。
---
## 2026-08-08 — 半自动以损定仓预览开仓量
### 变更
+175 -12
View File
@@ -225,6 +225,68 @@ function estimateSemiRiskQty(args: {
return { ok: false, detail: "预算内无合规 k" };
}
/**
* 标的顺向波动 move 点后的组合盈亏粗估(期权用内在价值变化,永续按点位)。
* 多=Call+永续空;空=Put+永续多。不含权利金时间价值/IV。
*/
function estimateSemiMoveProfit(args: {
view: "long" | "short";
indexPx: number;
strike: number;
movePoints: number;
optionQty: number;
perpQty: number;
feeRate?: number;
}): {
ok: boolean;
detail: string;
targetPx?: number;
optionPnl?: number;
perpPnl?: number;
feesEst?: number;
netEst?: number;
} {
const {
view,
indexPx,
strike,
movePoints,
optionQty,
perpQty,
feeRate = 0,
} = args;
const S = Number(indexPx);
const K = Number(strike);
const move = Number(movePoints);
const oq = Number(optionQty);
const pq = Number(perpQty);
if (!(S > 0) || !(K > 0) || !(move > 0) || !(oq > 0) || !(pq > 0)) {
return { ok: false, detail: "缺指数/行权价/数量" };
}
const tgt = view === "long" ? S + move : S - move;
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;
const fees =
feeRate > 0 ? S * feeRate * 3 * Math.max(oq, pq) /* 粗估开平 */ : 0;
const net = optionPnl + perpPnl - fees;
return {
ok: true,
detail: "ok",
targetPx: tgt,
optionPnl: Number(optionPnl.toFixed(2)),
perpPnl: Number(perpPnl.toFixed(2)),
feesEst: Number(fees.toFixed(2)),
netEst: Number(net.toFixed(2)),
};
}
export default function PlanPage() {
const [snap, setSnap] = useState<MarketSnapshot | null>(null);
const [plan, setPlan] = useState<PlanState | null>(null);
@@ -817,7 +879,8 @@ export default function PlanPage() {
<li>永续:期权配比与出场 //</li>
<li></li>
<li> 180 200/</li>
<li> ± &gt; 0 </li>
<li> N / + </li>
<li> ± &gt; 0 </li>
<li> × k </li>
<li> k</li>
</ul>
@@ -910,7 +973,7 @@ export default function PlanPage() {
/>
</div>
<div className="field">
<label htmlFor="semiMove"></label>
<label htmlFor="semiMove">()</label>
<input
id="semiMove"
className="mono"
@@ -976,16 +1039,116 @@ export default function PlanPage() {
</div>
<div className="mono meta plan-semi-summary">
{(() => {
const idx = snap?.index_px ?? ladder?.index_px;
if (idx == null || !Number.isFinite(Number(idx))) {
return `净利目标≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U`;
const shown = filterSemiLadderRows(
ladder?.rows || [],
semiMny,
Number(semiOtmOff) || 25,
semiView,
);
const pick = pickSizingAskRow(shown);
const idx =
ladder?.index_px != null
? Number(ladder.index_px)
: snap?.index_px != null
? Number(snap.index_px)
: null;
const strike =
pick?.strike != null
? Number(pick.strike)
: ladder?.atm_strike != null
? Number(ladder.atm_strike)
: null;
const ask = pick?.ask != null ? Number(pick.ask) : null;
const fee = Number(plan.fee_rate ?? 0.0005);
const feeOk = Number.isFinite(fee) && fee >= 0 ? fee : 0.0005;
let optQty = Number(semiOptU) || 4;
let perpQty = Number(semiPerpU) || 1;
if (riskLocked) {
optQty = Number(
plan.option_qty_eth ?? pos?.option_qty_eth ?? optQty,
);
perpQty = Number(
plan.perp_qty_eth ?? pos?.perp_qty_eth ?? perpQty,
);
} else if (riskBased) {
const budget = Number(plan.risk_sizing_preview?.budget);
if (
ask != null &&
idx != null &&
Number.isFinite(budget) &&
budget > 0
) {
const est = estimateSemiRiskQty({
budget,
indexPx: idx,
optionAsk: ask,
feeRate: feeOk,
perpUnit: Number(semiPerpU) || 1,
optionUnit: Number(semiOptU) || 4,
exitUnit: Number(semiExitU) || 5,
});
if (est.ok && est.optionQty != null && est.perpQty != null) {
optQty = est.optionQty;
perpQty = est.perpQty;
}
} else {
const prev = plan.risk_sizing_preview;
if (prev?.ok && prev.option_qty_eth != null) {
optQty = Number(prev.option_qty_eth);
perpQty = Number(prev.perp_qty_eth ?? perpQty);
}
}
}
const n = Number(idx);
const tgt =
semiView === "long"
? n + Number(semiMove)
: n - Number(semiMove);
return `${fmtExPx("index", n)}${fmtExPx("index", tgt)} · 净利≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U`;
const reverseTgt = Number(
plan.semi_net_exit_target ??
(riskBased
? Number(semiExitU) *
Number(
plan.risk_last_k ??
plan.risk_sizing_preview?.k ??
1,
)
: semiExitU),
);
if (idx == null || !Number.isFinite(idx)) {
return `逆向兑现≥${fmt(reverseTgt, 2)}U`;
}
if (strike == null || !Number.isFinite(strike)) {
const tgt =
semiView === "long"
? idx + Number(semiMove)
: idx - Number(semiMove);
return `${fmtExPx("index", idx)}${fmtExPx("index", tgt)} · 待行权价估盈利 · 逆向≥${fmt(reverseTgt, 2)}U`;
}
const prof = estimateSemiMoveProfit({
view: semiView,
indexPx: idx,
strike,
movePoints: Number(semiMove) || 50,
optionQty: optQty,
perpQty: perpQty,
feeRate: 0, // 预估展示用内在+永续点位;手续费另计
});
if (!prof.ok) {
return prof.detail;
}
return (
<>
{fmtExPx("index", idx)}{fmtExPx("index", prof.targetPx!)} ·
K{Math.round(strike)} · {fmt(prof.netEst, 2)}U
<span className="meta">
{" "}
({fmt(prof.optionPnl, 1)}/
{fmt(prof.perpPnl, 1)} · )
</span>
{" · "}
{fmt(reverseTgt, 2)}U
</>
);
})()}
</div>
<div className="mono meta plan-semi-sizing">
@@ -1080,7 +1243,7 @@ export default function PlanPage() {
? ` · K${Math.round(pick.strike)}`
: ""}
{est.netTarget != null
? ` · 净利目标≈${fmt(est.netTarget, 2)}U`
? ` · 逆向目标≈${fmt(est.netTarget, 2)}U`
: ""}
</>
);