Show semi risk-based open size from ask and unit ratio.

Semi sizing uses market ask with semi units; Plan panel previews option/perp qty under the form.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-08 14:27:19 +08:00
parent 7db8b724ac
commit b09d1b0886
9 changed files with 343 additions and 44 deletions
+1
View File
@@ -393,6 +393,7 @@ export type PlanState = {
oo_put_qty_eth?: number;
sizing_mode?: "manual" | "risk_based";
risk_based?: boolean;
fee_rate?: number;
ledger: { equity: number; available: number; reserved: number };
mode?: "SIM" | "LIVE";
sim?: boolean;
+225 -39
View File
@@ -105,6 +105,126 @@ function fmtBidLiquidity(
return `${px} / ${sz}`;
}
type LadderRow = {
strike: number;
offset: number;
tag: string;
ask: number | null;
ask_sz: number | null;
lev: number | null;
inst_id?: string | null;
};
/** 与报价表相同的候选档:ATM 1 / OTM 最近 2 / ITM 最近 2 */
function filterSemiLadderRows(
all: LadderRow[],
moneyness: "itm" | "atm" | "otm",
otmOff: number,
view: "long" | "short",
): LadderRow[] {
if (moneyness === "atm") {
return all.filter((r) => r.tag === "atm").slice(0, 1);
}
if (moneyness === "otm") {
const shown = all
.filter(
(r) => r.tag === "otm" && Math.abs(r.offset) <= Number(otmOff) + 1e-9,
)
.sort((a, b) => Math.abs(a.offset) - Math.abs(b.offset))
.slice(0, 2);
shown.sort((a, b) =>
view === "short" ? a.strike - b.strike : b.strike - a.strike,
);
return shown;
}
const shown = all
.filter((r) => r.tag === "itm")
.sort((a, b) => Math.abs(a.offset) - Math.abs(b.offset))
.slice(0, 2);
shown.sort((a, b) =>
view === "short" ? a.strike - b.strike : b.strike - a.strike,
);
return shown;
}
/** 定仓用:取最接近现价且有卖一的一档 */
function pickSizingAskRow(rows: LadderRow[]): LadderRow | null {
const withAsk = rows.filter(
(r) => r.ask != null && Number.isFinite(r.ask) && Number(r.ask) > 0,
);
if (withAsk.length === 0) return null;
return [...withAsk].sort(
(a, b) => Math.abs(a.offset) - Math.abs(b.offset),
)[0];
}
function floorK1dp(kRaw: number): number {
if (!(kRaw > 0) || !Number.isFinite(kRaw)) return 0;
return Math.floor(kRaw * 10 + 1e-12) / 10;
}
/** 卖一推期权量,再按永续:期权单位配比得永续量(与后端 compute_k 一致) */
function estimateSemiRiskQty(args: {
budget: number;
indexPx: number;
optionAsk: number;
feeRate: number;
perpUnit: number;
optionUnit: number;
exitUnit: number;
}): {
ok: boolean;
detail: string;
k?: number;
perpQty?: number;
optionQty?: number;
maxLoss?: number;
netTarget?: number;
} {
const {
budget,
indexPx,
optionAsk,
feeRate,
perpUnit,
optionUnit,
exitUnit,
} = args;
if (!(budget > 0) || !(indexPx > 0) || !(optionAsk > 0)) {
return { ok: false, detail: "缺预算/指数/卖一" };
}
if (!(perpUnit > 0) || !(optionUnit > 0) || !(exitUnit > 0)) {
return { ok: false, detail: "配比/净利基数须 > 0" };
}
const cost1 = optionAsk * optionUnit + indexPx * feeRate * 3;
if (!(cost1 > 1e-12)) return { ok: false, detail: "单位成本无效" };
let k = floorK1dp(budget / cost1);
if (k < 0.1 - 1e-12) {
return {
ok: false,
detail: `预算不足以开最小仓(单位成本≈${cost1.toFixed(2)}U`,
};
}
while (k >= 0.1 - 1e-12) {
const prem = optionAsk * optionUnit * k;
const fee = indexPx * feeRate * 3 * k;
const mx = prem + fee;
if (mx <= budget + 1e-6) {
return {
ok: true,
detail: "ok",
k,
perpQty: Number((perpUnit * k).toFixed(4)),
optionQty: Number((optionUnit * k).toFixed(4)),
maxLoss: Number(mx.toFixed(2)),
netTarget: Number((exitUnit * k).toFixed(2)),
};
}
k = Math.round((k - 0.1) * 10) / 10;
}
return { ok: false, detail: "预算内无合规 k" };
}
export default function PlanPage() {
const [snap, setSnap] = useState<MarketSnapshot | null>(null);
const [plan, setPlan] = useState<PlanState | null>(null);
@@ -856,7 +976,7 @@ export default function PlanPage() {
</div>
<div className="mono meta plan-semi-summary">
{(() => {
const idx = snap?.index_px;
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`;
}
@@ -868,6 +988,104 @@ export default function PlanPage() {
return `${fmtExPx("index", n)}${fmtExPx("index", tgt)} · 净利≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U`;
})()}
</div>
<div className="mono meta plan-semi-sizing">
{(() => {
const shown = filterSemiLadderRows(
ladder?.rows || [],
semiMny,
Number(semiOtmOff) || 25,
semiView,
);
const pick = pickSizingAskRow(shown);
const ask = pick?.ask != null ? Number(pick.ask) : null;
const idx =
ladder?.index_px != null
? Number(ladder.index_px)
: snap?.index_px != null
? Number(snap.index_px)
: null;
if (!riskBased) {
return (
<>
· {fmt(semiOptU, 2)} ETH / {" "}
{fmt(semiPerpU, 2)} ETH
<span className="meta">
{" "}
</span>
</>
);
}
if (riskLocked) {
return (
<>
() · {" "}
{fmt(plan.option_qty_eth ?? pos?.option_qty_eth, 2)} ETH /
{fmt(plan.perp_qty_eth ?? pos?.perp_qty_eth, 2)} ETH
{plan.risk_last_k != null
? ` · k=${fmt(plan.risk_last_k, 1)}`
: ""}
</>
);
}
const budget = Number(plan.risk_sizing_preview?.budget);
const fee = Number(plan.fee_rate ?? 0.0005);
if (
ask == null ||
idx == null ||
!Number.isFinite(budget) ||
budget <= 0
) {
const prev = plan.risk_sizing_preview;
if (prev?.ok && prev.option_qty_eth != null) {
return (
<>
· {fmt(Number(prev.option_qty_eth), 2)} ETH
/ {fmt(Number(prev.perp_qty_eth), 2)} ETH
{prev.k != null ? ` · k=${fmt(Number(prev.k), 1)}` : ""}
{prev.budget != null
? ` · 预算${fmt(Number(prev.budget), 2)}U`
: ""}
<span className="meta"> · </span>
</>
);
}
return (
<>
·{" "}
{prev?.ok === false
? String(prev.detail || "预览失败")
: "待卖一/预算推算开仓量"}
</>
);
}
const est = estimateSemiRiskQty({
budget,
indexPx: idx,
optionAsk: ask,
feeRate: Number.isFinite(fee) && fee >= 0 ? fee : 0.0005,
perpUnit: Number(semiPerpU) || 1,
optionUnit: Number(semiOptU) || 4,
exitUnit: Number(semiExitU) || 5,
});
if (!est.ok) {
return <> · {est.detail}</>;
}
return (
<>
· {fmt(est.optionQty, 2)} ETH / {" "}
{fmt(est.perpQty, 2)} ETH · k={fmt(est.k, 1)} ·
{fmt(budget, 2)}U · {fmtExPx("option", ask)}
{pick != null
? ` · K${Math.round(pick.strike)}`
: ""}
{est.netTarget != null
? ` · 净利目标≈${fmt(est.netTarget, 2)}U`
: ""}
</>
);
})()}
</div>
<div className="plan-semi-btns">
<button
className="btn ghost"
@@ -932,44 +1150,12 @@ export default function PlanPage() {
</thead>
<tbody>
{(() => {
const all = ladder.rows || [];
let shown = all;
if (semiMny === "atm") {
shown = all.filter((r) => r.tag === "atm").slice(0, 1);
} else if (semiMny === "otm") {
shown = all
.filter(
(r) =>
r.tag === "otm" &&
Math.abs(r.offset) <=
Number(semiOtmOff) + 1e-9,
)
.sort(
(a, b) =>
Math.abs(a.offset) - Math.abs(b.offset),
)
.slice(0, 2);
// Call:虚值高行权在上;Put:虚值低行权在上
shown.sort((a, b) =>
semiView === "short"
? a.strike - b.strike
: b.strike - a.strike,
);
} else {
// 实值:最接近现价的 2 档
shown = all
.filter((r) => r.tag === "itm")
.sort(
(a, b) =>
Math.abs(a.offset) - Math.abs(b.offset),
)
.slice(0, 2);
shown.sort((a, b) =>
semiView === "short"
? a.strike - b.strike
: b.strike - a.strike,
);
}
const shown = filterSemiLadderRows(
ladder.rows || [],
semiMny,
Number(semiOtmOff) || 25,
semiView,
);
if (shown.length === 0) {
return (
<tr>
+6
View File
@@ -537,6 +537,12 @@ input {
font-size: 12px;
}
.plan-semi-sizing {
margin-top: 2px !important;
font-size: 12px;
opacity: 0.95;
}
.plan-semi-btns {
display: flex;
flex-wrap: wrap;