数据统计改为后台热缓存与 SSE,曲线放大置底并支持选机,对比增加模式与收益率。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,8 @@ HUB_PULL_INTERVAL_SEC=10
|
|||||||
HUB_PULL_TIMEOUT_SEC=5
|
HUB_PULL_TIMEOUT_SEC=5
|
||||||
# 总览 SSE 心跳(秒);浏览器只读缓存,靠 SSE 收版本更新
|
# 总览 SSE 心跳(秒);浏览器只读缓存,靠 SSE 收版本更新
|
||||||
HUB_SSE_HEARTBEAT_SEC=25
|
HUB_SSE_HEARTBEAT_SEC=25
|
||||||
|
# 数据统计后台刷新间隔(秒);不设则回落 HUB_PULL_INTERVAL_SEC
|
||||||
|
HUB_STATS_POLL_INTERVAL_SEC=15
|
||||||
|
|
||||||
# 中控企业微信(离线/故障告警,与客户端通知分开)
|
# 中控企业微信(离线/故障告警,与客户端通知分开)
|
||||||
HUB_WECHAT_ENABLED=false
|
HUB_WECHAT_ENABLED=false
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ from lib.instances_lib import (
|
|||||||
)
|
)
|
||||||
from lib.overview_cache_lib import overview_store
|
from lib.overview_cache_lib import overview_store
|
||||||
from lib.sso_lib import build_embed_open_url
|
from lib.sso_lib import build_embed_open_url
|
||||||
from lib.stats_agg_lib import build_stats_curve, build_stats_summary, normalize_window
|
from lib.stats_agg_lib import normalize_window
|
||||||
|
from lib.stats_cache_lib import stats_store
|
||||||
from lib.wechat_lib import (
|
from lib.wechat_lib import (
|
||||||
hub_alert_cooldown_sec,
|
hub_alert_cooldown_sec,
|
||||||
hub_health_fail_threshold,
|
hub_health_fail_threshold,
|
||||||
@@ -157,21 +158,43 @@ def stats_page():
|
|||||||
@app.route("/api/stats/summary")
|
@app.route("/api/stats/summary")
|
||||||
@login_required
|
@login_required
|
||||||
def api_stats_summary():
|
def api_stats_summary():
|
||||||
|
"""只读统计热缓存;后台常驻刷新各窗口."""
|
||||||
window = normalize_window(request.args.get("window") or "7d")
|
window = normalize_window(request.args.get("window") or "7d")
|
||||||
try:
|
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
||||||
return jsonify(build_stats_summary(window))
|
if force:
|
||||||
except Exception as e:
|
stats_store.request_refresh()
|
||||||
return jsonify({"ok": False, "msg": str(e) or "统计失败"}), 500
|
return jsonify(stats_store.summary_dict(window))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/stats/curve")
|
@app.route("/api/stats/curve")
|
||||||
@login_required
|
@login_required
|
||||||
def api_stats_curve():
|
def api_stats_curve():
|
||||||
range_key = normalize_window(request.args.get("range") or request.args.get("window") or "7d")
|
range_key = normalize_window(request.args.get("range") or request.args.get("window") or "7d")
|
||||||
try:
|
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
||||||
return jsonify(build_stats_curve(range_key))
|
if force:
|
||||||
except Exception as e:
|
stats_store.request_refresh()
|
||||||
return jsonify({"ok": False, "msg": str(e) or "曲线失败"}), 500
|
return jsonify(stats_store.curve_dict(range_key))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/stats/refresh", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_stats_refresh():
|
||||||
|
stats_store.request_refresh()
|
||||||
|
return jsonify({"ok": True, "msg": "已触发统计后台刷新"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/stats/stream")
|
||||||
|
@login_required
|
||||||
|
def api_stats_stream():
|
||||||
|
return Response(
|
||||||
|
stream_with_context(stats_store.iter_sse()),
|
||||||
|
mimetype="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/instances", methods=["GET", "POST"])
|
@app.route("/instances", methods=["GET", "POST"])
|
||||||
@@ -533,7 +556,12 @@ def start_overview_cache() -> None:
|
|||||||
overview_store.start(pull_all_enabled, on_pull=_on_overview_pulled)
|
overview_store.start(pull_all_enabled, on_pull=_on_overview_pulled)
|
||||||
|
|
||||||
|
|
||||||
|
def start_stats_cache() -> None:
|
||||||
|
stats_store.start()
|
||||||
|
|
||||||
|
|
||||||
start_overview_cache()
|
start_overview_cache()
|
||||||
|
start_stats_cache()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
start_hub_backup_scheduler(
|
start_hub_backup_scheduler(
|
||||||
|
|||||||
+21
-15
@@ -36,14 +36,20 @@ def normalize_window(raw: Any) -> str:
|
|||||||
return w if w in ("today", "7d", "30d", "all") else "7d"
|
return w if w in ("today", "7d", "30d", "all") else "7d"
|
||||||
|
|
||||||
|
|
||||||
def build_stats_summary(window: str = "7d") -> dict[str, Any]:
|
def build_stats_summary(
|
||||||
|
window: str = "7d",
|
||||||
|
*,
|
||||||
|
snaps: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
w = normalize_window(window)
|
w = normalize_window(window)
|
||||||
# 实时状态(余额/权利金/浮盈)来自快照;窗口 KPI 来自各机 stats
|
# 实时状态(余额/权利金/浮盈)来自快照;窗口 KPI 来自各机 stats
|
||||||
snaps = pull_all_enabled()
|
# snaps 可由热缓存传入,避免统计页再现场重复拉全机
|
||||||
try:
|
if snaps is None:
|
||||||
record_cash_samples_from_overview(snaps)
|
snaps = pull_all_enabled()
|
||||||
except Exception:
|
try:
|
||||||
pass
|
record_cash_samples_from_overview(snaps)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
stats_rows = pull_all_stats(w)
|
stats_rows = pull_all_stats(w)
|
||||||
stats_by_id = {r.get("id"): r for r in stats_rows}
|
stats_by_id = {r.get("id"): r for r in stats_rows}
|
||||||
|
|
||||||
@@ -83,6 +89,12 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
|
|||||||
win_rate = _safe_float(stats.get("win_rate"))
|
win_rate = _safe_float(stats.get("win_rate"))
|
||||||
avg_hold = _safe_float(stats.get("avg_hold_sec"))
|
avg_hold = _safe_float(stats.get("avg_hold_sec"))
|
||||||
online = bool(snap.get("online"))
|
online = bool(snap.get("online"))
|
||||||
|
mode = ((body.get("instance") or {}) if isinstance(body.get("instance"), dict) else {}).get(
|
||||||
|
"mode"
|
||||||
|
)
|
||||||
|
return_pct = None
|
||||||
|
if online and stats.get("ok") and net is not None and cash is not None and cash > 0:
|
||||||
|
return_pct = round(float(net) / float(cash) * 100.0, 2)
|
||||||
if online:
|
if online:
|
||||||
online_n += 1
|
online_n += 1
|
||||||
if cash is not None:
|
if cash is not None:
|
||||||
@@ -110,9 +122,7 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
|
|||||||
"name": snap.get("name"),
|
"name": snap.get("name"),
|
||||||
"online": online,
|
"online": online,
|
||||||
"error": snap.get("error") or st.get("error") or "",
|
"error": snap.get("error") or st.get("error") or "",
|
||||||
"mode": ((body.get("instance") or {}) if isinstance(body.get("instance"), dict) else {}).get(
|
"mode": mode,
|
||||||
"mode"
|
|
||||||
),
|
|
||||||
"cash_usdt": cash,
|
"cash_usdt": cash,
|
||||||
"premium_usdt": paid,
|
"premium_usdt": paid,
|
||||||
"ratio_pct": ratio,
|
"ratio_pct": ratio,
|
||||||
@@ -123,6 +133,7 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
|
|||||||
"loss_count": losses if online and stats.get("ok") else None,
|
"loss_count": losses if online and stats.get("ok") else None,
|
||||||
"win_rate": win_rate if online and stats.get("ok") else None,
|
"win_rate": win_rate if online and stats.get("ok") else None,
|
||||||
"net_realized_pnl": net if online and stats.get("ok") else None,
|
"net_realized_pnl": net if online and stats.get("ok") else None,
|
||||||
|
"return_rate_pct": return_pct,
|
||||||
"total_profit": profit if online and stats.get("ok") else None,
|
"total_profit": profit if online and stats.get("ok") else None,
|
||||||
"total_loss": loss if online and stats.get("ok") else None,
|
"total_loss": loss if online and stats.get("ok") else None,
|
||||||
"avg_hold_sec": avg_hold if online and stats.get("ok") else None,
|
"avg_hold_sec": avg_hold if online and stats.get("ok") else None,
|
||||||
@@ -153,12 +164,7 @@ def build_stats_summary(window: str = "7d") -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def build_stats_curve(range_key: str = "7d") -> dict[str, Any]:
|
def build_stats_curve(range_key: str = "7d") -> dict[str, Any]:
|
||||||
# 顺手采一笔,让打开页面时也有点
|
# 曲线只读本地采样库;余额采样由总览常驻 pull 负责
|
||||||
try:
|
|
||||||
snaps = pull_all_enabled()
|
|
||||||
record_cash_samples_from_overview(snaps)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
data = query_cash_series(range_key=range_key)
|
data = query_cash_series(range_key=range_key)
|
||||||
data["ok"] = True
|
data["ok"] = True
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1656,6 +1656,62 @@ input.readonly {
|
|||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
}
|
}
|
||||||
.stats-curve-toggle input { width: auto; margin: 0; }
|
.stats-curve-toggle input { width: auto; margin: 0; }
|
||||||
|
.stats-curve-panel--large { margin-top: 18px; }
|
||||||
|
.stats-machine-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 10px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.stats-machine-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.18);
|
||||||
|
background: rgba(5, 12, 22, 0.65);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.stats-machine-chip input { width: auto; margin: 0; }
|
||||||
|
.stats-machine-chip i {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.stats-pick-all {
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.22);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.stats-pick-all:hover { color: var(--text); border-color: rgba(56, 189, 248, 0.4); }
|
||||||
|
.stats-mode-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.stats-mode-badge.stats-mode-sim {
|
||||||
|
color: #67e8f9;
|
||||||
|
border-color: rgba(34, 211, 238, 0.35);
|
||||||
|
background: rgba(34, 211, 238, 0.08);
|
||||||
|
}
|
||||||
|
.stats-mode-badge.stats-mode-live {
|
||||||
|
color: #fbbf24;
|
||||||
|
border-color: rgba(251, 191, 36, 0.35);
|
||||||
|
background: rgba(251, 191, 36, 0.08);
|
||||||
|
}
|
||||||
.stats-chart-wrap {
|
.stats-chart-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1665,11 +1721,19 @@ input.readonly {
|
|||||||
background: rgba(4, 10, 18, 0.65);
|
background: rgba(4, 10, 18, 0.65);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.stats-chart-wrap--large {
|
||||||
|
min-height: 480px;
|
||||||
|
height: min(62vh, 560px);
|
||||||
|
}
|
||||||
#stats-curve-canvas {
|
#stats-curve-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 320px;
|
height: 320px;
|
||||||
}
|
}
|
||||||
|
.stats-chart-wrap--large #stats-curve-canvas {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 480px;
|
||||||
|
}
|
||||||
.stats-chart-empty {
|
.stats-chart-empty {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
+212
-27
@@ -9,10 +9,32 @@
|
|||||||
"#f472b6",
|
"#f472b6",
|
||||||
"#2dd4bf",
|
"#2dd4bf",
|
||||||
];
|
];
|
||||||
|
const PICKER_KEY = "hub-stats-curve-machines";
|
||||||
|
|
||||||
let windowKey = "7d";
|
let windowKey = "7d";
|
||||||
let curveData = null;
|
let curveData = null;
|
||||||
let summaryData = 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) {
|
function escapeHtml(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
@@ -51,6 +73,13 @@
|
|||||||
return (n / 3600).toFixed(1) + "h";
|
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) {
|
function setStatus(msg, isErr) {
|
||||||
const el = document.getElementById("stats-status");
|
const el = document.getElementById("stats-status");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -92,7 +121,7 @@
|
|||||||
if (!body) return;
|
if (!body) return;
|
||||||
const list = machines || [];
|
const list = machines || [];
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
body.innerHTML = '<tr><td colspan="10" class="muted">暂无启用实例</td></tr>';
|
body.innerHTML = '<tr><td colspan="12" class="muted">暂无启用实例</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body.innerHTML = list
|
body.innerHTML = list
|
||||||
@@ -103,6 +132,8 @@
|
|||||||
const err = m.error
|
const err = m.error
|
||||||
? '<div class="muted stats-err-tip">' + escapeHtml(m.error) + "</div>"
|
? '<div class="muted stats-err-tip">' + escapeHtml(m.error) + "</div>"
|
||||||
: "";
|
: "";
|
||||||
|
const mode = String(m.mode || "").toLowerCase();
|
||||||
|
const modeCls = mode === "live" ? "stats-mode-live" : mode === "sim" ? "stats-mode-sim" : "";
|
||||||
return (
|
return (
|
||||||
"<tr>" +
|
"<tr>" +
|
||||||
"<td><strong>" +
|
"<td><strong>" +
|
||||||
@@ -113,6 +144,11 @@
|
|||||||
"<td>" +
|
"<td>" +
|
||||||
status +
|
status +
|
||||||
"</td>" +
|
"</td>" +
|
||||||
|
'<td><span class="stats-mode-badge ' +
|
||||||
|
modeCls +
|
||||||
|
'">' +
|
||||||
|
escapeHtml(modeLabel(m.mode)) +
|
||||||
|
"</span></td>" +
|
||||||
"<td>" +
|
"<td>" +
|
||||||
escapeHtml(fmtNum(m.cash_usdt)) +
|
escapeHtml(fmtNum(m.cash_usdt)) +
|
||||||
"</td>" +
|
"</td>" +
|
||||||
@@ -135,6 +171,13 @@
|
|||||||
'">' +
|
'">' +
|
||||||
escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") +
|
escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") +
|
||||||
"</td>" +
|
"</td>" +
|
||||||
|
'<td class="' +
|
||||||
|
pnlClass(m.return_rate_pct) +
|
||||||
|
'">' +
|
||||||
|
escapeHtml(
|
||||||
|
m.stats_ok && m.return_rate_pct != null ? fmtNum(m.return_rate_pct, 2) + "%" : "—"
|
||||||
|
) +
|
||||||
|
"</td>" +
|
||||||
"<td>" +
|
"<td>" +
|
||||||
escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") +
|
escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") +
|
||||||
"</td>" +
|
"</td>" +
|
||||||
@@ -147,6 +190,61 @@
|
|||||||
.join("");
|
.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 = '<span class="muted">暂无可选机器</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.innerHTML =
|
||||||
|
'<button type="button" class="stats-pick-all" data-act="all">全选</button>' +
|
||||||
|
'<button type="button" class="stats-pick-all" data-act="none">全不选</button>' +
|
||||||
|
series
|
||||||
|
.map(function (s, i) {
|
||||||
|
const id = String(s.id);
|
||||||
|
const checked = selectedIds && selectedIds.has(id) ? " checked" : "";
|
||||||
|
const color = COLORS[i % COLORS.length];
|
||||||
|
return (
|
||||||
|
'<label class="stats-machine-chip">' +
|
||||||
|
'<input type="checkbox" data-machine-id="' +
|
||||||
|
escapeHtml(id) +
|
||||||
|
'"' +
|
||||||
|
checked +
|
||||||
|
">" +
|
||||||
|
'<i style="background:' +
|
||||||
|
color +
|
||||||
|
'"></i>' +
|
||||||
|
escapeHtml(s.name || id) +
|
||||||
|
"</label>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
function drawCurve(data) {
|
function drawCurve(data) {
|
||||||
const canvas = document.getElementById("stats-curve-canvas");
|
const canvas = document.getElementById("stats-curve-canvas");
|
||||||
const empty = document.getElementById("stats-curve-empty");
|
const empty = document.getElementById("stats-curve-empty");
|
||||||
@@ -155,25 +253,28 @@
|
|||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const cssW = canvas.clientWidth || 960;
|
const cssW = canvas.clientWidth || 960;
|
||||||
const cssH = 320;
|
const wrap = canvas.parentElement;
|
||||||
|
const cssH = Math.max(420, (wrap && wrap.clientHeight) || 480);
|
||||||
canvas.width = Math.floor(cssW * dpr);
|
canvas.width = Math.floor(cssW * dpr);
|
||||||
canvas.height = Math.floor(cssH * dpr);
|
canvas.height = Math.floor(cssH * dpr);
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
ctx.clearRect(0, 0, cssW, cssH);
|
ctx.clearRect(0, 0, cssW, cssH);
|
||||||
|
|
||||||
const showMachines = !!(document.getElementById("stats-show-machines") || {}).checked;
|
const showTotal = !!(document.getElementById("stats-show-total") || {}).checked;
|
||||||
const lines = [];
|
const lines = [];
|
||||||
if (data && data.total && data.total.length) {
|
if (showTotal && data && data.total && data.total.length) {
|
||||||
lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.4 });
|
lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.6 });
|
||||||
}
|
}
|
||||||
if (showMachines && data && data.series) {
|
if (data && data.series) {
|
||||||
data.series.forEach(function (s, i) {
|
data.series.forEach(function (s, i) {
|
||||||
|
const id = String(s.id);
|
||||||
|
if (selectedIds && !selectedIds.has(id)) return;
|
||||||
if (!s.points || !s.points.length) return;
|
if (!s.points || !s.points.length) return;
|
||||||
lines.push({
|
lines.push({
|
||||||
name: s.name || String(s.id),
|
name: s.name || id,
|
||||||
color: COLORS[i % COLORS.length],
|
color: COLORS[i % COLORS.length],
|
||||||
points: s.points,
|
points: s.points,
|
||||||
width: 1.4,
|
width: 1.6,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -230,7 +331,7 @@
|
|||||||
maxV = minV + 1;
|
maxV = minV + 1;
|
||||||
minV = minV - 1;
|
minV = minV - 1;
|
||||||
}
|
}
|
||||||
const pad = { l: 52, r: 16, t: 16, b: 28 };
|
const pad = { l: 56, r: 18, t: 18, b: 32 };
|
||||||
const w = cssW - pad.l - pad.r;
|
const w = cssW - pad.l - pad.r;
|
||||||
const h = cssH - pad.t - pad.b;
|
const h = cssH - pad.t - pad.b;
|
||||||
|
|
||||||
@@ -243,13 +344,13 @@
|
|||||||
|
|
||||||
ctx.strokeStyle = "rgba(56,189,248,0.15)";
|
ctx.strokeStyle = "rgba(56,189,248,0.15)";
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
for (let i = 0; i <= 4; i++) {
|
for (let i = 0; i <= 5; i++) {
|
||||||
const y = pad.t + (h * i) / 4;
|
const y = pad.t + (h * i) / 5;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(pad.l, y);
|
ctx.moveTo(pad.l, y);
|
||||||
ctx.lineTo(pad.l + w, y);
|
ctx.lineTo(pad.l + w, y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
const val = maxV - ((maxV - minV) * i) / 4;
|
const val = maxV - ((maxV - minV) * i) / 5;
|
||||||
ctx.fillStyle = "#8aa0b8";
|
ctx.fillStyle = "#8aa0b8";
|
||||||
ctx.font = "11px IBM Plex Sans, sans-serif";
|
ctx.font = "11px IBM Plex Sans, sans-serif";
|
||||||
ctx.fillText(val.toFixed(0), 8, y + 4);
|
ctx.fillText(val.toFixed(0), 8, y + 4);
|
||||||
@@ -271,36 +372,93 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll(opts) {
|
||||||
setStatus("加载中…");
|
opts = opts || {};
|
||||||
|
if (statsLoading) return;
|
||||||
|
statsLoading = true;
|
||||||
|
if (!opts.silent) setStatus("读取缓存…");
|
||||||
try {
|
try {
|
||||||
const [sumRes, curveRes] = await Promise.all([
|
const [sumRes, curveRes] = await Promise.all([
|
||||||
fetch("/api/stats/summary?window=" + encodeURIComponent(windowKey)),
|
fetch("/api/stats/summary?window=" + encodeURIComponent(windowKey), {
|
||||||
fetch("/api/stats/curve?range=" + encodeURIComponent(windowKey)),
|
credentials: "same-origin",
|
||||||
|
}),
|
||||||
|
fetch("/api/stats/curve?range=" + encodeURIComponent(windowKey), {
|
||||||
|
credentials: "same-origin",
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const sum = await sumRes.json();
|
const sum = await sumRes.json();
|
||||||
const curve = await curveRes.json();
|
const curve = await curveRes.json();
|
||||||
if (!sum.ok) throw new Error(sum.msg || "汇总失败");
|
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 || "曲线失败");
|
if (!curve.ok) throw new Error(curve.msg || "曲线失败");
|
||||||
|
statsVersion = Number(sum.stats_version || curve.stats_version) || statsVersion;
|
||||||
summaryData = sum;
|
summaryData = sum;
|
||||||
curveData = curve;
|
curveData = curve;
|
||||||
renderKpis(sum.totals);
|
renderKpis(sum.totals || {});
|
||||||
renderTable(sum.machines);
|
renderTable(sum.machines || []);
|
||||||
|
renderMachinePicker(curve);
|
||||||
drawCurve(curve);
|
drawCurve(curve);
|
||||||
|
const sec = Number(sum.poll_interval_sec || curve.poll_interval_sec) || 15;
|
||||||
|
const live = statsEventSource && statsEventSource.readyState === 1;
|
||||||
setStatus(
|
setStatus(
|
||||||
"窗口 " +
|
(live ? "SSE 已连接 · " : "") +
|
||||||
|
"窗口 " +
|
||||||
windowKey +
|
windowKey +
|
||||||
" · 在线 " +
|
" · 缓存 " +
|
||||||
(sum.totals.online || 0) +
|
((sum.totals && sum.totals.online) || 0) +
|
||||||
"/" +
|
"/" +
|
||||||
(sum.totals.machines || 0) +
|
((sum.totals && sum.totals.machines) || 0) +
|
||||||
" · 曲线采样点以巡检累积"
|
(sum.updated_at ? " · " + sum.updated_at : "") +
|
||||||
|
(sum.refreshing ? " · 后台刷新中" : " · 后台每 " + sec + "s 刷新")
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(String(e.message || e), true);
|
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() {
|
function bind() {
|
||||||
const tabs = document.getElementById("stats-window-tabs");
|
const tabs = document.getElementById("stats-window-tabs");
|
||||||
if (tabs) {
|
if (tabs) {
|
||||||
@@ -314,12 +472,38 @@
|
|||||||
loadAll();
|
loadAll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const toggle = document.getElementById("stats-show-machines");
|
const showTotal = document.getElementById("stats-show-total");
|
||||||
if (toggle) {
|
if (showTotal) {
|
||||||
toggle.addEventListener("change", function () {
|
showTotal.addEventListener("change", function () {
|
||||||
if (curveData) drawCurve(curveData);
|
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 () {
|
window.addEventListener("resize", function () {
|
||||||
if (curveData) drawCurve(curveData);
|
if (curveData) drawCurve(curveData);
|
||||||
});
|
});
|
||||||
@@ -327,4 +511,5 @@
|
|||||||
|
|
||||||
bind();
|
bind();
|
||||||
loadAll();
|
loadAll();
|
||||||
|
connectStatsStream();
|
||||||
})();
|
})();
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
<link rel="icon" href="{{ url_for('static', filename='icons/favicon.ico') }}?v=2" sizes="any">
|
<link rel="icon" href="{{ url_for('static', filename='icons/favicon.ico') }}?v=2" sizes="any">
|
||||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/apple-touch-icon.png') }}?v=2">
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/apple-touch-icon.png') }}?v=2">
|
||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=45">
|
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=46">
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+26
-23
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="settings-eyebrow">Analytics</p>
|
<p class="settings-eyebrow">Analytics</p>
|
||||||
<h1>数据统计</h1>
|
<h1>数据统计</h1>
|
||||||
<p class="muted settings-lead">多机 KPI 对比与余额曲线(不含权利金/浮盈)。详情仍在工作台查看。</p>
|
<p class="muted settings-lead">多机 KPI 对比与余额曲线(不含权利金/浮盈)。后台常驻刷新,打开即读缓存。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-window-tabs" id="stats-window-tabs" role="tablist">
|
<div class="stats-window-tabs" id="stats-window-tabs" role="tablist">
|
||||||
<button type="button" class="stats-win-btn" data-window="today">今日</button>
|
<button type="button" class="stats-win-btn" data-window="today">今日</button>
|
||||||
@@ -15,35 +15,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p class="muted" id="stats-status">加载中…</p>
|
<p class="muted" id="stats-status">读取缓存…</p>
|
||||||
|
|
||||||
<section class="panel stats-kpi-panel" id="stats-kpi-panel">
|
<section class="panel stats-kpi-panel" id="stats-kpi-panel">
|
||||||
<div class="stats-kpi-grid" id="stats-kpi-grid"></div>
|
<div class="stats-kpi-grid" id="stats-kpi-grid"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel stats-curve-panel">
|
|
||||||
<div class="stats-card-head-inline">
|
|
||||||
<div>
|
|
||||||
<h2>余额曲线</h2>
|
|
||||||
<p class="muted">主指标为账户现金余额,不含已用权利金与浮盈。采样开启后往后累计。</p>
|
|
||||||
</div>
|
|
||||||
<label class="stats-curve-toggle">
|
|
||||||
<input type="checkbox" id="stats-show-machines" checked>
|
|
||||||
叠加各机
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="stats-chart-wrap">
|
|
||||||
<canvas id="stats-curve-canvas" width="960" height="320"></canvas>
|
|
||||||
<div class="stats-chart-empty muted" id="stats-curve-empty" hidden>暂无采样数据,后台巡检运行后会逐渐出现。</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-legend" id="stats-curve-legend"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel stats-table-panel">
|
<section class="panel stats-table-panel">
|
||||||
<div class="stats-card-head-inline">
|
<div class="stats-card-head-inline">
|
||||||
<div>
|
<div>
|
||||||
<h2>机器对比</h2>
|
<h2>机器对比</h2>
|
||||||
<p class="muted">实时资金状态 + 当前窗口交易 KPI。</p>
|
<p class="muted">实时资金状态 + 当前窗口交易 KPI。收益率 = 窗口已实现 ÷ 当前余额。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overview-table-wrap">
|
<div class="overview-table-wrap">
|
||||||
@@ -52,23 +34,44 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>机器</th>
|
<th>机器</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
|
<th>模式</th>
|
||||||
<th>余额</th>
|
<th>余额</th>
|
||||||
<th>权利金</th>
|
<th>权利金</th>
|
||||||
<th>浮盈</th>
|
<th>浮盈</th>
|
||||||
<th>笔数</th>
|
<th>笔数</th>
|
||||||
<th>胜率</th>
|
<th>胜率</th>
|
||||||
<th>已实现</th>
|
<th>已实现</th>
|
||||||
|
<th>收益率</th>
|
||||||
<th>均持仓</th>
|
<th>均持仓</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="stats-table-body">
|
<tbody id="stats-table-body">
|
||||||
<tr><td colspan="10" class="muted">加载中…</td></tr>
|
<tr><td colspan="12" class="muted">加载中…</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel stats-curve-panel stats-curve-panel--large">
|
||||||
|
<div class="stats-card-head-inline">
|
||||||
|
<div>
|
||||||
|
<h2>余额曲线</h2>
|
||||||
|
<p class="muted">主指标为账户现金余额,不含已用权利金与浮盈。可勾选机器叠加对比。</p>
|
||||||
|
</div>
|
||||||
|
<label class="stats-curve-toggle">
|
||||||
|
<input type="checkbox" id="stats-show-total" checked>
|
||||||
|
显示合计
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="stats-machine-picker" id="stats-machine-picker" aria-label="选择机器"></div>
|
||||||
|
<div class="stats-chart-wrap stats-chart-wrap--large">
|
||||||
|
<canvas id="stats-curve-canvas" width="960" height="480"></canvas>
|
||||||
|
<div class="stats-chart-empty muted" id="stats-curve-empty" hidden>暂无采样数据,后台巡检运行后会逐渐出现。</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-legend" id="stats-curve-legend"></div>
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='stats.js') }}?v=1"></script>
|
<script src="{{ url_for('static', filename='stats.js') }}?v=2"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user