- dashboard.go: SSE hub + HTTP server + price history ring buffer
- static.go: //go:embed for static files
- web/static/index.html: Full dashboard HTML (6 panels)
- web/static/app.js: SSE client, Chart.js price chart, live table updates
- web/static/style.css: GitHub-style dark theme
- main.go: Start dashboard on :8888 + wire price recording + scan results
Dashboard features:
- Real-time price table (6 coins × 4 exchanges)
- Arbitrage opportunities table
- Open positions view
- Historical trades table (from SQLite)
- Chart.js price chart with coin/exchange selector
- Stats summary (total/converged/diverged/flat)
- 🚫 Zero external Go dependencies (Chart.js loaded from CDN)
406 lines
11 KiB
JavaScript
406 lines
11 KiB
JavaScript
/* ============================================================
|
|
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 += `<tr><td>${coin}</td>${EXCHANGES.map(() => '<td class="text-dim">-</td>').join('')}</tr>`;
|
|
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 += `<span class="text-dim" style="font-size:10px"> (${sp.toFixed(3)}%)</span>`;
|
|
}
|
|
return `<td class="${cls}">${display}</td>`;
|
|
});
|
|
|
|
html += `<tr><td><strong>${coin}</strong></td>${cells.join('')}</tr>`;
|
|
}
|
|
|
|
els.priceBody.innerHTML = html;
|
|
|
|
// Update coin selector if needed
|
|
updateChartSelectors(prices);
|
|
};
|
|
|
|
eventHandlers.arb = (opps) => {
|
|
if (!opps || opps.length === 0) {
|
|
els.arbBody.innerHTML = '<tr><td colspan="5" class="text-dim">暂无套利机会</td></tr>';
|
|
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 `<tr>
|
|
<td>${opp.coin}</td>
|
|
<td>${opp.direction}</td>
|
|
<td class="text-right">${formatPrice(opp.buy_price)}</td>
|
|
<td class="text-right">${formatPrice(opp.sell_price)}</td>
|
|
<td class="text-right ${cls}"><strong>${opp.net_profit.toFixed(4)}</strong></td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
els.arbBody.innerHTML = html;
|
|
};
|
|
|
|
eventHandlers.positions = (positions) => {
|
|
if (!positions || positions.length === 0) {
|
|
els.posBody.innerHTML = '<tr><td colspan="6" class="text-dim">无持仓</td></tr>';
|
|
return;
|
|
}
|
|
|
|
const html = positions.map(p => `<tr>
|
|
<td><strong>${p.coin}</strong></td>
|
|
<td>${p.direction}</td>
|
|
<td class="text-right">$${p.amount_usd.toFixed(0)}</td>
|
|
<td class="text-right">${p.entry_spread.toFixed(4)}%</td>
|
|
<td class="text-right">${p.scales}</td>
|
|
<td>${p.duration}</td>
|
|
</tr>`).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 = '<option value="">-- 选择币种 --</option>';
|
|
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 = '<option value="">-- 选择交易所 --</option>';
|
|
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 = '<tr><td colspan="8" class="text-dim">暂无交易记录</td></tr>';
|
|
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 `<tr>
|
|
<td class="text-dim">${t.ClosedAt ? new Date(t.ClosedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '-'}</td>
|
|
<td><strong>${t.Coin}</strong></td>
|
|
<td>${t.Direction}</td>
|
|
<td class="text-right">${t.EntrySpread ? t.EntrySpread.toFixed(4) : '-'}</td>
|
|
<td class="text-right">${t.ExitSpread ? t.ExitSpread.toFixed(4) : '-'}</td>
|
|
<td class="text-right ${pnlCls}"><strong>${t.NetPnl != null ? t.NetPnl.toFixed(4) : '-'}</strong></td>
|
|
<td class="${convCls}">${t.Convergence || '-'}</td>
|
|
<td>${t.ExitReason || '-'}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
els.tradesBody.innerHTML = html;
|
|
} catch (err) {
|
|
els.tradesBody.innerHTML = '<tr><td colspan="8" class="text-red">加载失败</td></tr>';
|
|
}
|
|
}
|
|
|
|
// ---- 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();
|
|
}
|
|
|
|
})();
|