e2833505b1
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.
304 lines
12 KiB
Python
304 lines
12 KiB
Python
#!/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() |