ec3156021a
Co-authored-by: Cursor <cursoragent@cursor.com>
954 lines
33 KiB
TypeScript
954 lines
33 KiB
TypeScript
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);
|
||
}
|
||
|
||
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 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;
|
||
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 [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 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() {
|
||
const ids = [...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 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,
|
||
);
|
||
|
||
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={!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">
|
||
<span className="sr-only">选择</span>
|
||
</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>
|
||
)}
|
||
|
||
{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>{fmt(detail.index_px, 2)}</td>
|
||
<td className="mono">
|
||
{detail.pair
|
||
? `${detail.pair.expiry_ymd || "?"} @ ${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.expiry_ymd
|
||
? ` · 到期 ${String(detail.position.expiry_ymd)} @ ${fmt(detail.position.strike, 0)}`
|
||
: ""}
|
||
</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>
|
||
</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>{fmt(lg.qty, 4)}</td>
|
||
<td>{fmt(lg.avg_px, 4)}</td>
|
||
<td>{fmt(lg.mark_px, 4)}</td>
|
||
<td className={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>
|
||
);
|
||
}
|