Show strategy stats in control monitor detail modal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 09:49:27 +08:00
parent 0e3b9e53ac
commit 079dc83132
5 changed files with 211 additions and 7 deletions
+8
View File
@@ -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/summaryFleet 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()
+11 -6
View File
@@ -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()
+20
View File
@@ -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,
+170
View File
@@ -65,6 +65,40 @@ function leveragePair(strat: Record<string, unknown>): 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<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;
@@ -167,12 +201,43 @@ export default function MonitorPage() {
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 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<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");
@@ -611,6 +676,111 @@ export default function MonitorPage() {
</dl>
</section>
<section className="modal-section">
<h4></h4>
{detailStatsLoading ? (
<p className="meta"></p>
) : null}
{detailStatsErr ? (
<p className="err soft">{detailStatsErr}</p>
) : null}
{detailStats ? (
<>
<dl className="kv detail-kv">
<div>
<dt> / </dt>
<dd className="mono">
{detailStats.groups} /{" "}
{(detailStats.win_rate * 100).toFixed(1)}%
</dd>
</div>
<div>
<dt></dt>
<dd className={`mono ${pnlClass(detailStats.total_pnl)}`}>
{fmt(detailStats.total_pnl, 2)} USDT
</dd>
</div>
<div>
<dt></dt>
<dd className="mono">
{fmt(detailStats.fees_perp ?? 0, 4)}
</dd>
</div>
<div>
<dt></dt>
<dd className="mono">
{fmt(detailStats.fees_option ?? 0, 4)}
</dd>
</div>
<div>
<dt></dt>
<dd className="mono">
{fmt(detailStats.total_fees, 4)}
</dd>
</div>
{(detailStats.show_slip ?? detailStats.mode !== "LIVE") ? (
<div>
<dt></dt>
<dd className="mono">
{fmt(detailStats.total_slip ?? 0, 4)}
</dd>
</div>
) : null}
<div className="span-2">
<dt></dt>
<dd className="mono">
{Object.keys(detailStats.close_reasons || {}).length ===
0
? "—"
: Object.entries(detailStats.close_reasons)
.sort((a, b) => b[1] - a[1])
.map(([k, n]) => (
<div key={k}>
{closeReasonZh(k)} × {n}
</div>
))}
</dd>
</div>
</dl>
{detailStats.equity_curve?.length ? (
<>
<h4 style={{ marginTop: "0.75rem" }}></h4>
<div className="legs-table-wrap">
<table className="legs-table">
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{[...detailStats.equity_curve]
.reverse()
.slice(0, 30)
.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>
{detailStats.equity_curve.length > 30 ? (
<p className="meta">
30 {detailStats.equity_curve.length}
</p>
) : null}
</>
) : (
<p className="meta"></p>
)}
</>
) : null}
</section>
<section className="modal-section">
<h4></h4>
<p className="meta">
+2 -1
View File
@@ -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。