Files
winning-wallet-finder/live/serve_dashboard.py
T
gavindiaz 367c4724e8 feat(dashboard): wallet panel now shows forward P&L + warns on copy_pnl traps
serve_dashboard.py _serve_wallets():
- Reads portfolio.json per-wallet forward realized (out-of-sample)
- Returns fwd_realized + fwd_bets for each followed + suggested wallet
- Splits suggestions into 'suggestions' (forward positive) and
  'warnings' (forward negative despite high copy_pnl)

bot_dashboard.html renderWallets():
- Added 前瞻30d column to followed wallets table (green/red)
- Suggestions only show forward-positive wallets
- New warnings section: red badges for copy_pnl-high-but-forward-negative
  wallets (e.g. oliman2 #1 copy_pnl but - forward)

Prevents the oliman2/leegunner trap: dashboard used to recommend
copy_pnl #1 and #2, but both lose money in forward testing.
2026-07-21 09:18:49 +08:00

302 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 未跟)
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 >= 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()