Files
qihuo/static/js/dashboard.js
T
2026-06-29 21:40:53 +08:00

486 lines
20 KiB
JavaScript

/* 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 riskReasonEl = document.getElementById('dash-risk-reason');
var riskGridEl = document.getElementById('dash-risk-grid');
var pollTimer = null;
var positionSource = null;
var posRowCache = {};
var lastKeyIds = '';
var lastCloseHeadId = null;
var lastRiskPayload = 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 symbolCellHtml(row) {
var name = row.symbol_name || row.symbol || '';
var code = row.symbol_code || '';
var mainBadge = row.symbol_is_main
? ' <span class="badge planned dash-main-badge">主力</span>' : '';
var titleInner = escHtml(name) + mainBadge;
if (code && String(name).toLowerCase() !== String(code).toLowerCase()) {
titleInner += ' <span class="text-accent">' + escHtml(code) + '</span>';
} else if (!name && code) {
titleInner = '<span class="text-accent">' + escHtml(code) + '</span>';
}
var sub = row.symbol_exchange || code || '';
return (
'<div class="dash-symbol-cell">' +
'<div class="dash-symbol-title">' + titleInner + '</div>' +
(sub ? '<div class="dash-symbol-sub text-muted">' + escHtml(sub) + '</div>' : '') +
'</div>'
);
}
function directionBadgeHtml(row) {
var label = row.direction_label || (row.direction === 'short' ? '做空' : '做多');
return '<span class="badge dir">' + escHtml(label) + '</span>';
}
function fmtHours(h) {
if (h == null) return '—';
var n = Number(h);
if (isNaN(n)) return '—';
if (Math.abs(n - Math.round(n)) < 0.01) return String(Math.round(n)) + 'h';
return n.toFixed(1) + 'h';
}
function fmtRemainSec(sec) {
if (sec == null || sec <= 0) return '—';
var s = Math.max(0, Math.floor(sec));
var h = Math.floor(s / 3600);
var m = Math.floor((s % 3600) / 60);
if (h > 0) return h + 'h ' + m + 'm';
return m + 'm';
}
function applyRisk(risk, account) {
if (!riskGridEl || !risk) return;
if (risk.limits && Object.keys(risk.limits).length) {
lastRiskPayload = risk;
} else if (lastRiskPayload) {
risk = {
enabled: lastRiskPayload.enabled,
limits: lastRiskPayload.limits,
manual_close_count_today: lastRiskPayload.manual_close_count_today,
margin_pct_used: lastRiskPayload.margin_pct_used,
status: risk.status || lastRiskPayload.status,
};
}
if (account && account.equity > 0 && account.margin_used != null) {
risk.margin_pct_used = Math.round(account.margin_used / account.equity * 10000) / 100;
}
var lim = risk.limits || {};
var st = risk.status || {};
var enabled = risk.enabled !== false;
var active = st.active_count != null ? st.active_count : '—';
var maxPos = lim.max_active_positions != null ? lim.max_active_positions : st.max_active_positions;
var manualCnt = risk.manual_close_count_today != null ? risk.manual_close_count_today : 0;
var manualLim = lim.manual_close_daily_limit;
var marginPct = risk.margin_pct_used;
var maxMarginPct = lim.max_margin_pct;
var marginPctText = marginPct != null ? fmtNum(marginPct) + '%' : '—';
if (maxMarginPct != null && marginPct != null) {
marginPctText += ' / ' + fmtNum(maxMarginPct) + '%';
}
if (riskReasonEl) {
var reason = st.reason || (enabled ? '可新开仓' : '风控已关闭');
riskReasonEl.textContent = (st.status_label ? st.status_label + ' · ' : '') + reason;
riskReasonEl.className = 'dashboard-risk-reason';
if (st.can_trade === false) riskReasonEl.classList.add('is-blocked');
else if (st.can_trade) riskReasonEl.classList.add('is-ok');
}
var sizingDetail = lim.sizing_label || '—';
if (lim.sizing_mode === 'amount') {
sizingDetail += ' · ' + fmtNum(lim.fixed_amount, 0) + ' 元';
} else if (lim.fixed_lots != null) {
sizingDetail += ' · ' + lim.fixed_lots + ' 手';
}
var items = [
{ label: '风控开关', value: enabled ? '开启' : '关闭' },
{ label: '持仓限制', value: active + ' / ' + (maxPos != null ? maxPos : '—') },
{ label: '日手动平仓', value: manualCnt + ' / ' + (manualLim != null ? manualLim : '—') },
{ label: '冷静期(默认)', value: fmtHours(lim.cooling_hours_manual) },
{ label: '复盘后冷静', value: fmtHours(lim.cooling_hours_manual_journal) },
{ label: '冷静剩余', value: fmtRemainSec(st.freeze_remaining_sec) },
{ label: '保证金占比', value: marginPctText },
{ label: '保证金上限', value: maxMarginPct != null ? fmtNum(maxMarginPct) + '%' : '—' },
{ label: '滚仓保证金上限', value: lim.roll_max_margin_pct != null ? fmtNum(lim.roll_max_margin_pct) + '%' : '—' },
{ label: '计仓模式', value: sizingDetail },
{ label: '单笔风险', value: lim.risk_percent != null ? fmtNum(lim.risk_percent) + '%' : '—' },
{ label: '交易日切', value: lim.trading_day_reset_hour != null ? lim.trading_day_reset_hour + ':00' : '—' }
];
riskGridEl.innerHTML = items.map(function (it) {
return (
'<div class="stat-item">' +
'<div class="label">' + escHtml(it.label) + '</div>' +
'<div class="value">' + escHtml(it.value) + '</div>' +
'</div>'
);
}).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 = '<tr><td colspan="8" class="text-muted">暂无关键位监控</td></tr>';
return;
}
keysBody.innerHTML = keys.map(function (k) {
return (
'<tr data-key-id="' + k.id + '">' +
'<td>' + symbolCellHtml(k) + '</td>' +
'<td>' + escHtml(k.monitor_type || '—') + '</td>' +
'<td>' + escHtml(k.bar_period || '—') + '</td>' +
'<td>' + fmtNum(k.upper) + '</td>' +
'<td>' + fmtNum(k.lower) + '</td>' +
'<td class="dash-k-price">' + (k.price != null ? fmtNum(k.price) : '—') + '</td>' +
'<td class="dash-k-dist-up">' + (k.dist_upper != null ? fmtNum(k.dist_upper) : '—') + '</td>' +
'<td class="dash-k-dist-down">' + (k.dist_lower != null ? fmtNum(k.dist_lower) : '—') + '</td>' +
'</tr>'
);
}).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 = '<tr><td colspan="8" class="text-muted">暂无平仓记录</td></tr>';
return;
}
closesBody.innerHTML = closes.map(function (c) {
var pc = pnlClass(c.pnl_net != null ? c.pnl_net : c.pnl);
return (
'<tr data-close-id="' + c.id + '">' +
'<td>' + symbolCellHtml(c) + '</td>' +
'<td>' + directionBadgeHtml(c) + '</td>' +
'<td>' + fmtNum(c.lots) + '</td>' +
'<td>' + fmtNum(c.entry_price) + '</td>' +
'<td>' + fmtNum(c.close_price) + '</td>' +
'<td class="' + pnlClass(c.pnl) + '">' + fmtPnl(c.pnl) + '</td>' +
'<td class="' + pc + '">' + fmtPnl(c.pnl_net) + '</td>' +
'<td>' + escHtml(c.close_time || '—') + '</td>' +
'</tr>'
);
}).join('');
}
function applyKeys(keys) {
if (!keysBody) return;
var ids = (keys || []).map(function (k) { return String(k.id); }).join(',');
if (!ids) {
keysBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无关键位监控</td></tr>';
lastKeyIds = '';
return;
}
if (ids !== lastKeyIds) {
lastKeyIds = ids;
renderKeys(keys);
} else {
patchKeys(keys);
}
}
function applyCloses(closes) {
if (!closesBody) return;
if (!closes || !closes.length) {
closesBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无平仓记录</td></tr>';
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 = '<tr><td colspan="8" class="text-muted">暂无持仓</td></tr>';
posRowCache = {};
return;
}
posRowCache = {};
posBody.innerHTML = rows.map(function (r) {
var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || ''));
posRowCache[key] = true;
return (
'<tr data-pos-key="' + escHtml(key) + '">' +
'<td>' + symbolCellHtml(r) + '</td>' +
'<td>' + directionBadgeHtml(r) + '</td>' +
'<td class="dash-p-lots">' + escHtml(String(r.lots)) + '</td>' +
'<td class="dash-p-entry">' + fmtNum(r.entry_price) + '</td>' +
'<td class="dash-p-mark">' + (r.current_price != null ? fmtNum(r.current_price) : '—') + '</td>' +
'<td class="dash-p-pnl ' + pnlClass(r.float_pnl) + '">' + fmtPnl(r.float_pnl) + '</td>' +
'<td class="dash-p-margin">' + (r.margin != null ? fmtMoney(r.margin) : '—') + '</td>' +
'<td class="dash-p-sltp">' + escHtml(slTpText(r)) + '</td>' +
'</tr>'
);
}).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.risk_status) {
applyRisk({ status: data.risk_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);
applyRisk(data.risk, data.account);
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();
})();