diff --git a/backend/app/api/stats.py b/backend/app/api/stats.py index 71cb976..3830c5b 100644 --- a/backend/app/api/stats.py +++ b/backend/app/api/stats.py @@ -12,6 +12,17 @@ from .auth import require_user router = APIRouter(prefix="/api/stats", tags=["stats"]) +def _current_loss_streak(curve: list[dict[str, Any]]) -> int: + """从最近已平仓组往前数连续亏损次数。""" + streak = 0 + for item in reversed(curve): + if float(item.get("realized_pnl") or 0) < 0: + streak += 1 + else: + break + return streak + + def build_stats_summary(db: Any | None = None) -> dict[str, Any]: """已平仓组汇总(策略页统计 / 中控 Fleet 共用)。""" database = db or get_db() @@ -21,6 +32,10 @@ def build_stats_summary(db: Any | None = None) -> dict[str, Any]: n = len(rows) wins = sum(1 for r in rows if float(r["realized_pnl"] or 0) > 0) total_pnl = sum(float(r["realized_pnl"] or 0) for r in rows) + pnls = [float(r["realized_pnl"] or 0) for r in rows] + max_single_loss = min(pnls) if pnls else 0.0 + if max_single_loss > 0: + max_single_loss = 0.0 fees_perp = 0.0 fees_option = 0.0 @@ -51,6 +66,19 @@ def build_stats_summary(db: Any | None = None) -> dict[str, Any]: } for r in sorted(rows, key=lambda x: int(x["close_at_ms"] or 0)) ] + latest_funds = 0.0 + try: + from ..sim.funds_wallets import FundsWallets + + latest_funds = float(FundsWallets(database).total_usdt_equiv()) + except Exception: + try: + from ..sim.ledger import Ledger + + latest_funds = float(Ledger(database).snapshot().get("equity") or 0) + except Exception: + latest_funds = 0.0 + return { "mode": mode, "show_slip": mode == "SIM", @@ -64,6 +92,9 @@ def build_stats_summary(db: Any | None = None) -> dict[str, Any]: "total_slip": total_slip if mode == "SIM" else 0.0, "close_reasons": reasons, "equity_curve": curve, + "latest_funds": latest_funds, + "max_single_loss": max_single_loss, + "loss_streak": _current_loss_streak(curve), } diff --git a/backend/tests/test_stats_summary_extra.py b/backend/tests/test_stats_summary_extra.py new file mode 100644 index 0000000..d0ed6df --- /dev/null +++ b/backend/tests/test_stats_summary_extra.py @@ -0,0 +1,28 @@ +"""统计汇总:最大单笔亏损、连亏次数。""" + +from __future__ import annotations + +from app.api.stats import _current_loss_streak + + +def test_current_loss_streak() -> None: + assert _current_loss_streak([]) == 0 + assert ( + _current_loss_streak( + [ + {"realized_pnl": 10}, + {"realized_pnl": -1}, + {"realized_pnl": -2}, + ] + ) + == 2 + ) + assert ( + _current_loss_streak( + [ + {"realized_pnl": -5}, + {"realized_pnl": 1}, + ] + ) + == 0 + ) diff --git a/control/backend/app/api/nodes.py b/control/backend/app/api/nodes.py index 9d41954..fdcf762 100644 --- a/control/backend/app/api/nodes.py +++ b/control/backend/app/api/nodes.py @@ -278,6 +278,60 @@ async def node_status( return await _collect_one_status(node) +async def _collect_one_stats(node: dict[str, Any]) -> dict[str, Any]: + base = { + "id": node["id"], + "name": node["name"], + "ok": False, + "error": None, + "latest_funds": None, + "groups": None, + "fees_perp": None, + "fees_option": None, + "total_fees": None, + "max_single_loss": None, + "loss_streak": None, + "total_pnl": None, + } + if not node.get("token_sealed"): + base["error"] = "未生成 Token" + return base + try: + code, data = await call_node(node, "GET", "/api/fleet/stats", timeout=15.0) + except Exception as ex: + base["error"] = str(ex) + return base + if code >= 400 or not isinstance(data, dict): + base["error"] = _http_detail(data) if data else f"HTTP {code}" + return base + base.update( + { + "ok": True, + "latest_funds": data.get("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"), + "total_pnl": data.get("total_pnl"), + "mode": data.get("mode"), + } + ) + return base + + +@router.get("/stats/all") +async def stats_all(_user: Annotated[str, Depends(require_control_user)]) -> dict: + """并行拉取各策略机统计,供监控区「数据统计」表。""" + db = get_control_db() + nodes = db.list_nodes() + if not nodes: + return {"nodes": []} + items = list(await asyncio.gather(*[_collect_one_stats(n) for n in nodes])) + return {"nodes": items} + + @router.get("/{node_id}/stats") async def node_stats( node_id: int, diff --git a/control/frontend/src/pages/Monitor.tsx b/control/frontend/src/pages/Monitor.tsx index 471d604..37bf5b8 100644 --- a/control/frontend/src/pages/Monitor.tsx +++ b/control/frontend/src/pages/Monitor.tsx @@ -76,6 +76,9 @@ type NodeStats = { fees_option?: number; total_fees: number; total_slip?: number; + latest_funds?: number; + max_single_loss?: number; + loss_streak?: number; close_reasons: Record; equity_curve: { group_id: string; @@ -84,6 +87,21 @@ 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: "权利金倍数达标·双腿全平", @@ -235,12 +253,28 @@ 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); @@ -279,7 +313,14 @@ export default function MonitorPage() { } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); } - }, [applyNodes]); + void refreshStats(); + }, [applyNodes, refreshStats]); + + useEffect(() => { + void refreshStats(); + const id = window.setInterval(() => void refreshStats(), 60_000); + return () => window.clearInterval(id); + }, [refreshStats]); useEffect(() => { apiFetch<{ sse_interval_sec?: number }>("/api/auth/me") @@ -546,7 +587,14 @@ export default function MonitorPage() { - 选择 + 状态 机器名字 @@ -670,6 +718,126 @@ 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 ? (