diff --git a/.env.example b/.env.example
index 788712a..fe14951 100644
--- a/.env.example
+++ b/.env.example
@@ -9,6 +9,8 @@ HUB_PULL_INTERVAL_SEC=10
HUB_PULL_TIMEOUT_SEC=5
# 总览 SSE 心跳(秒);浏览器只读缓存,靠 SSE 收版本更新
HUB_SSE_HEARTBEAT_SEC=25
+# 数据统计后台刷新间隔(秒);不设则回落 HUB_PULL_INTERVAL_SEC
+HUB_STATS_POLL_INTERVAL_SEC=15
# 中控企业微信(离线/故障告警,与客户端通知分开)
HUB_WECHAT_ENABLED=false
diff --git a/app.py b/app.py
index 72bd326..7d46f1d 100644
--- a/app.py
+++ b/app.py
@@ -63,7 +63,8 @@ from lib.instances_lib import (
)
from lib.overview_cache_lib import overview_store
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 (
hub_alert_cooldown_sec,
hub_health_fail_threshold,
@@ -157,21 +158,43 @@ def stats_page():
@app.route("/api/stats/summary")
@login_required
def api_stats_summary():
+ """只读统计热缓存;后台常驻刷新各窗口."""
window = normalize_window(request.args.get("window") or "7d")
- try:
- return jsonify(build_stats_summary(window))
- except Exception as e:
- return jsonify({"ok": False, "msg": str(e) or "统计失败"}), 500
+ force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
+ if force:
+ stats_store.request_refresh()
+ return jsonify(stats_store.summary_dict(window))
@app.route("/api/stats/curve")
@login_required
def api_stats_curve():
range_key = normalize_window(request.args.get("range") or request.args.get("window") or "7d")
- try:
- return jsonify(build_stats_curve(range_key))
- except Exception as e:
- return jsonify({"ok": False, "msg": str(e) or "曲线失败"}), 500
+ force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
+ if force:
+ stats_store.request_refresh()
+ 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"])
@@ -533,7 +556,12 @@ def start_overview_cache() -> None:
overview_store.start(pull_all_enabled, on_pull=_on_overview_pulled)
+def start_stats_cache() -> None:
+ stats_store.start()
+
+
start_overview_cache()
+start_stats_cache()
try:
start_hub_backup_scheduler(
diff --git a/lib/stats_agg_lib.py b/lib/stats_agg_lib.py
index 9506728..8386832 100644
--- a/lib/stats_agg_lib.py
+++ b/lib/stats_agg_lib.py
@@ -36,14 +36,20 @@ def normalize_window(raw: Any) -> str:
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)
# 实时状态(余额/权利金/浮盈)来自快照;窗口 KPI 来自各机 stats
- snaps = pull_all_enabled()
- try:
- record_cash_samples_from_overview(snaps)
- except Exception:
- pass
+ # snaps 可由热缓存传入,避免统计页再现场重复拉全机
+ if snaps is None:
+ snaps = pull_all_enabled()
+ try:
+ record_cash_samples_from_overview(snaps)
+ except Exception:
+ pass
stats_rows = pull_all_stats(w)
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"))
avg_hold = _safe_float(stats.get("avg_hold_sec"))
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:
online_n += 1
if cash is not None:
@@ -110,9 +122,7 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
"name": snap.get("name"),
"online": online,
"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,
"premium_usdt": paid,
"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,
"win_rate": win_rate 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_loss": loss 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]:
- # 顺手采一笔,让打开页面时也有点
- try:
- snaps = pull_all_enabled()
- record_cash_samples_from_overview(snaps)
- except Exception:
- pass
+ # 曲线只读本地采样库;余额采样由总览常驻 pull 负责
data = query_cash_series(range_key=range_key)
data["ok"] = True
return data
diff --git a/lib/stats_cache_lib.py b/lib/stats_cache_lib.py
new file mode 100644
index 0000000..2d93515
--- /dev/null
+++ b/lib/stats_cache_lib.py
@@ -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()
diff --git a/static/hub.css b/static/hub.css
index 38b2ecd..185aa1e 100644
--- a/static/hub.css
+++ b/static/hub.css
@@ -1656,6 +1656,62 @@ input.readonly {
font-size: 0.86rem;
}
.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 {
position: relative;
width: 100%;
@@ -1665,11 +1721,19 @@ input.readonly {
background: rgba(4, 10, 18, 0.65);
overflow: hidden;
}
+.stats-chart-wrap--large {
+ min-height: 480px;
+ height: min(62vh, 560px);
+}
#stats-curve-canvas {
display: block;
width: 100%;
height: 320px;
}
+.stats-chart-wrap--large #stats-curve-canvas {
+ height: 100%;
+ min-height: 480px;
+}
.stats-chart-empty {
position: absolute;
inset: 0;
diff --git a/static/stats.js b/static/stats.js
index a2e349d..7b15a92 100644
--- a/static/stats.js
+++ b/static/stats.js
@@ -1,330 +1,515 @@
-(function () {
- const COLORS = [
- "#22d3ee",
- "#34d399",
- "#fbbf24",
- "#fb7185",
- "#a78bfa",
- "#60a5fa",
- "#f472b6",
- "#2dd4bf",
- ];
-
- let windowKey = "7d";
- let curveData = null;
- let summaryData = null;
-
- function escapeHtml(s) {
- return String(s)
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """);
- }
-
- function fmtNum(v, digits) {
- if (v == null || v === "") return "—";
- const n = Number(v);
- if (!Number.isFinite(n)) return "—";
- return n.toFixed(digits == null ? 2 : digits);
- }
-
- function fmtPnl(v) {
- if (v == null || v === "") return "—";
- const n = Number(v);
- if (!Number.isFinite(n)) return "—";
- return (n > 0 ? "+" : "") + n.toFixed(2) + " U";
- }
-
- function pnlClass(v) {
- const n = Number(v);
- if (!Number.isFinite(n) || n === 0) return "pnl-flat";
- return n > 0 ? "pnl-up" : "pnl-down";
- }
-
- function fmtHold(sec) {
- if (sec == null || sec === "") return "—";
- const n = Number(sec);
- if (!Number.isFinite(n) || n < 0) return "—";
- if (n < 60) return Math.round(n) + "s";
- if (n < 3600) return (n / 60).toFixed(1) + "m";
- return (n / 3600).toFixed(1) + "h";
- }
-
- function setStatus(msg, isErr) {
- const el = document.getElementById("stats-status");
- if (!el) return;
- el.textContent = msg || "";
- el.classList.toggle("err", !!isErr);
- }
-
- function renderKpis(totals) {
- const grid = document.getElementById("stats-kpi-grid");
- if (!grid) return;
- const t = totals || {};
- const items = [
- { label: "在线 / 总数", value: (t.online || 0) + " / " + (t.machines || 0) },
- { label: "合计余额", value: fmtNum(t.cash_usdt) + " U" },
- { label: "窗口已实现", value: fmtPnl(t.net_realized_pnl), cls: pnlClass(t.net_realized_pnl) },
- { label: "胜率", value: fmtNum(t.win_rate, 1) + "%" },
- { label: "平仓笔数", value: String(t.total_closed != null ? t.total_closed : "—") },
- { label: "合计浮盈", value: fmtPnl(t.unrealized_usdt), cls: pnlClass(t.unrealized_usdt) },
- ];
- grid.innerHTML = items
- .map(function (it) {
- return (
- '
' +
- '
' +
- escapeHtml(it.label) +
- "
" +
- '
' +
- escapeHtml(it.value) +
- "
"
- );
- })
- .join("");
- }
-
- function renderTable(machines) {
- const body = document.getElementById("stats-table-body");
- if (!body) return;
- const list = machines || [];
- if (!list.length) {
- body.innerHTML = '| 暂无启用实例 |
';
- return;
- }
- body.innerHTML = list
- .map(function (m) {
- const status = m.online
- ? '在线'
- : '离线';
- const err = m.error
- ? '' + escapeHtml(m.error) + "
"
- : "";
- return (
- "" +
- "| " +
- escapeHtml(m.name || m.id) +
- "" +
- err +
- " | " +
- "" +
- status +
- " | " +
- "" +
- escapeHtml(fmtNum(m.cash_usdt)) +
- " | " +
- "" +
- escapeHtml(fmtNum(m.premium_usdt)) +
- " | " +
- '' +
- escapeHtml(fmtPnl(m.unrealized_usdt)) +
- " | " +
- "" +
- escapeHtml(m.stats_ok ? String(m.total_closed ?? "—") : "—") +
- " | " +
- "" +
- escapeHtml(m.stats_ok ? fmtNum(m.win_rate, 1) + "%" : "—") +
- " | " +
- '' +
- escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") +
- " | " +
- "" +
- escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") +
- " | " +
- '工作台 | ' +
- "
"
- );
- })
- .join("");
- }
-
- function drawCurve(data) {
- const canvas = document.getElementById("stats-curve-canvas");
- const empty = document.getElementById("stats-curve-empty");
- const legend = document.getElementById("stats-curve-legend");
- if (!canvas) return;
- const ctx = canvas.getContext("2d");
- const dpr = window.devicePixelRatio || 1;
- const cssW = canvas.clientWidth || 960;
- const cssH = 320;
- canvas.width = Math.floor(cssW * dpr);
- canvas.height = Math.floor(cssH * dpr);
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
- ctx.clearRect(0, 0, cssW, cssH);
-
- const showMachines = !!(document.getElementById("stats-show-machines") || {}).checked;
- const lines = [];
- if (data && data.total && data.total.length) {
- lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.4 });
- }
- if (showMachines && data && data.series) {
- data.series.forEach(function (s, i) {
- if (!s.points || !s.points.length) return;
- lines.push({
- name: s.name || String(s.id),
- color: COLORS[i % COLORS.length],
- points: s.points,
- width: 1.4,
- });
- });
- }
-
- if (legend) {
- legend.innerHTML = lines
- .map(function (ln) {
- return (
- '' +
- escapeHtml(ln.name) +
- ""
- );
- })
- .join("");
- }
-
- const allPts = lines.reduce(function (acc, ln) {
- return acc.concat(ln.points || []);
- }, []);
- if (!allPts.length) {
- if (empty) empty.hidden = false;
- return;
- }
- if (empty) empty.hidden = true;
-
- let minT = Math.min.apply(
- null,
- allPts.map(function (p) {
- return p.t;
- })
- );
- let maxT = Math.max.apply(
- null,
- allPts.map(function (p) {
- return p.t;
- })
- );
- let minV = Math.min.apply(
- null,
- allPts.map(function (p) {
- return p.v;
- })
- );
- let maxV = Math.max.apply(
- null,
- allPts.map(function (p) {
- return p.v;
- })
- );
- if (maxT <= minT) maxT = minT + 1;
- if (maxV <= minV) {
- maxV = minV + 1;
- minV = minV - 1;
- }
- const pad = { l: 52, r: 16, t: 16, b: 28 };
- 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 <= 4; i++) {
- const y = pad.t + (h * i) / 4;
- ctx.beginPath();
- ctx.moveTo(pad.l, y);
- ctx.lineTo(pad.l + w, y);
- ctx.stroke();
- const val = maxV - ((maxV - minV) * i) / 4;
- 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() {
- setStatus("加载中…");
- try {
- const [sumRes, curveRes] = await Promise.all([
- 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 (!sum.ok) throw new Error(sum.msg || "汇总失败");
- if (!curve.ok) throw new Error(curve.msg || "曲线失败");
- summaryData = sum;
- curveData = curve;
- renderKpis(sum.totals);
- renderTable(sum.machines);
- drawCurve(curve);
- setStatus(
- "窗口 " +
- windowKey +
- " · 在线 " +
- (sum.totals.online || 0) +
- "/" +
- (sum.totals.machines || 0) +
- " · 曲线采样点以巡检累积"
- );
- } catch (e) {
- setStatus(String(e.message || e), true);
- }
- }
-
- 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 toggle = document.getElementById("stats-show-machines");
- if (toggle) {
- toggle.addEventListener("change", function () {
- if (curveData) drawCurve(curveData);
- });
- }
- window.addEventListener("resize", function () {
- if (curveData) drawCurve(curveData);
- });
- }
-
- bind();
- loadAll();
-})();
+(function () {
+ const COLORS = [
+ "#22d3ee",
+ "#34d399",
+ "#fbbf24",
+ "#fb7185",
+ "#a78bfa",
+ "#60a5fa",
+ "#f472b6",
+ "#2dd4bf",
+ ];
+ const PICKER_KEY = "hub-stats-curve-machines";
+
+ let windowKey = "7d";
+ let curveData = null;
+ let summaryData = null;
+ let selectedIds = loadSelectedIds();
+ let statsVersion = 0;
+ let statsLoading = false;
+ let statsEventSource = null;
+ let statsReconnectTimer = null;
+
+ function loadSelectedIds() {
+ try {
+ const raw = localStorage.getItem(PICKER_KEY);
+ if (!raw) return null;
+ const arr = JSON.parse(raw);
+ if (Array.isArray(arr)) return new Set(arr.map(String));
+ } catch (_) {}
+ return null;
+ }
+
+ function saveSelectedIds() {
+ try {
+ localStorage.setItem(PICKER_KEY, JSON.stringify(Array.from(selectedIds || [])));
+ } catch (_) {}
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+ }
+
+ function fmtNum(v, digits) {
+ if (v == null || v === "") return "—";
+ const n = Number(v);
+ if (!Number.isFinite(n)) return "—";
+ return n.toFixed(digits == null ? 2 : digits);
+ }
+
+ function fmtPnl(v) {
+ if (v == null || v === "") return "—";
+ const n = Number(v);
+ if (!Number.isFinite(n)) return "—";
+ return (n > 0 ? "+" : "") + n.toFixed(2) + " U";
+ }
+
+ function pnlClass(v) {
+ const n = Number(v);
+ if (!Number.isFinite(n) || n === 0) return "pnl-flat";
+ return n > 0 ? "pnl-up" : "pnl-down";
+ }
+
+ function fmtHold(sec) {
+ if (sec == null || sec === "") return "—";
+ const n = Number(sec);
+ if (!Number.isFinite(n) || n < 0) return "—";
+ if (n < 60) return Math.round(n) + "s";
+ if (n < 3600) return (n / 60).toFixed(1) + "m";
+ return (n / 3600).toFixed(1) + "h";
+ }
+
+ function modeLabel(mode) {
+ const m = String(mode || "").toLowerCase();
+ if (m === "sim") return "模拟";
+ if (m === "live") return "实盘";
+ return mode ? String(mode) : "—";
+ }
+
+ function setStatus(msg, isErr) {
+ const el = document.getElementById("stats-status");
+ if (!el) return;
+ el.textContent = msg || "";
+ el.classList.toggle("err", !!isErr);
+ }
+
+ function renderKpis(totals) {
+ const grid = document.getElementById("stats-kpi-grid");
+ if (!grid) return;
+ const t = totals || {};
+ const items = [
+ { label: "在线 / 总数", value: (t.online || 0) + " / " + (t.machines || 0) },
+ { label: "合计余额", value: fmtNum(t.cash_usdt) + " U" },
+ { label: "窗口已实现", value: fmtPnl(t.net_realized_pnl), cls: pnlClass(t.net_realized_pnl) },
+ { label: "胜率", value: fmtNum(t.win_rate, 1) + "%" },
+ { label: "平仓笔数", value: String(t.total_closed != null ? t.total_closed : "—") },
+ { label: "合计浮盈", value: fmtPnl(t.unrealized_usdt), cls: pnlClass(t.unrealized_usdt) },
+ ];
+ grid.innerHTML = items
+ .map(function (it) {
+ return (
+ '' +
+ '
' +
+ escapeHtml(it.label) +
+ "
" +
+ '
' +
+ escapeHtml(it.value) +
+ "
"
+ );
+ })
+ .join("");
+ }
+
+ function renderTable(machines) {
+ const body = document.getElementById("stats-table-body");
+ if (!body) return;
+ const list = machines || [];
+ if (!list.length) {
+ body.innerHTML = '| 暂无启用实例 |
';
+ return;
+ }
+ body.innerHTML = list
+ .map(function (m) {
+ const status = m.online
+ ? '在线'
+ : '离线';
+ const err = m.error
+ ? '' + escapeHtml(m.error) + "
"
+ : "";
+ const mode = String(m.mode || "").toLowerCase();
+ const modeCls = mode === "live" ? "stats-mode-live" : mode === "sim" ? "stats-mode-sim" : "";
+ return (
+ "" +
+ "| " +
+ escapeHtml(m.name || m.id) +
+ "" +
+ err +
+ " | " +
+ "" +
+ status +
+ " | " +
+ '' +
+ escapeHtml(modeLabel(m.mode)) +
+ " | " +
+ "" +
+ escapeHtml(fmtNum(m.cash_usdt)) +
+ " | " +
+ "" +
+ escapeHtml(fmtNum(m.premium_usdt)) +
+ " | " +
+ '' +
+ escapeHtml(fmtPnl(m.unrealized_usdt)) +
+ " | " +
+ "" +
+ escapeHtml(m.stats_ok ? String(m.total_closed ?? "—") : "—") +
+ " | " +
+ "" +
+ escapeHtml(m.stats_ok ? fmtNum(m.win_rate, 1) + "%" : "—") +
+ " | " +
+ '' +
+ escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") +
+ " | " +
+ '' +
+ escapeHtml(
+ m.stats_ok && m.return_rate_pct != null ? fmtNum(m.return_rate_pct, 2) + "%" : "—"
+ ) +
+ " | " +
+ "" +
+ escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") +
+ " | " +
+ '工作台 | ' +
+ "
"
+ );
+ })
+ .join("");
+ }
+
+ function ensureSelectedIds(series) {
+ const ids = (series || []).map(function (s) {
+ return String(s.id);
+ });
+ if (selectedIds == null) {
+ selectedIds = new Set(ids);
+ saveSelectedIds();
+ return;
+ }
+ // 新机器默认勾选
+ ids.forEach(function (id) {
+ if (!selectedIds.has(id) && !localStorage.getItem(PICKER_KEY + ":seen:" + id)) {
+ selectedIds.add(id);
+ try {
+ localStorage.setItem(PICKER_KEY + ":seen:" + id, "1");
+ } catch (_) {}
+ }
+ });
+ saveSelectedIds();
+ }
+
+ function renderMachinePicker(data) {
+ const box = document.getElementById("stats-machine-picker");
+ if (!box) return;
+ const series = (data && data.series) || [];
+ ensureSelectedIds(series);
+ if (!series.length) {
+ box.innerHTML = '暂无可选机器';
+ return;
+ }
+ box.innerHTML =
+ '' +
+ '' +
+ series
+ .map(function (s, i) {
+ const id = String(s.id);
+ const checked = selectedIds && selectedIds.has(id) ? " checked" : "";
+ const color = COLORS[i % COLORS.length];
+ return (
+ '"
+ );
+ })
+ .join("");
+ }
+
+ function drawCurve(data) {
+ const canvas = document.getElementById("stats-curve-canvas");
+ const empty = document.getElementById("stats-curve-empty");
+ const legend = document.getElementById("stats-curve-legend");
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ const dpr = window.devicePixelRatio || 1;
+ const cssW = canvas.clientWidth || 960;
+ const wrap = canvas.parentElement;
+ const cssH = Math.max(420, (wrap && wrap.clientHeight) || 480);
+ canvas.width = Math.floor(cssW * dpr);
+ canvas.height = Math.floor(cssH * dpr);
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, cssW, cssH);
+
+ const showTotal = !!(document.getElementById("stats-show-total") || {}).checked;
+ const lines = [];
+ if (showTotal && data && data.total && data.total.length) {
+ lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.6 });
+ }
+ if (data && data.series) {
+ data.series.forEach(function (s, i) {
+ const id = String(s.id);
+ if (selectedIds && !selectedIds.has(id)) return;
+ if (!s.points || !s.points.length) return;
+ lines.push({
+ name: s.name || id,
+ color: COLORS[i % COLORS.length],
+ points: s.points,
+ width: 1.6,
+ });
+ });
+ }
+
+ if (legend) {
+ legend.innerHTML = lines
+ .map(function (ln) {
+ return (
+ '' +
+ escapeHtml(ln.name) +
+ ""
+ );
+ })
+ .join("");
+ }
+
+ const allPts = lines.reduce(function (acc, ln) {
+ return acc.concat(ln.points || []);
+ }, []);
+ if (!allPts.length) {
+ if (empty) empty.hidden = false;
+ return;
+ }
+ if (empty) empty.hidden = true;
+
+ let minT = Math.min.apply(
+ null,
+ allPts.map(function (p) {
+ return p.t;
+ })
+ );
+ let maxT = Math.max.apply(
+ null,
+ allPts.map(function (p) {
+ return p.t;
+ })
+ );
+ let minV = Math.min.apply(
+ null,
+ allPts.map(function (p) {
+ return p.v;
+ })
+ );
+ let maxV = Math.max.apply(
+ null,
+ allPts.map(function (p) {
+ return p.v;
+ })
+ );
+ 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();
+})();
diff --git a/templates/base.html b/templates/base.html
index 393d07a..eafe61c 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -11,7 +11,7 @@
-
+
-{% endblock %}
+{% extends "base.html" %}
+{% block title %}数据统计 · 期权中控{% endblock %}
+{% block body %}
+
+
+
Analytics
+
数据统计
+
多机 KPI 对比与余额曲线(不含权利金/浮盈)。后台常驻刷新,打开即读缓存。
+
+
+
+
+
+
+
+
+
+读取缓存…
+
+
+
+
+
+
+
机器对比
+
实时资金状态 + 当前窗口交易 KPI。收益率 = 窗口已实现 ÷ 当前余额。
+
+
+
+
+
+
+ | 机器 |
+ 状态 |
+ 模式 |
+ 余额 |
+ 权利金 |
+ 浮盈 |
+ 笔数 |
+ 胜率 |
+ 已实现 |
+ 收益率 |
+ 均持仓 |
+ |
+
+
+
+ | 加载中… |
+
+
+
+
+
+
+
+
+
余额曲线
+
主指标为账户现金余额,不含已用权利金与浮盈。可勾选机器叠加对比。
+
+
+
+
+
+
+
暂无采样数据,后台巡检运行后会逐渐出现。
+
+
+
+{% endblock %}
+{% block scripts %}
+
+{% endblock %}