Files
eth_hedge_sim/frontend/src/pages/Plan.tsx
T
2026-07-25 12:25:27 +08:00

434 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { apiFetch, MarketSnapshot, PlanState } from "../api/client";
function fmt(n: number | null | undefined, d = 2) {
if (n == null || Number.isNaN(n)) return "—";
return n.toFixed(d);
}
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")}`;
}
const PHASE_ZH: Record<string, string> = {
idle: "空闲",
wait_signal: "等待信号",
opening: "开仓中",
open: "持仓中",
closing: "平仓中",
resting: "休息中",
paused: "已暂停",
stopped: "已停开",
outside_window: "窗外",
liquidity_wait: "流动性等待",
weekend_skip: "周末跳过",
};
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 [nowMs, setNowMs] = useState(() => Date.now());
async function refresh() {
try {
const [m, p] = await Promise.all([
apiFetch<MarketSnapshot>("/api/market/snapshot"),
apiFetch<PlanState>("/api/plan/state"),
]);
setSnap(m);
setPlan(p);
setErr("");
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
}
}
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("");
}
}
const bias = snap?.ask_compare?.bias;
const biasTag =
bias === "call_ask_gt_put" ? (
<span className="tag up"> Call + </span>
) : bias === "put_ask_gt_call" ? (
<span className="tag down"> Put + </span>
) : (
<span className="tag"> / </span>
);
const pos = plan?.position;
const open = !!pos?.has_position;
const exitMode = plan?.exit_mode ?? "fixed_usdt";
const exitTarget = plan?.exit_target_usdt ?? plan?.net_profit_target ?? 15;
const netPnl = pos?.net_pnl ?? 0;
const movePct = pos?.move_pct ?? 0;
const exitRuleLabel =
exitMode === "premium_multiple"
? `权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}`
: `固定 ${fmt(plan?.net_profit_target ?? 15)} U`;
const phaseLabel = PHASE_ZH[plan?.phase || ""] || plan?.phase || "—";
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;
return (
<div>
<h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)", marginTop: -8 }}>
SIM · {" "}
{(snap?.exchange || "okx").toUpperCase()}
{snap?.perp_inst_id ? ` · ${snap.perp_inst_id}` : ""} ·
</p>
{err ? <div className="err">{err}</div> : null}
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
<button
className="btn"
type="button"
disabled={!!busy || plan?.running}
onClick={() => act("/api/plan/start", "start")}
>
</button>
<button
className="btn ghost"
type="button"
disabled={!!busy || !plan?.running}
onClick={() => act("/api/plan/pause", "pause")}
>
</button>
<button
className="btn ghost"
type="button"
disabled={!!busy}
onClick={() => act("/api/sim/open-group", "open")}
>
</button>
<button
className="btn ghost"
type="button"
disabled={!!busy}
onClick={() => act("/api/sim/close-group", "close")}
>
</button>
<button
className="btn ghost"
type="button"
disabled={!!busy}
onClick={() => act("/api/plan/emergency-close", "emg")}
>
</button>
{busy ? <span className="meta">{busy}</span> : null}
</div>
<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">
{fmt(plan?.min_option_hours, 0)}h ·
{fmt(plan?.min_option_leverage, 0)}x
</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="mono">
{fmt(plan?.ledger?.equity)} / {fmt(plan?.ledger?.available)} ·{" "}
{fmt(plan?.leverage, 0)}x
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{pos?.group_id || "—"}</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" ? fmt(exitTarget) : "—"}
</span>
</div>
<div className="kv">
<span></span>
<span className={`mono ${pnlClass(pos?.perp_upl)}`}>{fmt(pos?.perp_upl)}</span>
</div>
<div className="kv">
<span></span>
<span className={`mono ${pnlClass(pos?.option_upl)}`}>{fmt(pos?.option_upl)}</span>
</div>
{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 ? (
<>
<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">{fmt(pos?.perp_entry_px)}</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">{fmt(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>
</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"> {fmt(pos?.strike, 0)}</span>
<span className="pos-meta-item mono">
{fmt(pos?.option_qty_eth, 2)} ETH · {fmt(pos?.option_qty_contracts, 0)}
</span>
</div>
<div className="pos-grid">
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">{fmt(pos?.option_entry_px)}</span>
</div>
<div className="pos-cell">
<span className="pos-label">/</span>
<span className="pos-value mono">{fmt(pos?.option_mark_px)}</span>
</div>
<div className="pos-cell">
<span className="pos-label"></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>
</div>
</>
) : (
<>
<div className="pos-empty"> · </div>
<div className="pos-empty"> · </div>
</>
)}
</div>
</div>
<div className="plan-market">
<div className="card">
<h3 className="plan-panel-title"></h3>
<div className="kv">
<span></span>
<span className="mono">{fmt(snap?.perp?.bid)}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmt(snap?.perp?.ask)}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmt(snap?.index_px)}</span>
</div>
</div>
<div className="card">
<h3 className="plan-panel-title">
ATM {snap?.pair ? `@ ${snap.pair.strike}` : ""}
</h3>
<div className="kv">
<span>Call /</span>
<span className="mono">
{fmt(snap?.call?.ask)} / {fmt(snap?.call?.bid)}
</span>
</div>
<div className="kv">
<span>Put /</span>
<span className="mono">
{fmt(snap?.put?.ask)} / {fmt(snap?.put?.bid)}
</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 d = strike - px;
const sign = d > 0 ? "+" : "";
return `${sign}${d.toFixed(0)}(最近可用行权价)`;
})()}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{snap?.pair?.expiry_ymd || "—"}</span>
</div>
</div>
</div>
</div>
</div>
);
}