diff --git a/backend/app/api/fleet.py b/backend/app/api/fleet.py index b31e308..a6e42cb 100644 --- a/backend/app/api/fleet.py +++ b/backend/app/api/fleet.py @@ -220,12 +220,26 @@ async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> di return None return _sf(key, default) + latest_funds = 0.0 + try: + from ..sim.funds_wallets import FundsWallets + + latest_funds = float(FundsWallets(db).total_usdt_equiv()) + except Exception: + try: + from ..sim.ledger import Ledger + + latest_funds = float(Ledger(db).snapshot().get("equity") or 0) + except Exception: + latest_funds = 0.0 + return { "ok": True, "mode": settings.mode, "env_name": settings.env_name, "exchange": exchange_name, "sim": settings.is_sim, + "latest_funds": latest_funds, "market_connected": bool(snap.connected) if snap else False, "pair": snap.pair.to_dict() if snap and snap.pair else None, "index_px": ( diff --git a/control/backend/app/api/nodes.py b/control/backend/app/api/nodes.py index fdcf762..d89bcdd 100644 --- a/control/backend/app/api/nodes.py +++ b/control/backend/app/api/nodes.py @@ -278,6 +278,26 @@ async def node_status( return await _collect_one_status(node) +def _max_single_loss_from_curve(curve: list[Any]) -> float: + pnls = [float(x.get("realized_pnl") or 0) for x in curve if isinstance(x, dict)] + if not pnls: + return 0.0 + worst = min(pnls) + return float(worst) if worst < 0 else 0.0 + + +def _loss_streak_from_curve(curve: list[Any]) -> int: + streak = 0 + for item in reversed(curve): + if not isinstance(item, dict): + continue + if float(item.get("realized_pnl") or 0) < 0: + streak += 1 + else: + break + return streak + + async def _collect_one_stats(node: dict[str, Any]) -> dict[str, Any]: base = { "id": node["id"], @@ -304,16 +324,37 @@ async def _collect_one_stats(node: dict[str, Any]) -> dict[str, Any]: if code >= 400 or not isinstance(data, dict): base["error"] = _http_detail(data) if data else f"HTTP {code}" return base + + curve = data.get("equity_curve") if isinstance(data.get("equity_curve"), list) else [] + max_loss = data.get("max_single_loss") + if max_loss is None: + max_loss = _max_single_loss_from_curve(curve) + loss_streak = data.get("loss_streak") + if loss_streak is None: + loss_streak = _loss_streak_from_curve(curve) + + latest_funds = data.get("latest_funds") + if latest_funds is None: + # 旧版策略机 stats 无资金字段:回落 status.latest_funds + try: + sc, sd = await call_node(node, "GET", "/api/fleet/status", timeout=8.0) + if sc < 400 and isinstance(sd, dict) and sd.get("latest_funds") is not None: + latest_funds = sd.get("latest_funds") + except Exception: + pass + if latest_funds is None: + latest_funds = 0.0 + base.update( { "ok": True, - "latest_funds": data.get("latest_funds"), + "latest_funds": latest_funds, "groups": data.get("groups"), "fees_perp": data.get("fees_perp"), "fees_option": data.get("fees_option"), "total_fees": data.get("total_fees"), - "max_single_loss": data.get("max_single_loss"), - "loss_streak": data.get("loss_streak"), + "max_single_loss": max_loss, + "loss_streak": loss_streak, "total_pnl": data.get("total_pnl"), "mode": data.get("mode"), } diff --git a/control/frontend/src/App.tsx b/control/frontend/src/App.tsx index 362ab45..ca5d100 100644 --- a/control/frontend/src/App.tsx +++ b/control/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { apiFetch, clearSession, getToken, getUsername } from "./api"; import LoginPage from "./pages/Login"; import MonitorPage from "./pages/Monitor"; import SettingsPage from "./pages/Settings"; +import StatsPage from "./pages/Stats"; function Shell({ children }: { children: ReactNode }) { const user = getUsername(); @@ -29,6 +30,9 @@ function Shell({ children }: { children: ReactNode }) { (isActive ? "active" : "")}> 监控区 + (isActive ? "active" : "")}> + 数据统计 + (isActive ? "active" : "")}> 系统设置 @@ -54,13 +58,15 @@ function Shell({ children }: { children: ReactNode }) { ); } -/** 监控页保活:切设置不断 SSE、不丢卡片状态 */ +/** 监控页保活:切设置/统计不断 SSE、不丢卡片状态 */ function AuthedPages() { const loc = useLocation(); const onMonitor = loc.pathname === "/" || loc.pathname === "/monitor" || loc.pathname.startsWith("/monitor/"); + const onStats = + loc.pathname === "/stats" || loc.pathname.startsWith("/stats/"); const onSettings = loc.pathname === "/settings" || loc.pathname.startsWith("/settings/"); return ( @@ -68,10 +74,15 @@ function AuthedPages() {
+
+ +
- {!onMonitor && !onSettings ? : null} + {!onMonitor && !onStats && !onSettings ? ( + + ) : null} ); } diff --git a/control/frontend/src/pages/Monitor.tsx b/control/frontend/src/pages/Monitor.tsx index 41dd64b..072645e 100644 --- a/control/frontend/src/pages/Monitor.tsx +++ b/control/frontend/src/pages/Monitor.tsx @@ -98,21 +98,6 @@ type NodeStats = { }[]; }; -type FleetStatsRow = { - id: number; - name: string; - ok: boolean; - error?: string | null; - latest_funds?: number | null; - groups?: number | null; - fees_perp?: number | null; - fees_option?: number | null; - total_fees?: number | null; - max_single_loss?: number | null; - loss_streak?: number | null; - total_pnl?: number | null; -}; - const CLOSE_REASON_ZH: Record = { fixed_usdt: "固定净盈利达标·双腿全平", premium_multiple: "权利金倍数达标·双腿全平", @@ -264,28 +249,12 @@ export default function MonitorPage() { const [detailStatsLoading, setDetailStatsLoading] = useState(false); const [equityPage, setEquityPage] = useState(0); const EQUITY_PAGE_SIZE = 5; - const [fleetStats, setFleetStats] = useState([]); - const [fleetStatsErr, setFleetStatsErr] = useState(""); - const [fleetStatsLoading, setFleetStatsLoading] = useState(false); const applyNodes = useCallback((list: NodeCard[]) => { cachedNodes = list; setNodes(list); }, []); - const refreshStats = useCallback(async () => { - setFleetStatsLoading(true); - try { - const r = await apiFetch<{ nodes: FleetStatsRow[] }>("/api/nodes/stats/all"); - setFleetStats(r.nodes || []); - setFleetStatsErr(""); - } catch (ex) { - setFleetStatsErr(ex instanceof Error ? ex.message : String(ex)); - } finally { - setFleetStatsLoading(false); - } - }, []); - useEffect(() => { if (detailId == null) { setDetailStats(null); @@ -324,14 +293,7 @@ export default function MonitorPage() { } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); } - void refreshStats(); - }, [applyNodes, refreshStats]); - - useEffect(() => { - void refreshStats(); - const id = window.setInterval(() => void refreshStats(), 60_000); - return () => window.clearInterval(id); - }, [refreshStats]); + }, [applyNodes]); useEffect(() => { apiFetch<{ sse_interval_sec?: number }>("/api/auth/me") @@ -729,126 +691,6 @@ export default function MonitorPage() {

暂无策略机。请到「系统设置」添加并生成 Token。

)} -
-
-

数据统计

-
- -
-
- {fleetStatsErr ?
{fleetStatsErr}
: null} - {fleetStats.length ? ( -
- - - - - - - - - - - - - - - - {fleetStats.map((row) => ( - - - - - - - - - - - - ))} - - - {(() => { - const okRows = fleetStats.filter((r) => r.ok); - const sum = (key: keyof FleetStatsRow) => - okRows.reduce((a, r) => a + (Number(r[key]) || 0), 0); - const maxLoss = okRows.length - ? Math.min( - 0, - ...okRows.map((r) => Number(r.max_single_loss) || 0), - ) - : 0; - return ( - - - - - - - - - - - - ); - })()} - -
机器名最新资金轮次永续手续费期权手续费手续费合计单笔最大亏损连亏次数盈亏
- {row.name} - {!row.ok && row.error ? ( -
- 拉取失败 -
- ) : null} -
- {row.latest_funds != null - ? fmt(row.latest_funds, 2) - : "—"} - - {row.groups != null ? row.groups : "—"} - - {row.fees_perp != null ? fmt(row.fees_perp, 4) : "—"} - - {row.fees_option != null - ? fmt(row.fees_option, 4) - : "—"} - - {row.total_fees != null ? fmt(row.total_fees, 4) : "—"} - - {row.max_single_loss != null - ? fmt(row.max_single_loss, 2) - : "—"} - - {row.loss_streak != null ? row.loss_streak : "—"} - - {row.total_pnl != null ? fmt(row.total_pnl, 2) : "—"} -
- 合计 - - {" "} - ({okRows.length}/{fleetStats.length}) - - {fmt(sum("latest_funds"), 2)}{sum("groups")}{fmt(sum("fees_perp"), 4)}{fmt(sum("fees_option"), 4)}{fmt(sum("total_fees"), 4)} - {fmt(maxLoss, 2)} - - {fmt(sum("total_pnl"), 2)} -
-
- ) : ( -

- {fleetStatsLoading ? "正在拉取各策略机统计…" : "暂无统计数据"} -

- )} -
- {detailNode && detail ? (
0 ? "pnl-pos" : "pnl-neg"; +} + +export default function StatsPage() { + const [fleetStats, setFleetStats] = useState([]); + const [fleetStatsErr, setFleetStatsErr] = useState(""); + const [fleetStatsLoading, setFleetStatsLoading] = useState(false); + + const refreshStats = useCallback(async () => { + setFleetStatsLoading(true); + try { + const r = await apiFetch<{ nodes: FleetStatsRow[] }>("/api/nodes/stats/all"); + setFleetStats(r.nodes || []); + setFleetStatsErr(""); + } catch (ex) { + setFleetStatsErr(ex instanceof Error ? ex.message : String(ex)); + } finally { + setFleetStatsLoading(false); + } + }, []); + + useEffect(() => { + void refreshStats(); + const id = window.setInterval(() => void refreshStats(), 60_000); + return () => window.clearInterval(id); + }, [refreshStats]); + + const okRows = fleetStats.filter((r) => r.ok); + const sum = (key: keyof FleetStatsRow) => + okRows.reduce((a, r) => a + (Number(r[key]) || 0), 0); + const maxLoss = okRows.length + ? Math.min(0, ...okRows.map((r) => Number(r.max_single_loss) || 0)) + : 0; + + return ( +
+
+

数据统计

+
+ +
+
+ {fleetStatsErr ?
{fleetStatsErr}
: null} + {fleetStats.length ? ( +
+ + + + + + + + + + + + + + + + {fleetStats.map((row) => ( + + + + + + + + + + + + ))} + + + + + + + + + + + + + + +
机器名最新资金轮次永续手续费期权手续费手续费合计单笔最大亏损连亏次数盈亏
+ {row.name} + {!row.ok && row.error ? ( +
+ 拉取失败 +
+ ) : null} +
+ {row.ok ? fmt(row.latest_funds ?? 0, 2) : "—"} + + {row.groups != null ? row.groups : "—"} + + {row.fees_perp != null ? fmt(row.fees_perp, 4) : "—"} + + {row.fees_option != null ? fmt(row.fees_option, 4) : "—"} + + {row.total_fees != null ? fmt(row.total_fees, 4) : "—"} + + {row.ok ? fmt(row.max_single_loss ?? 0, 2) : "—"} + + {row.ok ? String(row.loss_streak ?? 0) : "—"} + + {row.total_pnl != null ? fmt(row.total_pnl, 2) : "—"} +
+ 合计 + + {" "} + ({okRows.length}/{fleetStats.length}) + + {fmt(sum("latest_funds"), 2)}{sum("groups")}{fmt(sum("fees_perp"), 4)}{fmt(sum("fees_option"), 4)}{fmt(sum("total_fees"), 4)} + {fmt(maxLoss, 2)} + + {fmt(sum("total_pnl"), 2)} +
+
+ ) : ( +

+ {fleetStatsLoading ? "正在拉取各策略机统计…" : "暂无统计数据"} +

+ )} +
+ ); +}