Files
eth_hedge_sim/frontend/src/pages/Plan.tsx
T

406 lines
15 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: "流动性等待",
};
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 · · ·
</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="card plan-summary" style={{ marginBottom: 12 }}>
<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">
<span className={pnlClass(pos?.perp_upl)}>{fmt(pos?.perp_upl)}</span>
{" / "}
<span className={pnlClass(pos?.option_upl)}>{fmt(pos?.option_upl)}</span>
</span>
</div>
{plan?.last_error ? (
<div className="kv">
<span></span>
<span className="err" style={{ margin: 0 }}>
{plan.last_error}
</span>
</div>
) : null}
<div className="pos-embed-grid">
{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>
<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 pos-cell-wide">
<span className="pos-label"></span>
<span
className={`pos-value mono ${countdownUrgent ? "pos-countdown-urgent" : ""}`}
>
{countdown}
</span>
</div>
<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="grid-2">
<div className="card">
<h3 style={{ marginTop: 0 }}></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 style={{ marginTop: 0 }}>
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">{snap?.pair?.expiry_ymd || "—"}</span>
</div>
</div>
</div>
</div>
);
}