Files
winning-wallet-finder/live/bot_dashboard.html
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

420 lines
17 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Polymarket 跟单 Bot · 实时</title>
<style>
:root{--bg:#0b0e14;--card:#141925;--line:#222b3a;--dim:#8a97ad;--fg:#e8edf5;
--green:#37d67a;--red:#ff5c6c;--amber:#ffcc55;--blue:#5b9dff;--violet:#b18cff}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei","PingFang SC",Roboto,sans-serif}
.wrap{max-width:1280px;margin:0 auto;padding:22px 16px 60px}
h1{font-size:24px;margin:0 0 4px;font-weight:600}
.sub{color:var(--dim);margin:0 0 18px;font-size:13px}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(155px,1fr));
gap:12px;margin-bottom:24px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:14px 16px}
.card .lbl{color:var(--dim);font-size:11px;text-transform:uppercase;
letter-spacing:0.5px;margin-bottom:6px}
.card .val{font-size:22px;font-weight:600;font-variant-numeric:tabular-nums}
.val.pos{color:var(--green)}
.val.neg{color:var(--red)}
.val.warn{color:var(--amber)}
.section{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:16px 18px;margin-bottom:16px}
.section h2{font-size:14px;margin:0 0 12px;font-weight:600;
text-transform:uppercase;letter-spacing:0.5px;color:var(--dim)}
.section h2 .count{color:var(--dim);font-weight:400;margin-left:6px}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{padding:8px 10px;text-align:left;border-bottom:1px solid var(--line);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:380px}
th{color:var(--dim);font-weight:500;font-size:11px;text-transform:uppercase;
letter-spacing:0.3px;position:sticky;top:0;background:var(--card)}
tr:last-child td{border-bottom:none}
.empty{color:var(--dim);padding:30px;text-align:center;font-style:italic}
.updated{font-size:12px;color:var(--dim);font-weight:400;text-transform:none;
letter-spacing:0}
.wallet-badge{display:inline-block;padding:3px 8px;border-radius:4px;
background:var(--line);font-size:11px;margin:0 6px 6px 0}
.live-dot{display:inline-block;width:8px;height:8px;border-radius:50%;
background:var(--green);margin-right:8px;animation:pulse 2s infinite;
vertical-align:middle}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
.error{color:var(--red);font-weight:600}
.title-cell{cursor:help}
footer{text-align:center;color:var(--dim);font-size:12px;margin-top:30px}
</style>
</head>
<body>
<div class="wrap">
<h1>Polymarket 跟单 Bot · 实时</h1>
<div class="sub">
<span class="live-dot"></span><span id="status">连接中…</span>
· 数据更新于 <span id="updated">-</span>
· 浏览器每 30 秒自动拉取
· <button id="toggle-mode" onclick="toggleMode()" style="background:var(--line);color:var(--fg);border:1px solid var(--line);border-radius:4px;padding:3px 12px;cursor:pointer;font-size:12px;">PAPER</button>
</div>
<!-- 系统健康面板 -->
<div class="section" id="health-section">
<h2>系统健康</h2>
<div id="health-content" style="display:flex;flex-wrap:wrap;gap:8px;font-size:13px;">
<span style="color:var(--dim)">加载中…</span>
</div>
</div>
<!-- 每日流水线面板 -->
<div class="section" id="daily-section">
<h2>每日流水线</h2>
<div id="daily-content" style="font-size:13px;color:var(--dim)">加载中…</div>
</div>
<!-- 钱包建议面板 -->
<div class="section" id="wallets-section">
<h2>钱包建议</h2>
<div id="wallets-content" style="font-size:13px;color:var(--dim)">加载中…</div>
</div>
<div class="cards">
<div class="card"><div class="lbl">现金</div><div class="val" id="m-cash"></div></div>
<div class="card"><div class="lbl">已部署</div><div class="val" id="m-deployed"></div></div>
<div class="card"><div class="lbl">已实现盈亏</div><div class="val" id="m-realized"></div></div>
<div class="card"><div class="lbl">最高水位 HWM</div><div class="val" id="m-hwm"></div></div>
<div class="card"><div class="lbl">开放订单</div><div class="val" id="m-open"></div></div>
<div class="card"><div class="lbl">账本漂移</div><div class="val" id="m-drift"></div></div>
<div class="card"><div class="lbl">累计手续费</div><div class="val" id="m-fees"></div></div>
<div class="card"><div class="lbl">平均滑点</div><div class="val" id="m-slip"></div></div>
</div>
<div class="section">
<h2>当前持仓<span class="count" id="open-count"></span></h2>
<table id="open-table">
<thead><tr>
<th>钱包</th><th>市场</th><th>方向</th>
<th>入场价</th><th>状态</th><th>数量</th><th>成本</th><th>开仓时间</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
<div class="section">
<h2>最近结算<span class="count" id="settled-count"></span></h2>
<table id="settled-table">
<thead><tr>
<th>钱包</th><th>市场</th><th>方向</th>
<th>入场</th><th>结算价</th><th>盈亏</th><th>状态</th><th>结算时间</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
<div class="section">
<h2>跟单钱包<span class="count" id="wallet-count"></span></h2>
<div id="wallets"></div>
</div>
<footer>
Bot 模式:<span id="mode"></span> · 起始资金 $<span id="bankroll"></span> ·
单笔下注 <span id="stake"></span> (资金 <span id="stake-pct"></span>)
</footer>
</div>
<script>
let currentMode = 'paper';
function toggleMode() {
currentMode = currentMode === 'paper' ? 'live' : 'paper';
const btn = document.getElementById('toggle-mode');
btn.textContent = currentMode.toUpperCase();
btn.style.background = currentMode === 'live' ? '#3a1a1a' : 'var(--line)';
btn.style.color = currentMode === 'live' ? 'var(--red)' : 'var(--fg)';
render();
}
async function fetchFeed() {
const endpoint = currentMode === 'live' ? '/api/live' : '/api/feed';
const r = await fetch(endpoint, {cache: 'no-store'});
if (!r.ok) throw new Error('HTTP ' + r.status);
return await r.json();
}
function fmtUsd(n) {
if (n == null) return '—';
const abs = Math.abs(n);
const sign = n < 0 ? '-' : '';
return sign + '$' + abs.toLocaleString('en-US', {maximumFractionDigits: 2});
}
function fmtPct(n) {
if (n == null) return '—';
return (n * 100).toFixed(2) + '%';
}
function fmtTs(unix) {
if (!unix) return '—';
const d = new Date(unix * 1000);
return d.toLocaleString('zh-CN', {hour12: false});
}
function shorten(s, n=42) {
if (!s) return '—';
return s.length > n ? s.slice(0, n) + '…' : s;
}
function setVal(id, v, cls) {
const el = document.getElementById(id);
el.textContent = v;
el.classList.remove('pos', 'neg', 'warn');
if (cls) el.classList.add(cls);
}
async function render() {
let d;
try {
d = await fetchFeed();
document.getElementById('status').textContent = '已连接';
} catch (e) {
document.getElementById('status').innerHTML =
'<span class="error">断开:' + e.message + '</span>';
return;
}
// 持仓(包含两类:HELD=跟到结算,SOLD=已平仓但还没在链上结算完成)
const openTbody = document.querySelector('#open-table tbody');
const bets = d.bets || [];
const open = bets.filter(b => !b.settled);
setVal('m-cash', fmtUsd(d.cash));
setVal('m-deployed', fmtUsd(d.deployed));
const real = d.realized || 0;
setVal('m-realized', (real >= 0 ? '+' : '') + fmtUsd(real), real >= 0 ? 'pos' : 'neg');
setVal('m-hwm', fmtUsd(d.hwm));
setVal('m-open', open.length);
const drift = d.ledger_drift || 0;
const driftCls = Math.abs(drift) < 0.01 ? 'pos' : 'neg';
setVal('m-drift', (drift >= 0 ? '+' : '') + fmtUsd(drift), driftCls);
setVal('m-fees', fmtUsd(d.fees_paid));
const slip = d.lag && d.lag.avg_slip_pct;
if (slip != null) {
setVal('m-slip', (slip * 100).toFixed(2) + '%', slip < 0 ? 'pos' : 'neg');
} else {
setVal('m-slip', '—');
}
document.getElementById('updated').textContent = fmtTs(d.updated);
document.getElementById('open-count').textContent = '(' + open.length + ')';
if (open.length === 0) {
openTbody.innerHTML = '<tr><td colspan="8" class="empty">暂无开放仓位</td></tr>';
} else {
openTbody.innerHTML = open.map(b => {
const isSold = b.exit_t && !b.settled;
const statusBadge = isSold
? `<span class="wallet-badge" style="background:#3a2c0a;color:var(--amber)">已平仓@${(b.exit_price||0).toFixed(3)} · 待结算</span>`
: `<span class="wallet-badge" style="background:#1a3322;color:var(--green)">持有中</span>`;
return `
<tr>
<td><span class="wallet-badge">${b.name || (b.wallet ? b.wallet.slice(0,10) : '—')}</span></td>
<td class="title-cell" title="${(b.title||'').replace(/"/g,'&quot;')}">${shorten(b.title||'')}</td>
<td>${b.outcome || '—'}</td>
<td>${(b.my_price||0).toFixed(3)}</td>
<td>${statusBadge}</td>
<td>${(b.shares||0).toFixed(2)}</td>
<td>${fmtUsd(b.cost)}</td>
<td class="updated">${fmtTs(b.opened)}</td>
</tr>
`}).join('');
}
// 已结算(最近 20 条,按时间倒序)
const settled = bets.filter(b => b.settled).slice(-20).reverse();
document.getElementById('settled-count').textContent = '(' + settled.length + ')';
const settledTbody = document.querySelector('#settled-table tbody');
if (settled.length === 0) {
settledTbody.innerHTML = '<tr><td colspan="8" class="empty">暂无结算</td></tr>';
} else {
settledTbody.innerHTML = settled.map(b => {
const pnl = b.pnl || 0;
const cls = pnl > 0 ? 'pos' : (pnl < 0 ? 'neg' : '');
return `
<tr>
<td><span class="wallet-badge">${b.name || (b.wallet ? b.wallet.slice(0,10) : '—')}</span></td>
<td class="title-cell" title="${(b.title||'').replace(/"/g,'&quot;')}">${shorten(b.title||'')}</td>
<td>${b.outcome || '—'}</td>
<td>${(b.my_price||0).toFixed(3)}</td>
<td>${(b.exit_price!=null ? b.exit_price : 0).toFixed(3)}</td>
<td class="${cls}">${pnl>=0?'+':''}${fmtUsd(pnl)}</td>
<td>${b.status || '—'}</td>
<td class="updated">${fmtTs(b.settled)}</td>
</tr>
`}).join('');
}
// 跟单钱包
const wDiv = document.getElementById('wallets');
const ws = d.wallets || [];
document.getElementById('wallet-count').textContent = '(' + ws.length + ')';
const floors = d.floors || {};
const classes = d.classes || {};
wDiv.innerHTML = ws.map(w => {
const f = floors[w];
const c = classes[w] || 'volume';
return `<span class="wallet-badge">${w} · ${c} · ${f ? '门槛 $'+Math.round(f) : '无门槛'}</span>`;
}).join('') || '<span class="empty">未配置跟单钱包</span>';
// 底部
document.getElementById('mode').textContent = d.mode || '—';
document.getElementById('bankroll').textContent =
d.bankroll ? d.bankroll.toLocaleString('en-US') : '—';
document.getElementById('stake').textContent = d.stake ? '$'+d.stake : '—';
document.getElementById('stake-pct').textContent =
d.stake_pct ? (d.stake_pct*100).toFixed(1)+'%' : '—';
}
render();
setInterval(render, 30000);
// ========== 系统健康面板 ==========
async function renderHealth() {
let h;
try {
const r = await fetch('/api/health', {cache: 'no-store'});
h = await r.json();
} catch (e) {
document.getElementById('health-content').innerHTML =
'<span class="error">获取失败: ' + e.message + '</span>';
return;
}
const svcNames = {
'copybot-paper': 'Paper Bot',
'copybot-live': 'Live Bot',
'copybot-dashboard': 'Dashboard',
'mihomo': '代理'
};
let html = '';
for (const [svc, label] of Object.entries(svcNames)) {
const state = h.services?.[svc] || 'unknown';
const color = state === 'active' ? 'var(--green)' : 'var(--red)';
html += `<span class="wallet-badge" style="background:${color === 'var(--green)' ? '#1a3322' : '#3a1a1a'};color:${color}">${label}: ${state}</span>`;
}
// 代理
const proxyOk = h.proxy === 'ok';
html += `<span class="wallet-badge" style="background:${proxyOk ? '#1a3322' : '#3a1a1a'};color:${proxyOk ? 'var(--green)' : 'var(--red)'}">代理: ${h.proxy || '?'}</span>`;
// CASH≠CHAIN
if (h.cash_drift !== null && h.cash_drift !== undefined) {
const driftOk = Math.abs(h.cash_drift) < 0.5;
html += `<span class="wallet-badge" style="background:${driftOk ? '#1a3322' : '#3a1a1a'};color:${driftOk ? 'var(--green)' : 'var(--red)'}">CASH≠CHAIN: ${driftOk ? '正常' : '$' + h.cash_drift}</span>`;
}
// 心跳
if (h.last_heartbeat) {
const ageMin = Math.floor((h.heartbeat_age_s || 0) / 60);
const ageSec = (h.heartbeat_age_s || 0) % 60;
const fresh = (h.heartbeat_age_s || 999) < 300;
html += `<span class="wallet-badge" style="background:${fresh ? '#1a3322' : '#3a1a1a'};color:${fresh ? 'var(--green)' : 'var(--red)'}">心跳: ${ageMin}${ageSec}秒前</span>`;
}
document.getElementById('health-content').innerHTML = html;
}
// ========== 每日流水线面板 ==========
async function renderDaily() {
let d;
try {
const r = await fetch('/api/daily', {cache: 'no-store'});
d = await r.json();
} catch (e) {
document.getElementById('daily-content').innerHTML =
'<span class="error">获取失败</span>';
return;
}
const ok = d.status === 'ok';
const color = ok ? 'var(--green)' : 'var(--red)';
let html = `<span class="wallet-badge" style="background:${ok ? '#1a3322' : '#3a1a1a'};color:${color}">${d.status === 'ok' ? '✓ 正常' : d.status}</span>`;
if (d.last_run) {
html += ` <span style="color:var(--dim)">上次运行: ${d.last_run}</span>`;
}
html += ` <span style="color:var(--dim)">· 钱包池: ${d.skilled_count} skilled / ${d.sharps_count} sharps</span>`;
document.getElementById('daily-content').innerHTML = html;
}
// ========== 钱包建议面板 ==========
async function renderWallets() {
let w;
try {
const r = await fetch('/api/wallets', {cache: 'no-store'});
w = await r.json();
} catch (e) {
document.getElementById('wallets-content').innerHTML =
'<span class="error">获取失败</span>';
return;
}
let html = '<table style="width:100%"><thead><tr>' +
'<th>当前跟的</th><th>排名</th><th>copy_pnl</th><th>前瞻30d</th><th>信念胜率</th><th>状态</th></tr></thead><tbody>';
for (const f of (w.followed || [])) {
let statusBadge, statusColor;
if (f.status === 'ok') {
statusBadge = '✅ 正常';
statusColor = 'var(--green)';
} else {
statusBadge = '❌ 已淘汰';
statusColor = 'var(--red)';
}
const fwdStr = f.fwd_realized != null
? (f.fwd_realized >= 0 ? '+$' + f.fwd_realized.toFixed(0) : '-$' + Math.abs(f.fwd_realized).toFixed(0))
: '?';
const fwdColor = f.fwd_realized != null
? (f.fwd_realized >= 0 ? 'var(--green)' : 'var(--red)')
: 'var(--dim)';
html += `<tr>
<td>${f.name}</td>
<td>${f.rank ? '#' + f.rank : '-'}</td>
<td>${f.copy_pnl != null ? '+' + f.copy_pnl : '-'}</td>
<td style="color:${fwdColor}">${fwdStr}</td>
<td>${f.conv_win != null ? f.conv_win + '%' : '-'}</td>
<td style="color:${statusColor}">${statusBadge}</td>
</tr>`;
}
html += '</tbody></table>';
// 建议加的(前瞻盈利的)
const suggestions = w.suggestions || [];
if (suggestions.length > 0) {
html += '<div style="margin-top:10px;color:var(--dim);font-size:12px">建议加(前瞻盈利 + copy_pnl 前 8 未跟):</div>';
html += '<div style="margin-top:4px">';
for (const s of suggestions) {
const fwdStr = s.fwd_realized != null ? '+$' + s.fwd_realized.toFixed(0) : '?';
html += `<span class="wallet-badge" style="background:#1a2438;color:var(--blue)">⬆️ ${s.name} · #${s.rank} · copy_pnl+${s.copy_pnl} · 前瞻${fwdStr}${s.conv_win != null ? ' · ' + s.conv_win + '%' : ''}</span>`;
}
html += '</div>';
} else {
html += '<div style="margin-top:10px;color:var(--green);font-size:12px">无前瞻盈利的未跟钱包,无需调整 ✓</div>';
}
// 警告:前瞻亏损的(copy_pnl 高但前瞻亏)
const warnings = w.warnings || [];
if (warnings.length > 0) {
html += '<div style="margin-top:8px;color:var(--dim);font-size:12px">⚠️ 不推荐(copy_pnl 高但前瞻亏损):</div>';
html += '<div style="margin-top:4px">';
for (const s of warnings) {
const fwdStr = s.fwd_realized != null ? '-$' + Math.abs(s.fwd_realized).toFixed(0) : '?';
html += `<span class="wallet-badge" style="background:#3a1a1a;color:var(--red)">❌ ${s.name} · #${s.rank} · copy_pnl+${s.copy_pnl} · 前瞻-${fwdStr}</span>`;
}
html += '</div>';
}
document.getElementById('wallets-content').innerHTML = html;
}
// 健康面板 60 秒刷新(比交易数据慢,系统状态变化少)
renderHealth();
renderDaily();
renderWallets();
setInterval(renderHealth, 60000);
setInterval(renderDaily, 60000);
setInterval(renderWallets, 60000);
</script>
</body>
</html>