Show strategy stats in control monitor detail modal.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user