a4bcc3ab0d
Co-authored-by: Cursor <cursoragent@cursor.com>
434 lines
16 KiB
TypeScript
434 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|