/* ============================================================ Exchange Monitor Dashboard — Frontend Logic ============================================================ */ (function() { 'use strict'; // ---- DOM refs ---- const $ = id => document.getElementById(id); const els = { clock: $('clock'), connStatus: $('conn-status'), 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'), }; // ---- Clock ---- function updateClock() { const now = new Date(); els.clock.textContent = now.toLocaleTimeString('zh-CN', { hour12: false }); } setInterval(updateClock, 1000); updateClock(); // ---- Price table helpers ---- 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(lastPrice, currentPrice) { if (lastPrice == null || currentPrice == null) return ''; if (currentPrice > lastPrice) return 'text-green'; if (currentPrice < lastPrice) return 'text-red'; return ''; } // ---- Price history for chart ---- const priceCache = {}; // coin.exchange -> { last: float, points: [{t,p}] } // ---- 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; // Build table rows 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) : ''; // Store for directional arrows next time if (prev) { prev.last = curP; } else { priceCache[key] = { last: curP, points: [] }; } // Record for chart if (p > 0) { const pt = { t: Date.now(), p: p }; if (!priceCache[key]) priceCache[key] = { last: p, points: [] }; priceCache[key].points.push(pt); 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}`; }); html += `${coin}${cells.join('')}`; } els.priceBody.innerHTML = html; // Update coin selector if needed 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; }; eventHandlers.positions = (positions) => { if (!positions || positions.length === 0) { els.posBody.innerHTML = '无持仓'; return; } const html = positions.map(p => ` ${p.coin} ${p.direction} $${p.amount_usd.toFixed(0)} ${p.entry_spread.toFixed(4)}% ${p.scales} ${p.duration} `).join(''); els.posBody.innerHTML = html; }; 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; }; // ---- Chart ---- let chart = null; function initChart() { const ctx = els.chartCanvas.getContext('2d'); chart = 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) => { if (items.length > 0) { const d = new Date(items[0].parsed.x); return d.toLocaleTimeString('zh-CN', { hour12: false }); } return ''; }, label: (item) => item.parsed.y.toFixed(4), } } }, scales: { x: { type: 'linear', display: true, ticks: { color: '#8b949e', maxTicksLimit: 10, callback: (val) => { const d = new Date(val); return d.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); } }, grid: { color: 'rgba(48, 54, 61, 0.5)' }, }, y: { display: true, ticks: { color: '#8b949e', callback: (val) => val.toFixed(4), }, grid: { color: 'rgba(48, 54, 61, 0.3)' }, } } } }); } function updateChartSelectors(prices) { const coinSel = els.chartCoin; const exSel = els.chartExch; // Populate coins if empty if (coinSel.options.length <= 1) { const currentCoin = 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); } // Try to restore selection if (currentCoin) { coinSel.value = currentCoin; } else if (prices.length > 0) { coinSel.value = prices[0].coin; } } // Populate exchanges if empty 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'; } // Update chart when selections change const selectedCoin = coinSel.value; const selectedEx = exSel.value; if (selectedCoin && selectedEx) { updateChart(selectedCoin, selectedEx); } } function updateChart(coin, exchange) { const key = coin + '.' + exchange; const cache = priceCache[key]; if (!cache || !cache.points || cache.points.length < 2) { if (chart) { chart.data.datasets[0].data = []; chart.data.datasets[0].label = `${coin} @ ${exchange}`; chart.update('none'); } return; } const pts = cache.points; const data = pts.map(p => ({ x: p.t, y: p.p })); if (chart) { chart.data.datasets[0].data = data; chart.data.datasets[0].label = `${coin} @ ${exchange}`; chart.update('none'); } } // ---- Chart controls ---- els.chartCoin.addEventListener('change', () => { const coin = els.chartCoin.value; const ex = els.chartExch.value; if (coin && ex) updateChart(coin, ex); }); els.chartExch.addEventListener('change', () => { const coin = els.chartCoin.value; const ex = els.chartExch.value; if (coin && ex) updateChart(coin, ex); }); // ---- Chart auto-refresh ---- let chartRefreshTimer = null; let chartRefreshInterval = 2000; // refresh chart every 2s function startChartRefresh() { if (chartRefreshTimer) return; chartRefreshTimer = setInterval(() => { const coin = els.chartCoin.value; const ex = els.chartExch.value; if (coin && ex) updateChart(coin, ex); }, chartRefreshInterval); } // ---- Trades loading ---- 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 ? t.EntrySpread.toFixed(4) : '-'} ${t.ExitSpread ? 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 = '加载失败'; } } // ---- Init ---- function init() { connectSSE(); initChart(); startChartRefresh(); loadTrades(); // Refresh trades every 10s setInterval(loadTrades, 10000); } // Start when DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();