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 %}