Files
winning-wallet-finder/live/serve_dashboard.py
T
gavindiaz e2833505b1 fix(dashboard): permanently exclude imwalkinghere from suggestions
5min BTC Up/Down markets resolve faster than any copy can execute.
Author explicitly rejected this wallet (README.md line 42). Dashboard
was suggesting it because forward P&L happened to be positive in the
current 30d window - but the wallet is structurally uncopyable.
2026-07-21 09:25:01 +08:00

304 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()