diff --git a/backend/app/api/fleet.py b/backend/app/api/fleet.py index b801233..dddd2ca 100644 --- a/backend/app/api/fleet.py +++ b/backend/app/api/fleet.py @@ -278,6 +278,14 @@ async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> di } +@router.get("/stats") +async def fleet_stats(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict: + """中控拉取策略机整体统计(同 /api/stats/summary,Fleet Token 鉴权)。""" + from .stats import build_stats_summary + + return build_stats_summary() + + @router.post("/start") async def fleet_start(_tok: Annotated[str, Depends(require_fleet_token)]) -> dict: return await get_engine().start() diff --git a/backend/app/api/stats.py b/backend/app/api/stats.py index 7732567..71cb976 100644 --- a/backend/app/api/stats.py +++ b/backend/app/api/stats.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Annotated +from typing import Annotated, Any from fastapi import APIRouter, Depends @@ -12,12 +12,12 @@ from .auth import require_user router = APIRouter(prefix="/api/stats", tags=["stats"]) -@router.get("/summary") -async def stats_summary(_user: Annotated[str, Depends(require_user)]) -> dict: - db = get_db() +def build_stats_summary(db: Any | None = None) -> dict[str, Any]: + """已平仓组汇总(策略页统计 / 中控 Fleet 共用)。""" + database = db or get_db() s = get_settings() mode = "LIVE" if not s.is_sim else "SIM" - rows = db.fetchall("SELECT * FROM groups WHERE status='closed'") + rows = database.fetchall("SELECT * FROM groups WHERE status='closed'") 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) @@ -26,7 +26,7 @@ async def stats_summary(_user: Annotated[str, Depends(require_user)]) -> dict: fees_option = 0.0 total_slip = 0.0 for r in rows: - fills = db.fetchall( + fills = database.fetchall( "SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (r["group_id"],), ) @@ -65,3 +65,8 @@ async def stats_summary(_user: Annotated[str, Depends(require_user)]) -> dict: "close_reasons": reasons, "equity_curve": curve, } + + +@router.get("/summary") +async def stats_summary(_user: Annotated[str, Depends(require_user)]) -> dict: + return build_stats_summary() diff --git a/control/backend/app/api/nodes.py b/control/backend/app/api/nodes.py index bd67d86..9d41954 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) +@router.get("/{node_id}/stats") +async def node_stats( + node_id: int, + _user: Annotated[str, Depends(require_control_user)], +) -> dict: + """代理策略机整体统计(点击详情时按需拉取,不进 SSE)。""" + db = get_control_db() + node = db.get_node(node_id) + if not node: + raise HTTPException(status_code=404, detail="节点不存在") + if not node.get("token_sealed"): + raise HTTPException(status_code=400, detail="未生成 Token") + code, data = await call_node(node, "GET", "/api/fleet/stats", timeout=15.0) + if code >= 400: + _raise_node_error(code, data) + if not isinstance(data, dict): + raise HTTPException(status_code=502, detail="策略机统计返回异常") + return data + + @router.post("/{node_id}/start") async def node_start( node_id: int, diff --git a/control/frontend/src/pages/Monitor.tsx b/control/frontend/src/pages/Monitor.tsx index 3280fc7..3a525bd 100644 --- a/control/frontend/src/pages/Monitor.tsx +++ b/control/frontend/src/pages/Monitor.tsx @@ -65,6 +65,40 @@ function leveragePair(strat: Record): string { 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; + equity_curve: { + group_id: string; + realized_pnl: number; + close_at_ms?: number | null; + }[]; +}; + +const CLOSE_REASON_ZH: Record = { + 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; @@ -167,12 +201,43 @@ export default function MonitorPage() { cachedNodes.length ? "live" : "connecting", ); const [detailId, setDetailId] = useState(null); + const [detailStats, setDetailStats] = useState(null); + const [detailStatsErr, setDetailStatsErr] = useState(""); + const [detailStatsLoading, setDetailStatsLoading] = useState(false); const applyNodes = useCallback((list: NodeCard[]) => { cachedNodes = list; setNodes(list); }, []); + useEffect(() => { + if (detailId == null) { + setDetailStats(null); + setDetailStatsErr(""); + setDetailStatsLoading(false); + return; + } + let cancelled = false; + setDetailStats(null); + setDetailStatsErr(""); + setDetailStatsLoading(true); + apiFetch(`/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"); @@ -611,6 +676,111 @@ export default function MonitorPage() { +
+

整体统计

+ {detailStatsLoading ? ( +

正在从策略机拉取统计…

+ ) : null} + {detailStatsErr ? ( +

{detailStatsErr}

+ ) : null} + {detailStats ? ( + <> +
+
+
组数 / 胜率
+
+ {detailStats.groups} /{" "} + {(detailStats.win_rate * 100).toFixed(1)}% +
+
+
+
总盈亏
+
+ {fmt(detailStats.total_pnl, 2)} USDT +
+
+
+
永续手续费
+
+ {fmt(detailStats.fees_perp ?? 0, 4)} +
+
+
+
期权手续费
+
+ {fmt(detailStats.fees_option ?? 0, 4)} +
+
+
+
手续费合计
+
+ {fmt(detailStats.total_fees, 4)} +
+
+ {(detailStats.show_slip ?? detailStats.mode !== "LIVE") ? ( +
+
滑点合计
+
+ {fmt(detailStats.total_slip ?? 0, 4)} +
+
+ ) : null} +
+
平仓原因
+
+ {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} +
+ ))} +
+
+
+ {detailStats.equity_curve?.length ? ( + <> +

按组盈亏

+
+ + + + + + + + + {[...detailStats.equity_curve] + .reverse() + .slice(0, 30) + .map((x) => ( + + + + + ))} + +
盈亏
{x.group_id} + {fmt(x.realized_pnl, 2)} +
+
+ {detailStats.equity_curve.length > 30 ? ( +

+ 仅显示最近 30 组(共 {detailStats.equity_curve.length}) +

+ ) : null} + + ) : ( +

暂无已平仓组

+ )} + + ) : null} +
+

持仓

diff --git a/docs/中控Fleet说明.md b/docs/中控Fleet说明.md index 251bf94..2c5c8e1 100644 --- a/docs/中控Fleet说明.md +++ b/docs/中控Fleet说明.md @@ -121,6 +121,7 @@ bash /opt/eth_hedge_sim/control/deploy/update.sh | 方法 | 路径 | 作用 | |------|------|------| | GET | `/api/fleet/status` | 状态摘要 + 持仓/策略详情 | +| GET | `/api/fleet/stats` | 整体统计(组数/胜率/盈亏/手续费/平仓原因/按组盈亏) | | POST | `/api/fleet/start` | 启动策略(LIVE 仍过策略机门禁) | | POST | `/api/fleet/pause` | 停止/暂停 | | POST | `/api/fleet/update` | 本机 `deploy/lib/update.sh`(reload,不自动 start) | @@ -139,7 +140,7 @@ bash /opt/eth_hedge_sim/control/deploy/update.sh - **刷新**:仍可手动走 `/api/nodes/status/all` 拉一次。 - **卡片**:在线/离线、SIM/LIVE、阶段、持仓、轮次、行情、Token 状态。 - **运行中**:卡片绿色;底部按钮显示「运行中」且不可点启动。 -- **点击卡片**:放大弹层 — 策略详情 + 持仓腿表;**净浮盈 / 浮盈** 正绿负红加粗。 +- **点击卡片**:放大弹层 — 策略详情 + **整体统计**(按需拉 `/api/nodes/{id}/stats` → 策略机 `/api/fleet/stats`,同策略页统计)+ 持仓腿表;**净浮盈 / 浮盈** 正绿负红加粗。 - **登录策略机**:免密新标签打开策略页。 - **更新代码 / 勾选更新**:**二次确认**后执行;会 reload 进程,**不会**自动 start。