From 36fbcc2d1b6b86e74e0ea5ca26cc55576f9ccfc5 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 3 Oct 2026 02:59:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=BB=9F=E8=AE=A1=E9=A1=B5?= =?UTF-8?q?=EF=BC=9A=E5=A4=9A=E6=9C=BA=20KPI=20=E5=AF=B9=E6=AF=94=E4=B8=8E?= =?UTF-8?q?=E4=BD=99=E9=A2=9D=E6=9B=B2=E7=BA=BF=E9=87=87=E6=A0=B7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.example | 4 + app.py | 31 +++- lib/bridge_client_lib.py | 60 +++++++ lib/cash_sample_lib.py | 175 +++++++++++++++++++++ lib/db_lib.py | 107 +++++++------ lib/stats_agg_lib.py | 164 +++++++++++++++++++ static/hub.css | 135 ++++++++++++++++ static/stats.js | 330 +++++++++++++++++++++++++++++++++++++++ templates/base.html | 3 +- templates/stats.html | 74 +++++++++ 使用说明.md | 1 + 11 files changed, 1036 insertions(+), 48 deletions(-) create mode 100644 lib/cash_sample_lib.py create mode 100644 lib/stats_agg_lib.py create mode 100644 static/stats.js create mode 100644 templates/stats.html diff --git a/.env.example b/.env.example index b6d12d9..040c886 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,7 @@ HUB_WECHAT_ENABLED=false HUB_WECHAT_WEBHOOK= HUB_ALERT_COOLDOWN_SEC=600 HUB_HEALTH_FAIL_THRESHOLD=2 + +# 数据统计:余额曲线采样(秒)与保留天数 +HUB_CASH_SAMPLE_SEC=60 +HUB_CASH_SAMPLE_KEEP_DAYS=90 diff --git a/app.py b/app.py index f21cc48..4df6cbb 100644 --- a/app.py +++ b/app.py @@ -35,6 +35,7 @@ from lib.bridge_client_lib import ( post_cond_exit, pull_all_enabled, ) +from lib.cash_sample_lib import record_cash_samples_from_overview from lib.db_lib import init_db from lib.env_lib import apply_env_updates, env_get from lib.health_watch_lib import inspect_overview_rows @@ -46,6 +47,7 @@ from lib.instances_lib import ( update_instance, ) 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.wechat_lib import ( hub_alert_cooldown_sec, hub_health_fail_threshold, @@ -130,6 +132,32 @@ def dashboard(): return render_template("dashboard.html") +@app.route("/stats") +@login_required +def stats_page(): + return render_template("stats.html") + + +@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 + + +@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 + + @app.route("/instances", methods=["GET", "POST"]) @login_required def instances_page(): @@ -323,11 +351,12 @@ def _health_pull_interval_sec() -> float: def _health_watch_loop() -> None: - """后台巡检:即使无人打开总览也会告警.""" + """后台巡检:即使无人打开总览也会告警;并采样余额曲线.""" while True: try: rows = pull_all_enabled() inspect_overview_rows(rows) + record_cash_samples_from_overview(rows) except Exception: pass time.sleep(_health_pull_interval_sec()) diff --git a/lib/bridge_client_lib.py b/lib/bridge_client_lib.py index b60f57a..3b278c5 100644 --- a/lib/bridge_client_lib.py +++ b/lib/bridge_client_lib.py @@ -209,6 +209,66 @@ def pull_all_enabled() -> list[dict[str, Any]]: return results +def fetch_one_stats(inst: dict[str, Any], *, window: str = "7d") -> dict[str, Any]: + """拉取单机窗口化期权统计.""" + base = (inst.get("base_url") or "").rstrip("/") + token = (inst.get("bridge_token") or "").strip() + out: dict[str, Any] = { + "id": inst.get("id"), + "name": inst.get("name"), + "online": False, + "error": "", + "stats": None, + } + if not base or not token: + out["error"] = "缺少 URL 或 Token" + return out + url = f"{base}/api/hub/options/stats" + try: + r = requests.get( + url, + headers={"X-Hub-Token": token, "Accept": "application/json"}, + params={"window": window}, + timeout=max(_timeout(), 15.0), + ) + data = {} + try: + data = r.json() + except Exception: + data = {} + if r.status_code != 200 or not isinstance(data, dict) or not data.get("ok"): + out["error"] = (data.get("msg") if isinstance(data, dict) else None) or f"HTTP {r.status_code}" + out["stats"] = data if isinstance(data, dict) else None + return out + out["online"] = True + out["stats"] = data + return out + except requests.Timeout: + out["error"] = "超时" + return out + except Exception as e: + out["error"] = str(e) or "拉取失败" + return out + + +def pull_all_stats(window: str = "7d") -> list[dict[str, Any]]: + instances = list_instances(enabled_only=True) + if not instances: + return [] + results: list[dict[str, Any]] = [] + with ThreadPoolExecutor(max_workers=min(8, len(instances))) as pool: + futs = { + pool.submit(fetch_one_stats, inst, window=window): inst["id"] for inst in instances + } + by_id: dict[Any, dict[str, Any]] = {} + for fut in as_completed(futs): + row = fut.result() + by_id[row.get("id")] = row + for inst in instances: + results.append(by_id.get(inst["id"]) or fetch_one_stats(inst, window=window)) + return results + + def dispatch_cond_order_to_ids(instance_ids: list[int], payload: dict[str, Any]) -> list[dict[str, Any]]: results = [] for iid in instance_ids: diff --git a/lib/cash_sample_lib.py b/lib/cash_sample_lib.py new file mode 100644 index 0000000..214a4c2 --- /dev/null +++ b/lib/cash_sample_lib.py @@ -0,0 +1,175 @@ +"""中控余额采样(曲线主指标=现金,不含权利金/浮盈).""" +from __future__ import annotations + +import os +import threading +import time +from typing import Any + +from lib.db_lib import get_db + +_lock = threading.Lock() +_last_sample_at: dict[Any, float] = {} + + +def cash_sample_interval_sec() -> float: + try: + return max(30.0, float(os.getenv("HUB_CASH_SAMPLE_SEC") or 60)) + except (TypeError, ValueError): + return 60.0 + + +def cash_sample_keep_days() -> int: + try: + return max(7, int(float(os.getenv("HUB_CASH_SAMPLE_KEEP_DAYS") or 90))) + except (TypeError, ValueError): + return 90 + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def cash_from_overview_row(row: dict[str, Any]) -> float | None: + snap = row.get("snapshot") if isinstance(row.get("snapshot"), dict) else {} + premium = snap.get("premium") if isinstance(snap.get("premium"), dict) else {} + cash = _safe_float(premium.get("cash_usdt")) + if cash is not None: + return round(cash, 4) + funds = snap.get("funds") if isinstance(snap.get("funds"), dict) else {} + parts = [_safe_float(funds.get("trading_usdt")), _safe_float(funds.get("funding_usdt"))] + if all(x is None for x in parts): + parts = [_safe_float(funds.get("trading_usdc")), _safe_float(funds.get("funding_usdc"))] + known = [x for x in parts if x is not None] + return round(sum(known), 4) if known else None + + +def record_cash_samples_from_overview(rows: list[dict[str, Any]]) -> int: + """从总览拉取结果按间隔写入余额样本.返回写入条数.""" + if not rows: + return 0 + interval = cash_sample_interval_sec() + now = time.time() + ts_ms = int(now * 1000) + to_write: list[tuple[int, int, float]] = [] + with _lock: + for row in rows: + if not row.get("online"): + continue + iid = row.get("id") + if iid is None: + continue + cash = cash_from_overview_row(row) + if cash is None: + continue + last = float(_last_sample_at.get(iid) or 0) + if now - last < interval: + continue + _last_sample_at[iid] = now + to_write.append((int(iid), ts_ms, float(cash))) + if not to_write: + return 0 + conn = get_db() + try: + conn.executemany( + "INSERT OR IGNORE INTO hub_cash_samples(instance_id, ts_ms, cash_usdt) VALUES (?,?,?)", + to_write, + ) + # 偶尔清理过期 + if int(now) % 50 < len(to_write): + keep_ms = int(now * 1000) - cash_sample_keep_days() * 86400 * 1000 + conn.execute("DELETE FROM hub_cash_samples WHERE ts_ms < ?", (keep_ms,)) + conn.commit() + finally: + conn.close() + return len(to_write) + + +def query_cash_series( + *, + range_key: str = "7d", + instance_ids: list[int] | None = None, +) -> dict[str, Any]: + """返回 {series: [{id,name,points:[{t,v}]}], total:[{t,v}]}.""" + from lib.instances_lib import list_instances + + rk = (range_key or "7d").strip().lower() + now_ms = int(time.time() * 1000) + if rk in ("today", "1d"): + # 今日 0 点起(本地) + import datetime as dt + + start = dt.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_ms = int(start.timestamp() * 1000) + elif rk in ("30d", "30"): + start_ms = now_ms - 30 * 86400 * 1000 + elif rk in ("all", "0"): + start_ms = 0 + else: + start_ms = now_ms - 7 * 86400 * 1000 + + instances = list_instances(enabled_only=True) + if instance_ids is not None: + want = {int(x) for x in instance_ids} + instances = [i for i in instances if int(i["id"]) in want] + id_name = {int(i["id"]): i.get("name") or str(i["id"]) for i in instances} + ids = list(id_name.keys()) + if not ids: + return {"series": [], "total": [], "range": rk, "start_ms": start_ms, "end_ms": now_ms} + + placeholders = ",".join("?" * len(ids)) + conn = get_db() + try: + rows = conn.execute( + f""" + SELECT instance_id, ts_ms, cash_usdt + FROM hub_cash_samples + WHERE instance_id IN ({placeholders}) + AND ts_ms >= ? + ORDER BY ts_ms ASC + """, + (*ids, start_ms), + ).fetchall() + finally: + conn.close() + + by_id: dict[int, list[dict[str, float]]] = {i: [] for i in ids} + for r in rows: + iid = int(r["instance_id"]) + t = int(r["ts_ms"]) + v = float(r["cash_usdt"]) + by_id.setdefault(iid, []).append({"t": t, "v": v}) + + # 合计:按 1 分钟桶,取该桶内各机最后样本求和,并前向填充 + last_in_bucket: dict[int, dict[int, float]] = {} + for iid, pts in by_id.items(): + for p in pts: + bt = (int(p["t"]) // 60000) * 60000 + last_in_bucket.setdefault(bt, {})[iid] = float(p["v"]) + running: dict[int, float] = {} + total_pts: list[dict[str, float]] = [] + for bt in sorted(last_in_bucket.keys()): + running.update(last_in_bucket[bt]) + if running: + total_pts.append({"t": bt, "v": round(sum(running.values()), 4)}) + + series = [ + { + "id": iid, + "name": id_name.get(iid, str(iid)), + "points": by_id.get(iid) or [], + } + for iid in ids + ] + return { + "series": series, + "total": total_pts, + "range": rk, + "start_ms": start_ms, + "end_ms": now_ms, + } diff --git a/lib/db_lib.py b/lib/db_lib.py index 5637c9c..1b0a451 100644 --- a/lib/db_lib.py +++ b/lib/db_lib.py @@ -1,46 +1,61 @@ -"""SQLite 注册表.""" -from __future__ import annotations - -import os -import sqlite3 -from typing import Any - - -def db_path() -> str: - return (os.getenv("DB_PATH") or "hub.db").strip() or "hub.db" - - -def get_db() -> sqlite3.Connection: - conn = sqlite3.connect(db_path(), timeout=30) - conn.row_factory = sqlite3.Row - return conn - - -def init_db() -> None: - conn = get_db() - try: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS hub_instances ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - base_url TEXT NOT NULL, - bridge_token TEXT NOT NULL, - embed_path TEXT NOT NULL DEFAULT '/embed', - tags TEXT DEFAULT '', - enabled INTEGER NOT NULL DEFAULT 1, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - """ - ) - conn.commit() - finally: - conn.close() - - -def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: - if row is None: - return None - return {k: row[k] for k in row.keys()} +"""SQLite 注册表.""" +from __future__ import annotations + +import os +import sqlite3 +from typing import Any + + +def db_path() -> str: + return (os.getenv("DB_PATH") or "hub.db").strip() or "hub.db" + + +def get_db() -> sqlite3.Connection: + conn = sqlite3.connect(db_path(), timeout=30) + conn.row_factory = sqlite3.Row + return conn + + +def init_db() -> None: + conn = get_db() + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS hub_instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + base_url TEXT NOT NULL, + bridge_token TEXT NOT NULL, + embed_path TEXT NOT NULL DEFAULT '/embed', + tags TEXT DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS hub_cash_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id INTEGER NOT NULL, + ts_ms INTEGER NOT NULL, + cash_usdt REAL NOT NULL, + UNIQUE(instance_id, ts_ms) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_hub_cash_samples_inst_ts " + "ON hub_cash_samples(instance_id, ts_ms)" + ) + conn.commit() + finally: + conn.close() + + +def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: + if row is None: + return None + return {k: row[k] for k in row.keys()} diff --git a/lib/stats_agg_lib.py b/lib/stats_agg_lib.py new file mode 100644 index 0000000..9506728 --- /dev/null +++ b/lib/stats_agg_lib.py @@ -0,0 +1,164 @@ +"""中控统计汇总:KPI 对比 + 余额曲线.""" +from __future__ import annotations + +from typing import Any + +from lib.bridge_client_lib import pull_all_enabled, pull_all_stats +from lib.cash_sample_lib import query_cash_series, record_cash_samples_from_overview + + +def _safe_float(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _safe_int(v: Any) -> int: + try: + return int(float(v)) + except (TypeError, ValueError): + return 0 + + +def normalize_window(raw: Any) -> str: + w = str(raw or "7d").strip().lower() + if w in ("1d", "day", "今日"): + return "today" + if w in ("7", "week"): + return "7d" + if w in ("30", "month"): + return "30d" + if w in ("0", "全部", "total"): + return "all" + return w if w in ("today", "7d", "30d", "all") else "7d" + + +def build_stats_summary(window: str = "7d") -> dict[str, Any]: + w = normalize_window(window) + # 实时状态(余额/权利金/浮盈)来自快照;窗口 KPI 来自各机 stats + 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} + + machines: list[dict[str, Any]] = [] + sum_cash = 0.0 + sum_premium = 0.0 + sum_unreal = 0.0 + sum_net = 0.0 + sum_profit = 0.0 + sum_loss = 0.0 + sum_closed = 0 + sum_wins = 0 + sum_losses = 0 + online_n = 0 + cash_known = False + premium_known = False + unreal_known = False + + for snap in snaps: + iid = snap.get("id") + st = stats_by_id.get(iid) or {} + stats = st.get("stats") if isinstance(st.get("stats"), dict) else {} + body = snap.get("snapshot") if isinstance(snap.get("snapshot"), dict) else {} + premium = body.get("premium") if isinstance(body.get("premium"), dict) else {} + pnl = body.get("pnl") if isinstance(body.get("pnl"), dict) else {} + brief = body.get("positions_brief") if isinstance(body.get("positions_brief"), list) else [] + cash = _safe_float(premium.get("cash_usdt")) + paid = _safe_float(premium.get("paid_usdt")) + ratio = _safe_float(premium.get("ratio_pct")) + unreal = _safe_float(pnl.get("unrealized_usdt")) + net = _safe_float(stats.get("net_realized_pnl")) + profit = _safe_float(stats.get("total_profit")) + loss = _safe_float(stats.get("total_loss")) + closed = _safe_int(stats.get("total_closed")) + wins = _safe_int(stats.get("win_count")) + losses = _safe_int(stats.get("loss_count")) + win_rate = _safe_float(stats.get("win_rate")) + avg_hold = _safe_float(stats.get("avg_hold_sec")) + online = bool(snap.get("online")) + if online: + online_n += 1 + if cash is not None: + sum_cash += cash + cash_known = True + if paid is not None: + sum_premium += paid + premium_known = True + if unreal is not None: + sum_unreal += unreal + unreal_known = True + if online and stats.get("ok"): + if net is not None: + sum_net += net + if profit is not None: + sum_profit += profit + if loss is not None: + sum_loss += loss + sum_closed += closed + sum_wins += wins + sum_losses += losses + machines.append( + { + "id": iid, + "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" + ), + "cash_usdt": cash, + "premium_usdt": paid, + "ratio_pct": ratio, + "unrealized_usdt": unreal, + "pos_count": len(brief), + "total_closed": closed if online and stats.get("ok") else None, + "win_count": wins 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, + "net_realized_pnl": net 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, + "avg_hold_sec": avg_hold if online and stats.get("ok") else None, + "stats_ok": bool(online and stats.get("ok")), + } + ) + + agg_win_rate = round(sum_wins / sum_closed * 100, 2) if sum_closed else 0.0 + return { + "ok": True, + "window": w, + "totals": { + "online": online_n, + "machines": len(machines), + "cash_usdt": round(sum_cash, 4) if cash_known else None, + "premium_usdt": round(sum_premium, 4) if premium_known else None, + "unrealized_usdt": round(sum_unreal, 4) if unreal_known else None, + "net_realized_pnl": round(sum_net, 4), + "total_profit": round(sum_profit, 4), + "total_loss": round(sum_loss, 4), + "total_closed": sum_closed, + "win_count": sum_wins, + "loss_count": sum_losses, + "win_rate": agg_win_rate, + }, + "machines": machines, + } + + +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 + data = query_cash_series(range_key=range_key) + data["ok"] = True + return data diff --git a/static/hub.css b/static/hub.css index 85e0862..82079e0 100644 --- a/static/hub.css +++ b/static/hub.css @@ -795,3 +795,138 @@ input.readonly { .cond-st.st-open { color: var(--ok); background: rgba(52, 211, 153, 0.12); } .cond-st.st-done { color: var(--muted); background: rgba(138, 160, 184, 0.12); } .cond-table-wrap { margin-top: 0; } + +/* —— 数据统计 —— */ +.stats-head { + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 4px; +} +.stats-window-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.stats-win-btn { + margin: 0; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(8, 14, 24, 0.7); + color: var(--muted); + font-weight: 600; + box-shadow: none; +} +.stats-win-btn.is-active { + color: #041018; + border-color: transparent; + background: linear-gradient(90deg, var(--accent-deep), var(--accent)); +} +#stats-status.err { color: var(--bad); } +.stats-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} +.stats-kpi-card { + padding: 12px 14px; + border-radius: 12px; + border: 1px solid rgba(56, 189, 248, 0.14); + background: rgba(5, 12, 22, 0.55); +} +.stats-kpi-label { + font-size: 0.75rem; + color: var(--muted); + margin-bottom: 6px; +} +.stats-kpi-value { + font-size: 1.15rem; + font-weight: 700; + letter-spacing: 0.02em; +} +.stats-card-head-inline { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 12px; +} +.stats-card-head-inline h2 { + margin: 0 0 4px; + font-family: var(--font-display); + font-size: 1.05rem; + letter-spacing: 0.04em; +} +.stats-card-head-inline .muted { + margin: 0; + max-width: 640px; + line-height: 1.45; +} +.stats-curve-toggle { + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; + color: var(--muted); + font-size: 0.86rem; +} +.stats-curve-toggle input { width: auto; margin: 0; } +.stats-chart-wrap { + position: relative; + width: 100%; + min-height: 320px; + border-radius: 12px; + border: 1px solid rgba(56, 189, 248, 0.12); + background: rgba(4, 10, 18, 0.65); + overflow: hidden; +} +#stats-curve-canvas { + display: block; + width: 100%; + height: 320px; +} +.stats-chart-empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} +.stats-legend { + display: flex; + flex-wrap: wrap; + gap: 10px 14px; + margin-top: 10px; +} +.stats-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + color: var(--muted); +} +.stats-legend-item i { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} +.stats-table th, +.stats-table td { + text-align: center; + vertical-align: middle; +} +.stats-table td:first-child, +.stats-table th:first-child { + text-align: left; +} +.stats-err-tip { + margin-top: 4px; + font-size: 0.75rem; +} +body.page-stats .main { + max-width: 1400px; +} diff --git a/static/stats.js b/static/stats.js new file mode 100644 index 0000000..a2e349d --- /dev/null +++ b/static/stats.js @@ -0,0 +1,330 @@ +(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(); +})(); diff --git a/templates/base.html b/templates/base.html index 2ef9679..f9c5870 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,11 +6,12 @@ {% block title %}期权中控{% endblock %} - + {% if session.get('logged_in') %}