数据统计改为后台热缓存与 SSE,曲线放大置底并支持选机,对比增加模式与收益率。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+21
-15
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user