Files
eth_hedge_sim/control/frontend/src/pages/Monitor.tsx
T
dekun a7aee8f425 Add control residual options table with liquidity-only manual close.
Fleet status exposes enriched residuals; manual close skips the premium recovery gate while auto mid-close remains unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 15:13:29 +08:00

1243 lines
43 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 { useCallback, useEffect, useState } from "react";
import { apiFetch, clearSession, getToken, type NodeCard } from "../api";
function pickStrategy(n: NodeCard) {
const fleet = (n.fleet || {}) as Record<string, unknown>;
const health = (n.health || {}) as Record<string, unknown>;
const strat =
(fleet.strategy as Record<string, unknown> | undefined) ||
(health.strategy as Record<string, unknown> | undefined) ||
{};
const position =
(fleet.position as Record<string, unknown> | undefined) || {};
return {
mode: String(fleet.mode || health.mode || "-"),
running: strat.running,
phase: String(strat.phase ?? "-"),
rounds: strat.rounds_done,
market: fleet.market_connected ?? health.market_connected,
exchange: String(fleet.exchange || health.exchange || "-"),
strat,
position,
pair: fleet.pair as Record<string, unknown> | null | undefined,
index_px: fleet.index_px,
};
}
function fmt(v: unknown, digits = 4): string {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return String(v);
return n.toFixed(digits);
}
/** 价格按交易所常见精度:永续/指数 0.01;期权权利金 0.1 */
function fmtExPx(kind: unknown, px: unknown): string {
if (px == null || px === "") return "—";
const n = Number(px);
if (!Number.isFinite(n)) return String(px);
if (String(kind || "").toLowerCase() === "option") {
return (Math.round(n * 10) / 10).toFixed(1);
}
return (Math.round(n * 100) / 100).toFixed(2);
}
function pnlClass(v: unknown): string {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "pnl-flat";
return n > 0 ? "pnl-pos" : "pnl-neg";
}
const OPEN_STATUSES = new Set([
"open",
"half_open",
"option_closed_perp_pending",
"opening",
]);
function hasOpenPosition(position: Record<string, unknown>): boolean {
if (position.has_position === true) return true;
const st = String(position.status || "").toLowerCase();
return OPEN_STATUSES.has(st);
}
function unitLabel(v: unknown): string {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
if (Number.isInteger(n)) return String(n);
return n.toFixed(2).replace(/\.?0+$/, "");
}
function leveragePair(strat: Record<string, unknown>): string {
const lev = Number(strat.leverage);
const opt = Number(strat.min_option_leverage);
const a = Number.isFinite(lev) ? `${Math.round(lev)}x` : "—";
const b = Number.isFinite(opt) ? `${Math.round(opt)}x` : "—";
return `${a}/${b}`;
}
type ResidualRow = {
key: string;
nodeId: number;
nodeName: string;
group_id: string;
option_inst_id: string;
option_qty_eth: number | null;
initial_premium: number | null;
bid_px: number | null;
current_premium: number | null;
recovery_pct: number | null;
liquidity_ok: boolean;
};
function collectResiduals(nodes: NodeCard[]): ResidualRow[] {
const out: ResidualRow[] = [];
for (const n of nodes) {
const fleet = (n.fleet || {}) as Record<string, unknown>;
const list = fleet.residuals;
if (!Array.isArray(list)) continue;
for (const raw of list) {
if (!raw || typeof raw !== "object") continue;
const r = raw as Record<string, unknown>;
const gid = String(r.group_id || "");
if (!gid) continue;
const numOrNull = (v: unknown) => {
if (v == null || v === "") return null;
const x = Number(v);
return Number.isFinite(x) ? x : null;
};
out.push({
key: `${n.id}:${gid}`,
nodeId: n.id,
nodeName: n.name,
group_id: gid,
option_inst_id: String(r.option_inst_id || "—"),
option_qty_eth: numOrNull(r.option_qty_eth),
initial_premium: numOrNull(r.initial_premium),
bid_px: numOrNull(r.bid_px),
current_premium: numOrNull(r.current_premium),
recovery_pct: numOrNull(r.recovery_pct),
liquidity_ok: r.liquidity_ok === true,
});
}
}
return out;
}
type NodeStats = {
mode?: string;
show_slip?: boolean;
groups: number;
wins: number;
win_rate: number;
total_pnl: number;
fees_perp?: number;
fees_option?: number;
total_fees: number;
total_slip?: number;
latest_funds?: number;
max_single_loss?: number;
loss_streak?: number;
close_reasons: Record<string, number>;
equity_curve: {
group_id: string;
realized_pnl: number;
close_at_ms?: number | null;
}[];
};
const CLOSE_REASON_ZH: Record<string, string> = {
fixed_usdt: "固定净盈利达标·双腿全平",
premium_multiple: "权利金倍数达标·双腿全平",
target_perp_only: "净盈利达标·只平永续(期权归档)",
expiry: "到期结算",
emergency: "紧急全平",
manual: "手动平仓",
liquidity_retry: "等待流动性后全平",
unknown: "未知",
};
function closeReasonZh(v: string): string {
return CLOSE_REASON_ZH[v] || v;
}
type RiskLines = {
riskBased: boolean;
sizing: string;
sizingShort: string;
lossPct: string | null;
exit: string;
openRatio: string | null;
/** 表格「风险/开仓」列:如 5%/1:2;手动为 —/名义比 */
riskOrOpen: string;
leverage: string;
};
function riskLines(strat: Record<string, unknown>): RiskLines {
const riskBased =
strat.sizing_mode === "risk_based" || strat.risk_based === true;
const exitMode = String(strat.exit_mode || "fixed_usdt");
let exit: string;
if (riskBased) {
// 必须读策略机 risk_exit_unit,禁止写死 15
exit =
strat.risk_exit_unit != null && Number.isFinite(Number(strat.risk_exit_unit))
? `基数${unitLabel(strat.risk_exit_unit)}`
: "基数—";
} else if (exitMode === "premium_multiple") {
exit =
strat.premium_exit_multiple != null
? `权利金×${fmt(strat.premium_exit_multiple, 2)}`
: "权利金×—";
} else {
const t = strat.net_profit_target ?? strat.exit_target_usdt;
exit =
t != null && Number.isFinite(Number(t))
? `固定 ${fmt(t, 2)}U`
: "固定 —";
}
let lossPct: string | null = null;
if (riskBased) {
const lossN = Number(strat.risk_loss_pct);
lossPct = Number.isFinite(lossN)
? `${fmt(lossN, lossN % 1 === 0 ? 0 : 2)}%`
: "—";
}
const openRatio = riskBased
? `${unitLabel(strat.risk_perp_unit)}:${unitLabel(strat.risk_option_unit)}`
: `${unitLabel(strat.perp_qty_eth)}:${unitLabel(strat.option_qty_eth)}`;
const riskOrOpen = `${lossPct || "—"}/${openRatio}`;
return {
riskBased,
sizing: riskBased ? "以损定仓" : "手动仓位",
sizingShort: riskBased ? "以损" : "手动",
lossPct,
exit,
openRatio,
riskOrOpen,
leverage: leveragePair(strat),
};
}
function modeLabel(mode: string, strat: Record<string, unknown>): string {
const m = String(mode || "-").toUpperCase();
const short = m.includes("LIVE") ? "LIVE" : m.includes("SIM") ? "SIM" : m || "-";
return `${short}/${riskLines(strat).sizingShort}`;
}
function tokenDotClass(n: {
token_configured?: boolean;
fleet_ok?: boolean | null;
}): string {
if (!n.token_configured) return "dot muted";
if (n.fleet_ok === false) return "dot warn";
return "dot ok";
}
function tokenDotTitle(n: {
token_configured?: boolean;
fleet_ok?: boolean | null;
}): string {
if (!n.token_configured) return "Token 未配对";
if (n.fleet_ok === false) return "Token 配对失败";
return "Token 已配对";
}
function RiskParamsBox({ strat }: { strat: Record<string, unknown> }) {
const r = riskLines(strat);
return (
<div className="risk-box">
<div className="risk-box-title"></div>
<dl className="kv risk-kv">
<div>
<dt></dt>
<dd>{r.sizing}</dd>
</div>
{r.lossPct != null ? (
<div>
<dt></dt>
<dd>{r.lossPct}</dd>
</div>
) : null}
<div>
<dt></dt>
<dd>{r.exit}</dd>
</div>
{r.openRatio ? (
<div>
<dt></dt>
<dd>{r.openRatio}</dd>
</div>
) : null}
<div>
<dt></dt>
<dd>{r.leverage}</dd>
</div>
</dl>
</div>
);
}
/** 页面级快照:整页刷新前保留上次卡片,避免空白等待 */
let cachedNodes: NodeCard[] = [];
let cachedSseSec = 1;
export default function MonitorPage() {
const [nodes, setNodes] = useState<NodeCard[]>(() => cachedNodes);
const [err, setErr] = useState("");
const [busy, setBusy] = useState<Record<number, string>>({});
const [batchBusy, setBatchBusy] = useState(false);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [sseSec, setSseSec] = useState(cachedSseSec);
const [liveState, setLiveState] = useState<"connecting" | "live" | "retry">(
cachedNodes.length ? "live" : "connecting",
);
const [detailId, setDetailId] = useState<number | null>(null);
const [detailStats, setDetailStats] = useState<NodeStats | null>(null);
const [detailStatsErr, setDetailStatsErr] = useState("");
const [detailStatsLoading, setDetailStatsLoading] = useState(false);
const [equityPage, setEquityPage] = useState(0);
const [residualBusy, setResidualBusy] = useState<string>("");
const EQUITY_PAGE_SIZE = 5;
const applyNodes = useCallback((list: NodeCard[]) => {
cachedNodes = list;
setNodes(list);
}, []);
useEffect(() => {
if (detailId == null) {
setDetailStats(null);
setDetailStatsErr("");
setDetailStatsLoading(false);
setEquityPage(0);
return;
}
let cancelled = false;
setDetailStats(null);
setDetailStatsErr("");
setDetailStatsLoading(true);
setEquityPage(0);
apiFetch<NodeStats>(`/api/nodes/${detailId}/stats`)
.then((s) => {
if (!cancelled) setDetailStats(s);
})
.catch((ex) => {
if (!cancelled) {
setDetailStatsErr(ex instanceof Error ? ex.message : String(ex));
}
})
.finally(() => {
if (!cancelled) setDetailStatsLoading(false);
});
return () => {
cancelled = true;
};
}, [detailId]);
const refresh = useCallback(async () => {
try {
const r = await apiFetch<{ nodes: NodeCard[] }>("/api/nodes/status/all");
applyNodes(r.nodes || []);
setErr("");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}, [applyNodes]);
useEffect(() => {
apiFetch<{ sse_interval_sec?: number }>("/api/auth/me")
.then((m) => {
if (m.sse_interval_sec && Number(m.sse_interval_sec) > 0) {
const sec = Number(m.sse_interval_sec);
cachedSseSec = sec;
setSseSec(sec);
}
})
.catch(() => undefined);
}, []);
useEffect(() => {
let cancelled = false;
let retryMs = 1000;
let timer: number | undefined;
const ac = new AbortController();
// 无缓存时先拉一次;有缓存则等 SSE,避免切页/重挂载双倍打满策略机
if (!cachedNodes.length) {
void refresh();
}
async function runStream() {
while (!cancelled) {
setLiveState(retryMs > 1000 ? "retry" : "connecting");
try {
const token = getToken();
const headers = new Headers({ Accept: "text/event-stream" });
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch("/api/nodes/status/stream", {
headers,
signal: ac.signal,
});
if (res.status === 401) {
clearSession();
setErr("登录已失效,请重新登录");
setLiveState("retry");
break;
}
if (!res.ok || !res.body) {
throw new Error(`SSE HTTP ${res.status}`);
}
setLiveState("live");
setErr("");
retryMs = 1000;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let eventName = "message";
while (!cancelled) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n");
buf = parts.pop() || "";
for (const rawLine of parts) {
const line = rawLine.replace(/\r$/, "");
if (line.startsWith("event:")) {
eventName = line.slice(6).trim() || "message";
continue;
}
if (line.startsWith("data:")) {
const data = line.slice(5).trim();
if (!data) continue;
try {
const parsed = JSON.parse(data) as {
nodes?: NodeCard[];
detail?: string;
};
if (eventName === "error") {
setErr(parsed.detail || "SSE 错误");
} else if (parsed.nodes) {
applyNodes(parsed.nodes);
setErr("");
}
} catch {
/* ignore malformed chunk */
}
eventName = "message";
continue;
}
if (line === "") {
eventName = "message";
}
}
}
} catch (ex) {
if (cancelled || (ex instanceof DOMException && ex.name === "AbortError")) {
break;
}
setLiveState("retry");
setErr(ex instanceof Error ? ex.message : String(ex));
}
if (cancelled) break;
await new Promise<void>((resolve) => {
timer = window.setTimeout(() => resolve(), retryMs);
});
retryMs = Math.min(retryMs * 2, 15000);
}
}
void runStream();
return () => {
cancelled = true;
ac.abort();
if (timer != null) window.clearTimeout(timer);
};
}, [refresh, applyNodes]);
async function act(id: number, action: "start" | "pause" | "update" | "login") {
if (action === "update") {
const node = nodes.find((x) => x.id === id);
const name = node?.name || `#${id}`;
const ok = window.confirm(
`确认更新「${name}」代码?\n\n将 git pull 并 reload 策略进程,短暂中断 API;不会自动启动策略。\n请勿误点。`,
);
if (!ok) return;
}
setBusy((b) => ({ ...b, [id]: action }));
setErr("");
try {
if (action === "login") {
const r = await apiFetch<{ url: string }>(`/api/nodes/${id}/login-url`, {
method: "POST",
});
window.open(r.url, "_blank", "noopener,noreferrer");
} else {
await apiFetch(`/api/nodes/${id}/${action === "pause" ? "pause" : action}`, {
method: "POST",
});
await refresh();
}
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setBusy((b) => {
const n = { ...b };
delete n[id];
return n;
});
}
}
async function batchUpdate(idsOverride?: number[]) {
const ids = idsOverride ?? [...selected];
if (!ids.length) return;
const names = nodes
.filter((n) => ids.includes(n.id))
.map((n) => n.name)
.join("、");
const ok = window.confirm(
`确认批量更新 ${ids.length} 台策略机?\n\n${names}\n\n将分别 git pull 并 reload,短暂中断;不会自动启动策略。`,
);
if (!ok) return;
setErr("");
try {
await apiFetch("/api/nodes/update-batch", {
method: "POST",
body: JSON.stringify({ ids }),
});
await refresh();
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
function toggle(id: number) {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
}
const updatableIds = nodes
.filter((n) => n.token_configured)
.map((n) => n.id);
const allSelected =
updatableIds.length > 0 && updatableIds.every((id) => selected.has(id));
function toggleSelectAll() {
setSelected((prev) => {
if (updatableIds.length === 0) return prev;
if (updatableIds.every((id) => prev.has(id))) return new Set();
return new Set(updatableIds);
});
}
async function updateAll() {
if (!updatableIds.length) {
setErr("没有已配对 Token 的策略机可更新");
return;
}
setSelected(new Set(updatableIds));
await batchUpdate(updatableIds);
}
const startableIds = nodes
.filter((n) => {
if (!n.token_configured) return false;
const s = pickStrategy(n);
const running = s.running === true || s.running === 1;
return !running;
})
.map((n) => n.id);
async function closeResidual(row: ResidualRow) {
const ok = window.confirm(
`确认平掉残留期权?\n\n机器:${row.nodeName}\n组:${row.group_id}\n合约:${row.option_inst_id}\n\n仅校验买一流动性,不要求权利金回收比例。`,
);
if (!ok) return;
setResidualBusy(row.key);
setErr("");
try {
await apiFetch(`/api/nodes/${row.nodeId}/residual/close`, {
method: "POST",
body: JSON.stringify({ group_id: row.group_id }),
});
await refresh();
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setResidualBusy("");
}
}
async function startAll() {
if (!startableIds.length) {
setErr("没有可启动的策略机(需已配对且当前未运行)");
return;
}
const names = nodes
.filter((n) => startableIds.includes(n.id))
.map((n) => n.name)
.join("、");
const ok = window.confirm(
`确认全部启动 ${startableIds.length} 台策略机?\n\n${names}\n\n将并行调用各机 Fleet 启动(已在运行的不会列入)。`,
);
if (!ok) return;
setErr("");
setBatchBusy(true);
try {
const r = await apiFetch<{
started?: number;
total?: number;
results?: { id: number; name?: string; ok: boolean; detail?: string }[];
}>("/api/nodes/start-batch", {
method: "POST",
body: JSON.stringify({ ids: startableIds }),
});
await refresh();
const fails = (r.results || []).filter((x) => !x.ok);
if (fails.length) {
setErr(
`启动完成 ${r.started ?? 0}/${r.total ?? startableIds.length};失败:` +
fails
.map((x) => `${x.name || x.id}${x.detail ? `(${x.detail})` : ""}`)
.join(""),
);
}
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setBatchBusy(false);
}
}
const detailNode = detailId != null ? nodes.find((x) => x.id === detailId) : null;
const detail = detailNode ? pickStrategy(detailNode) : null;
const detailRunning =
detail != null && (detail.running === true || detail.running === 1);
const detailLegs = Array.isArray(detail?.position?.legs)
? (detail!.position.legs as Record<string, unknown>[])
: [];
const equityNewestFirst = detailStats?.equity_curve?.length
? [...detailStats.equity_curve].reverse()
: [];
const equityPageCount = Math.max(
1,
Math.ceil(equityNewestFirst.length / EQUITY_PAGE_SIZE),
);
const equityPageSafe = Math.min(equityPage, equityPageCount - 1);
const equityPageRows = equityNewestFirst.slice(
equityPageSafe * EQUITY_PAGE_SIZE,
equityPageSafe * EQUITY_PAGE_SIZE + EQUITY_PAGE_SIZE,
);
const residualRows = collectResiduals(nodes);
return (
<div>
<div className="toolbar">
<h2></h2>
<div className="toolbar-actions">
<span
className={`live-pill ${
liveState === "live" ? "ok" : liveState === "retry" ? "bad" : ""
}`}
title="中控 SSE 秒级推送"
>
{liveState === "live"
? `实时 · ${sseSec}s`
: liveState === "retry"
? "重连中…"
: "连接中…"}
</span>
<button type="button" className="btn ghost" onClick={() => void refresh()}>
</button>
<button
type="button"
className="btn"
disabled={batchBusy || !startableIds.length}
onClick={() => void startAll()}
title="并行启动全部已配对且未运行的策略机"
>
{batchBusy ? "启动中…" : `全部启动 (${startableIds.length})`}
</button>
<button
type="button"
className="btn ghost"
disabled={!updatableIds.length}
onClick={() => void updateAll()}
title="选中全部已配对策略机并批量更新代码"
>
({updatableIds.length})
</button>
<button
type="button"
className="btn"
disabled={!selected.size}
onClick={() => void batchUpdate()}
>
({selected.size})
</button>
</div>
</div>
{err ? <div className="err">{err}</div> : null}
{nodes.length ? (
<div className="monitor-table-wrap">
<table className="monitor-table">
<thead>
<tr>
<th className="col-check">
<input
type="checkbox"
checked={allSelected}
disabled={!updatableIds.length}
onChange={toggleSelectAll}
aria-label="全选"
title="全选已配对机器"
/>
</th>
<th className="col-status"></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>/</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{nodes.map((n) => {
const s = pickStrategy(n);
const running = s.running === true || s.running === 1;
const inPos = hasOpenPosition(s.position);
const risk = riskLines(s.strat);
return (
<tr
key={n.id}
className={`monitor-row ${running ? "is-running" : "is-stopped"}`}
onClick={() => setDetailId(n.id)}
>
<td
className="col-check"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={selected.has(n.id)}
onChange={() => toggle(n.id)}
aria-label={`选择 ${n.name}`}
/>
</td>
<td className="col-status">
<div
className="status-dots"
title={`在线:${n.online ? "是" : "否"} · ${tokenDotTitle(n)} · ${running ? "运行中" : "已停"}`}
>
<span
className={`dot pulse d1 ${n.online ? "ok" : "bad"}`}
title={n.online ? "在线" : "离线"}
/>
<span
className={`dot pulse d2 ${tokenDotClass(n).replace("dot ", "")}`}
title={tokenDotTitle(n)}
/>
<span
className={`dot pulse d3 ${running ? "ok" : "bad"}`}
title={running ? "运行中" : "已停"}
/>
</div>
</td>
<td>
<strong className="machine-name">{n.name}</strong>
</td>
<td className="mono">{modeLabel(s.mode, s.strat)}</td>
<td className="mono">{s.exchange}</td>
<td className={inPos ? "pnl-pos" : "pnl-neg"}>
{inPos ? "持仓中" : "无持仓"}
</td>
<td className="mono">{s.rounds ?? "—"}</td>
<td
className="mono"
title="风险比例 / 开仓比例"
>
{risk.riskOrOpen}
</td>
<td className="mono">{risk.exit}</td>
<td className="mono">{risk.leverage}</td>
<td
className="col-actions"
onClick={(e) => e.stopPropagation()}
>
<div className="row-actions">
<button
type="button"
className={`btn btn-sm ${running ? "btn-running" : ""}`}
disabled={
!!busy[n.id] || !n.token_configured || running
}
onClick={() => void act(n.id, "start")}
>
{running ? "运行中" : "启动"}
</button>
<button
type="button"
className="btn btn-sm ghost"
disabled={!!busy[n.id] || !n.token_configured}
onClick={() => void act(n.id, "pause")}
>
</button>
<button
type="button"
className="btn btn-sm"
disabled={!!busy[n.id] || !n.token_configured}
onClick={() => void act(n.id, "login")}
>
</button>
<button
type="button"
className="btn btn-sm ghost"
disabled={!!busy[n.id] || !n.token_configured}
onClick={() => void act(n.id, "update")}
>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="meta"> Token</p>
)}
{residualRows.length ? (
<>
<div className="toolbar residual-toolbar">
<h2></h2>
<span className="meta">
</span>
</div>
<div className="monitor-table-wrap">
<table className="monitor-table residual-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>%</th>
<th></th>
</tr>
</thead>
<tbody>
{residualRows.map((r) => (
<tr key={r.key}>
<td>{r.nodeName}</td>
<td className="mono">{r.group_id}</td>
<td className="mono">{r.option_inst_id}</td>
<td className="mono">{fmt(r.option_qty_eth, 4)}</td>
<td className="mono">{fmt(r.initial_premium, 2)}</td>
<td className="mono">{fmt(r.current_premium, 2)}</td>
<td className="mono">
{r.recovery_pct == null ? "—" : fmt(r.recovery_pct, 1)}
</td>
<td className="col-actions">
<button
type="button"
className="btn btn-sm"
disabled={!!residualBusy || !r.liquidity_ok}
title={
r.liquidity_ok
? "按最新买一 IOC 平仓(不验权利金比例)"
: "买一流动性不足或盘口不可用"
}
onClick={() => void closeResidual(r)}
>
{residualBusy === r.key ? "平仓中…" : "平仓"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : null}
{detailNode && detail ? (
<div
className="modal-backdrop"
onClick={() => setDetailId(null)}
role="presentation"
>
<div
className={`modal-panel ${detailRunning ? "running" : ""}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={`${detailNode.name} 详情`}
>
<header className="modal-head">
<div>
<h3>{detailNode.name}</h3>
<div className="mono meta">{detailNode.base_url}</div>
</div>
<button
type="button"
className="btn ghost"
onClick={() => setDetailId(null)}
>
</button>
</header>
<section className="modal-section">
<h4></h4>
{(() => {
const r = riskLines(detail.strat);
return (
<div className="legs-table-wrap">
<table className="legs-table">
<thead>
<tr>
<th></th>
{r.lossPct != null ? <th></th> : null}
<th></th>
{r.openRatio ? <th></th> : null}
<th></th>
<th></th>
<th>/</th>
<th></th>
<th> ID</th>
<th></th>
<th>/</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>{r.sizing}</td>
{r.lossPct != null ? <td>{r.lossPct}</td> : null}
<td>{r.exit}</td>
{r.openRatio ? <td>{r.openRatio}</td> : null}
<td>{r.leverage}</td>
<td>
{detailRunning ? "运行中" : "已停"} · {detail.phase}
</td>
<td>
{detail.mode} / {detail.exchange}
</td>
<td>{detail.rounds ?? "—"}</td>
<td className="mono">
{String(
detail.strat.group_id ||
detail.position.group_id ||
"—",
)}
</td>
<td>{String(detail.strat.perp_margin_mode || "—")}</td>
<td>
{fmt(detail.strat.perp_qty_eth, 4)} /{" "}
{fmt(detail.strat.option_qty_eth, 4)} ETH
</td>
<td>{fmt(detail.strat.exit_target_usdt, 2)} USDT</td>
<td className="mono">{fmtExPx("perp", detail.index_px)}</td>
<td className="mono">
{detail.pair
? `${detail.pair.expiry_ymd || "?"} @ ${
detail.pair.strike != null
? Math.round(Number(detail.pair.strike))
: "?"
}`
: "—"}
</td>
</tr>
</tbody>
</table>
</div>
);
})()}
{detail.strat.last_error ? (
<p className="err soft" style={{ marginTop: 8 }}>
{String(detail.strat.last_error)}
</p>
) : null}
</section>
<section className="modal-section">
<h4></h4>
{detailStatsLoading ? (
<p className="meta"></p>
) : null}
{detailStatsErr ? (
<p className="err soft">{detailStatsErr}</p>
) : null}
{detailStats ? (
<div className="legs-table-wrap">
<table className="legs-table">
<thead>
<tr>
<th>/</th>
<th></th>
<th></th>
<th></th>
<th></th>
{(detailStats.show_slip ??
detailStats.mode !== "LIVE") ? (
<th></th>
) : null}
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td className="mono">
{detailStats.groups} /{" "}
{(detailStats.win_rate * 100).toFixed(1)}%
</td>
<td className={pnlClass(detailStats.total_pnl)}>
{fmt(detailStats.total_pnl, 2)} USDT
</td>
<td className="mono">
{fmt(detailStats.fees_perp ?? 0, 4)}
</td>
<td className="mono">
{fmt(detailStats.fees_option ?? 0, 4)}
</td>
<td className="mono">
{fmt(detailStats.total_fees, 4)}
</td>
{(detailStats.show_slip ??
detailStats.mode !== "LIVE") ? (
<td className="mono">
{fmt(detailStats.total_slip ?? 0, 4)}
</td>
) : null}
<td className="mono">
{Object.keys(detailStats.close_reasons || {})
.length === 0
? "—"
: Object.entries(detailStats.close_reasons)
.sort((a, b) => b[1] - a[1])
.map(([k, n]) => `${closeReasonZh(k)}×${n}`)
.join(" · ")}
</td>
</tr>
</tbody>
</table>
</div>
) : !detailStatsLoading && !detailStatsErr ? (
<p className="meta"></p>
) : null}
</section>
<section className="modal-section">
<h4></h4>
{detailStatsLoading ? (
<p className="meta"></p>
) : equityNewestFirst.length ? (
<>
<div className="legs-table-wrap">
<table className="legs-table">
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{equityPageRows.map((x) => (
<tr key={x.group_id}>
<td className="mono">{x.group_id}</td>
<td className={pnlClass(x.realized_pnl)}>
{fmt(x.realized_pnl, 2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="pager">
<span className="pager-meta">
{equityPageSafe + 1}/{equityPageCount} · {" "}
{EQUITY_PAGE_SIZE} · {equityNewestFirst.length}
</span>
<div className="pager-actions">
<button
type="button"
className="btn ghost"
disabled={equityPageSafe <= 0}
onClick={() =>
setEquityPage(Math.max(0, equityPageSafe - 1))
}
>
</button>
<button
type="button"
className="btn ghost"
disabled={equityPageSafe >= equityPageCount - 1}
onClick={() =>
setEquityPage(
Math.min(equityPageCount - 1, equityPageSafe + 1),
)
}
>
</button>
</div>
</div>
</>
) : (
<p className="meta"></p>
)}
</section>
<section className="modal-section">
<h4></h4>
<p className="meta">
{String(detail.position.status || "flat")}
{detail.position.net_pnl != null ? (
<>
{" · 净浮盈 "}
<span className={pnlClass(detail.position.net_pnl)}>
{fmt(detail.position.net_pnl, 2)} USDT
</span>
</>
) : null}
{detail.position.perp_margin != null ? (
<>
{" · 保证金 "}
<span className="mono">
{fmt(detail.position.perp_margin, 2)} U
</span>
</>
) : null}
{detail.position.initial_premium != null ? (
<>
{" · 权利金 "}
<span className="mono">
{fmt(detail.position.initial_premium, 2)} U
</span>
</>
) : null}
{detail.position.expiry_ymd
? ` · 到期 ${String(detail.position.expiry_ymd)} @ ${
detail.position.strike != null
? Math.round(Number(detail.position.strike))
: "—"
}`
: ""}
</p>
{detailLegs.length ? (
<div className="legs-table-wrap">
<table className="legs-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{detailLegs.map((lg, i) => (
<tr key={`${lg.inst_id || i}`}>
<td>{String(lg.kind || "—")}</td>
<td>{String(lg.side || "—")}</td>
<td className="mono">{String(lg.inst_id || "—")}</td>
<td className="mono">{fmt(lg.qty, 2)}</td>
<td className="mono">
{fmtExPx(lg.kind, lg.avg_px)}
</td>
<td className="mono">
{fmtExPx(lg.kind, lg.mark_px)}
</td>
<td className="mono">
{lg.kind === "perp"
? fmt(
lg.margin ?? detail.position.perp_margin,
2,
)
: "—"}
</td>
<td className="mono">
{lg.kind === "option"
? fmt(
lg.premium ??
detail.position.initial_premium,
2,
)
: "—"}
</td>
<td className={`mono ${pnlClass(lg.upl)}`}>
{fmt(lg.upl, 2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="meta"></p>
)}
</section>
<div className="node-actions">
<button
type="button"
className={`btn ${detailRunning ? "btn-running" : ""}`}
disabled={
!!busy[detailNode.id] ||
!detailNode.token_configured ||
detailRunning
}
onClick={() => void act(detailNode.id, "start")}
>
{detailRunning ? "运行中" : "启动"}
</button>
<button
type="button"
className="btn ghost"
disabled={!!busy[detailNode.id] || !detailNode.token_configured}
onClick={() => void act(detailNode.id, "pause")}
>
</button>
<button
type="button"
className="btn"
disabled={!!busy[detailNode.id] || !detailNode.token_configured}
onClick={() => void act(detailNode.id, "login")}
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}