#!/usr/bin/env python3 """极简 HTTP server:把 bot 的实时账本 JSON 暴露成可读的网页。 服务在 0.0.0.0:18080(端口通过 PORT 环境变量改)。 只依赖 Python 3 stdlib。 路由: GET / -> 重定向到 /bot.html GET /bot.html -> 中文实时仪表盘 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): """默认行为是当文件服务器,但 /api/* 走自定义 JSON 接口。""" def do_GET(self): if self.path in ("/", "/bot.html"): self.path = "/bot_dashboard.html" elif self.path == "/api/feed": self._serve_json("copybot_live.json", "paper feed") return elif self.path == "/api/live": self._serve_json("copybot_live_real.json", "live feed") return 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() def _serve_json(self, filename, kind): path = os.path.join(HERE, filename) if not os.path.exists(path): self.send_error(404, f"{kind} not yet written ({filename} missing)") return try: with open(path, "rb") as f: data = f.read() 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": [], "warnings": []} # 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 排名(copy_pnl = in-sample) 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. 读 portfolio.json 的前瞻 realized(out-of-sample,真实表现) fwd_pnl = {} # wallet_addr_lower -> {realized, bets, wins} portfolio_path = os.path.join(HERE, "portfolio.json") if os.path.exists(portfolio_path): try: pf = json.load(open(portfolio_path)) for w in pf.get("wallets", []): addr = w.get("wallet", "").lower() if addr: fwd_pnl[addr] = { "realized": w.get("realized", 0), "bets": w.get("bets", 0), "won": w.get("won", 0), "lost": w.get("lost", 0), "sold": w.get("sold", 0), } except Exception: pass # 4. 标记当前跟的钱包(加前瞻数据) for w in followed: addr = w.get("wallet", "").lower() rank = sharps_addrs.get(addr) name = w.get("name") or addr[:10] fwd = fwd_pnl.get(addr, {}) fwd_realized = fwd.get("realized") entry = { "name": name, "rank": rank, "copy_pnl": None, "fwd_realized": fwd_realized, "fwd_bets": fwd.get("bets", 0), "conv_win": None, "status": "ok" } if rank: sharp_data = next((s for s in sharps_sorted if s.get("wallet", "").lower() == addr), {}) entry["copy_pnl"] = sharp_data.get("copy_pnl", 0) entry["conv_win"] = sharp_data.get("conv_win") else: entry["status"] = "eliminated" result["followed"].append(entry) # 5. 建议加的钱包(前瞻盈利 + copy_pnl 排名前 8 未跟) # 永久排除的钱包(作者明确拒绝,不论前瞻表现) never_follow = {"0xd96750bf8d941a8186e592b0ae6e096da66aa266"} # imwalkinghere: 5min BTC,结算比跟单快 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 or addr in never_follow: continue if i >= 8: break name = s.get("name") or addr[:10] fwd = fwd_pnl.get(addr, {}) fwd_realized = fwd.get("realized") entry = { "name": name, "rank": i + 1, "copy_pnl": s.get("copy_pnl", 0), "fwd_realized": fwd_realized, "fwd_bets": fwd.get("bets", 0), "conv_win": s.get("conv_win"), "wallet": addr } # 前瞻盈利 -> 建议;前瞻亏损 -> 警告;无前瞻数据 -> 未知 if fwd_realized is not None and fwd_realized > 0: result["suggestions"].append(entry) elif fwd_realized is not None and fwd_realized < 0: result["warnings"].append(entry) # 无前瞻数据的不推荐也不警告 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): sys.stderr.write(f"[dashboard] {self.address_string()} - {fmt % args}\n") class ReusableTCPServer(socketserver.TCPServer): """默认 TCPServer 关闭前不释放端口(TIME_WAIT),加 SO_REUSEADDR 让重启无缝。""" allow_reuse_address = True def main(): port = int(os.environ.get("PORT", "18080")) os.chdir(HERE) httpd = ReusableTCPServer(("0.0.0.0", port), Handler) sys.stderr.write(f"[dashboard] serving {HERE} on 0.0.0.0:{port}\n") try: httpd.serve_forever() except KeyboardInterrupt: pass finally: httpd.server_close() if __name__ == "__main__": main()