/* ============================================================ 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'), chartCoin: $('chart-coin'), chartExch: $('chart-exchange'), chartCanvas: $('priceChart'), spreadCoin: $('spread-coin'), spreadCanvas: $('spreadChart'), }; // ---- Clock ---- function updateClock() { const now = new Date(); els.clock.textContent = now.toLocaleTimeString('zh-CN', { hour12: false }); } setInterval(updateClock, 1000); updateClock(); const EXCHANGES = ['Binance', 'HyperLiquid', 'Bitget', 'dYdX']; const COINS = ['DOGE', 'LINK', 'ONDO', 'OP', 'WIF', 'ARB']; 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' : ''; } // ---- Price cache for chart data ---- 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; 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 sp = row[ex + '_spread']; 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, points: [] }; } if (p > 0) { if (!priceCache[key]) priceCache[key] = { last: p, points: [] }; priceCache[key].points.push({ t: Date.now(), p: p }); if (priceCache[key].points.length > 500) { priceCache[key].points = priceCache[key].points.slice(-500); } } let display = formatPrice(p); if (sp && sp > 0.01) { display += ` (${sp.toFixed(3)}%)`; } return `${display}`; }); // P3-2: Add spread column const spread = row['bg_hl_spread']; const spreadCls = spread > 0.1 ? 'text-green' : spread < -0.1 ? '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 }); updateChartSelectors(prices); }; 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.1 ? '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 eventHandlers.positions = (positions) => { if (!positions || positions.length === 0) { els.posBody.innerHTML = '无持仓'; return; } const html = positions.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; $('stat-total-pnl').textContent = (d.total_pnl != null) ? d.total_pnl.toFixed(2) + '%' : '—'; $('stat-avg-pnl').textContent = (d.avg_pnl != null) ? d.avg_pnl.toFixed(2) + '%' : '—'; $('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; } }; // 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); }; // ---- Price Chart ---- let priceChart = null; function initPriceChart() { const ctx = els.chartCanvas.getContext('2d'); priceChart = new Chart(ctx, { type: 'line', data: { datasets: [{ label: 'Price', data: [], borderColor: '#58a6ff', backgroundColor: 'rgba(88, 166, 255, 0.1)', borderWidth: 2, pointRadius: 0, fill: true, tension: 0.2, }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 0 }, plugins: { legend: { display: false }, tooltip: { mode: 'index', intersect: false, callbacks: { title: (items) => items.length ? new Date(items[0].parsed.x).toLocaleTimeString('zh-CN', { hour12: false }) : '', label: (item) => item.parsed.y.toFixed(4), }, }, }, scales: { x: { type: 'linear', ticks: { color: '#8b949e', maxTicksLimit: 10, callback: (v) => new Date(v).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' }), }, grid: { color: 'rgba(48,54,61,0.5)' }, }, y: { ticks: { color: '#8b949e', callback: (v) => v.toFixed(4) }, grid: { color: 'rgba(48,54,61,0.3)' }, }, }, }, }); } // ---- P3-2: Spread Chart ---- let spreadChart = null; function initSpreadChart() { const ctx = els.spreadCanvas.getContext('2d'); spreadChart = new Chart(ctx, { type: 'line', data: { datasets: [{ label: 'BG↔HL Spread %', data: [], borderColor: '#d29922', backgroundColor: 'rgba(210, 153, 34, 0.1)', borderWidth: 2, pointRadius: 0, fill: true, tension: 0.2, }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 0 }, plugins: { legend: { display: false }, tooltip: { mode: 'index', intersect: false, callbacks: { title: (items) => items.length ? new Date(items[0].parsed.x).toLocaleTimeString('zh-CN', { hour12: false }) : '', label: (item) => item.parsed.y.toFixed(4) + '%', }, }, }, scales: { x: { type: 'linear', ticks: { color: '#8b949e', maxTicksLimit: 10, callback: (v) => new Date(v).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' }), }, grid: { color: 'rgba(48,54,61,0.5)' }, }, y: { ticks: { color: '#8b949e', callback: (v) => v.toFixed(3) + '%' }, grid: { color: 'rgba(48,54,61,0.3)' }, }, }, }, }); } // ---- Chart Selectors ---- function updateChartSelectors(prices) { const coinSel = els.chartCoin; const exSel = els.chartExch; const spreadSel = els.spreadCoin; // Price chart coin selector if (coinSel.options.length <= 1) { const cur = coinSel.value; coinSel.innerHTML = ''; for (const row of prices) { const opt = document.createElement('option'); opt.value = row.coin; opt.textContent = row.coin; coinSel.appendChild(opt); } if (cur) coinSel.value = cur; else if (prices.length > 0) coinSel.value = prices[0].coin; } // Price chart exchange selector if (exSel.options.length <= 1) { exSel.innerHTML = ''; for (const ex of EXCHANGES) { const opt = document.createElement('option'); opt.value = ex; opt.textContent = ex; exSel.appendChild(opt); } exSel.value = 'HyperLiquid'; } // Spread chart coin selector if (spreadSel.options.length <= 1) { const cur = spreadSel.value; spreadSel.innerHTML = ''; for (const row of prices) { const opt = document.createElement('option'); opt.value = row.coin; opt.textContent = row.coin; spreadSel.appendChild(opt); } if (cur) spreadSel.value = cur; else if (prices.length > 0) spreadSel.value = prices[0].coin; } // Update charts on selection change const selCoin = coinSel.value, selEx = exSel.value; if (selCoin && selEx) updatePriceChart(selCoin, selEx); const spCoin = spreadSel.value; if (spCoin) updateSpreadChart(spCoin); } function updatePriceChart(coin, exchange) { const key = coin + '.' + exchange; const cache = priceCache[key]; if (!cache || !cache.points || cache.points.length < 2) { if (priceChart) { priceChart.data.datasets[0].data = []; priceChart.data.datasets[0].label = `${coin} @ ${exchange}`; priceChart.update('none'); } return; } const data = cache.points.map(p => ({ x: p.t, y: p.p })); if (priceChart) { priceChart.data.datasets[0].data = data; priceChart.data.datasets[0].label = `${coin} @ ${exchange}`; priceChart.update('none'); } } async function updateSpreadChart(coin) { try { const resp = await fetch(`/api/spread-history?coin=${coin}`); const data = await resp.json(); const pts = data.points || []; if (pts.length < 2) { if (spreadChart) { spreadChart.data.datasets[0].data = []; spreadChart.data.datasets[0].label = `${coin} BG↔HL`; spreadChart.update('none'); } return; } const chartData = pts.map(p => ({ x: p.t, y: p.s })); if (spreadChart) { spreadChart.data.datasets[0].data = chartData; spreadChart.data.datasets[0].label = `${coin} BG↔HL`; spreadChart.update('none'); } } catch (err) { // ignore } } // ---- Chart controls ---- els.chartCoin.addEventListener('change', () => { const coin = els.chartCoin.value; const ex = els.chartExch.value; if (coin && ex) updatePriceChart(coin, ex); }); els.chartExch.addEventListener('change', () => { const coin = els.chartCoin.value; const ex = els.chartExch.value; if (coin && ex) updatePriceChart(coin, ex); }); els.spreadCoin.addEventListener('change', () => { if (els.spreadCoin.value) updateSpreadChart(els.spreadCoin.value); }); // ---- Auto-refresh charts ---- setInterval(() => { const coin = els.chartCoin.value; const ex = els.chartExch.value; if (coin && ex) updatePriceChart(coin, ex); }, 2000); setInterval(() => { if (els.spreadCoin.value) updateSpreadChart(els.spreadCoin.value); }, 3000); // ---- 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(); // run before charts in case Chart CDN is slow setInterval(loadTrades, 10000); try { initPriceChart(); } catch(e) { console.warn('Price chart init failed:', e); } try { initSpreadChart(); } catch(e) { console.warn('Spread chart init failed:', e); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();