Add fleet stats table with funds, fees, loss streak, and totals.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, number>;
|
||||
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<string, string> = {
|
||||
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<FleetStatsRow[]>([]);
|
||||
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() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-check">
|
||||
<span className="sr-only">选择</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
disabled={!updatableIds.length}
|
||||
onChange={toggleSelectAll}
|
||||
aria-label="全选"
|
||||
title="全选已配对机器"
|
||||
/>
|
||||
</th>
|
||||
<th className="col-status">状态</th>
|
||||
<th>机器名字</th>
|
||||
@@ -670,6 +718,126 @@ export default function MonitorPage() {
|
||||
<p className="meta">暂无策略机。请到「系统设置」添加并生成 Token。</p>
|
||||
)}
|
||||
|
||||
<section className="stats-section">
|
||||
<div className="toolbar stats-toolbar">
|
||||
<h2>数据统计</h2>
|
||||
<div className="toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
disabled={fleetStatsLoading}
|
||||
onClick={() => void refreshStats()}
|
||||
>
|
||||
{fleetStatsLoading ? "加载中…" : "刷新统计"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{fleetStatsErr ? <div className="err soft">{fleetStatsErr}</div> : null}
|
||||
{fleetStats.length ? (
|
||||
<div className="monitor-table-wrap">
|
||||
<table className="monitor-table stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>机器名</th>
|
||||
<th>最新资金</th>
|
||||
<th>轮次</th>
|
||||
<th>永续手续费</th>
|
||||
<th>期权手续费</th>
|
||||
<th>手续费合计</th>
|
||||
<th>单笔最大亏损</th>
|
||||
<th>连亏次数</th>
|
||||
<th>盈亏</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fleetStats.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<strong className="machine-name">{row.name}</strong>
|
||||
{!row.ok && row.error ? (
|
||||
<div className="meta" title={row.error}>
|
||||
拉取失败
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="mono">
|
||||
{row.latest_funds != null
|
||||
? fmt(row.latest_funds, 2)
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="mono">
|
||||
{row.groups != null ? row.groups : "—"}
|
||||
</td>
|
||||
<td className="mono">
|
||||
{row.fees_perp != null ? fmt(row.fees_perp, 4) : "—"}
|
||||
</td>
|
||||
<td className="mono">
|
||||
{row.fees_option != null
|
||||
? fmt(row.fees_option, 4)
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="mono">
|
||||
{row.total_fees != null ? fmt(row.total_fees, 4) : "—"}
|
||||
</td>
|
||||
<td className={`mono ${pnlClass(row.max_single_loss)}`}>
|
||||
{row.max_single_loss != null
|
||||
? fmt(row.max_single_loss, 2)
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="mono">
|
||||
{row.loss_streak != null ? row.loss_streak : "—"}
|
||||
</td>
|
||||
<td className={`mono ${pnlClass(row.total_pnl)}`}>
|
||||
{row.total_pnl != null ? fmt(row.total_pnl, 2) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{(() => {
|
||||
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 (
|
||||
<tr className="stats-total-row">
|
||||
<td>
|
||||
<strong>合计</strong>
|
||||
<span className="meta">
|
||||
{" "}
|
||||
({okRows.length}/{fleetStats.length})
|
||||
</span>
|
||||
</td>
|
||||
<td className="mono">{fmt(sum("latest_funds"), 2)}</td>
|
||||
<td className="mono">{sum("groups")}</td>
|
||||
<td className="mono">{fmt(sum("fees_perp"), 4)}</td>
|
||||
<td className="mono">{fmt(sum("fees_option"), 4)}</td>
|
||||
<td className="mono">{fmt(sum("total_fees"), 4)}</td>
|
||||
<td className={`mono ${pnlClass(maxLoss)}`}>
|
||||
{fmt(maxLoss, 2)}
|
||||
</td>
|
||||
<td className="mono">—</td>
|
||||
<td className={`mono ${pnlClass(sum("total_pnl"))}`}>
|
||||
{fmt(sum("total_pnl"), 2)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})()}
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="meta">
|
||||
{fleetStatsLoading ? "正在拉取各策略机统计…" : "暂无统计数据"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{detailNode && detail ? (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
|
||||
@@ -407,6 +407,21 @@ input {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.stats-toolbar {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stats-table tfoot .stats-total-row td {
|
||||
border-top: 1px solid rgba(0, 229, 255, 0.35);
|
||||
background: rgba(0, 40, 60, 0.45);
|
||||
font-weight: 650;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.78rem;
|
||||
|
||||
Reference in New Issue
Block a user