数据统计改为后台热缓存与 SSE,曲线放大置底并支持选机,对比增加模式与收益率。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-07 14:35:02 +08:00
parent 0e3fffef44
commit 05661f3f30
8 changed files with 985 additions and 429 deletions
+2
View File
@@ -9,6 +9,8 @@ HUB_PULL_INTERVAL_SEC=10
HUB_PULL_TIMEOUT_SEC=5 HUB_PULL_TIMEOUT_SEC=5
# 总览 SSE 心跳(秒);浏览器只读缓存,靠 SSE 收版本更新 # 总览 SSE 心跳(秒);浏览器只读缓存,靠 SSE 收版本更新
HUB_SSE_HEARTBEAT_SEC=25 HUB_SSE_HEARTBEAT_SEC=25
# 数据统计后台刷新间隔(秒);不设则回落 HUB_PULL_INTERVAL_SEC
HUB_STATS_POLL_INTERVAL_SEC=15
# 中控企业微信(离线/故障告警,与客户端通知分开) # 中控企业微信(离线/故障告警,与客户端通知分开)
HUB_WECHAT_ENABLED=false HUB_WECHAT_ENABLED=false
+37 -9
View File
@@ -63,7 +63,8 @@ from lib.instances_lib import (
) )
from lib.overview_cache_lib import overview_store from lib.overview_cache_lib import overview_store
from lib.sso_lib import build_embed_open_url from lib.sso_lib import build_embed_open_url
from lib.stats_agg_lib import build_stats_curve, build_stats_summary, normalize_window from lib.stats_agg_lib import normalize_window
from lib.stats_cache_lib import stats_store
from lib.wechat_lib import ( from lib.wechat_lib import (
hub_alert_cooldown_sec, hub_alert_cooldown_sec,
hub_health_fail_threshold, hub_health_fail_threshold,
@@ -157,21 +158,43 @@ def stats_page():
@app.route("/api/stats/summary") @app.route("/api/stats/summary")
@login_required @login_required
def api_stats_summary(): def api_stats_summary():
"""只读统计热缓存;后台常驻刷新各窗口."""
window = normalize_window(request.args.get("window") or "7d") window = normalize_window(request.args.get("window") or "7d")
try: force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
return jsonify(build_stats_summary(window)) if force:
except Exception as e: stats_store.request_refresh()
return jsonify({"ok": False, "msg": str(e) or "统计失败"}), 500 return jsonify(stats_store.summary_dict(window))
@app.route("/api/stats/curve") @app.route("/api/stats/curve")
@login_required @login_required
def api_stats_curve(): def api_stats_curve():
range_key = normalize_window(request.args.get("range") or request.args.get("window") or "7d") range_key = normalize_window(request.args.get("range") or request.args.get("window") or "7d")
try: force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
return jsonify(build_stats_curve(range_key)) if force:
except Exception as e: stats_store.request_refresh()
return jsonify({"ok": False, "msg": str(e) or "曲线失败"}), 500 return jsonify(stats_store.curve_dict(range_key))
@app.route("/api/stats/refresh", methods=["POST"])
@login_required
def api_stats_refresh():
stats_store.request_refresh()
return jsonify({"ok": True, "msg": "已触发统计后台刷新"})
@app.route("/api/stats/stream")
@login_required
def api_stats_stream():
return Response(
stream_with_context(stats_store.iter_sse()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.route("/instances", methods=["GET", "POST"]) @app.route("/instances", methods=["GET", "POST"])
@@ -533,7 +556,12 @@ def start_overview_cache() -> None:
overview_store.start(pull_all_enabled, on_pull=_on_overview_pulled) overview_store.start(pull_all_enabled, on_pull=_on_overview_pulled)
def start_stats_cache() -> None:
stats_store.start()
start_overview_cache() start_overview_cache()
start_stats_cache()
try: try:
start_hub_backup_scheduler( start_hub_backup_scheduler(
+21 -15
View File
@@ -36,14 +36,20 @@ def normalize_window(raw: Any) -> str:
return w if w in ("today", "7d", "30d", "all") else "7d" return w if w in ("today", "7d", "30d", "all") else "7d"
def build_stats_summary(window: str = "7d") -> dict[str, Any]: def build_stats_summary(
window: str = "7d",
*,
snaps: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
w = normalize_window(window) w = normalize_window(window)
# 实时状态(余额/权利金/浮盈)来自快照;窗口 KPI 来自各机 stats # 实时状态(余额/权利金/浮盈)来自快照;窗口 KPI 来自各机 stats
snaps = pull_all_enabled() # snaps 可由热缓存传入,避免统计页再现场重复拉全机
try: if snaps is None:
record_cash_samples_from_overview(snaps) snaps = pull_all_enabled()
except Exception: try:
pass record_cash_samples_from_overview(snaps)
except Exception:
pass
stats_rows = pull_all_stats(w) stats_rows = pull_all_stats(w)
stats_by_id = {r.get("id"): r for r in stats_rows} stats_by_id = {r.get("id"): r for r in stats_rows}
@@ -83,6 +89,12 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
win_rate = _safe_float(stats.get("win_rate")) win_rate = _safe_float(stats.get("win_rate"))
avg_hold = _safe_float(stats.get("avg_hold_sec")) avg_hold = _safe_float(stats.get("avg_hold_sec"))
online = bool(snap.get("online")) online = bool(snap.get("online"))
mode = ((body.get("instance") or {}) if isinstance(body.get("instance"), dict) else {}).get(
"mode"
)
return_pct = None
if online and stats.get("ok") and net is not None and cash is not None and cash > 0:
return_pct = round(float(net) / float(cash) * 100.0, 2)
if online: if online:
online_n += 1 online_n += 1
if cash is not None: if cash is not None:
@@ -110,9 +122,7 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
"name": snap.get("name"), "name": snap.get("name"),
"online": online, "online": online,
"error": snap.get("error") or st.get("error") or "", "error": snap.get("error") or st.get("error") or "",
"mode": ((body.get("instance") or {}) if isinstance(body.get("instance"), dict) else {}).get( "mode": mode,
"mode"
),
"cash_usdt": cash, "cash_usdt": cash,
"premium_usdt": paid, "premium_usdt": paid,
"ratio_pct": ratio, "ratio_pct": ratio,
@@ -123,6 +133,7 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
"loss_count": losses if online and stats.get("ok") else None, "loss_count": losses if online and stats.get("ok") else None,
"win_rate": win_rate if online and stats.get("ok") else None, "win_rate": win_rate if online and stats.get("ok") else None,
"net_realized_pnl": net if online and stats.get("ok") else None, "net_realized_pnl": net if online and stats.get("ok") else None,
"return_rate_pct": return_pct,
"total_profit": profit if online and stats.get("ok") else None, "total_profit": profit if online and stats.get("ok") else None,
"total_loss": loss if online and stats.get("ok") else None, "total_loss": loss if online and stats.get("ok") else None,
"avg_hold_sec": avg_hold if online and stats.get("ok") else None, "avg_hold_sec": avg_hold if online and stats.get("ok") else None,
@@ -153,12 +164,7 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
def build_stats_curve(range_key: str = "7d") -> dict[str, Any]: def build_stats_curve(range_key: str = "7d") -> dict[str, Any]:
# 顺手采一笔,让打开页面时也有点 # 曲线只读本地采样库;余额采样由总览常驻 pull 负责
try:
snaps = pull_all_enabled()
record_cash_samples_from_overview(snaps)
except Exception:
pass
data = query_cash_series(range_key=range_key) data = query_cash_series(range_key=range_key)
data["ok"] = True data["ok"] = True
return data return data
+268
View File
@@ -0,0 +1,268 @@
"""数据统计热缓存:后台按窗口并行刷新 summary/curve;浏览器只读缓存 + SSE."""
from __future__ import annotations
import json
import os
import queue
import threading
import time
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
from lib.stats_agg_lib import build_stats_curve, build_stats_summary, normalize_window
STATS_WINDOWS = ("today", "7d", "30d", "all")
def _poll_interval_sec() -> float:
try:
return max(5.0, float(os.getenv("HUB_STATS_POLL_INTERVAL_SEC") or os.getenv("HUB_PULL_INTERVAL_SEC") or 15))
except (TypeError, ValueError):
return 15.0
def _sse_heartbeat_sec() -> float:
try:
return max(10.0, float(os.getenv("HUB_SSE_HEARTBEAT_SEC") or 25))
except (TypeError, ValueError):
return 25.0
class StatsStore:
def __init__(self) -> None:
self._lock = threading.RLock()
self.version = 0
self.summaries: dict[str, dict[str, Any]] = {}
self.curves: dict[str, dict[str, Any]] = {}
self.refreshing = True
self.last_error: str | None = None
self.updated_at: str = ""
self._ready = False
self._subscribers: list[queue.Queue[str | None]] = []
self._stop = threading.Event()
self._refresh = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
with self._lock:
self.refreshing = True
self._ready = False
self._thread = threading.Thread(
target=self._loop,
daemon=True,
name="hub-stats-pull",
)
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._refresh.set()
self._broadcast(close=True)
def request_refresh(self) -> None:
self._refresh.set()
def summary_dict(self, window: str = "7d") -> dict[str, Any]:
w = normalize_window(window)
with self._lock:
body = dict(self.summaries.get(w) or {})
ver = self.version
refreshing = self.refreshing
err = self.last_error
updated_at = self.updated_at
ready = self._ready
if not body:
return {
"ok": False,
"window": w,
"msg": "统计缓存尚未就绪" if not ready or refreshing else "暂无统计数据",
"stats_version": ver,
"refreshing": refreshing,
"updated_at": updated_at,
"error": err,
"from_cache": True,
"poll_interval_sec": _poll_interval_sec(),
}
body = dict(body)
body["stats_version"] = ver
body["refreshing"] = refreshing
body["updated_at"] = updated_at
body["error"] = err
body["from_cache"] = True
body["poll_interval_sec"] = _poll_interval_sec()
body.setdefault("ok", True)
return body
def curve_dict(self, range_key: str = "7d") -> dict[str, Any]:
w = normalize_window(range_key)
with self._lock:
body = dict(self.curves.get(w) or {})
ver = self.version
refreshing = self.refreshing
err = self.last_error
updated_at = self.updated_at
if not body:
return {
"ok": True,
"range": w,
"total": [],
"series": [],
"stats_version": ver,
"refreshing": refreshing,
"updated_at": updated_at,
"error": err,
"from_cache": True,
"poll_interval_sec": _poll_interval_sec(),
}
body = dict(body)
body["stats_version"] = ver
body["refreshing"] = refreshing
body["updated_at"] = updated_at
body["error"] = err
body["from_cache"] = True
body["poll_interval_sec"] = _poll_interval_sec()
body.setdefault("ok", True)
return body
def event_dict(self) -> dict[str, Any]:
with self._lock:
return {
"stats_version": self.version,
"updated_at": self.updated_at,
"refreshing": self.refreshing,
"ok": True,
"error": self.last_error,
"poll_interval_sec": _poll_interval_sec(),
}
def _loop(self) -> None:
while not self._stop.is_set():
self._refresh_once()
if self._stop.is_set():
break
self._refresh.clear()
self._refresh.wait(timeout=_poll_interval_sec())
def _current_snaps(self) -> list[dict[str, Any]]:
try:
from lib.overview_cache_lib import overview_store
snap = overview_store.snapshot_dict()
rows = snap.get("instances") if isinstance(snap, dict) else None
if isinstance(rows, list) and rows:
return rows
except Exception:
pass
try:
from lib.bridge_client_lib import pull_all_enabled
return pull_all_enabled()
except Exception:
return []
def _refresh_once(self) -> None:
with self._lock:
self.refreshing = True
self._broadcast()
try:
snaps = self._current_snaps()
summaries: dict[str, dict[str, Any]] = {}
curves: dict[str, dict[str, Any]] = {}
# 多窗口并行刷;每窗内部 pull_all_stats 已并行拉机
with ThreadPoolExecutor(max_workers=len(STATS_WINDOWS)) as pool:
futs = {
pool.submit(build_stats_summary, w, snaps=snaps): ("summary", w)
for w in STATS_WINDOWS
}
futs.update(
{
pool.submit(build_stats_curve, w): ("curve", w)
for w in STATS_WINDOWS
}
)
for fut in as_completed(futs):
kind, w = futs[fut]
try:
data = fut.result()
except Exception as e:
data = {"ok": False, "msg": str(e) or "failed", "window": w}
if kind == "summary":
summaries[w] = data if isinstance(data, dict) else {"ok": False}
else:
curves[w] = data if isinstance(data, dict) else {"ok": False, "total": [], "series": []}
with self._lock:
self.version += 1
self.summaries = summaries
self.curves = curves
self.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
self.last_error = None
self.refreshing = False
self._ready = True
except Exception as e:
with self._lock:
self.version += 1
self.last_error = str(e) or "stats_refresh_failed"
self.refreshing = False
self._ready = True
self._broadcast()
def _broadcast(self, *, close: bool = False) -> None:
with self._lock:
subs = list(self._subscribers)
event = None if close else json.dumps(self.event_dict(), ensure_ascii=False)
dead: list[queue.Queue[str | None]] = []
for q in subs:
try:
q.put_nowait(None if close else event)
except queue.Full:
try:
q.get_nowait()
except queue.Empty:
pass
try:
q.put_nowait(event)
except queue.Full:
dead.append(q)
except Exception:
dead.append(q)
if dead:
with self._lock:
for q in dead:
if q in self._subscribers:
self._subscribers.remove(q)
def iter_sse(self) -> Iterator[str]:
q: queue.Queue[str | None] = queue.Queue(maxsize=32)
with self._lock:
self._subscribers.append(q)
try:
yield _sse_frame(self.event_dict())
while True:
try:
raw = q.get(timeout=_sse_heartbeat_sec())
except queue.Empty:
yield ": heartbeat\n\n"
continue
if raw is None:
break
try:
data = json.loads(raw)
except Exception:
data = self.event_dict()
yield _sse_frame(data)
finally:
with self._lock:
if q in self._subscribers:
self._subscribers.remove(q)
def _sse_frame(data: dict[str, Any]) -> str:
body = json.dumps(data, ensure_ascii=False)
return f"event: stats\ndata: {body}\n\n"
stats_store = StatsStore()
+64
View File
@@ -1656,6 +1656,62 @@ input.readonly {
font-size: 0.86rem; font-size: 0.86rem;
} }
.stats-curve-toggle input { width: auto; margin: 0; } .stats-curve-toggle input { width: auto; margin: 0; }
.stats-curve-panel--large { margin-top: 18px; }
.stats-machine-picker {
display: flex;
flex-wrap: wrap;
gap: 8px 10px;
margin: 0 0 12px;
align-items: center;
}
.stats-machine-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid rgba(56, 189, 248, 0.18);
background: rgba(5, 12, 22, 0.65);
color: var(--text);
font-size: 0.82rem;
cursor: pointer;
user-select: none;
}
.stats-machine-chip input { width: auto; margin: 0; }
.stats-machine-chip i {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.stats-pick-all {
border: 1px solid rgba(56, 189, 248, 0.22);
background: transparent;
color: var(--muted);
border-radius: 999px;
padding: 5px 10px;
font-size: 0.78rem;
cursor: pointer;
}
.stats-pick-all:hover { color: var(--text); border-color: rgba(56, 189, 248, 0.4); }
.stats-mode-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.75rem;
border: 1px solid rgba(148, 163, 184, 0.25);
color: var(--muted);
}
.stats-mode-badge.stats-mode-sim {
color: #67e8f9;
border-color: rgba(34, 211, 238, 0.35);
background: rgba(34, 211, 238, 0.08);
}
.stats-mode-badge.stats-mode-live {
color: #fbbf24;
border-color: rgba(251, 191, 36, 0.35);
background: rgba(251, 191, 36, 0.08);
}
.stats-chart-wrap { .stats-chart-wrap {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -1665,11 +1721,19 @@ input.readonly {
background: rgba(4, 10, 18, 0.65); background: rgba(4, 10, 18, 0.65);
overflow: hidden; overflow: hidden;
} }
.stats-chart-wrap--large {
min-height: 480px;
height: min(62vh, 560px);
}
#stats-curve-canvas { #stats-curve-canvas {
display: block; display: block;
width: 100%; width: 100%;
height: 320px; height: 320px;
} }
.stats-chart-wrap--large #stats-curve-canvas {
height: 100%;
min-height: 480px;
}
.stats-chart-empty { .stats-chart-empty {
position: absolute; position: absolute;
inset: 0; inset: 0;
+515 -330
View File
@@ -1,330 +1,515 @@
(function () { (function () {
const COLORS = [ const COLORS = [
"#22d3ee", "#22d3ee",
"#34d399", "#34d399",
"#fbbf24", "#fbbf24",
"#fb7185", "#fb7185",
"#a78bfa", "#a78bfa",
"#60a5fa", "#60a5fa",
"#f472b6", "#f472b6",
"#2dd4bf", "#2dd4bf",
]; ];
const PICKER_KEY = "hub-stats-curve-machines";
let windowKey = "7d";
let curveData = null; let windowKey = "7d";
let summaryData = null; let curveData = null;
let summaryData = null;
function escapeHtml(s) { let selectedIds = loadSelectedIds();
return String(s) let statsVersion = 0;
.replace(/&/g, "&amp;") let statsLoading = false;
.replace(/</g, "&lt;") let statsEventSource = null;
.replace(/>/g, "&gt;") let statsReconnectTimer = null;
.replace(/"/g, "&quot;");
} function loadSelectedIds() {
try {
function fmtNum(v, digits) { const raw = localStorage.getItem(PICKER_KEY);
if (v == null || v === "") return "—"; if (!raw) return null;
const n = Number(v); const arr = JSON.parse(raw);
if (!Number.isFinite(n)) return "—"; if (Array.isArray(arr)) return new Set(arr.map(String));
return n.toFixed(digits == null ? 2 : digits); } catch (_) {}
} return null;
}
function fmtPnl(v) {
if (v == null || v === "") return "—"; function saveSelectedIds() {
const n = Number(v); try {
if (!Number.isFinite(n)) return "—"; localStorage.setItem(PICKER_KEY, JSON.stringify(Array.from(selectedIds || [])));
return (n > 0 ? "+" : "") + n.toFixed(2) + " U"; } catch (_) {}
} }
function pnlClass(v) { function escapeHtml(s) {
const n = Number(v); return String(s)
if (!Number.isFinite(n) || n === 0) return "pnl-flat"; .replace(/&/g, "&amp;")
return n > 0 ? "pnl-up" : "pnl-down"; .replace(/</g, "&lt;")
} .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
function fmtHold(sec) { }
if (sec == null || sec === "") return "—";
const n = Number(sec); function fmtNum(v, digits) {
if (!Number.isFinite(n) || n < 0) return "—"; if (v == null || v === "") return "—";
if (n < 60) return Math.round(n) + "s"; const n = Number(v);
if (n < 3600) return (n / 60).toFixed(1) + "m"; if (!Number.isFinite(n)) return "—";
return (n / 3600).toFixed(1) + "h"; return n.toFixed(digits == null ? 2 : digits);
} }
function setStatus(msg, isErr) { function fmtPnl(v) {
const el = document.getElementById("stats-status"); if (v == null || v === "") return "—";
if (!el) return; const n = Number(v);
el.textContent = msg || ""; if (!Number.isFinite(n)) return "—";
el.classList.toggle("err", !!isErr); return (n > 0 ? "+" : "") + n.toFixed(2) + " U";
} }
function renderKpis(totals) { function pnlClass(v) {
const grid = document.getElementById("stats-kpi-grid"); const n = Number(v);
if (!grid) return; if (!Number.isFinite(n) || n === 0) return "pnl-flat";
const t = totals || {}; return n > 0 ? "pnl-up" : "pnl-down";
const items = [ }
{ label: "在线 / 总数", value: (t.online || 0) + " / " + (t.machines || 0) },
{ label: "合计余额", value: fmtNum(t.cash_usdt) + " U" }, function fmtHold(sec) {
{ label: "窗口已实现", value: fmtPnl(t.net_realized_pnl), cls: pnlClass(t.net_realized_pnl) }, if (sec == null || sec === "") return "—";
{ label: "胜率", value: fmtNum(t.win_rate, 1) + "%" }, const n = Number(sec);
{ label: "平仓笔数", value: String(t.total_closed != null ? t.total_closed : "—") }, if (!Number.isFinite(n) || n < 0) return "—";
{ label: "合计浮盈", value: fmtPnl(t.unrealized_usdt), cls: pnlClass(t.unrealized_usdt) }, if (n < 60) return Math.round(n) + "s";
]; if (n < 3600) return (n / 60).toFixed(1) + "m";
grid.innerHTML = items return (n / 3600).toFixed(1) + "h";
.map(function (it) { }
return (
'<div class="stats-kpi-card">' + function modeLabel(mode) {
'<div class="stats-kpi-label">' + const m = String(mode || "").toLowerCase();
escapeHtml(it.label) + if (m === "sim") return "模拟";
"</div>" + if (m === "live") return "实盘";
'<div class="stats-kpi-value ' + return mode ? String(mode) : "—";
escapeHtml(it.cls || "") + }
'">' +
escapeHtml(it.value) + function setStatus(msg, isErr) {
"</div></div>" const el = document.getElementById("stats-status");
); if (!el) return;
}) el.textContent = msg || "";
.join(""); el.classList.toggle("err", !!isErr);
} }
function renderTable(machines) { function renderKpis(totals) {
const body = document.getElementById("stats-table-body"); const grid = document.getElementById("stats-kpi-grid");
if (!body) return; if (!grid) return;
const list = machines || []; const t = totals || {};
if (!list.length) { const items = [
body.innerHTML = '<tr><td colspan="10" class="muted">暂无启用实例</td></tr>'; { label: "在线 / 总数", value: (t.online || 0) + " / " + (t.machines || 0) },
return; { label: "合计余额", value: fmtNum(t.cash_usdt) + " U" },
} { label: "窗口已实现", value: fmtPnl(t.net_realized_pnl), cls: pnlClass(t.net_realized_pnl) },
body.innerHTML = list { label: "胜率", value: fmtNum(t.win_rate, 1) + "%" },
.map(function (m) { { label: "平仓笔数", value: String(t.total_closed != null ? t.total_closed : "—") },
const status = m.online { label: "合计浮盈", value: fmtPnl(t.unrealized_usdt), cls: pnlClass(t.unrealized_usdt) },
? '<span class="status-on">在线</span>' ];
: '<span class="status-off">离线</span>'; grid.innerHTML = items
const err = m.error .map(function (it) {
? '<div class="muted stats-err-tip">' + escapeHtml(m.error) + "</div>" return (
: ""; '<div class="stats-kpi-card">' +
return ( '<div class="stats-kpi-label">' +
"<tr>" + escapeHtml(it.label) +
"<td><strong>" + "</div>" +
escapeHtml(m.name || m.id) + '<div class="stats-kpi-value ' +
"</strong>" + escapeHtml(it.cls || "") +
err + '">' +
"</td>" + escapeHtml(it.value) +
"<td>" + "</div></div>"
status + );
"</td>" + })
"<td>" + .join("");
escapeHtml(fmtNum(m.cash_usdt)) + }
"</td>" +
"<td>" + function renderTable(machines) {
escapeHtml(fmtNum(m.premium_usdt)) + const body = document.getElementById("stats-table-body");
"</td>" + if (!body) return;
'<td class="' + const list = machines || [];
pnlClass(m.unrealized_usdt) + if (!list.length) {
'">' + body.innerHTML = '<tr><td colspan="12" class="muted">暂无启用实例</td></tr>';
escapeHtml(fmtPnl(m.unrealized_usdt)) + return;
"</td>" + }
"<td>" + body.innerHTML = list
escapeHtml(m.stats_ok ? String(m.total_closed ?? "—") : "—") + .map(function (m) {
"</td>" + const status = m.online
"<td>" + ? '<span class="status-on">在线</span>'
escapeHtml(m.stats_ok ? fmtNum(m.win_rate, 1) + "%" : "—") + : '<span class="status-off">离线</span>';
"</td>" + const err = m.error
'<td class="' + ? '<div class="muted stats-err-tip">' + escapeHtml(m.error) + "</div>"
pnlClass(m.net_realized_pnl) + : "";
'">' + const mode = String(m.mode || "").toLowerCase();
escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") + const modeCls = mode === "live" ? "stats-mode-live" : mode === "sim" ? "stats-mode-sim" : "";
"</td>" + return (
"<td>" + "<tr>" +
escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") + "<td><strong>" +
"</td>" + escapeHtml(m.name || m.id) +
'<td><a class="btn btn-sm" href="/desk?id=' + "</strong>" +
encodeURIComponent(String(m.id)) + err +
'">工作台</a></td>' + "</td>" +
"</tr>" "<td>" +
); status +
}) "</td>" +
.join(""); '<td><span class="stats-mode-badge ' +
} modeCls +
'">' +
function drawCurve(data) { escapeHtml(modeLabel(m.mode)) +
const canvas = document.getElementById("stats-curve-canvas"); "</span></td>" +
const empty = document.getElementById("stats-curve-empty"); "<td>" +
const legend = document.getElementById("stats-curve-legend"); escapeHtml(fmtNum(m.cash_usdt)) +
if (!canvas) return; "</td>" +
const ctx = canvas.getContext("2d"); "<td>" +
const dpr = window.devicePixelRatio || 1; escapeHtml(fmtNum(m.premium_usdt)) +
const cssW = canvas.clientWidth || 960; "</td>" +
const cssH = 320; '<td class="' +
canvas.width = Math.floor(cssW * dpr); pnlClass(m.unrealized_usdt) +
canvas.height = Math.floor(cssH * dpr); '">' +
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); escapeHtml(fmtPnl(m.unrealized_usdt)) +
ctx.clearRect(0, 0, cssW, cssH); "</td>" +
"<td>" +
const showMachines = !!(document.getElementById("stats-show-machines") || {}).checked; escapeHtml(m.stats_ok ? String(m.total_closed ?? "—") : "—") +
const lines = []; "</td>" +
if (data && data.total && data.total.length) { "<td>" +
lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.4 }); escapeHtml(m.stats_ok ? fmtNum(m.win_rate, 1) + "%" : "—") +
} "</td>" +
if (showMachines && data && data.series) { '<td class="' +
data.series.forEach(function (s, i) { pnlClass(m.net_realized_pnl) +
if (!s.points || !s.points.length) return; '">' +
lines.push({ escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") +
name: s.name || String(s.id), "</td>" +
color: COLORS[i % COLORS.length], '<td class="' +
points: s.points, pnlClass(m.return_rate_pct) +
width: 1.4, '">' +
}); escapeHtml(
}); m.stats_ok && m.return_rate_pct != null ? fmtNum(m.return_rate_pct, 2) + "%" : "—"
} ) +
"</td>" +
if (legend) { "<td>" +
legend.innerHTML = lines escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") +
.map(function (ln) { "</td>" +
return ( '<td><a class="btn btn-sm" href="/desk?id=' +
'<span class="stats-legend-item"><i style="background:' + encodeURIComponent(String(m.id)) +
ln.color + '">工作台</a></td>' +
'"></i>' + "</tr>"
escapeHtml(ln.name) + );
"</span>" })
); .join("");
}) }
.join("");
} function ensureSelectedIds(series) {
const ids = (series || []).map(function (s) {
const allPts = lines.reduce(function (acc, ln) { return String(s.id);
return acc.concat(ln.points || []); });
}, []); if (selectedIds == null) {
if (!allPts.length) { selectedIds = new Set(ids);
if (empty) empty.hidden = false; saveSelectedIds();
return; return;
} }
if (empty) empty.hidden = true; // 新机器默认勾选
ids.forEach(function (id) {
let minT = Math.min.apply( if (!selectedIds.has(id) && !localStorage.getItem(PICKER_KEY + ":seen:" + id)) {
null, selectedIds.add(id);
allPts.map(function (p) { try {
return p.t; localStorage.setItem(PICKER_KEY + ":seen:" + id, "1");
}) } catch (_) {}
); }
let maxT = Math.max.apply( });
null, saveSelectedIds();
allPts.map(function (p) { }
return p.t;
}) function renderMachinePicker(data) {
); const box = document.getElementById("stats-machine-picker");
let minV = Math.min.apply( if (!box) return;
null, const series = (data && data.series) || [];
allPts.map(function (p) { ensureSelectedIds(series);
return p.v; if (!series.length) {
}) box.innerHTML = '<span class="muted">暂无可选机器</span>';
); return;
let maxV = Math.max.apply( }
null, box.innerHTML =
allPts.map(function (p) { '<button type="button" class="stats-pick-all" data-act="all">全选</button>' +
return p.v; '<button type="button" class="stats-pick-all" data-act="none">全不选</button>' +
}) series
); .map(function (s, i) {
if (maxT <= minT) maxT = minT + 1; const id = String(s.id);
if (maxV <= minV) { const checked = selectedIds && selectedIds.has(id) ? " checked" : "";
maxV = minV + 1; const color = COLORS[i % COLORS.length];
minV = minV - 1; return (
} '<label class="stats-machine-chip">' +
const pad = { l: 52, r: 16, t: 16, b: 28 }; '<input type="checkbox" data-machine-id="' +
const w = cssW - pad.l - pad.r; escapeHtml(id) +
const h = cssH - pad.t - pad.b; '"' +
checked +
function xOf(t) { ">" +
return pad.l + ((t - minT) / (maxT - minT)) * w; '<i style="background:' +
} color +
function yOf(v) { '"></i>' +
return pad.t + (1 - (v - minV) / (maxV - minV)) * h; escapeHtml(s.name || id) +
} "</label>"
);
ctx.strokeStyle = "rgba(56,189,248,0.15)"; })
ctx.lineWidth = 1; .join("");
for (let i = 0; i <= 4; i++) { }
const y = pad.t + (h * i) / 4;
ctx.beginPath(); function drawCurve(data) {
ctx.moveTo(pad.l, y); const canvas = document.getElementById("stats-curve-canvas");
ctx.lineTo(pad.l + w, y); const empty = document.getElementById("stats-curve-empty");
ctx.stroke(); const legend = document.getElementById("stats-curve-legend");
const val = maxV - ((maxV - minV) * i) / 4; if (!canvas) return;
ctx.fillStyle = "#8aa0b8"; const ctx = canvas.getContext("2d");
ctx.font = "11px IBM Plex Sans, sans-serif"; const dpr = window.devicePixelRatio || 1;
ctx.fillText(val.toFixed(0), 8, y + 4); const cssW = canvas.clientWidth || 960;
} const wrap = canvas.parentElement;
const cssH = Math.max(420, (wrap && wrap.clientHeight) || 480);
lines.forEach(function (ln) { canvas.width = Math.floor(cssW * dpr);
const pts = ln.points || []; canvas.height = Math.floor(cssH * dpr);
if (pts.length < 1) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.strokeStyle = ln.color; ctx.clearRect(0, 0, cssW, cssH);
ctx.lineWidth = ln.width || 1.5;
ctx.beginPath(); const showTotal = !!(document.getElementById("stats-show-total") || {}).checked;
pts.forEach(function (p, idx) { const lines = [];
const x = xOf(p.t); if (showTotal && data && data.total && data.total.length) {
const y = yOf(p.v); lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.6 });
if (idx === 0) ctx.moveTo(x, y); }
else ctx.lineTo(x, y); if (data && data.series) {
}); data.series.forEach(function (s, i) {
ctx.stroke(); const id = String(s.id);
}); if (selectedIds && !selectedIds.has(id)) return;
} if (!s.points || !s.points.length) return;
lines.push({
async function loadAll() { name: s.name || id,
setStatus("加载中…"); color: COLORS[i % COLORS.length],
try { points: s.points,
const [sumRes, curveRes] = await Promise.all([ width: 1.6,
fetch("/api/stats/summary?window=" + encodeURIComponent(windowKey)), });
fetch("/api/stats/curve?range=" + encodeURIComponent(windowKey)), });
]); }
const sum = await sumRes.json();
const curve = await curveRes.json(); if (legend) {
if (!sum.ok) throw new Error(sum.msg || "汇总失败"); legend.innerHTML = lines
if (!curve.ok) throw new Error(curve.msg || "曲线失败"); .map(function (ln) {
summaryData = sum; return (
curveData = curve; '<span class="stats-legend-item"><i style="background:' +
renderKpis(sum.totals); ln.color +
renderTable(sum.machines); '"></i>' +
drawCurve(curve); escapeHtml(ln.name) +
setStatus( "</span>"
"窗口 " + );
windowKey + })
" · 在线 " + .join("");
(sum.totals.online || 0) + }
"/" +
(sum.totals.machines || 0) + const allPts = lines.reduce(function (acc, ln) {
" · 曲线采样点以巡检累积" return acc.concat(ln.points || []);
); }, []);
} catch (e) { if (!allPts.length) {
setStatus(String(e.message || e), true); if (empty) empty.hidden = false;
} return;
} }
if (empty) empty.hidden = true;
function bind() {
const tabs = document.getElementById("stats-window-tabs"); let minT = Math.min.apply(
if (tabs) { null,
tabs.addEventListener("click", function (ev) { allPts.map(function (p) {
const btn = ev.target.closest("[data-window]"); return p.t;
if (!btn) return; })
windowKey = btn.getAttribute("data-window") || "7d"; );
tabs.querySelectorAll(".stats-win-btn").forEach(function (b) { let maxT = Math.max.apply(
b.classList.toggle("is-active", b === btn); null,
}); allPts.map(function (p) {
loadAll(); return p.t;
}); })
} );
const toggle = document.getElementById("stats-show-machines"); let minV = Math.min.apply(
if (toggle) { null,
toggle.addEventListener("change", function () { allPts.map(function (p) {
if (curveData) drawCurve(curveData); return p.v;
}); })
} );
window.addEventListener("resize", function () { let maxV = Math.max.apply(
if (curveData) drawCurve(curveData); null,
}); allPts.map(function (p) {
} return p.v;
})
bind(); );
loadAll(); if (maxT <= minT) maxT = minT + 1;
})(); if (maxV <= minV) {
maxV = minV + 1;
minV = minV - 1;
}
const pad = { l: 56, r: 18, t: 18, b: 32 };
const w = cssW - pad.l - pad.r;
const h = cssH - pad.t - pad.b;
function xOf(t) {
return pad.l + ((t - minT) / (maxT - minT)) * w;
}
function yOf(v) {
return pad.t + (1 - (v - minV) / (maxV - minV)) * h;
}
ctx.strokeStyle = "rgba(56,189,248,0.15)";
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const y = pad.t + (h * i) / 5;
ctx.beginPath();
ctx.moveTo(pad.l, y);
ctx.lineTo(pad.l + w, y);
ctx.stroke();
const val = maxV - ((maxV - minV) * i) / 5;
ctx.fillStyle = "#8aa0b8";
ctx.font = "11px IBM Plex Sans, sans-serif";
ctx.fillText(val.toFixed(0), 8, y + 4);
}
lines.forEach(function (ln) {
const pts = ln.points || [];
if (pts.length < 1) return;
ctx.strokeStyle = ln.color;
ctx.lineWidth = ln.width || 1.5;
ctx.beginPath();
pts.forEach(function (p, idx) {
const x = xOf(p.t);
const y = yOf(p.v);
if (idx === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
});
}
async function loadAll(opts) {
opts = opts || {};
if (statsLoading) return;
statsLoading = true;
if (!opts.silent) setStatus("读取缓存…");
try {
const [sumRes, curveRes] = await Promise.all([
fetch("/api/stats/summary?window=" + encodeURIComponent(windowKey), {
credentials: "same-origin",
}),
fetch("/api/stats/curve?range=" + encodeURIComponent(windowKey), {
credentials: "same-origin",
}),
]);
const sum = await sumRes.json();
const curve = await curveRes.json();
if (!sum.ok && !(sum.machines && sum.machines.length)) {
if (sum.refreshing) {
setStatus("后台首次刷新统计中…");
return;
}
throw new Error(sum.msg || sum.error || "汇总失败");
}
if (!curve.ok) throw new Error(curve.msg || "曲线失败");
statsVersion = Number(sum.stats_version || curve.stats_version) || statsVersion;
summaryData = sum;
curveData = curve;
renderKpis(sum.totals || {});
renderTable(sum.machines || []);
renderMachinePicker(curve);
drawCurve(curve);
const sec = Number(sum.poll_interval_sec || curve.poll_interval_sec) || 15;
const live = statsEventSource && statsEventSource.readyState === 1;
setStatus(
(live ? "SSE 已连接 · " : "") +
"窗口 " +
windowKey +
" · 缓存 " +
((sum.totals && sum.totals.online) || 0) +
"/" +
((sum.totals && sum.totals.machines) || 0) +
(sum.updated_at ? " · " + sum.updated_at : "") +
(sum.refreshing ? " · 后台刷新中" : " · 后台每 " + sec + "s 刷新")
);
} catch (e) {
setStatus(String(e.message || e), true);
} finally {
statsLoading = false;
}
}
function closeStatsStream() {
if (statsEventSource) {
statsEventSource.close();
statsEventSource = null;
}
if (statsReconnectTimer) {
clearTimeout(statsReconnectTimer);
statsReconnectTimer = null;
}
}
function connectStatsStream() {
if (typeof EventSource === "undefined") return;
closeStatsStream();
statsEventSource = new EventSource("/api/stats/stream");
statsEventSource.addEventListener("stats", function (ev) {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.stats_version) || 0;
if (ver && ver !== statsVersion) {
loadAll({ silent: true });
} else if (st.refreshing) {
setStatus("后台刷新统计中…");
}
} catch (_) {}
});
statsEventSource.onerror = function () {
closeStatsStream();
setStatus("SSE 断开,8s 后重连…");
statsReconnectTimer = setTimeout(function () {
connectStatsStream();
loadAll({ silent: true });
}, 8000);
};
}
function bind() {
const tabs = document.getElementById("stats-window-tabs");
if (tabs) {
tabs.addEventListener("click", function (ev) {
const btn = ev.target.closest("[data-window]");
if (!btn) return;
windowKey = btn.getAttribute("data-window") || "7d";
tabs.querySelectorAll(".stats-win-btn").forEach(function (b) {
b.classList.toggle("is-active", b === btn);
});
loadAll();
});
}
const showTotal = document.getElementById("stats-show-total");
if (showTotal) {
showTotal.addEventListener("change", function () {
if (curveData) drawCurve(curveData);
});
}
const picker = document.getElementById("stats-machine-picker");
if (picker) {
picker.addEventListener("change", function (ev) {
const input = ev.target.closest("input[data-machine-id]");
if (!input) return;
const id = String(input.getAttribute("data-machine-id") || "");
if (!selectedIds) selectedIds = new Set();
if (input.checked) selectedIds.add(id);
else selectedIds.delete(id);
saveSelectedIds();
if (curveData) drawCurve(curveData);
});
picker.addEventListener("click", function (ev) {
const btn = ev.target.closest("[data-act]");
if (!btn || !curveData) return;
const act = btn.getAttribute("data-act");
const ids = ((curveData && curveData.series) || []).map(function (s) {
return String(s.id);
});
if (act === "all") selectedIds = new Set(ids);
else if (act === "none") selectedIds = new Set();
saveSelectedIds();
renderMachinePicker(curveData);
drawCurve(curveData);
});
}
window.addEventListener("resize", function () {
if (curveData) drawCurve(curveData);
});
}
bind();
loadAll();
connectStatsStream();
})();
+1 -1
View File
@@ -11,7 +11,7 @@
<link rel="icon" href="{{ url_for('static', filename='icons/favicon.ico') }}?v=2" sizes="any"> <link rel="icon" href="{{ url_for('static', filename='icons/favicon.ico') }}?v=2" sizes="any">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/apple-touch-icon.png') }}?v=2"> <link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/apple-touch-icon.png') }}?v=2">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=45"> <link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=46">
<script> <script>
(function () { (function () {
try { try {
+77 -74
View File
@@ -1,74 +1,77 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}数据统计 · 期权中控{% endblock %} {% block title %}数据统计 · 期权中控{% endblock %}
{% block body %} {% block body %}
<section class="page-head stats-head"> <section class="page-head stats-head">
<div> <div>
<p class="settings-eyebrow">Analytics</p> <p class="settings-eyebrow">Analytics</p>
<h1>数据统计</h1> <h1>数据统计</h1>
<p class="muted settings-lead">多机 KPI 对比与余额曲线(不含权利金/浮盈)。详情仍在工作台查看。</p> <p class="muted settings-lead">多机 KPI 对比与余额曲线(不含权利金/浮盈)。后台常驻刷新,打开即读缓存。</p>
</div> </div>
<div class="stats-window-tabs" id="stats-window-tabs" role="tablist"> <div class="stats-window-tabs" id="stats-window-tabs" role="tablist">
<button type="button" class="stats-win-btn" data-window="today">今日</button> <button type="button" class="stats-win-btn" data-window="today">今日</button>
<button type="button" class="stats-win-btn is-active" data-window="7d">近7日</button> <button type="button" class="stats-win-btn is-active" data-window="7d">近7日</button>
<button type="button" class="stats-win-btn" data-window="30d">近30日</button> <button type="button" class="stats-win-btn" data-window="30d">近30日</button>
<button type="button" class="stats-win-btn" data-window="all">全部</button> <button type="button" class="stats-win-btn" data-window="all">全部</button>
</div> </div>
</section> </section>
<p class="muted" id="stats-status">加载中…</p> <p class="muted" id="stats-status">读取缓存…</p>
<section class="panel stats-kpi-panel" id="stats-kpi-panel"> <section class="panel stats-kpi-panel" id="stats-kpi-panel">
<div class="stats-kpi-grid" id="stats-kpi-grid"></div> <div class="stats-kpi-grid" id="stats-kpi-grid"></div>
</section> </section>
<section class="panel stats-curve-panel"> <section class="panel stats-table-panel">
<div class="stats-card-head-inline"> <div class="stats-card-head-inline">
<div> <div>
<h2>余额曲线</h2> <h2>机器对比</h2>
<p class="muted">主指标为账户现金余额,不含已用权利金与浮盈。采样开启后往后累计。</p> <p class="muted">实时资金状态 + 当前窗口交易 KPI。收益率 = 窗口已实现 ÷ 当前余额。</p>
</div> </div>
<label class="stats-curve-toggle"> </div>
<input type="checkbox" id="stats-show-machines" checked> <div class="overview-table-wrap">
叠加各机 <table class="overview-table stats-table" id="stats-table">
</label> <thead>
</div> <tr>
<div class="stats-chart-wrap"> <th>机器</th>
<canvas id="stats-curve-canvas" width="960" height="320"></canvas> <th>状态</th>
<div class="stats-chart-empty muted" id="stats-curve-empty" hidden>暂无采样数据,后台巡检运行后会逐渐出现。</div> <th>模式</th>
</div> <th>余额</th>
<div class="stats-legend" id="stats-curve-legend"></div> <th>权利金</th>
</section> <th>浮盈</th>
<th>笔数</th>
<section class="panel stats-table-panel"> <th>胜率</th>
<div class="stats-card-head-inline"> <th>已实现</th>
<div> <th>收益率</th>
<h2>机器对比</h2> <th>均持仓</th>
<p class="muted">实时资金状态 + 当前窗口交易 KPI。</p> <th></th>
</div> </tr>
</div> </thead>
<div class="overview-table-wrap"> <tbody id="stats-table-body">
<table class="overview-table stats-table" id="stats-table"> <tr><td colspan="12" class="muted">加载中…</td></tr>
<thead> </tbody>
<tr> </table>
<th>机器</th> </div>
<th>状态</th> </section>
<th>余额</th>
<th>权利金</th> <section class="panel stats-curve-panel stats-curve-panel--large">
<th>浮盈</th> <div class="stats-card-head-inline">
<th>笔数</th> <div>
<th>胜率</th> <h2>余额曲线</h2>
<th>已实现</th> <p class="muted">主指标为账户现金余额,不含已用权利金与浮盈。可勾选机器叠加对比。</p>
<th>均持仓</th> </div>
<th></th> <label class="stats-curve-toggle">
</tr> <input type="checkbox" id="stats-show-total" checked>
</thead> 显示合计
<tbody id="stats-table-body"> </label>
<tr><td colspan="10" class="muted">加载中…</td></tr> </div>
</tbody> <div class="stats-machine-picker" id="stats-machine-picker" aria-label="选择机器"></div>
</table> <div class="stats-chart-wrap stats-chart-wrap--large">
</div> <canvas id="stats-curve-canvas" width="960" height="480"></canvas>
</section> <div class="stats-chart-empty muted" id="stats-curve-empty" hidden>暂无采样数据,后台巡检运行后会逐渐出现。</div>
{% endblock %} </div>
{% block scripts %} <div class="stats-legend" id="stats-curve-legend"></div>
<script src="{{ url_for('static', filename='stats.js') }}?v=1"></script> </section>
{% endblock %} {% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='stats.js') }}?v=2"></script>
{% endblock %}