Files
eth_hedge_sim/frontend/src/pages/Plan.tsx
T
dekun 4db1e0c470 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>
2026-08-08 14:35:10 +08:00

2250 lines
86 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import { apiFetch, MarketSnapshot, PlanState } from "../api/client";
import { fmtBidLiqEx, fmtExPx, fmtTopEx } from "../format";
function fmt(n: number | null | undefined, d = 2) {
if (n == null || Number.isNaN(n)) return "—";
return n.toFixed(d);
}
/** 期权杠杆 = 标的 ÷ 权利金 */
function optionLevLabel(
underlying: number | null | undefined,
premium: number | null | undefined,
) {
if (
underlying == null ||
premium == null ||
!Number.isFinite(underlying) ||
!Number.isFinite(premium) ||
underlying <= 0 ||
premium <= 0
) {
return "—";
}
return `${(underlying / premium).toFixed(0)}x`;
}
function pnlClass(n: number | null | undefined) {
if (n == null || Number.isNaN(n) || n === 0) return "";
return n > 0 ? "pos-pnl-profit" : "pos-pnl-loss";
}
/** OKX 期权到期:YYMMDD 当日 08:00 UTC(上海 16:00 */
function expiryMsFromYmd(ymd: string | null | undefined): number | null {
if (!ymd || ymd.length !== 6) return null;
const yy = 2000 + Number(ymd.slice(0, 2));
const mm = Number(ymd.slice(2, 4));
const dd = Number(ymd.slice(4, 6));
if (![yy, mm, dd].every((x) => Number.isFinite(x))) return null;
return Date.UTC(yy, mm - 1, dd, 8, 0, 0);
}
function formatCountdown(msLeft: number): string {
if (msLeft <= 0) return "已到期";
const s = Math.floor(msLeft / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (d > 0) return `${d}${h}${String(m).padStart(2, "0")}`;
if (h > 0) return `${h}${String(m).padStart(2, "0")}${String(sec).padStart(2, "0")}`;
return `${m}${String(sec).padStart(2, "0")}`;
}
/** 上海时区开仓时间 */
function fmtOpenTime(ms: number | null | undefined) {
if (ms == null || !Number.isFinite(ms) || ms <= 0) return "—";
return new Date(ms).toLocaleString("zh-CN", {
timeZone: "Asia/Shanghai",
hour12: false,
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
/** 持仓时长(实时) */
function fmtHoldDuration(openAtMs: number | null | undefined, now: number) {
if (openAtMs == null || !Number.isFinite(openAtMs) || openAtMs <= 0) return "—";
const ms = Math.max(0, now - openAtMs);
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (h > 0) return `${h}${m}${s}`;
if (m > 0) return `${m}${s}`;
return `${s}`;
}
const PHASE_ZH: Record<string, string> = {
idle: "空闲",
wait_signal: "等待信号",
wait_human: "等待人工授权",
opening: "开仓中",
open: "持仓中",
closing: "平仓中",
resting: "休息中",
paused: "已暂停",
stopped: "已停开",
outside_window: "窗外",
liquidity_wait: "流动性等待",
weekend_skip: "周末跳过",
wait_funds: "资金不足",
};
function fmtBidLiquidity(
bidPx: number | null | undefined,
bidSzEth: number | null | undefined,
): string {
if (bidPx == null && bidSzEth == null) return "—";
const px = bidPx == null ? "—" : fmtExPx("option", bidPx);
const sz = bidSzEth == null ? "—" : fmt(bidSzEth, 2);
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" };
}
/**
* 标的顺向波动 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);
const [err, setErr] = useState("");
const [busy, setBusy] = useState("");
const [residualBusy, setResidualBusy] = useState("");
const [nowMs, setNowMs] = useState(() => Date.now());
const [semiView, setSemiView] = useState<"long" | "short">("long");
const [semiMove, setSemiMove] = useState(50);
const [semiExitU, setSemiExitU] = useState(5);
const [semiMinH, setSemiMinH] = useState(30);
const [semiMinLev, setSemiMinLev] = useState(200);
const [semiMny, setSemiMny] = useState<"itm" | "atm" | "otm">("otm");
const [semiOtmOff, setSemiOtmOff] = useState(25);
const [semiPerpU, setSemiPerpU] = useState(1);
const [semiOptU, setSemiOptU] = useState(4);
const [semiDirty, setSemiDirty] = useState(false);
const semiDirtyRef = useRef(false);
const semiViewRef = useRef<"long" | "short">("long");
const [planTab, setPlanTab] = useState<"semi" | "monitor">("semi");
const [ladder, setLadder] = useState<{
ok?: boolean;
detail?: string;
side?: string;
index_px?: number | null;
expiry_ymd?: string;
hours_left?: number | null;
min_hours?: number;
atm_strike?: number;
rows?: Array<{
strike: number;
offset: number;
tag: string;
ask: number | null;
ask_sz: number | null;
lev: number | null;
}>;
} | null>(null);
const semiMinHRef = useRef(30);
const semiParamsBody = () => ({
semi_view_side: semiView,
semi_option_move_points: semiMove,
semi_perp_exit_unit: semiExitU,
semi_min_option_hours: semiMinH,
semi_min_option_leverage: semiMinLev,
semi_moneyness: semiMny,
semi_otm_max_offset: semiOtmOff,
semi_perp_unit: semiPerpU,
semi_option_unit: semiOptU,
});
async function refresh() {
try {
const [m, p] = await Promise.all([
apiFetch<MarketSnapshot>("/api/market/snapshot"),
apiFetch<PlanState>("/api/plan/state"),
]);
setSnap(m);
setPlan(p);
// 用 refinterval 闭包里的 semiDirty 会过期,导致改行权类型被刷回虚值
if (!semiDirtyRef.current) {
setSemiView(p.semi_view_side === "short" ? "short" : "long");
setSemiMove(Number(p.semi_option_move_points ?? 50));
setSemiExitU(Number(p.semi_perp_exit_unit ?? 5));
setSemiMinH(Number(p.semi_min_option_hours ?? 30));
setSemiMinLev(Number(p.semi_min_option_leverage ?? 200));
const mny = p.semi_moneyness;
setSemiMny(mny === "itm" || mny === "atm" || mny === "otm" ? mny : "otm");
setSemiOtmOff(Number(p.semi_otm_max_offset ?? 25));
setSemiPerpU(Number(p.semi_perp_unit ?? 1));
setSemiOptU(Number(p.semi_option_unit ?? 4));
}
if (
p.semi_auto_enabled &&
String(p.hedge_mode || "") !== "option_option"
) {
try {
const viewSide = semiDirtyRef.current
? semiViewRef.current
: p.semi_view_side === "short"
? "short"
: "long";
const optSide = viewSide === "short" ? "put" : "call";
const minH = semiMinHRef.current;
setLadder(
await apiFetch<NonNullable<typeof ladder>>(
`/api/market/option-ladder?wings=5&side=${optSide}&min_hours=${minH}`,
),
);
} catch {
/* ignore ladder errors */
}
}
setErr("");
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
}
}
async function saveSemiParams() {
setBusy("semi-save");
setErr("");
try {
const p = await apiFetch<PlanState>("/api/plan/semi/params", {
method: "PUT",
body: JSON.stringify(semiParamsBody()),
});
setPlan(p);
setSemiDirty(false);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy("");
}
}
async function armSemi(armed: boolean) {
setBusy(armed ? "semi-arm" : "semi-disarm");
setErr("");
try {
if (semiDirty) {
await apiFetch<PlanState>("/api/plan/semi/params", {
method: "PUT",
body: JSON.stringify(semiParamsBody()),
});
setSemiDirty(false);
}
const p = await apiFetch<PlanState>("/api/plan/semi/arm", {
method: "POST",
body: JSON.stringify({ armed }),
});
setPlan(p);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy("");
}
}
useEffect(() => {
semiDirtyRef.current = semiDirty;
}, [semiDirty]);
useEffect(() => {
semiViewRef.current = semiView;
}, [semiView]);
useEffect(() => {
semiMinHRef.current = Number(semiMinH) > 0 ? Number(semiMinH) : 30;
}, [semiMinH]);
useEffect(() => {
refresh();
const t = window.setInterval(refresh, 1500);
return () => window.clearInterval(t);
}, []);
useEffect(() => {
const t = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(t);
}, []);
async function act(path: string, label: string) {
setBusy(label);
setErr("");
try {
await apiFetch(path, { method: "POST", body: "{}" });
await refresh();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy("");
}
}
async function closeResidual(groupId: string, optionInstId: string) {
const ok = window.confirm(
`确认平掉残留期权?\n\n组:${groupId}\n合约:${optionInstId}\n\n仅校验买一流动性,不要求权利金回收比例。`,
);
if (!ok) return;
setResidualBusy(groupId);
setErr("");
try {
await apiFetch("/api/plan/residual/close", {
method: "POST",
body: JSON.stringify({ group_id: groupId }),
});
await refresh();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setResidualBusy("");
}
}
const bias = snap?.ask_compare?.bias;
const pos = plan?.position;
const open = !!pos?.has_position;
// 持仓中展示本组成交方向,勿用监控 ATM 的实时盘口信号(会漂)
const heldOpt = String(pos?.option_side || "").toLowerCase();
const heldPerp = String(pos?.perp_side || "").toLowerCase();
const biasTag = open ? (
heldOpt === "call" || heldPerp === "short" ? (
<span className="tag up"> Call + </span>
) : heldOpt === "put" || heldPerp === "long" ? (
<span className="tag down"> Put + </span>
) : (
<span className="tag"></span>
)
) : bias === "strike_below_spot" ||
bias === "call_ask_gt_put" ||
bias === "fixed_short_call" ? (
<span className="tag up"> Call + </span>
) : bias === "strike_above_spot" ||
bias === "put_ask_gt_call" ||
bias === "fixed_long_put" ? (
<span className="tag down"> Put + </span>
) : (
<span className="tag"> / </span>
);
const isOo =
plan?.hedge_mode === "option_option" ||
snap?.hedge_mode === "option_option" ||
pos?.hedge_mode === "option_option" ||
!!pos?.option2_inst_id;
const semiOn = !!plan?.semi_auto_enabled && !isOo;
const showAmpCard = plan?.oo_amplitude_filter_enabled === true;
// 看法 / 最短小时变化时立刻刷新报价链(小时输入防抖)
useEffect(() => {
if (!semiOn || planTab !== "semi") return;
const optSide = semiView === "short" ? "put" : "call";
const minH = Number(semiMinH) > 0 ? Number(semiMinH) : 30;
let cancelled = false;
const t = window.setTimeout(() => {
void (async () => {
try {
const lad = await apiFetch<NonNullable<typeof ladder>>(
`/api/market/option-ladder?wings=5&side=${optSide}&min_hours=${minH}`,
);
if (!cancelled) setLadder(lad);
} catch {
/* ignore */
}
})();
}, 350);
return () => {
cancelled = true;
window.clearTimeout(t);
};
}, [semiView, semiMinH, semiOn, planTab]);
const exitMode = plan?.exit_mode ?? "fixed_usdt";
const riskBased =
plan?.risk_based === true || plan?.sizing_mode === "risk_based";
const riskLocked =
open &&
(plan?.risk_sizing_locked === true ||
plan?.risk_sizing_preview?.locked === true);
const riskLiveOk =
!riskBased || riskLocked || plan?.risk_sizing_preview?.ok === true;
const displayPerpQty = riskLiveOk ? (plan?.perp_qty_eth ?? 1) : null;
const displayOptQty = riskLiveOk ? (plan?.option_qty_eth ?? 2) : null;
const exitTarget = riskLiveOk
? (plan?.exit_target_usdt ?? plan?.net_profit_target ?? 15)
: null;
const netPnl = pos?.net_pnl ?? 0;
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 riskRatioLabel = isOo
? "预算平分 Call/Put"
: `比例${Number(plan?.risk_perp_unit ?? 1)}:${Number(plan?.risk_option_unit ?? 2)}`;
const sizingModeLabel = riskBased
? [
isOo ? "期期对冲" : "以损定仓",
riskRatioLabel,
!isOo && plan?.risk_last_k != null
? `k=${fmt(plan.risk_last_k, 1)}`
: null,
riskLocked
? exitTarget != null
? `目标锁定${fmt(exitTarget, 2)}U`
: "目标已锁定"
: plan?.risk_sizing_preview?.budget != null
? `预算${fmt(plan.risk_sizing_preview.budget, 2)}U`
: null,
plan?.martingale_enabled &&
(plan?.martingale_doubles ?? 0) > 0 &&
!riskLocked
? `倍投×${2 ** Number(plan.martingale_doubles)}(${fmt(plan.risk_effective_loss_pct ?? plan.risk_loss_pct ?? 0, 2)}%)`
: plan?.martingale_enabled && !riskLocked
? "倍投开"
: null,
!riskLocked && plan?.risk_sizing_preview?.ok === false
? String(plan.risk_sizing_preview.detail || "预览失败")
: null,
]
.filter(Boolean)
.join(" ")
: isOo
? "期期对冲"
: "手动仓位";
const phaseLabel = PHASE_ZH[plan?.phase || ""] || plan?.phase || "—";
const atmRule = plan?.fixed_direction_enabled
? plan?.fixed_perp_side === "short"
? "固定:永续空+买Call(实/平)"
: "固定:永续多+买Put(实/平)"
: plan?.atm_open_offset_enabled
? `ATM偏差≤${fmt(plan?.max_atm_open_offset ?? 3, 0)}`
: "ATM偏差关";
const ooSelectLabel = [
plan?.oo_amplitude_filter_enabled
? `振幅≤${fmt(plan?.oo_amplitude_pct ?? 1.5, 1)}%/${fmt(plan?.oo_amplitude_hours ?? 12, 0)}h`
: `振幅回看${fmt(plan?.oo_amplitude_hours ?? 12, 0)}h(过滤关)`,
`贴高低≤${fmt(plan?.oo_strike_max_dev_pct ?? 1, 1)}%`,
"虚值Call@高·Put@低",
`剩余≥${fmt(plan?.oo_min_option_hours ?? 24, 0)}h`,
`杠杆≥${fmt(plan?.oo_min_leverage ?? 200, 0)}x`,
displayOptQty != null
? `Call${fmt(displayOptQty, 1)}/Put${fmt(
Number(
plan?.risk_sizing_preview?.put_qty_eth ??
plan?.oo_put_qty_eth ??
displayOptQty,
),
1,
)}ETH`
: null,
]
.filter(Boolean)
.join(" · ");
const poAmpLabel = plan?.oo_amplitude_filter_enabled
? `振幅≤${fmt(plan?.oo_amplitude_pct ?? 1.5, 1)}%/${fmt(plan?.oo_amplitude_hours ?? 12, 0)}h(第一关)`
: null;
const exitDetail = isOo
? `净盈利≥预算×${fmt(ooRatio, 1)} → 只平盈利腿,亏损腿残留`
: exitMode === "premium_multiple"
? `净盈利≥初始权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}`
: `净盈利≥${fmt(plan?.net_profit_target ?? 15)} U`;
const expiryMs =
pos?.expiry_ms ?? expiryMsFromYmd(pos?.expiry_ymd) ?? null;
const countdown =
open && expiryMs != null ? formatCountdown(expiryMs - nowMs) : "—";
const countdownUrgent =
open && expiryMs != null && expiryMs - nowMs > 0 && expiryMs - nowMs < 6 * 3600_000;
const venueShort =
plan?.mode === "LIVE"
? `实盘·${(snap?.exchange || "okx").toUpperCase()}`
: "模拟盘";
const openAtMs = pos?.open_at_ms ?? null;
const openTimeLabel = fmtOpenTime(openAtMs);
const holdDurationLabel = open ? fmtHoldDuration(openAtMs, nowMs) : "—";
return (
<div className="plan-page">
{/* 手机端一屏监控卡:状态 / 净利 / 倒计时 */}
<section className="plan-mobile-hero" aria-label="监控摘要">
<div className="plan-mobile-hero-top">
<span
className={
plan?.running
? "plan-pill plan-pill-on"
: plan?.phase === "paused"
? "plan-pill plan-pill-pause"
: "plan-pill plan-pill-off"
}
>
{plan?.running ? "启动中" : plan?.phase === "paused" ? "已暂停" : "已停"}
</span>
<span className="plan-pill plan-pill-muted mono">{venueShort}</span>
<span className="plan-mobile-phase mono">{phaseLabel}</span>
</div>
<div className="plan-mobile-hero-pnl">
<div className="plan-mobile-pnl-block">
<span className="plan-mobile-label"></span>
<span className={`plan-mobile-pnl-num mono ${pnlClass(open ? netPnl : null)}`}>
{open ? fmt(netPnl) : "—"}
</span>
<span className="plan-mobile-sub mono">
{exitTarget != null ? `${fmt(exitTarget)} U` : "—"}
</span>
</div>
<div className="plan-mobile-pnl-block">
<span className="plan-mobile-label"></span>
<span
className={`plan-mobile-countdown mono ${countdownUrgent ? "pos-countdown-urgent" : ""}`}
>
{countdown}
</span>
<span className="plan-mobile-sub mono">
{open ? pos?.group_id || "持仓中" : "空仓"}
</span>
</div>
</div>
{plan?.last_error ? (
<div className="plan-mobile-err">{plan.last_error}</div>
) : null}
</section>
{err ? <div className="err">{err}</div> : null}
<div className="plan-rules-fold">
<details>
<summary></summary>
<div className="plan-rules-body">
<p>
<span className="plan-rules-k"></span>
{String(plan?.exchange || "okx").toUpperCase()} · ETH-USDT-SWAP
</p>
<p>
<span className="plan-rules-k"></span>
{sizingModeLabel}
</p>
<p>
<span className="plan-rules-k"></span>
{isOo ? (
<>
{ooSelectLabel}
{plan?.skip_weekends ? " · 周末跳过开仓" : ""}
{plan?.one_expiry_per_day !== false
? " · 每个到期只开一次"
: ""}
</>
) : (
<>
{poAmpLabel ? `${poAmpLabel} · ` : ""}
{" "}
{displayPerpQty != null
? `${fmt(displayPerpQty, 2)} ETH`
: "—"}{" "}
/ {fmt(plan?.leverage, 0)}x
· {" "}
{displayOptQty != null
? `${fmt(displayOptQty, 2)} ETH`
: "—"}{" "}
·
{fmt(plan?.min_option_hours, 0)}h ·
{fmt(plan?.min_option_leverage, 0)}x · {atmRule}
{plan?.skip_weekends ? " · 周末跳过开仓" : ""}
{plan?.one_expiry_per_day !== false
? " · 每个到期只开一次"
: ""}
</>
)}
</p>
<p>
<span className="plan-rules-k"></span>
{isOo
? `${exitDetail};否则到期结算`
: `${exitDetail} → 双腿全平;期权远虚则只平永续、期权到期结算;未达标拖到期`}
</p>
<p>
<span className="plan-rules-k"></span>
{plan?.rest_seconds ?? "—"}s ·
{plan?.one_expiry_per_day !== false
? " · 同到期用过后改开下一到期"
: ""}
</p>
</div>
</details>
</div>
<div className="plan-actions">
<button
className={plan?.running ? "btn btn-running" : "btn"}
type="button"
disabled={
!!busy ||
!!plan?.running ||
(plan?.mode === "LIVE" && plan.live_ready === false)
}
title={
plan?.mode === "LIVE" && plan.live_ready === false
? plan.live_ready_reason || "LIVE 未就绪"
: undefined
}
onClick={() => act("/api/plan/start", "start")}
>
{plan?.running ? "启动中" : "启动策略"}
</button>
<button
className="btn ghost"
type="button"
disabled={!!busy || !plan?.running}
onClick={() => act("/api/plan/pause", "pause")}
>
</button>
{plan?.show_manual_trade_buttons ? (
<>
<button
className="btn ghost"
type="button"
disabled={
!!busy ||
!!plan?.running ||
(plan?.mode === "LIVE" && plan.live_ready === false)
}
title={
plan?.running
? "策略自动运行中,禁止手动开仓"
: plan?.mode === "LIVE" && plan.live_ready === false
? plan.live_ready_reason || "LIVE 未就绪"
: undefined
}
onClick={() => act("/api/sim/open-group", "open")}
>
</button>
<button
className="btn ghost"
type="button"
disabled={!!busy}
onClick={() => act("/api/sim/close-group", "close")}
>
</button>
</>
) : null}
<button
className="btn danger"
type="button"
disabled={!!busy}
onClick={() => act("/api/plan/emergency-close", "emg")}
>
</button>
{busy ? <span className="meta">{busy}</span> : null}
</div>
{semiOn ? (
<div className="tabs plan-main-tabs" role="tablist" aria-label="计划页">
<button
type="button"
role="tab"
className={planTab === "semi" ? "tab active" : "tab"}
aria-selected={planTab === "semi"}
onClick={() => setPlanTab("semi")}
>
</button>
<button
type="button"
role="tab"
className={planTab === "monitor" ? "tab active" : "tab"}
aria-selected={planTab === "monitor"}
onClick={() => setPlanTab("monitor")}
>
</button>
</div>
) : null}
{semiOn && planTab === "semi" ? (
<div className="plan-semi-tab">
<section className="card plan-semi-card plan-semi-compact" aria-label="半自动本单">
<div className="plan-semi-head">
<h3 className="plan-panel-title"> · </h3>
<span className="mono meta">
{plan.semi_armed ? "已授权" : "未授权"}
{" · "}
{semiMny === "itm" ? "实值" : semiMny === "atm" ? "平值" : "虚值"}
{" · "}
{fmt(semiPerpU, 0)}:{fmt(semiOptU, 0)}
</span>
</div>
<details className="plan-semi-rules">
<summary></summary>
<ul>
<li>永续:期权配比与出场 //</li>
<li></li>
<li> 180 200/</li>
<li> N / + </li>
<li> ± &gt; 0 </li>
<li> × k </li>
<li> k</li>
</ul>
</details>
<div className="settings-fields plan-semi-fields">
<div className="field">
<label htmlFor="semiView"></label>
<select
id="semiView"
className="mono"
disabled={open || !!busy || !!plan.semi_armed}
value={semiView}
onChange={(e) => {
setSemiView(e.target.value === "short" ? "short" : "long");
setSemiDirty(true);
}}
>
<option value="long"> · Call+</option>
<option value="short"> · Put+</option>
</select>
</div>
<div className="field">
<label htmlFor="semiMny"></label>
<select
id="semiMny"
className="mono"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMny}
onChange={(e) => {
const v = e.target.value;
setSemiMny(
v === "itm" || v === "atm" || v === "otm" ? v : "otm",
);
setSemiDirty(true);
}}
>
<option value="itm"></option>
<option value="atm"></option>
<option value="otm"></option>
</select>
</div>
{semiMny === "otm" ? (
<div className="field">
<label htmlFor="semiOtmOff"></label>
<input
id="semiOtmOff"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiOtmOff}
onChange={(e) => {
setSemiOtmOff(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
) : null}
<div className="field">
<label htmlFor="semiPerpU"></label>
<input
id="semiPerpU"
className="mono"
type="number"
step="0.1"
min="0.01"
disabled={open || !!busy || !!plan.semi_armed}
value={semiPerpU}
onChange={(e) => {
setSemiPerpU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiOptU"></label>
<input
id="semiOptU"
className="mono"
type="number"
step="0.1"
min="0.01"
disabled={open || !!busy || !!plan.semi_armed}
value={semiOptU}
onChange={(e) => {
setSemiOptU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMove">()</label>
<input
id="semiMove"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMove}
onChange={(e) => {
setSemiMove(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiExitU"></label>
<input
id="semiExitU"
className="mono"
type="number"
step="0.1"
min="0.1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiExitU}
onChange={(e) => {
setSemiExitU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMinH"> h</label>
<input
id="semiMinH"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMinH}
onChange={(e) => {
setSemiMinH(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMinLev"></label>
<input
id="semiMinLev"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMinLev}
onChange={(e) => {
setSemiMinLev(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
</div>
<div className="mono meta plan-semi-summary">
{(() => {
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 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">
{(() => {
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"
type="button"
disabled={open || !!busy || !semiDirty}
onClick={() => void saveSemiParams()}
>
</button>
<button
className="btn"
type="button"
disabled={open || !!busy || !!plan.semi_armed}
onClick={() => void armSemi(true)}
>
</button>
<button
className="btn ghost"
type="button"
disabled={open || !!busy || !plan.semi_armed}
onClick={() => void armSemi(false)}
>
</button>
</div>
</section>
<section className="card plan-t-quote" aria-label="期权报价列表">
<div className="plan-semi-head">
<h3 className="plan-panel-title">
{semiView === "short" ? "Put" : "Call"} · / /
</h3>
<span className="mono meta">
{ladder?.expiry_ymd
? `到期 ${ladder.expiry_ymd}`
: "—"}
{ladder?.hours_left != null
? ` · 剩余≈${fmt(ladder.hours_left, 1)}h`
: ""}
{` · 门槛≥${fmt(Number(semiMinH) || 30, 0)}h`}
{ladder?.atm_strike != null
? ` · ATM ${Math.round(ladder.atm_strike)}`
: ""}
{ladder?.index_px != null
? ` · 指数 ${fmtExPx("index", ladder.index_px)}`
: ""}
</span>
</div>
{!ladder?.ok ? (
<p className="meta">{ladder?.detail || "暂无报价链"}</p>
) : (
<div className="plan-t-wrap">
<table className="plan-t-table">
<thead>
<tr>
<th></th>
<th></th>
<th> / </th>
<th></th>
</tr>
</thead>
<tbody>
{(() => {
const shown = filterSemiLadderRows(
ladder.rows || [],
semiMny,
Number(semiOtmOff) || 25,
semiView,
);
if (shown.length === 0) {
return (
<tr>
<td colSpan={4} className="meta">
{semiMny === "itm"
? "实值"
: semiMny === "atm"
? "平值"
: "虚值"}
{semiMny === "otm"
? `(偏离≤${fmt(semiOtmOff, 0)}`
: ""}
</td>
</tr>
);
}
return shown.map((r) => {
const tagZh =
r.tag === "atm"
? "平值"
: r.tag === "itm"
? "实值"
: "虚值";
return (
<tr
key={r.strike}
className={
r.tag === "atm" ? "plan-t-atm" : "plan-t-pick"
}
>
<td className="mono plan-t-k">
{Math.round(r.strike)}
<span className="meta">
{" "}
({r.offset >= 0 ? "+" : ""}
{fmt(r.offset, 0)})
</span>
</td>
<td>{tagZh}</td>
<td className="mono">
{fmtTopEx("option", r.ask, r.ask_sz)}
</td>
<td className="mono">
{r.lev != null ? `${r.lev.toFixed(0)}x` : "—"}
</td>
</tr>
);
});
})()}
</tbody>
</table>
</div>
)}
</section>
</div>
) : null}
{!semiOn || planTab === "monitor" ? (
<div className="plan-shell">
<div className="plan-board">
<div className="card plan-strategy">
<h3 className="plan-panel-title"></h3>
<div className="plan-metrics">
<div className="kv">
<span></span>
<span>
{plan?.running ? (
<span className="status-running"></span>
) : (
<span className="status-stopped"></span>
)}
<span className="mono">
{" "}
· {phaseLabel} · {plan?.rounds_done ?? 0}
</span>
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{semiOn ? (
<span className="status-running">
·{" "}
{semiMny === "itm"
? "实值"
: semiMny === "atm"
? "平值"
: "虚值"}{" "}
· {fmt(semiPerpU, 0)}:{fmt(semiOptU, 0)}
{plan?.semi_armed ? " · 已授权" : " · 未授权"}
</span>
) : riskBased ? (
<span className="status-running">{sizingModeLabel}</span>
) : (
sizingModeLabel
)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{semiOn ? (
<>
{fmt(semiMinH, 0)}h · {fmt(semiMinLev, 0)}x
{semiMny === "otm"
? ` · 虚值≤${fmt(semiOtmOff, 0)}`
: ""}
{" · "}
±{fmt(semiMove, 0)}&gt;0 /
{fmt(semiExitU, 1)}×k
</>
) : isOo ? (
ooSelectLabel
) : (
<>
{poAmpLabel ? `${poAmpLabel} · ` : ""}
{displayPerpQty != null
? `${fmt(displayPerpQty, 2)}ETH`
: "—"}
/
{displayOptQty != null
? `${fmt(displayOptQty, 2)}ETH`
: "—"}{" "}
·
{fmt(plan?.min_option_hours, 0)}h ·
{fmt(plan?.min_option_leverage, 0)}x
{plan?.fixed_direction_enabled
? plan?.fixed_perp_side === "short"
? " · 固定空+Call(实/平)"
: " · 固定多+Put(实/平)"
: plan?.atm_open_offset_enabled
? ` · ATM偏差≤${fmt(plan?.max_atm_open_offset ?? 3, 0)}`
: " · ATM偏差关"}
</>
)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{plan?.rest_left_sec ? `${plan.rest_left_sec}s / ${plan.rest_seconds}s` : "—"}
</span>
</div>
<div className="kv">
<span> / </span>
<span className="open-cap-row">
<span
className={
plan?.open_capacity?.perp_can_open === true
? "open-cap-ok"
: plan?.open_capacity?.perp_can_open === false
? "open-cap-bad"
: "mono"
}
title={
plan?.open_capacity?.perp_need_usdt != null
? `需≈${plan.open_capacity.perp_need_usdt}U / 有${plan.open_capacity.perp_have_usdt ?? "—"}U`
: undefined
}
>
{plan?.open_capacity?.perp_label ||
`永续${fmt(plan?.leverage, 0)}x —`}
</span>
<span className="open-cap-sep">·</span>
<span
className={
plan?.open_capacity?.option_can_open === true
? "open-cap-ok"
: plan?.open_capacity?.option_can_open === false
? "open-cap-bad"
: "mono"
}
title={
plan?.open_capacity?.option_need_usdc != null
? `需≈${plan.open_capacity.option_need_usdc}U / 有${plan.open_capacity.option_have_usdc ?? "—"}U`
: undefined
}
>
{plan?.open_capacity?.option_label || "期权 —"}
</span>
</span>
</div>
{isOo ? (
<div className="kv">
<span></span>
<span className="mono">
<span className="tag"> Call + Put</span>
<span className="meta"> · </span>
</span>
</div>
) : (
<div className="kv">
<span></span>
<span className="mono">{biasTag}</span>
</div>
)}
<div className="kv">
<span></span>
<span className="mono">{exitRuleLabel}</span>
</div>
<div className="kv">
<span> / </span>
<span className="mono">
<span className={pnlClass(open ? netPnl : null)}>
{open ? fmt(netPnl) : "—"}
</span>
{" / "}
{open || exitMode === "fixed_usdt" || isOo
? exitTarget != null
? fmt(exitTarget)
: "—"
: "—"}
</span>
</div>
<div className="kv">
<span>{isOo ? "Call/Put 浮盈" : "永续/期权浮盈"}</span>
<span className="mono">
<span className={pnlClass(pos?.perp_upl)}>
{fmt(isOo ? pos?.option_upl : pos?.perp_upl)}
</span>
{" / "}
<span className={pnlClass(isOo ? pos?.option2_upl : pos?.option_upl)}>
{fmt(isOo ? pos?.option2_upl : pos?.option_upl)}
</span>
</span>
</div>
{plan?.mode === "LIVE" && open ? (
<>
<div className="kv">
<span></span>
<span className="mono">{fmt(pos?.fees_paid)}</span>
</div>
<div className="kv">
<span></span>
<span className={`mono ${pnlClass(pos?.funding_usdt)}`}>
{fmt(pos?.funding_usdt)}
</span>
</div>
</>
) : null}
{plan?.last_error ? (
<div className="kv">
<span></span>
<span className="err" style={{ margin: 0 }}>
{plan.last_error}
</span>
</div>
) : null}
</div>
</div>
<div className="plan-positions">
{open ? (
<>
{isOo ? (
<>
<div className="pos-card">
<div className="pos-card-head">
<div className="pos-card-symbol">
<strong>{pos?.option_inst_id || "Call"}</strong>
<span className="pos-side-badge pos-side-long">
Call
</span>
</div>
<div
className={`pos-countdown mono ${countdownUrgent ? "pos-countdown-urgent" : ""}`}
title="到期倒计时"
>
{countdown}
</div>
</div>
<div className="pos-meta">
<span className="pos-meta-item"> · Call</span>
<span className="pos-meta-item">
{pos?.expiry_ymd || "—"}
</span>
<span className="pos-meta-item">
{" "}
{pos?.strike != null
? Math.round(Number(pos.strike))
: "—"}
</span>
<span className="pos-meta-item mono">
{fmt(pos?.option_qty_eth, 2)} ETH ·{" "}
{fmt(pos?.option_qty_contracts, 0)}
</span>
<span
className="pos-meta-item mono"
title="开仓指数÷开仓均价"
>
{fmt(pos?.option_leverage, 0)}x
</span>
</div>
<div className="pos-grid">
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{fmtExPx("option", pos?.option_entry_px)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{fmtBidLiqEx(
"option",
pos?.option_mark_px,
pos?.option_bid_sz,
)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label">
{plan?.mode === "LIVE" ? "净盈亏" : "浮盈亏"}
</span>
<span
className={`pos-value mono ${pnlClass(pos?.option_upl)}`}
>
{fmt(pos?.option_upl)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{fmt(pos?.initial_premium)}
</span>
</div>
<div className="pos-time-row">
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">{openTimeLabel}</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{holdDurationLabel}
</span>
</div>
</div>
</div>
</div>
<div className="pos-card">
<div className="pos-card-head">
<div className="pos-card-symbol">
<strong>{pos?.option2_inst_id || "Put"}</strong>
<span className="pos-side-badge pos-side-long">
Put
</span>
</div>
<div
className={`pos-countdown mono ${countdownUrgent ? "pos-countdown-urgent" : ""}`}
title="到期倒计时"
>
{countdown}
</div>
</div>
<div className="pos-meta">
<span className="pos-meta-item"> · Put</span>
<span className="pos-meta-item">
{pos?.expiry_ymd || "—"}
</span>
<span className="pos-meta-item">
{" "}
{pos?.strike2 != null
? Math.round(Number(pos.strike2))
: "—"}
</span>
<span className="pos-meta-item mono">
{fmt(
pos?.option2_qty_eth ??
plan?.oo_put_qty_eth ??
plan?.risk_sizing_preview?.put_qty_eth,
2,
)}{" "}
ETH · {fmt(pos?.option2_qty_contracts, 0)}
</span>
<span
className="pos-meta-item mono"
title="开仓指数÷开仓均价"
>
{" "}
{pos?.option2_leverage != null
? `${fmt(pos.option2_leverage, 0)}x`
: optionLevLabel(
pos?.entry_index_px ?? snap?.index_px,
pos?.option2_entry_px,
)}
</span>
</div>
<div className="pos-grid">
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{fmtExPx("option", pos?.option2_entry_px)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{fmtBidLiqEx(
"option",
pos?.option2_mark_px ?? snap?.put?.bid,
pos?.option2_bid_sz ?? snap?.put?.bid_sz,
)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label">
{plan?.mode === "LIVE" ? "净盈亏" : "浮盈亏"}
</span>
<span
className={`pos-value mono ${pnlClass(pos?.option2_upl)}`}
>
{fmt(pos?.option2_upl)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{fmt(pos?.initial_premium2)}
</span>
</div>
<div className="pos-time-row">
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">{openTimeLabel}</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{holdDurationLabel}
</span>
</div>
</div>
</div>
</div>
</>
) : (
<>
<div className="pos-card">
<div className="pos-card-head">
<div className="pos-card-symbol">
<strong>{pos?.perp_inst_id || "ETH-USDT-SWAP"}</strong>
<span
className={
pos?.perp_side === "long"
? "pos-side-badge pos-side-long"
: "pos-side-badge pos-side-short"
}
>
{pos?.perp_side === "long" ? "做多" : "做空"}
</span>
</div>
</div>
<div className="pos-meta">
<span className="pos-meta-item"></span>
<span className="pos-meta-item"> {pos?.group_id}</span>
<span className="pos-meta-item mono">
{fmt(pos?.perp_qty_eth, 2)} ETH
</span>
<span className="pos-meta-item mono">
{fmt(pos?.leverage ?? plan?.leverage, 0)}x
</span>
</div>
<div className="pos-grid">
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{fmtExPx("perp", pos?.perp_entry_px)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{fmtExPx("perp", pos?.perp_mark_px)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span
className={`pos-value mono ${pnlClass(pos?.perp_upl)}`}
>
{fmt(pos?.perp_upl)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{fmt(pos?.perp_margin)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{fmt(pos?.perp_notional)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{fmt(movePct, 2)}%
</span>
</div>
<div className="pos-time-row">
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">{openTimeLabel}</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{holdDurationLabel}
</span>
</div>
</div>
</div>
</div>
<div className="pos-card">
<div className="pos-card-head">
<div className="pos-card-symbol">
<strong>{pos?.option_inst_id || "期权"}</strong>
<span
className={
pos?.option_side === "call"
? "pos-side-badge pos-side-long"
: "pos-side-badge pos-side-short"
}
>
{pos?.option_side === "call"
? "看涨 Call"
: "看跌 Put"}
</span>
</div>
<div
className={`pos-countdown mono ${countdownUrgent ? "pos-countdown-urgent" : ""}`}
title="到期倒计时"
>
{countdown}
</div>
</div>
<div className="pos-meta">
<span className="pos-meta-item"></span>
<span className="pos-meta-item">
{pos?.expiry_ymd || "—"}
</span>
<span className="pos-meta-item">
{" "}
{pos?.strike != null
? Math.round(Number(pos.strike))
: "—"}
</span>
<span className="pos-meta-item mono">
{fmt(pos?.option_qty_eth, 2)} ETH ·{" "}
{fmt(pos?.option_qty_contracts, 0)}
</span>
<span
className="pos-meta-item mono"
title="开仓指数÷开仓均价"
>
{fmt(pos?.option_leverage, 0)}x
</span>
</div>
<div className="pos-grid">
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{fmtExPx("option", pos?.option_entry_px)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{fmtBidLiqEx(
"option",
pos?.option_mark_px,
pos?.option_bid_sz,
)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label">
{plan?.mode === "LIVE" ? "净盈亏" : "浮盈亏"}
</span>
<span
className={`pos-value mono ${pnlClass(pos?.option_upl)}`}
>
{fmt(pos?.option_upl)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{fmt(pos?.initial_premium)}
</span>
</div>
<div className="pos-time-row">
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">{openTimeLabel}</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">
{holdDurationLabel}
</span>
</div>
</div>
</div>
</div>
</>
)}
</>
) : isOo ? (
<>
<div className="pos-empty">Call · </div>
<div className="pos-empty">Put · </div>
</>
) : (
<>
<div className="pos-empty"> · </div>
<div className="pos-empty"> · </div>
</>
)}
</div>
</div>
<div className="plan-market">
{showAmpCard ? (
<div className="card plan-card-compact">
<h3 className="plan-panel-title"> / </h3>
<div className="kv">
<span></span>
<span className="mono">{fmtExPx("index", snap?.index_px)}</span>
</div>
<div className="kv">
<span>
{snap?.oo_amplitude?.hours != null
? `${fmt(snap.oo_amplitude.hours, 0)}h`
: ""}
</span>
<span className="mono">
{fmtExPx("index", snap?.oo_amplitude?.high)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{fmtExPx("index", snap?.oo_amplitude?.low)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{snap?.oo_amplitude?.range_pct != null
? `${fmt(snap.oo_amplitude.range_pct, 2)}%`
: "—"}
{(snap?.oo_amplitude?.max_pct ?? snap?.oo_amplitude?.min_pct) !=
null
? ` · 上限≤${fmt(
Number(
snap.oo_amplitude?.max_pct ??
snap.oo_amplitude?.min_pct,
),
1,
)}%`
: ""}
{snap?.oo_amplitude?.filter_enabled === false
? " · 过滤关"
: snap?.oo_amplitude?.ok === false
? " · 超限"
: snap?.oo_amplitude?.ok === true
? " · 可开"
: ""}
</span>
</div>
</div>
) : null}
{!isOo ? (
<div className="card">
<h3 className="plan-panel-title"></h3>
<div className="kv">
<span></span>
<span className="mono">{fmtExPx("perp", snap?.perp?.bid)}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmtExPx("perp", snap?.perp?.ask)}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{fmtExPx(
"perp",
snap?.perp?.mark_px ??
(snap?.perp?.bid != null && snap?.perp?.ask != null
? (snap.perp.bid + snap.perp.ask) / 2
: null),
)}
</span>
</div>
</div>
) : null}
<div className="card">
<h3 className="plan-panel-title">
{isOo
? open
? "持仓虚值"
: "期期虚值"
: open
? "持仓期权"
: "期权 ATM"}
{isOo && snap?.pair
? ` · C@${Math.round(
Number(
snap.pair.call_strike ?? snap.pair.strike ?? 0,
),
)} / P@${Math.round(
Number(
snap.pair.put_strike ?? snap.pair.strike ?? 0,
),
)}`
: snap?.pair
? ` @ ${
snap.pair.strike != null
? Math.round(Number(snap.pair.strike))
: "—"
}`
: ""}
</h3>
{(() => {
const under =
snap?.index_px ??
(snap?.perp?.bid != null && snap?.perp?.ask != null
? (snap.perp.bid + snap.perp.ask) / 2
: snap?.perp?.mark_px ?? null);
const callPx = open ? snap?.call?.bid : snap?.call?.ask;
const callSz = open ? snap?.call?.bid_sz : snap?.call?.ask_sz;
const putPx = open ? snap?.put?.bid : snap?.put?.ask;
const putSz = open ? snap?.put?.bid_sz : snap?.put?.ask_sz;
const sideLabel = open ? "买一/流动性" : "卖一/流动性";
const callLev = optionLevLabel(under, callPx);
const putLev = optionLevLabel(under, putPx);
const heldSide = String(pos?.option_side || "").toLowerCase();
const heldLev =
open && pos?.option_leverage != null
? `${fmt(pos.option_leverage, 0)}x`
: open && heldSide === "call"
? callLev
: open && heldSide === "put"
? putLev
: null;
const callK = snap?.pair?.call_strike ?? snap?.pair?.strike;
const putK = snap?.pair?.put_strike ?? snap?.pair?.strike;
return (
<>
<div className="kv">
<span>
Call {sideLabel}
{isOo && callK != null
? ` @${Math.round(Number(callK))}`
: ""}
</span>
<span className="mono">
{fmtTopEx("option", callPx, callSz)}
{!open ? ` · 杠杆 ${callLev}` : ""}
</span>
</div>
<div className="kv">
<span>
Put {sideLabel}
{isOo && putK != null
? ` @${Math.round(Number(putK))}`
: ""}
</span>
<span className="mono">
{fmtTopEx("option", putPx, putSz)}
{!open ? ` · 杠杆 ${putLev}` : ""}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{open && !isOo
? heldLev ||
(heldSide
? "—"
: `Call ${callLev} · Put ${putLev}`)
: `Call ${callLev} · Put ${putLev}`}
</span>
</div>
</>
);
})()}
{isOo ? (
<div className="kv">
<span></span>
<span className="mono">
{(() => {
const px = snap?.index_px;
const ck = snap?.pair?.call_strike ?? snap?.pair?.strike;
const pk = snap?.pair?.put_strike;
if (px == null || ck == null) return "—";
const cOff = Number(ck) - Number(px);
const pOff =
pk != null ? Number(pk) - Number(px) : null;
const fmtOff = (n: number) =>
`${n > 0 ? "+" : ""}${n.toFixed(0)}`;
return pOff != null
? `C ${fmtOff(cOff)} · P ${fmtOff(pOff)}`
: `C ${fmtOff(cOff)}`;
})()}
</span>
</div>
) : (
<div className="kv">
<span></span>
<span className="mono">
{(() => {
const strike = snap?.pair?.strike;
const px =
snap?.index_px ??
(snap?.perp?.bid != null && snap?.perp?.ask != null
? (snap.perp.bid + snap.perp.ask) / 2
: null);
if (strike == null || px == null) return "—";
const abs = Math.abs(strike - px);
const sign = strike - px > 0 ? "+" : "";
const delta = `${sign}${(strike - px).toFixed(0)}`;
if (!plan?.atm_open_offset_enabled) {
return `${delta} · 偏差限制关`;
}
const lim = plan?.max_atm_open_offset ?? 3;
const ok = abs <= lim + 1e-9;
return `${delta} · 开仓${ok ? "可" : "不可"}(|Δ|≤${lim})`;
})()}
</span>
</div>
)}
<div className="kv">
<span></span>
<span className="mono">{snap?.pair?.expiry_ymd || "—"}</span>
</div>
</div>
</div>
{plan?.residuals && plan.residuals.length > 0 ? (
<div className="card plan-residual-block">
<div className="plan-residual-head">
<h4></h4>
<span className="meta">
</span>
</div>
<div className="plan-residual-wrap">
<table className="plan-residual-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th title="最新买一价格 / 买一数量(ETH)"></th>
<th title="买一价 × 持仓数量"></th>
<th title="买一权利金 ÷ 开仓权利金"></th>
<th></th>
</tr>
</thead>
<tbody>
{plan.residuals.map((r) => {
const gid = String(r.group_id || "");
const inst = String(r.option_inst_id || "—");
const liqOk = r.liquidity_ok === true;
return (
<tr key={`${gid}:${inst}`}>
<td className="mono">{gid || "—"}</td>
<td className="mono">{inst}</td>
<td className="mono">{fmt(r.option_qty_eth, 2)}</td>
<td className="mono">{fmt(r.initial_premium, 2)}</td>
<td
className="mono"
title={
liqOk
? "买一深度可覆盖持仓"
: "买一不足或盘口不可用"
}
>
{fmtBidLiquidity(r.bid_px, r.bid_sz_eth)}
</td>
<td className="mono">{fmt(r.current_premium, 2)}</td>
<td className="mono">
{r.recovery_pct == null
? "—"
: `${fmt(r.recovery_pct, 1)}%`}
</td>
<td>
<button
type="button"
className="btn ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
disabled={!!residualBusy || !!busy || !liqOk}
title={
liqOk
? "按最新买一 IOC 平仓(不验权利金比例)"
: "买一流动性不足或盘口不可用"
}
onClick={() => void closeResidual(gid, inst)}
>
{residualBusy === gid ? "平仓中…" : "平仓"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
) : null}
</div>
) : null}
</div>
);
}