数据统计页:多机 KPI 对比与余额曲线采样。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+61
-46
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user