/* Copyright (c) 2025-2026 马建军. All rights reserved. * 详见 LICENSE.zh-CN.txt */ (function () { 'use strict'; var posBody = document.getElementById('dash-positions-body'); var keysBody = document.getElementById('dash-keys-body'); var closesBody = document.getElementById('dash-closes-body'); var equityEl = document.getElementById('dash-equity'); var marginEl = document.getElementById('dash-margin'); var availEl = document.getElementById('dash-available'); var ctpBadge = document.getElementById('dash-ctp-badge'); var modeBadge = document.getElementById('dash-mode-badge'); var updatedEl = document.getElementById('dash-updated'); var pollTimer = null; var positionSource = null; var posRowCache = {}; var lastKeyIds = ''; var lastCloseHeadId = null; function fmtNum(v, digits) { if (v === null || v === undefined || v === '') return '—'; var n = Number(v); if (isNaN(n)) return '—'; if (digits != null) return n.toFixed(digits); var s = n.toFixed(2); return s.replace(/\.?0+$/, function (m) { return m === '.00' ? '.00' : ''; }); } function fmtMoney(v) { if (v === null || v === undefined) return '—'; return fmtNum(v, 2) + ' 元'; } function fmtPnl(v) { if (v === null || v === undefined) return '—'; var n = Number(v); if (isNaN(n)) return '—'; return (n >= 0 ? '+' : '') + fmtNum(n, 2) + ' 元'; } function pnlClass(v) { if (v === null || v === undefined) return ''; var n = Number(v); if (isNaN(n) || n === 0) return ''; return n > 0 ? 'pnl-pos' : 'pnl-neg'; } function escHtml(s) { return String(s || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function slTpText(row) { var parts = []; if (row.stop_loss != null) parts.push('SL ' + fmtNum(row.stop_loss)); if (row.take_profit != null) parts.push('TP ' + fmtNum(row.take_profit)); if (row.trailing_be) parts.push('移动保本'); return parts.length ? parts.join(' · ') : '—'; } function updateCtpBadge(st) { if (!ctpBadge || !st) return; var connected = !!st.connected; var connecting = !!st.connecting && !(st.login_cooldown_sec > 0); ctpBadge.className = 'badge ' + (connected ? 'profit' : (connecting ? 'planned' : 'loss')); if (connected) { ctpBadge.textContent = 'CTP 已连接'; } else if (connecting) { ctpBadge.textContent = 'CTP 连接中…'; } else if (st.disabled_hint) { ctpBadge.textContent = 'CTP 自动连接已关闭'; } else if (st.last_error) { ctpBadge.textContent = 'CTP 未连接'; ctpBadge.title = st.last_error; } else { ctpBadge.textContent = 'CTP 未连接'; ctpBadge.title = ''; } } function applyAccount(data) { if (!data) return; var acc = data.account || {}; var st = data.ctp_status || {}; updateCtpBadge(st); if (modeBadge && data.trading_mode_label) { modeBadge.textContent = data.trading_mode_label; modeBadge.className = 'badge dir'; } if (updatedEl && data.updated_at) { updatedEl.textContent = '更新 ' + data.updated_at; } if (equityEl) { equityEl.textContent = fmtMoney(acc.equity != null ? acc.equity : acc.capital_fallback); } if (marginEl) { marginEl.textContent = acc.margin_used != null ? fmtMoney(acc.margin_used) : '—'; } if (availEl) { availEl.textContent = acc.available != null ? fmtMoney(acc.available) : '—'; } } function renderKeys(keys) { if (!keysBody) return; if (!keys || !keys.length) { keysBody.innerHTML = '暂无关键位监控'; return; } keysBody.innerHTML = keys.map(function (k) { return ( '' + '' + escHtml(k.symbol_name || k.symbol) + '' + '' + escHtml(k.monitor_type || '—') + '' + '' + escHtml(k.bar_period || '—') + '' + '' + fmtNum(k.upper) + '' + '' + fmtNum(k.lower) + '' + '' + (k.price != null ? fmtNum(k.price) : '—') + '' + '' + (k.dist_upper != null ? fmtNum(k.dist_upper) : '—') + '' + '' + (k.dist_lower != null ? fmtNum(k.dist_lower) : '—') + '' + '' ); }).join(''); } function patchKeys(keys) { if (!keysBody || !keys) return; keys.forEach(function (k) { var row = keysBody.querySelector('tr[data-key-id="' + k.id + '"]'); if (!row) return; var priceEl = row.querySelector('.dash-k-price'); var upEl = row.querySelector('.dash-k-dist-up'); var downEl = row.querySelector('.dash-k-dist-down'); if (priceEl) priceEl.textContent = k.price != null ? fmtNum(k.price) : '—'; if (upEl) upEl.textContent = k.dist_upper != null ? fmtNum(k.dist_upper) : '—'; if (downEl) downEl.textContent = k.dist_lower != null ? fmtNum(k.dist_lower) : '—'; }); } function renderCloses(closes) { if (!closesBody) return; if (!closes || !closes.length) { closesBody.innerHTML = '暂无平仓记录'; return; } closesBody.innerHTML = closes.map(function (c) { var pc = pnlClass(c.pnl_net != null ? c.pnl_net : c.pnl); return ( '' + '' + escHtml(c.symbol) + '' + '' + escHtml(c.direction_label || c.direction) + '' + '' + fmtNum(c.lots) + '' + '' + fmtNum(c.entry_price) + '' + '' + fmtNum(c.close_price) + '' + '' + fmtPnl(c.pnl) + '' + '' + fmtPnl(c.pnl_net) + '' + '' + escHtml(c.close_time || '—') + '' + '' ); }).join(''); } function applyKeys(keys) { if (!keysBody) return; var ids = (keys || []).map(function (k) { return String(k.id); }).join(','); if (!ids) { keysBody.innerHTML = '暂无关键位监控'; lastKeyIds = ''; return; } if (ids !== lastKeyIds) { lastKeyIds = ids; renderKeys(keys); } else { patchKeys(keys); } } function applyCloses(closes) { if (!closesBody) return; if (!closes || !closes.length) { closesBody.innerHTML = '暂无平仓记录'; lastCloseHeadId = null; return; } var headId = closes[0].id; if (headId !== lastCloseHeadId) { lastCloseHeadId = headId; renderCloses(closes); } } function positionRows(data) { return (data.rows || []).filter(function (r) { return r.order_state !== 'pending' && (r.lots || 0) > 0; }); } function renderPositions(rows) { if (!posBody) return; if (!rows.length) { posBody.innerHTML = '暂无持仓'; posRowCache = {}; return; } posRowCache = {}; posBody.innerHTML = rows.map(function (r) { var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || '')); posRowCache[key] = true; var name = r.symbol || r.symbol_name || r.symbol_code || '—'; return ( '' + '' + escHtml(name) + '' + '' + escHtml(r.direction_label || r.direction) + '' + '' + escHtml(String(r.lots)) + '' + '' + fmtNum(r.entry_price) + '' + '' + (r.current_price != null ? fmtNum(r.current_price) : '—') + '' + '' + fmtPnl(r.float_pnl) + '' + '' + (r.margin != null ? fmtMoney(r.margin) : '—') + '' + '' + escHtml(slTpText(r)) + '' + '' ); }).join(''); } function findPosRow(key) { if (!posBody || !key) return null; var rows = posBody.querySelectorAll('tr[data-pos-key]'); for (var i = 0; i < rows.length; i++) { if (rows[i].getAttribute('data-pos-key') === key) return rows[i]; } return null; } function patchPositionQuotes(quotes) { if (!quotes) return; quotes.forEach(function (q) { var row = findPosRow(q.key || q.position_key); if (!row) return; var markEl = row.querySelector('.dash-p-mark'); var pnlEl = row.querySelector('.dash-p-pnl'); if (markEl && q.mark_price != null) markEl.textContent = fmtNum(q.mark_price); if (pnlEl && q.float_pnl != null) { pnlEl.textContent = fmtPnl(q.float_pnl); pnlEl.className = 'dash-p-pnl ' + pnlClass(q.float_pnl); } }); } function applyPositionsData(data) { if (!data) return; if (data.ctp_status) updateCtpBadge(data.ctp_status); if (data.trading_mode_label && modeBadge) { modeBadge.textContent = data.trading_mode_label; } if (equityEl && data.capital != null) { equityEl.textContent = fmtMoney(data.capital); } var rows = positionRows(data); var keys = rows.map(function (r) { return r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || '')); }).sort().join('|'); var cachedKeys = Object.keys(posRowCache).sort().join('|'); if (keys !== cachedKeys) { renderPositions(rows); } else { rows.forEach(function (r) { var key = r.key || r.position_key; var row = findPosRow(key); if (!row) return; var lotsEl = row.querySelector('.dash-p-lots'); var entryEl = row.querySelector('.dash-p-entry'); var markEl = row.querySelector('.dash-p-mark'); var pnlEl = row.querySelector('.dash-p-pnl'); var marginEl = row.querySelector('.dash-p-margin'); var sltpEl = row.querySelector('.dash-p-sltp'); if (lotsEl) lotsEl.textContent = String(r.lots); if (entryEl) entryEl.textContent = fmtNum(r.entry_price); if (markEl) markEl.textContent = r.current_price != null ? fmtNum(r.current_price) : '—'; if (pnlEl) { pnlEl.textContent = fmtPnl(r.float_pnl); pnlEl.className = 'dash-p-pnl ' + pnlClass(r.float_pnl); } if (marginEl) marginEl.textContent = r.margin != null ? fmtMoney(r.margin) : '—'; if (sltpEl) sltpEl.textContent = slTpText(r); }); } } function pollDashboard() { fetch('/api/dashboard/live') .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (data) { if (!data.ok) return; applyAccount(data); applyKeys(data.keys || []); applyCloses(data.closes || []); }) .catch(function () { if (updatedEl) updatedEl.textContent = '看板数据加载失败'; }); } function connectPositionStream() { if (positionSource) { positionSource.close(); positionSource = null; } positionSource = new EventSource('/api/trading/stream'); positionSource.addEventListener('positions', function (ev) { try { applyPositionsData(JSON.parse(ev.data)); } catch (e) { /* ignore */ } }); positionSource.addEventListener('position_quotes', function (ev) { try { var data = JSON.parse(ev.data); patchPositionQuotes(data.quotes || []); } catch (e) { /* ignore */ } }); positionSource.onerror = function () { if (positionSource) { positionSource.close(); positionSource = null; } setTimeout(connectPositionStream, 3000); }; } function startPolling() { if (pollTimer) clearInterval(pollTimer); pollDashboard(); pollTimer = setInterval(pollDashboard, 1000); } function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (positionSource) { positionSource.close(); positionSource = null; } } document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') { startPolling(); if (!positionSource) connectPositionStream(); } else { stopPolling(); } }); startPolling(); connectPositionStream(); })();