Files
winning-wallet-finder/live/serve_dashboard.py
T

304 lines
12 KiB
Python
Raw Normal View History

#!/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.jsonpaper bot feed
GET /api/live -> live/copybot_live_real.jsonlive 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 的前瞻 realizedout-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()