421 lines
17 KiB
JavaScript
421 lines
17 KiB
JavaScript
/* ============================================================
|
|
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 += `<tr><td>${coin}</td>${EXCHANGES.map(() => '<td class="text-dim">-</td>').join('')}<td class="text-dim">-</td></tr>`;
|
|
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 `<td class="${cls}">${display}</td>`;
|
|
});
|
|
|
|
// 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 += `<tr><td><strong>${coin}</strong></td>${cells.join('')}<td class="${spreadCls}">${spreadStr}</td></tr>`;
|
|
}
|
|
|
|
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 = '<tr><td colspan="5" class="text-dim">暂无套利机会</td></tr>';
|
|
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 `<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;
|
|
};
|
|
|
|
// P3-3: Positions with live PnL — sorted by time (oldest first)
|
|
eventHandlers.positions = (positions) => {
|
|
if (!positions || positions.length === 0) {
|
|
els.posBody.innerHTML = '<tr><td colspan="8" class="text-dim">无持仓</td></tr>';
|
|
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 `<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">${curSpread}</td>
|
|
<td class="text-right ${pnlClass(pnl)}"><strong>${pnlStr}</strong></td>
|
|
<td class="text-right">${p.scales}</td>
|
|
<td>${p.duration}</td>
|
|
</tr>`;
|
|
}).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 `<span style="color:${color}">●</span> ${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 `<span style="color:#f85149;margin-right:12px;font-size:13px">⛔ ${b.coin} (${b.since} 剩余 ${minLeft}:${secLeft.toString().padStart(2,'0')})</span>`;
|
|
}).join('');
|
|
$('bl-body').innerHTML = html;
|
|
} else {
|
|
$('bl-body').innerHTML = '<span class="text-dim">暂无</span>';
|
|
}
|
|
};
|
|
|
|
// 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 = '<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 class="trade-row" data-id="${t.ID}" onclick="openTradeDetail(${t.ID})">
|
|
<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 != null ? t.EntrySpread.toFixed(4) : '-'}</td>
|
|
<td class="text-right">${t.ExitSpread != null ? 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>';
|
|
}
|
|
}
|
|
|
|
// ---- 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 = '<div class="loading">加载中...</div>';
|
|
|
|
fetch('/api/trade/' + id)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const t = data.trade;
|
|
if (!t || !t.ID) {
|
|
body.innerHTML = '<div class="loading text-red">交易数据加载失败</div>';
|
|
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 = `<div class="detail-grid">
|
|
<div class="detail-section">
|
|
<h3>概览</h3>
|
|
<div class="detail-row"><span class="label">币种</span><span class="value"><strong>${t.Coin}</strong>/USDT</span></div>
|
|
<div class="detail-row"><span class="label">方向</span><span class="value">${t.Direction || '-'}</span></div>
|
|
<div class="detail-row"><span class="label">状态</span><span class="value">${t.Status === 'closed' ? '已平仓' : t.Status}</span></div>
|
|
<div class="detail-row"><span class="label">加仓次数</span><span class="value">${t.ScaleCount || 0} 次</span></div>
|
|
<div class="detail-row"><span class="label">总规模</span><span class="value">$${(t.AmountUSD || 0).toFixed(0)}</span></div>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h3>时间</h3>
|
|
<div class="detail-row"><span class="label">开仓</span><span class="value">${opened.toLocaleString('zh-CN', { hour12: false })}</span></div>
|
|
<div class="detail-row"><span class="label">平仓</span><span class="value">${closed ? closed.toLocaleString('zh-CN', { hour12: false }) : '-'}</span></div>
|
|
<div class="detail-row"><span class="label">持仓时长</span><span class="value">${dur}</span></div>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h3>价差</h3>
|
|
<div class="detail-row"><span class="label">入场价差</span><span class="value">${t.EntrySpread != null ? t.EntrySpread.toFixed(4) + '%' : '-'}</span></div>
|
|
<div class="detail-row"><span class="label">出场价差</span><span class="value">${t.ExitSpread != null ? t.ExitSpread.toFixed(4) + '%' : '-'}</span></div>
|
|
<div class="detail-row"><span class="label">收敛情况</span><span class="value ${t.Convergence === '价差收敛' ? 'text-green' : t.Convergence === '价差发散' ? 'text-red' : ''}">${t.Convergence || '-'}</span></div>
|
|
<div class="detail-row"><span class="label">平仓原因</span><span class="value">${t.ExitReason || '-'}</span></div>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h3>手续费</h3>
|
|
<div class="detail-row"><span class="label">开仓费</span><span class="value">${feeEntry}</span></div>
|
|
<div class="detail-row"><span class="label">平仓费</span><span class="value">${feeExit}</span></div>
|
|
<div class="detail-row"><span class="label">总手续费</span><span class="value">${totalFee}</span></div>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h3>多仓 ${t.LongExchange || '-'}</h3>
|
|
<div class="detail-row"><span class="label">入场价</span><span class="value">$${le}</span></div>
|
|
<div class="detail-row"><span class="label">出场价</span><span class="value">$${lx}</span></div>
|
|
<div class="detail-row"><span class="label">盈亏</span><span class="value ${t.LongPnl > 0 ? 'text-green' : t.LongPnl < 0 ? 'text-red' : ''}">${lpnl}</span></div>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h3>空仓 ${t.ShortExchange || '-'}</h3>
|
|
<div class="detail-row"><span class="label">入场价</span><span class="value">$${se}</span></div>
|
|
<div class="detail-row"><span class="label">出场价</span><span class="value">$${sx}</span></div>
|
|
<div class="detail-row"><span class="label">盈亏</span><span class="value ${t.ShortPnl > 0 ? 'text-green' : t.ShortPnl < 0 ? 'text-red' : ''}">${spnl}</span></div>
|
|
</div>
|
|
<div class="detail-section detail-section-full">
|
|
<h3>净收益</h3>
|
|
<div class="detail-row" style="font-size:16px"><span class="label">总计</span><span class="value ${pnlCls}" style="font-weight:700">${t.NetPnl != null ? t.NetPnl.toFixed(4) + '%' : '-'}</span></div>
|
|
</div>
|
|
</div>`;
|
|
|
|
// 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 `<tr><td>${typeLabel}</td><td>${o.Side === 'buy' ? '买' : '卖'}</td><td>${o.Exchange}</td><td>$${o.Price ? o.Price.toFixed(6) : '-'}</td><td>${o.Size || '-'}</td><td>${o.Fee ? o.Fee.toFixed(4) + '%' : '-'}</td><td>${o.OrderID ? o.OrderID.substring(0, 12) + '...' : '-'}</td></tr>`;
|
|
}).join('');
|
|
body.innerHTML += `<div class="detail-section detail-section-full" style="border-top:1px solid var(--border)">
|
|
<h3>订单明细 (${data.orders.length})</h3>
|
|
<table class="detail-orders">
|
|
<thead><tr><th>类型</th><th>方向</th><th>交易所</th><th>价格</th><th>数量</th><th>手续费</th><th>订单ID</th></tr></thead>
|
|
<tbody>${ordersHtml}</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
})
|
|
.catch(err => {
|
|
body.innerHTML = '<div class="loading text-red">加载失败: ' + err.message + '</div>';
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
})();
|