数据统计页:多机 KPI 对比与余额曲线采样。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,3 +13,7 @@ HUB_WECHAT_ENABLED=false
|
|||||||
HUB_WECHAT_WEBHOOK=
|
HUB_WECHAT_WEBHOOK=
|
||||||
HUB_ALERT_COOLDOWN_SEC=600
|
HUB_ALERT_COOLDOWN_SEC=600
|
||||||
HUB_HEALTH_FAIL_THRESHOLD=2
|
HUB_HEALTH_FAIL_THRESHOLD=2
|
||||||
|
|
||||||
|
# 数据统计:余额曲线采样(秒)与保留天数
|
||||||
|
HUB_CASH_SAMPLE_SEC=60
|
||||||
|
HUB_CASH_SAMPLE_KEEP_DAYS=90
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from lib.bridge_client_lib import (
|
|||||||
post_cond_exit,
|
post_cond_exit,
|
||||||
pull_all_enabled,
|
pull_all_enabled,
|
||||||
)
|
)
|
||||||
|
from lib.cash_sample_lib import record_cash_samples_from_overview
|
||||||
from lib.db_lib import init_db
|
from lib.db_lib import init_db
|
||||||
from lib.env_lib import apply_env_updates, env_get
|
from lib.env_lib import apply_env_updates, env_get
|
||||||
from lib.health_watch_lib import inspect_overview_rows
|
from lib.health_watch_lib import inspect_overview_rows
|
||||||
@@ -46,6 +47,7 @@ from lib.instances_lib import (
|
|||||||
update_instance,
|
update_instance,
|
||||||
)
|
)
|
||||||
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.wechat_lib import (
|
from lib.wechat_lib import (
|
||||||
hub_alert_cooldown_sec,
|
hub_alert_cooldown_sec,
|
||||||
hub_health_fail_threshold,
|
hub_health_fail_threshold,
|
||||||
@@ -130,6 +132,32 @@ def dashboard():
|
|||||||
return render_template("dashboard.html")
|
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"])
|
@app.route("/instances", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def instances_page():
|
def instances_page():
|
||||||
@@ -323,11 +351,12 @@ def _health_pull_interval_sec() -> float:
|
|||||||
|
|
||||||
|
|
||||||
def _health_watch_loop() -> None:
|
def _health_watch_loop() -> None:
|
||||||
"""后台巡检:即使无人打开总览也会告警."""
|
"""后台巡检:即使无人打开总览也会告警;并采样余额曲线."""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
rows = pull_all_enabled()
|
rows = pull_all_enabled()
|
||||||
inspect_overview_rows(rows)
|
inspect_overview_rows(rows)
|
||||||
|
record_cash_samples_from_overview(rows)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
time.sleep(_health_pull_interval_sec())
|
time.sleep(_health_pull_interval_sec())
|
||||||
|
|||||||
@@ -209,6 +209,66 @@ def pull_all_enabled() -> list[dict[str, Any]]:
|
|||||||
return results
|
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]]:
|
def dispatch_cond_order_to_ids(instance_ids: list[int], payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
results = []
|
results = []
|
||||||
for iid in instance_ids:
|
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,
|
||||||
|
}
|
||||||
@@ -35,6 +35,21 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
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()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -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
|
||||||
+135
@@ -795,3 +795,138 @@ input.readonly {
|
|||||||
.cond-st.st-open { color: var(--ok); background: rgba(52, 211, 153, 0.12); }
|
.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-st.st-done { color: var(--muted); background: rgba(138, 160, 184, 0.12); }
|
||||||
.cond-table-wrap { margin-top: 0; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
+330
@@ -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, ">")
|
||||||
|
.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 (
|
||||||
|
'<div class="stats-kpi-card">' +
|
||||||
|
'<div class="stats-kpi-label">' +
|
||||||
|
escapeHtml(it.label) +
|
||||||
|
"</div>" +
|
||||||
|
'<div class="stats-kpi-value ' +
|
||||||
|
escapeHtml(it.cls || "") +
|
||||||
|
'">' +
|
||||||
|
escapeHtml(it.value) +
|
||||||
|
"</div></div>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(machines) {
|
||||||
|
const body = document.getElementById("stats-table-body");
|
||||||
|
if (!body) return;
|
||||||
|
const list = machines || [];
|
||||||
|
if (!list.length) {
|
||||||
|
body.innerHTML = '<tr><td colspan="10" class="muted">暂无启用实例</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = list
|
||||||
|
.map(function (m) {
|
||||||
|
const status = m.online
|
||||||
|
? '<span class="status-on">在线</span>'
|
||||||
|
: '<span class="status-off">离线</span>';
|
||||||
|
const err = m.error
|
||||||
|
? '<div class="muted stats-err-tip">' + escapeHtml(m.error) + "</div>"
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
"<tr>" +
|
||||||
|
"<td><strong>" +
|
||||||
|
escapeHtml(m.name || m.id) +
|
||||||
|
"</strong>" +
|
||||||
|
err +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
status +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
escapeHtml(fmtNum(m.cash_usdt)) +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
escapeHtml(fmtNum(m.premium_usdt)) +
|
||||||
|
"</td>" +
|
||||||
|
'<td class="' +
|
||||||
|
pnlClass(m.unrealized_usdt) +
|
||||||
|
'">' +
|
||||||
|
escapeHtml(fmtPnl(m.unrealized_usdt)) +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
escapeHtml(m.stats_ok ? String(m.total_closed ?? "—") : "—") +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
escapeHtml(m.stats_ok ? fmtNum(m.win_rate, 1) + "%" : "—") +
|
||||||
|
"</td>" +
|
||||||
|
'<td class="' +
|
||||||
|
pnlClass(m.net_realized_pnl) +
|
||||||
|
'">' +
|
||||||
|
escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") +
|
||||||
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") +
|
||||||
|
"</td>" +
|
||||||
|
'<td><a class="btn btn-sm" href="/desk?id=' +
|
||||||
|
encodeURIComponent(String(m.id)) +
|
||||||
|
'">工作台</a></td>' +
|
||||||
|
"</tr>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.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 (
|
||||||
|
'<span class="stats-legend-item"><i style="background:' +
|
||||||
|
ln.color +
|
||||||
|
'"></i>' +
|
||||||
|
escapeHtml(ln.name) +
|
||||||
|
"</span>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.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();
|
||||||
|
})();
|
||||||
+2
-1
@@ -6,11 +6,12 @@
|
|||||||
<title>{% block title %}期权中控{% endblock %}</title>
|
<title>{% block title %}期权中控{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body class="{% if request.path.startswith('/login') %}page-login{% elif request.path.startswith('/desk') %}page-desk{% elif request.path == '/' %}page-dashboard{% endif %}">
|
<body class="{% if request.path.startswith('/login') %}page-login{% elif request.path.startswith('/desk') %}page-desk{% elif request.path.startswith('/stats') %}page-stats{% elif request.path == '/' %}page-dashboard{% endif %}">
|
||||||
{% if session.get('logged_in') %}
|
{% if session.get('logged_in') %}
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/" class="{% if request.path == '/' %}active{% endif %}">总览</a>
|
<a href="/" class="{% if request.path == '/' %}active{% endif %}">总览</a>
|
||||||
|
<a href="/stats" class="{% if request.path.startswith('/stats') %}active{% endif %}">数据统计</a>
|
||||||
<a href="/cond_orders" class="{% if request.path.startswith('/cond_orders') %}active{% endif %}">条件单</a>
|
<a href="/cond_orders" class="{% if request.path.startswith('/cond_orders') %}active{% endif %}">条件单</a>
|
||||||
<a href="/instances" class="{% if request.path.startswith('/instances') %}active{% endif %}">实例</a>
|
<a href="/instances" class="{% if request.path.startswith('/instances') %}active{% endif %}">实例</a>
|
||||||
<a href="/desk" class="{% if request.path.startswith('/desk') %}active{% endif %}">工作台</a>
|
<a href="/desk" class="{% if request.path.startswith('/desk') %}active{% endif %}">工作台</a>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}数据统计 · 期权中控{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<section class="page-head stats-head">
|
||||||
|
<div>
|
||||||
|
<p class="settings-eyebrow">Analytics</p>
|
||||||
|
<h1>数据统计</h1>
|
||||||
|
<p class="muted settings-lead">多机 KPI 对比与余额曲线(不含权利金/浮盈)。详情仍在工作台查看。</p>
|
||||||
|
</div>
|
||||||
|
<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 is-active" data-window="7d">近7日</button>
|
||||||
|
<button type="button" class="stats-win-btn" data-window="30d">近30日</button>
|
||||||
|
<button type="button" class="stats-win-btn" data-window="all">全部</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="muted" id="stats-status">加载中…</p>
|
||||||
|
|
||||||
|
<section class="panel stats-kpi-panel" id="stats-kpi-panel">
|
||||||
|
<div class="stats-kpi-grid" id="stats-kpi-grid"></div>
|
||||||
|
</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">
|
||||||
|
<div class="stats-card-head-inline">
|
||||||
|
<div>
|
||||||
|
<h2>机器对比</h2>
|
||||||
|
<p class="muted">实时资金状态 + 当前窗口交易 KPI。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overview-table-wrap">
|
||||||
|
<table class="overview-table stats-table" id="stats-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>机器</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>余额</th>
|
||||||
|
<th>权利金</th>
|
||||||
|
<th>浮盈</th>
|
||||||
|
<th>笔数</th>
|
||||||
|
<th>胜率</th>
|
||||||
|
<th>已实现</th>
|
||||||
|
<th>均持仓</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="stats-table-body">
|
||||||
|
<tr><td colspan="10" class="muted">加载中…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='stats.js') }}?v=1"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -25,6 +25,7 @@ OKX **期权多机中控**:在一个入口查看多台期权客户端的持仓
|
|||||||
|
|
||||||
| 功能 | 说明 |
|
| 功能 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| 数据统计 | 多机 KPI 对比 + 余额曲线(不含权利金/浮盈);窗口可切换 |
|
||||||
| 多机持仓总览 | 按客户端卡片展示在线状态、sim/live、浮盈、持仓摘要 |
|
| 多机持仓总览 | 按客户端卡片展示在线状态、sim/live、浮盈、持仓摘要 |
|
||||||
| 内嵌打开 | 在中控工作台 iframe 中打开该客户端 |
|
| 内嵌打开 | 在中控工作台 iframe 中打开该客户端 |
|
||||||
| 新标签打开 | 浏览器新标签直达同一客户端公网地址 |
|
| 新标签打开 | 浏览器新标签直达同一客户端公网地址 |
|
||||||
|
|||||||
Reference in New Issue
Block a user