4db1e0c470
Replace misleading exit-unit label with option/perp point-move preview vs selected K. Co-authored-by: Cursor <cursoragent@cursor.com>
2250 lines
86 KiB
TypeScript
2250 lines
86 KiB
TypeScript
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);
|
||
// 用 ref:interval 闭包里的 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>顺向出场:开仓指数 ± 波动点,且组合净利 > 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)}点且净利>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>
|
||
);
|
||
}
|