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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user