/* ============================================================
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})
| 类型 | 方向 | 交易所 | 价格 | 数量 | 手续费 | 订单ID |
${ordersHtml}
`;
}
})
.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();
}
})();