367c4724e8
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.
420 lines
17 KiB
HTML
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,'"')}">${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,'"')}">${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> |