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 = { 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(null); const [plan, setPlan] = useState(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("/api/market/snapshot"), apiFetch("/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>( `/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("/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("/api/plan/semi/params", { method: "PUT", body: JSON.stringify(semiParamsBody()), }); setSemiDirty(false); } const p = await apiFetch("/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" ? ( 买 Call + 永续空 ) : heldOpt === "put" || heldPerp === "long" ? ( 买 Put + 永续多 ) : ( 持仓中 ) ) : bias === "strike_below_spot" || bias === "call_ask_gt_put" || bias === "fixed_short_call" ? ( 买 Call + 永续空 ) : bias === "strike_above_spot" || bias === "put_ask_gt_call" || bias === "fixed_long_put" ? ( 买 Put + 永续多 ) : ( 等待 / 相等 ); 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>( `/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 (
{/* 手机端一屏监控卡:状态 / 净利 / 倒计时 */}
{plan?.running ? "启动中" : plan?.phase === "paused" ? "已暂停" : "已停"} {venueShort} {phaseLabel}
净盈利 {open ? fmt(netPnl) : "—"} 目标 {exitTarget != null ? `${fmt(exitTarget)} U` : "—"}
到期 {countdown} {open ? pos?.group_id || "持仓中" : "空仓"}
{plan?.last_error ? (
{plan.last_error}
) : null}
{err ?
{err}
: null}
规则说明

行情 {String(plan?.exchange || "okx").toUpperCase()} · ETH-USDT-SWAP

模式 {sizingModeLabel}

开仓 {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 ? " · 每个到期只开一次" : ""} )}

平仓 {isOo ? `${exitDetail};否则到期结算` : `${exitDetail} → 双腿全平;期权远虚则只平永续、期权到期结算;未达标拖到期`}

节奏 组间休息 {plan?.rest_seconds ?? "—"}s · 可开判定看交易账户 {plan?.one_expiry_per_day !== false ? " · 同到期用过后改开下一到期" : ""}

{plan?.show_manual_trade_buttons ? ( <> ) : null} {busy ? {busy}… : null}
{semiOn ? (
) : null} {semiOn && planTab === "semi" ? (

半自动 · 本单

{plan.semi_armed ? "已授权" : "未授权"} {" · "} {semiMny === "itm" ? "实值" : semiMny === "atm" ? "平值" : "虚值"} {" · "} {fmt(semiPerpU, 0)}:{fmt(semiOptU, 0)}
规则说明
  • 人工定方向、行权类型、永续:期权配比与出场 →「授权开下一单」后机器盯选约/开/平。
  • 平仓后停在等待授权,不自动连开;与全自动循环无关。
  • 虚值:偏离 ≤ 设定点数;杠杆门 ≥180(默认 200)。实值/平值用本单最低杠杆。
  • 波动点:标的相对当前价顺向波动 N 点(多涨/空跌);下方按行权价内在价值变化 + 永续点位粗估盈利。
  • 顺向出场:开仓指数 ± 波动点,且组合净利 > 0 → 双腿全平(先期权后永续)。
  • 逆向兑现:组合净利 ≥ 净利基数 × k → 全平。
  • 以损定仓时配比为单位再乘 k;手动仓按配比直接开。
{semiMny === "otm" ? (
{ setSemiOtmOff(Number(e.target.value)); setSemiDirty(true); }} />
) : null}
{ setSemiPerpU(Number(e.target.value)); setSemiDirty(true); }} />
{ setSemiOptU(Number(e.target.value)); setSemiDirty(true); }} />
{ setSemiMove(Number(e.target.value)); setSemiDirty(true); }} />
{ setSemiExitU(Number(e.target.value)); setSemiDirty(true); }} />
{ setSemiMinH(Number(e.target.value)); setSemiDirty(true); }} />
{ setSemiMinLev(Number(e.target.value)); setSemiDirty(true); }} />
{(() => { 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 {" "} (期权{fmt(prof.optionPnl, 1)}/永续 {fmt(prof.perpPnl, 1)} · 内在粗估) {" · "} 逆向≥{fmt(reverseTgt, 2)}U ); })()}
{(() => { 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 {" "} (系统设置可开以损定仓) ); } 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` : ""} · 待本单卖一 ); } 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` : ""} ); })()}

{semiView === "short" ? "Put" : "Call"} 报价 · 实值 / 平值 / 虚值

{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)}` : ""}
{!ladder?.ok ? (

{ladder?.detail || "暂无报价链"}

) : (
{(() => { const shown = filterSemiLadderRows( ladder.rows || [], semiMny, Number(semiOtmOff) || 25, semiView, ); if (shown.length === 0) { return ( ); } return shown.map((r) => { const tagZh = r.tag === "atm" ? "平值" : r.tag === "itm" ? "实值" : "虚值"; return ( ); }); })()}
行权价 类型 卖一 / 流动性 杠杆
当前无符合「 {semiMny === "itm" ? "实值" : semiMny === "atm" ? "平值" : "虚值"} 」的报价档 {semiMny === "otm" ? `(偏离≤${fmt(semiOtmOff, 0)})` : ""}
{Math.round(r.strike)} {" "} ({r.offset >= 0 ? "+" : ""} {fmt(r.offset, 0)}) {tagZh} {fmtTopEx("option", r.ask, r.ask_sz)} {r.lev != null ? `${r.lev.toFixed(0)}x` : "—"}
)}
) : null} {!semiOn || planTab === "monitor" ? (

策略

状态 {plan?.running ? ( 启动中 ) : ( 已停 )} {" "} · {phaseLabel} · 已完成 {plan?.rounds_done ?? 0} 组
模式 {semiOn ? ( 半自动 ·{" "} {semiMny === "itm" ? "实值" : semiMny === "atm" ? "平值" : "虚值"}{" "} · 配比 {fmt(semiPerpU, 0)}:{fmt(semiOptU, 0)} {plan?.semi_armed ? " · 已授权" : " · 未授权"} ) : riskBased ? ( {sizingModeLabel} ) : ( sizingModeLabel )}
选约条件 {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偏差关"} )}
休息 {plan?.rest_left_sec ? `${plan.rest_left_sec}s / ${plan.rest_seconds}s` : "—"}
权益 / 杠杆 {plan?.open_capacity?.perp_label || `永续${fmt(plan?.leverage, 0)}x —`} · {plan?.open_capacity?.option_label || "期权 —"}
{isOo ? (
开仓结构 买 Call + 买 Put · 不按永期方向信号
) : (
信号方向 {biasTag}
)}
出场规则 {exitRuleLabel}
净盈利 / 目标 {open ? fmt(netPnl) : "—"} {" / "} {open || exitMode === "fixed_usdt" || isOo ? exitTarget != null ? fmt(exitTarget) : "—" : "—"}
{isOo ? "Call/Put 浮盈" : "永续/期权浮盈"} {fmt(isOo ? pos?.option_upl : pos?.perp_upl)} {" / "} {fmt(isOo ? pos?.option2_upl : pos?.option_upl)}
{plan?.mode === "LIVE" && open ? ( <>
已付手续费 {fmt(pos?.fees_paid)}
资金费 {fmt(pos?.funding_usdt)}
) : null} {plan?.last_error ? (
最近错误 {plan.last_error}
) : null}
{open ? ( <> {isOo ? ( <>
{pos?.option_inst_id || "Call"} 看涨 Call
{countdown}
期期 · Call 到期 {pos?.expiry_ymd || "—"} 行权{" "} {pos?.strike != null ? Math.round(Number(pos.strike)) : "—"} {fmt(pos?.option_qty_eth, 2)} ETH ·{" "} {fmt(pos?.option_qty_contracts, 0)} 张 杠杆 {fmt(pos?.option_leverage, 0)}x
开仓均价 {fmtExPx("option", pos?.option_entry_px)}
买一 {fmtBidLiqEx( "option", pos?.option_mark_px, pos?.option_bid_sz, )}
{plan?.mode === "LIVE" ? "净盈亏" : "浮盈亏"} {fmt(pos?.option_upl)}
初始权利金 {fmt(pos?.initial_premium)}
开仓时间 {openTimeLabel}
持仓时长 {holdDurationLabel}
{pos?.option2_inst_id || "Put"} 看跌 Put
{countdown}
期期 · Put 到期 {pos?.expiry_ymd || "—"} 行权{" "} {pos?.strike2 != null ? Math.round(Number(pos.strike2)) : "—"} {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)} 张 杠杆{" "} {pos?.option2_leverage != null ? `${fmt(pos.option2_leverage, 0)}x` : optionLevLabel( pos?.entry_index_px ?? snap?.index_px, pos?.option2_entry_px, )}
开仓均价 {fmtExPx("option", pos?.option2_entry_px)}
买一 {fmtBidLiqEx( "option", pos?.option2_mark_px ?? snap?.put?.bid, pos?.option2_bid_sz ?? snap?.put?.bid_sz, )}
{plan?.mode === "LIVE" ? "净盈亏" : "浮盈亏"} {fmt(pos?.option2_upl)}
初始权利金 {fmt(pos?.initial_premium2)}
开仓时间 {openTimeLabel}
持仓时长 {holdDurationLabel}
) : ( <>
{pos?.perp_inst_id || "ETH-USDT-SWAP"} {pos?.perp_side === "long" ? "做多" : "做空"}
永续持仓 组 {pos?.group_id} 数量 {fmt(pos?.perp_qty_eth, 2)} ETH {fmt(pos?.leverage ?? plan?.leverage, 0)}x
开仓价 {fmtExPx("perp", pos?.perp_entry_px)}
可平价 {fmtExPx("perp", pos?.perp_mark_px)}
浮盈亏 {fmt(pos?.perp_upl)}
保证金 {fmt(pos?.perp_margin)}
名义价值 {fmt(pos?.perp_notional)}
波动比例 {fmt(movePct, 2)}%
开仓时间 {openTimeLabel}
持仓时长 {holdDurationLabel}
{pos?.option_inst_id || "期权"} {pos?.option_side === "call" ? "看涨 Call" : "看跌 Put"}
{countdown}
期权持仓 到期 {pos?.expiry_ymd || "—"} 行权{" "} {pos?.strike != null ? Math.round(Number(pos.strike)) : "—"} {fmt(pos?.option_qty_eth, 2)} ETH ·{" "} {fmt(pos?.option_qty_contracts, 0)} 张 杠杆 {fmt(pos?.option_leverage, 0)}x
开仓均价 {fmtExPx("option", pos?.option_entry_px)}
买一 {fmtBidLiqEx( "option", pos?.option_mark_px, pos?.option_bid_sz, )}
{plan?.mode === "LIVE" ? "净盈亏" : "浮盈亏"} {fmt(pos?.option_upl)}
初始权利金 {fmt(pos?.initial_premium)}
开仓时间 {openTimeLabel}
持仓时长 {holdDurationLabel}
)} ) : isOo ? ( <>
Call 持仓 · 暂无
Put 持仓 · 暂无
) : ( <>
永续持仓 · 暂无
期权持仓 · 暂无
)}
{showAmpCard ? (

指数 / 振幅

指数 {fmtExPx("index", snap?.index_px)}
高点 {snap?.oo_amplitude?.hours != null ? `(${fmt(snap.oo_amplitude.hours, 0)}h)` : ""} {fmtExPx("index", snap?.oo_amplitude?.high)}
低点 {fmtExPx("index", snap?.oo_amplitude?.low)}
振幅 {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 ? " · 可开" : ""}
) : null} {!isOo ? (

永续行情

买一 {fmtExPx("perp", snap?.perp?.bid)}
卖一 {fmtExPx("perp", snap?.perp?.ask)}
市价 {fmtExPx( "perp", snap?.perp?.mark_px ?? (snap?.perp?.bid != null && snap?.perp?.ask != null ? (snap.perp.bid + snap.perp.ask) / 2 : null), )}
) : null}

{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)) : "—" }` : ""}

{(() => { 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 ( <>
Call {sideLabel} {isOo && callK != null ? ` @${Math.round(Number(callK))}` : ""} {fmtTopEx("option", callPx, callSz)} {!open ? ` · 杠杆 ${callLev}` : ""}
Put {sideLabel} {isOo && putK != null ? ` @${Math.round(Number(putK))}` : ""} {fmtTopEx("option", putPx, putSz)} {!open ? ` · 杠杆 ${putLev}` : ""}
实际杠杆 {open && !isOo ? heldLev || (heldSide ? "—" : `Call ${callLev} · Put ${putLev}`) : `Call ${callLev} · Put ${putLev}`}
); })()} {isOo ? (
相对现价 {(() => { 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)}`; })()}
) : (
距现价 {(() => { 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})`; })()}
)}
到期 {snap?.pair?.expiry_ymd || "—"}
{plan?.residuals && plan.residuals.length > 0 ? (

残留期权

自动平仓仍要求权利金≥设定比例;「平仓」仅验流动性
{plan.residuals.map((r) => { const gid = String(r.group_id || ""); const inst = String(r.option_inst_id || "—"); const liqOk = r.liquidity_ok === true; return ( ); })}
组名 期权名 数量 开仓权利金 买一流动性 买一权利金 占比 操作
{gid || "—"} {inst} {fmt(r.option_qty_eth, 2)} {fmt(r.initial_premium, 2)} {fmtBidLiquidity(r.bid_px, r.bid_sz_eth)} {fmt(r.current_premium, 2)} {r.recovery_pct == null ? "—" : `${fmt(r.recovery_pct, 1)}%`}
) : null}
) : null}
); }