/* ============================================================ Exchange Monitor Dashboard — Frontend Logic v3 (P3) ============================================================ */ (function() { 'use strict'; // ---- DOM refs ---- const $ = id => document.getElementById(id); const els = { clock: $('clock'), connStatus: $('conn-status'), connDetail: $('conn-detail'), pricesAge: $('prices-age'), priceBody: $('price-body'), arbBody: $('arb-body'), posBody: $('positions-body'), tradesBody: $('trades-body'), statTotal: $('stat-total'), statConv: $('stat-converged'), statDiv: $('stat-diverged'), statFlat: $('stat-flat'), statPos: $('stat-positions'), statCoins: $('stat-coins'), }; // ---- Clock ---- function updateClock() { const now = new Date(); els.clock.textContent = now.toLocaleTimeString('zh-CN', { hour12: false }); } setInterval(updateClock, 1000); updateClock(); const EXCHANGES = ['HyperLiquid', 'Bitget']; const COINS = []; // populated dynamically from SSE data function formatPrice(p) { if (p == null || p <= 0) return '-'; if (p >= 100) return p.toFixed(2); if (p >= 1) return p.toFixed(4); return p.toFixed(6); } function priceClass(last, cur) { if (last == null || cur == null) return ''; return cur > last ? 'text-green' : cur < last ? 'text-red' : ''; } function pnlClass(val) { if (val == null) return ''; return val > 0 ? 'text-green' : val < 0 ? 'text-red' : ''; } const priceCache = {}; // ---- SSE Connection ---- let eventSource = null; function connectSSE() { if (eventSource) eventSource.close(); eventSource = new EventSource('/events'); eventSource.addEventListener('connected', () => { els.connStatus.textContent = '● 已连接'; els.connStatus.className = 'status-online'; }); eventSource.onerror = () => { els.connStatus.textContent = '● 已断开 (重连中...)'; els.connStatus.className = 'status-offline'; setTimeout(connectSSE, 3000); }; eventSource.onmessage = (e) => { try { const msg = JSON.parse(e.data); const handler = eventHandlers[msg.event]; if (handler) handler(msg.data); } catch(err) { // ignore parse errors } }; } // ---- Event Handlers ---- const eventHandlers = {}; eventHandlers.prices = (prices) => { if (!prices || prices.length === 0) return; // Dynamically populate COINS list on first data if (COINS.length === 0) { for (const row of prices) { COINS.push(row.coin); } } let html = ''; let coinsOnline = 0; for (const coin of COINS) { const row = prices.find(p => p.coin === coin); if (!row) { html += `${coin}${EXCHANGES.map(() => '-').join('')}-`; continue; } coinsOnline++; const cells = EXCHANGES.map(ex => { const p = row[ex]; const key = coin + '.' + ex; const prev = priceCache[key]; const curP = p || 0; const cls = prev ? priceClass(prev.last, curP) : ''; if (prev) { prev.last = curP; } else { priceCache[key] = { last: curP }; } let display = formatPrice(p); return `${display}`; }); // P3-2: Add spread column const spread = row['bg_hl_spread']; const spreadCls = spread > 0.2 ? 'text-green' : spread < -0.2 ? 'text-red' : ''; const spreadStr = spread != null ? spread.toFixed(4) + '%' : '-'; html += `${coin}${cells.join('')}${spreadStr}`; } els.priceBody.innerHTML = html; els.pricesAge.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); }; eventHandlers.arb = (opps) => { if (!opps || opps.length === 0) { els.arbBody.innerHTML = '暂无套利机会'; return; } const html = opps.slice(0, 10).map(opp => { const cls = opp.net_profit > 0.10 ? 'text-green' : opp.net_profit > 0.05 ? 'text-yellow' : ''; return ` ${opp.coin} ${opp.direction} ${formatPrice(opp.buy_price)} ${formatPrice(opp.sell_price)} ${opp.net_profit.toFixed(4)} `; }).join(''); els.arbBody.innerHTML = html; }; // P3-3: Positions with live PnL — sorted by time (oldest first) eventHandlers.positions = (positions) => { if (!positions || positions.length === 0) { els.posBody.innerHTML = '无持仓'; return; } // Sort by coin name (stable, deterministic) const sorted = [...positions].sort((a, b) => a.coin.localeCompare(b.coin)); const html = sorted.map(p => { const pnl = p.pnl_est; const pnlStr = pnl != null ? pnl.toFixed(4) + '%' : '-'; const curSpread = p.current_spread != null ? p.current_spread.toFixed(4) + '%' : '-'; return ` ${p.coin} ${p.direction} $${p.amount_usd.toFixed(0)} ${p.entry_spread.toFixed(4)}% ${curSpread} ${pnlStr} ${p.scales} ${p.duration} `; }).join(''); els.posBody.innerHTML = html; }; // P3-5: Connection status in stats eventHandlers.stats = (stats) => { els.statTotal.textContent = stats.total_trades || 0; els.statConv.textContent = stats.converged || 0; els.statDiv.textContent = stats.diverged || 0; els.statFlat.textContent = stats.flat || 0; els.statPos.textContent = stats.open_positions || 0; els.statCoins.textContent = stats.coins || 0; // Detailed PnL stats if (stats.detail) { const d = stats.detail; // Total PnL: show both USD and percentage of capital const usdStr = (d.total_pnl_usd != null) ? '$' + d.total_pnl_usd.toFixed(2) : '—'; const pctStr = (d.capital_pnl != null) ? d.capital_pnl.toFixed(4) + '%' : '—'; $('stat-total-pnl').textContent = usdStr + ' (' + pctStr + ')'; $('stat-total-pnl').className = pnlClass(d.capital_pnl); $('stat-capital').textContent = (stats.capital != null) ? '$' + stats.capital.toFixed(0) : '—'; $('stat-win-rate').textContent = (d.win_rate != null) ? d.win_rate.toFixed(1) + '%' : '—'; $('stat-max-profit').textContent = (d.max_profit != null) ? '+' + d.max_profit.toFixed(2) + '%' : '—'; $('stat-max-loss').textContent = (d.max_loss != null) ? d.max_loss.toFixed(2) + '%' : '—'; $('stat-avg-dur').textContent = d.avg_dur || '—'; } // Connection status dots if (stats.connections) { const dots = Object.entries(stats.connections).map(([ex, status]) => { const color = status === 'online' ? '#3fb950' : status === 'stale' ? '#d29922' : '#f85149'; return ` ${ex}`; }).join(' '); els.connDetail.innerHTML = dots; } // Blacklist — stale spread coins if (stats.blacklist && stats.blacklist.length > 0) { const html = stats.blacklist.map(b => { const minLeft = Math.floor(b.remaining_sec / 60); const secLeft = b.remaining_sec % 60; return `⛔ ${b.coin} (${b.since} 剩余 ${minLeft}:${secLeft.toString().padStart(2,'0')})`; }).join(''); $('bl-body').innerHTML = html; } else { $('bl-body').innerHTML = '暂无'; } }; // P3-4: Real-time trade events eventHandlers.trade_open = (trade) => { // Flash the positions card to draw attention const card = $('positions-card'); card.style.transition = 'border-color 0.3s'; card.style.borderColor = '#3fb950'; setTimeout(() => { card.style.borderColor = ''; }, 2000); // Refresh trades table setTimeout(loadTrades, 500); }; eventHandlers.trade_close = (trade) => { const card = $('trades-card'); card.style.transition = 'border-color 0.3s'; card.style.borderColor = trade.pnl_pct > 0 ? '#3fb950' : '#f85149'; setTimeout(() => { card.style.borderColor = ''; }, 2000); setTimeout(loadTrades, 500); }; // ---- Trades from API ---- async function loadTrades() { try { const resp = await fetch('/api/trades'); const data = await resp.json(); const trades = data.trades || []; if (trades.length === 0) { els.tradesBody.innerHTML = '暂无交易记录'; return; } const html = trades.slice(0, 20).map(t => { const pnlCls = t.NetPnl > 0 ? 'text-green' : t.NetPnl < 0 ? 'text-red' : ''; const convCls = t.Convergence === '价差收敛' ? 'text-green' : t.Convergence === '价差发散' ? 'text-red' : 'text-yellow'; return ` ${t.ClosedAt ? new Date(t.ClosedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '-'} ${t.Coin} ${t.Direction} ${t.EntrySpread != null ? t.EntrySpread.toFixed(4) : '-'} ${t.ExitSpread != null ? t.ExitSpread.toFixed(4) : '-'} ${t.NetPnl != null ? t.NetPnl.toFixed(4) : '-'} ${t.Convergence || '-'} ${t.ExitReason || '-'} `; }).join(''); els.tradesBody.innerHTML = html; } catch (err) { els.tradesBody.innerHTML = '加载失败'; } } // ---- Trade Detail Modal ---- function openTradeDetail(id) { const modal = document.getElementById('trade-modal'); const body = document.getElementById('trade-detail-body'); modal.style.display = 'flex'; body.innerHTML = '
加载中...
'; fetch('/api/trade/' + id) .then(r => r.json()) .then(data => { const t = data.trade; if (!t || !t.ID) { body.innerHTML = '
交易数据加载失败
'; return; } const opened = new Date(t.OpenedAt); const closed = t.ClosedAt ? new Date(t.ClosedAt) : null; const dur = closed ? Math.round((closed - opened) / 1000) + 's' : '-'; const pnlCls = t.NetPnl > 0 ? 'text-green' : t.NetPnl < 0 ? 'text-red' : ''; const feeEntry = t.FeeEntry != null ? t.FeeEntry.toFixed(3) + '%' : '-'; const feeExit = t.FeeExit != null ? t.FeeExit.toFixed(3) + '%' : '-'; const totalFee = t.FeeEntry != null && t.FeeExit != null ? (t.FeeEntry + t.FeeExit).toFixed(3) + '%' : '-'; const le = t.LongEntry != null ? t.LongEntry.toFixed(6) : '-'; const lx = t.LongExit != null ? t.LongExit.toFixed(6) : '-'; const se = t.ShortEntry != null ? t.ShortEntry.toFixed(6) : '-'; const sx = t.ShortExit != null ? t.ShortExit.toFixed(6) : '-'; const lpnl = t.LongPnl != null ? t.LongPnl.toFixed(4) + '%' : '-'; const spnl = t.ShortPnl != null ? t.ShortPnl.toFixed(4) + '%' : '-'; body.innerHTML = `

概览

币种${t.Coin}/USDT
方向${t.Direction || '-'}
状态${t.Status === 'closed' ? '已平仓' : t.Status}
加仓次数${t.ScaleCount || 0} 次
总规模$${(t.AmountUSD || 0).toFixed(0)}

时间

开仓${opened.toLocaleString('zh-CN', { hour12: false })}
平仓${closed ? closed.toLocaleString('zh-CN', { hour12: false }) : '-'}
持仓时长${dur}

价差

入场价差${t.EntrySpread != null ? t.EntrySpread.toFixed(4) + '%' : '-'}
出场价差${t.ExitSpread != null ? t.ExitSpread.toFixed(4) + '%' : '-'}
收敛情况${t.Convergence || '-'}
平仓原因${t.ExitReason || '-'}

手续费

开仓费${feeEntry}
平仓费${feeExit}
总手续费${totalFee}

多仓 ${t.LongExchange || '-'}

入场价$${le}
出场价$${lx}
盈亏${lpnl}

空仓 ${t.ShortExchange || '-'}

入场价$${se}
出场价$${sx}
盈亏${spnl}

净收益

总计${t.NetPnl != null ? t.NetPnl.toFixed(4) + '%' : '-'}
`; // Append orders table if available if (data.orders && data.orders.length > 0) { const ordersHtml = data.orders.map(o => { const typeLabel = o.Type === 'entry' ? '开仓' : o.Type === 'exit' ? '平仓' : o.Type === 'scale' ? '加仓' : o.Type; return `${typeLabel}${o.Side === 'buy' ? '买' : '卖'}${o.Exchange}$${o.Price ? o.Price.toFixed(6) : '-'}${o.Size || '-'}${o.Fee ? o.Fee.toFixed(4) + '%' : '-'}${o.OrderID ? o.OrderID.substring(0, 12) + '...' : '-'}`; }).join(''); body.innerHTML += `

订单明细 (${data.orders.length})

${ordersHtml}
类型方向交易所价格数量手续费订单ID
`; } }) .catch(err => { body.innerHTML = '
加载失败: ' + err.message + '
'; }); } function closeTradeDetail() { document.getElementById('trade-modal').style.display = 'none'; } // Close modal on overlay click document.addEventListener('click', function(e) { const modal = document.getElementById('trade-modal'); if (e.target === modal) closeTradeDetail(); }); // Close on Escape document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeTradeDetail(); }); // Expose modal functions to global scope for HTML onclick handlers window.openTradeDetail = openTradeDetail; window.closeTradeDetail = closeTradeDetail; // ---- Init ---- function init() { connectSSE(); loadTrades(); setInterval(loadTrades, 10000); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();