diff --git a/live/bot_dashboard.html b/live/bot_dashboard.html index c6b9d295..e152e733 100644 --- a/live/bot_dashboard.html +++ b/live/bot_dashboard.html @@ -58,6 +58,26 @@ footer{text-align:center;color:var(--dim);font-size:12px;margin-top:30px} · + +
+

系统健康

+
+ 加载中… +
+
+ + +
+

每日流水线

+
加载中…
+
+ + +
+

钱包建议

+
加载中…
+
+
现金
已部署
@@ -249,6 +269,132 @@ async function render() { render(); setInterval(render, 30000); + +// ========== 系统健康面板 ========== +async function renderHealth() { + let h; + try { + const r = await fetch('/api/health', {cache: 'no-store'}); + h = await r.json(); + } catch (e) { + document.getElementById('health-content').innerHTML = + '获取失败: ' + e.message + ''; + return; + } + + const svcNames = { + 'copybot-paper': 'Paper Bot', + 'copybot-live': 'Live Bot', + 'copybot-dashboard': 'Dashboard', + 'mihomo': '代理' + }; + let html = ''; + for (const [svc, label] of Object.entries(svcNames)) { + const state = h.services?.[svc] || 'unknown'; + const color = state === 'active' ? 'var(--green)' : 'var(--red)'; + html += `${label}: ${state}`; + } + + // 代理 + const proxyOk = h.proxy === 'ok'; + html += `代理: ${h.proxy || '?'}`; + + // CASH≠CHAIN + if (h.cash_drift !== null && h.cash_drift !== undefined) { + const driftOk = Math.abs(h.cash_drift) < 0.5; + html += `CASH≠CHAIN: ${driftOk ? '正常' : '$' + h.cash_drift}`; + } + + // 心跳 + if (h.last_heartbeat) { + const ageMin = Math.floor((h.heartbeat_age_s || 0) / 60); + const ageSec = (h.heartbeat_age_s || 0) % 60; + const fresh = (h.heartbeat_age_s || 999) < 300; + html += `心跳: ${ageMin}分${ageSec}秒前`; + } + + document.getElementById('health-content').innerHTML = html; +} + +// ========== 每日流水线面板 ========== +async function renderDaily() { + let d; + try { + const r = await fetch('/api/daily', {cache: 'no-store'}); + d = await r.json(); + } catch (e) { + document.getElementById('daily-content').innerHTML = + '获取失败'; + return; + } + + const ok = d.status === 'ok'; + const color = ok ? 'var(--green)' : 'var(--red)'; + let html = `${d.status === 'ok' ? '✓ 正常' : d.status}`; + if (d.last_run) { + html += ` 上次运行: ${d.last_run}`; + } + html += ` · 钱包池: ${d.skilled_count} skilled / ${d.sharps_count} sharps`; + document.getElementById('daily-content').innerHTML = html; +} + +// ========== 钱包建议面板 ========== +async function renderWallets() { + let w; + try { + const r = await fetch('/api/wallets', {cache: 'no-store'}); + w = await r.json(); + } catch (e) { + document.getElementById('wallets-content').innerHTML = + '获取失败'; + return; + } + + let html = '' + + ''; + + for (const f of (w.followed || [])) { + let statusBadge, statusColor; + if (f.status === 'ok') { + statusBadge = '✅ 正常'; + statusColor = 'var(--green)'; + } else { + statusBadge = '❌ 已淘汰'; + statusColor = 'var(--red)'; + } + html += ` + + + + + + `; + } + html += '
当前跟的排名copy_pnl信念胜率状态
${f.name}${f.rank ? '#' + f.rank : '-'}${f.copy_pnl != null ? '+' + f.copy_pnl : '-'}${f.conv_win != null ? f.conv_win + '%' : '-'}${statusBadge}
'; + + // 建议加的 + const suggestions = w.suggestions || []; + if (suggestions.length > 0) { + html += '
建议加(watch_sharps 排名前 6 未跟的):
'; + html += '
'; + for (const s of suggestions) { + html += `⬆️ ${s.name} · #${s.rank} · +${s.copy_pnl}${s.conv_win != null ? ' · ' + s.conv_win + '%' : ''}`; + } + html += '
'; + } else { + html += '
当前跟的钱包都在 watch_sharps 里,无需调整 ✓
'; + } + + document.getElementById('wallets-content').innerHTML = html; +} + +// 健康面板 60 秒刷新(比交易数据慢,系统状态变化少) +renderHealth(); +renderDaily(); +renderWallets(); +setInterval(renderHealth, 60000); +setInterval(renderDaily, 60000); +setInterval(renderWallets, 60000); \ No newline at end of file diff --git a/live/serve_dashboard.py b/live/serve_dashboard.py index d79584cf..8296fe8c 100644 --- a/live/serve_dashboard.py +++ b/live/serve_dashboard.py @@ -7,24 +7,23 @@ 路由: GET / -> 重定向到 /bot.html GET /bot.html -> 中文实时仪表盘 - GET /api/feed -> live/copybot_live.json(bot 写入的) - GET /api/portfolio -> live/portfolio.json(portfolio.py 算出的) - -为什么不直接用 live/dashboard.html 那个: - 那个是从 watch_skilled.json 生成的"钱包池"(71 个跟单对象), - 这个是 bot 的"实时账本"(现金/持仓/盈亏)。两件事。 - -用法: - python3 serve_dashboard.py # 默认 18080 - PORT=19090 python3 serve_dashboard.py # 自定义端口 + GET /api/feed -> live/copybot_live.json(paper bot feed) + GET /api/live -> live/copybot_live_real.json(live bot feed) + GET /api/portfolio -> live/portfolio.json + GET /api/health -> 系统健康(服务状态 + 代理 + RTDS + CASH≠CHAIN) + GET /api/daily -> daily.sh 最近运行状态 + GET /api/wallets -> 钱包建议(当前跟的 vs watch_sharps 排名) """ import http.server import json import os import socketserver +import subprocess import sys +import time HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) # /opt/winning-wallet-finder class Handler(http.server.SimpleHTTPRequestHandler): @@ -42,6 +41,15 @@ class Handler(http.server.SimpleHTTPRequestHandler): elif self.path == "/api/portfolio": self._serve_json("portfolio.json", "portfolio") return + elif self.path == "/api/health": + self._serve_health() + return + elif self.path == "/api/daily": + self._serve_daily() + return + elif self.path == "/api/wallets": + self._serve_wallets() + return # 让父类继续处理(serve 文件) return super().do_GET() @@ -53,23 +61,193 @@ class Handler(http.server.SimpleHTTPRequestHandler): try: with open(path, "rb") as f: data = f.read() - self.send_response(200) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + self._send_json_bytes(data) except OSError as e: self.send_error(500, f"read {filename} failed: {e}") + def _send_json_bytes(self, data): + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _send_json_obj(self, obj): + self._send_json_bytes(json.dumps(obj, ensure_ascii=False).encode()) + + def _serve_health(self): + """系统健康状态:4 个服务 + 代理 + RTDS + CASH≠CHAIN + 心跳。""" + health = {"services": {}, "proxy": None, "rtds": None, + "cash_drift": None, "last_heartbeat": None, "ok": True} + + # 1. 4 个 systemd 服务状态 + for svc in ("copybot-paper", "copybot-live", "copybot-dashboard", "mihomo"): + try: + r = subprocess.run(["systemctl", "is-active", svc], + capture_output=True, text=True, timeout=3) + state = r.stdout.strip() + health["services"][svc] = state + if state != "active": + health["ok"] = False + except Exception: + health["services"][svc] = "unknown" + health["ok"] = False + + # 2. 代理状态(跑 healthcheck 脚本,3 秒超时) + try: + r = subprocess.run(["/opt/mihomo-healthcheck.sh"], + capture_output=True, timeout=8) + health["proxy"] = "ok" if r.returncode == 0 else "fail" + if r.returncode != 0: + health["ok"] = False + except Exception: + health["proxy"] = "unknown" + + # 3. 从 live feed 读 RTDS / CASH≠CHAIN / 心跳 + feed_path = os.path.join(HERE, "copybot_live_real.json") + if os.path.exists(feed_path): + try: + feed = json.load(open(feed_path)) + updated = feed.get("updated", 0) + health["last_heartbeat"] = updated + age = time.time() - updated if updated else 999 + health["heartbeat_age_s"] = int(age) + if age > 300: + health["ok"] = False + drift = feed.get("ledger_drift", 0) + health["cash_drift"] = drift + if abs(drift) > 0.5: + health["ok"] = False + except Exception: + pass + # 也读 paper feed 的心跳 + paper_path = os.path.join(HERE, "copybot_live.json") + if os.path.exists(paper_path): + try: + feed = json.load(open(paper_path)) + health["paper_heartbeat"] = feed.get("updated", 0) + except Exception: + pass + + self._send_json_obj(health) + + def _serve_daily(self): + """daily.sh 最近运行状态。""" + daily = {"last_run": None, "status": "unknown", + "skilled_count": 0, "sharps_count": 0} + + # 读 daily.log 最后一行带时间戳的 + log_path = os.path.join(HERE, "daily.log") + if os.path.exists(log_path): + try: + lines = open(log_path, encoding="utf-8", errors="replace").readlines() + for line in reversed(lines): + if "done ->" in line: + daily["status"] = "ok" + # 提取时间 "2026-07-19 02:36:56" + if "] " in line: + ts_part = line.split("] ")[1].split(" ")[0:2] + daily["last_run"] = " ".join(ts_part) + break + elif "[daily]" in line and "error" in line.lower(): + daily["status"] = "error" + break + if daily["status"] == "unknown" and lines: + daily["last_run"] = "有日志但未完成" + except Exception: + pass + + # 钱包池数量 + for name, key in [("watch_skilled.json", "skilled_count"), + ("watch_sharps.json", "sharps_count")]: + p = os.path.join(HERE, name) + if os.path.exists(p): + try: + data = json.load(open(p)) + daily[key] = len(data) if isinstance(data, list) else 0 + except Exception: + pass + + self._send_json_obj(daily) + + def _serve_wallets(self): + """钱包建议:当前跟的 vs watch_sharps 排名。""" + result = {"followed": [], "suggestions": []} + + # 1. 读当前跟的钱包(config.live.json) + live_cfg_path = os.path.join(ROOT, "config.live.json") + followed = [] + if os.path.exists(live_cfg_path): + try: + cfg = json.load(open(live_cfg_path)) + followed = cfg.get("wallets", []) + except Exception: + pass + + # 2. 读 watch_sharps.json 排名 + sharps_path = os.path.join(HERE, "watch_sharps.json") + sharps = [] + if os.path.exists(sharps_path): + try: + sharps = json.load(open(sharps_path)) + except Exception: + pass + + # 按 copy_pnl 排序 + sharps_sorted = sorted(sharps, key=lambda w: w.get("copy_pnl", 0), reverse=True) + sharps_addrs = {w.get("wallet", "").lower(): i + 1 for i, w in enumerate(sharps_sorted)} + + # 3. 标记当前跟的钱包 + for w in followed: + addr = w.get("wallet", "").lower() + rank = sharps_addrs.get(addr) + name = w.get("name") or addr[:10] + if rank: + # 在 sharps 里 + sharp_data = next((s for s in sharps_sorted + if s.get("wallet", "").lower() == addr), {}) + result["followed"].append({ + "name": name, + "rank": rank, + "copy_pnl": sharp_data.get("copy_pnl", 0), + "conv_win": sharp_data.get("conv_win"), + "status": "ok" + }) + else: + # 不在 sharps 里 = 被淘汰 + result["followed"].append({ + "name": name, + "rank": None, + "copy_pnl": None, + "status": "eliminated" + }) + + # 4. 建议加的钱包(在 sharps 里但没跟,排名前 6) + followed_addrs = {w.get("wallet", "").lower() for w in followed} + for i, s in enumerate(sharps_sorted): + addr = s.get("wallet", "").lower() + if addr in followed_addrs: + continue + if i >= 6: # 只推荐前 6 名 + break + name = s.get("name") or addr[:10] + result["suggestions"].append({ + "name": name, + "rank": i + 1, + "copy_pnl": s.get("copy_pnl", 0), + "conv_win": s.get("conv_win"), + "wallet": addr + }) + + self._send_json_obj(result) + def end_headers(self): - # 关掉缓存(开发期方便,部署也无所谓——数据本来就在变) self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") super().end_headers() def log_message(self, fmt, *args): - # 走 stderr 让 journalctl 抓到 sys.stderr.write(f"[dashboard] {self.address_string()} - {fmt % args}\n")