feat: 持仓监控后台 SSE 推送与浏览器缓存,刷新不再阻塞读柜台。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-25 13:49:44 +08:00
parent f31164076f
commit bbcc5607ad
4 changed files with 304 additions and 97 deletions
+112 -77
View File
@@ -11,8 +11,8 @@
var tpInput = document.getElementById('trade-tp');
var marketHint = document.getElementById('market-hint');
var metricsHint = document.getElementById('trade-metrics-hint');
var pollTimer = null;
var recommendSource = null;
var positionSource = null;
var quoteTimer = null;
var calcTimer = null;
var lastQuotePrice = null;
@@ -22,10 +22,11 @@
var isTradingSession = false;
var hasSlTpMonitoring = false;
var ctpConnected = false;
var pollIntervalMs = 0;
var positionsRendered = false;
var selectedMaxLots = null;
var recommendMaxByProduct = {};
var recommendMaxByCode = {};
var POS_CACHE_KEY = 'qihuo_trading_live_v1';
function runWhenReady(fn) {
if (document.readyState === 'loading') {
@@ -98,22 +99,86 @@
}
}
function loadPosCache() {
try {
var raw = sessionStorage.getItem(POS_CACHE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (e) {
return null;
}
}
function savePosCache(data) {
try {
sessionStorage.setItem(POS_CACHE_KEY, JSON.stringify(data));
} catch (e) { /* quota */ }
}
function applyPositionsData(data) {
if (!list || !data) return;
var cap = document.getElementById('cap-display');
if (cap && data.capital != null) cap.textContent = Number(data.capital).toFixed(2);
var connected = data.ctp_status && data.ctp_status.connected;
var connecting = data.ctp_status && data.ctp_status.connecting;
ctpConnected = !!connected;
isTradingSession = !!data.trading_session;
updateCtpBadge(!!connected, !!connecting);
var riskBadge = document.getElementById('risk-badge');
if (riskBadge && data.risk_status) {
riskBadge.textContent = data.risk_status.status_label || '';
riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss');
}
var rows = data.rows || [];
hasSlTpMonitoring = rows.some(function (row) {
return row.stop_loss != null || row.take_profit != null;
});
updateSessionUi();
savePosCache(data);
positionsRendered = true;
if (!connected) {
if (connecting) {
list.innerHTML = '<div class="empty-hint">CTP 连接中,请稍候…</div>';
return;
}
list.innerHTML = '<div class="empty-hint">CTP 未连接,正在尝试自动重连…</div>';
tryAutoCtpReconnect();
return;
}
if (!rows.length) {
var pendingOnly = data.pending_orders || [];
if (pendingOnly.length) {
list.innerHTML = '<div class="empty-hint" style="margin-bottom:.75rem">柜台暂无持仓</div>' +
pendingOnly.map(function (p) {
var dismissBtn = p.monitor_id ?
'<button type="button" class="pos-dismiss-btn" data-monitor-id="' + p.monitor_id + '">取消</button>' : '';
return (
'<div class="pos-pending-item ' +
(p.order_kind === 'stop_loss' ? 'sl' : (p.order_kind === 'take_profit' ? 'tp' : 'ctp')) +
'"><span>' + (p.label || '挂单') + ' · ' + (p.symbol || p.symbol_code) + '</span>' +
'<span class="pos-pending-right"><strong>' + fmtNum(p.price) + '</strong> · ' +
(p.lots || 1) + ' 手' + dismissBtn + '</span></div>'
);
}).join('');
bindPendingDismiss(list);
} else {
list.innerHTML = '<div class="empty-hint">柜台暂无持仓。</div>';
}
return;
}
list.innerHTML = rows.map(buildPosCard).join('');
bindPendingDismiss(list);
bindSlTpButtons(list);
bindPlaceOrderButtons(list);
list.querySelectorAll('[data-close]').forEach(function (btn) {
btn.addEventListener('click', function () {
closePosition(JSON.parse(decodeURIComponent(btn.getAttribute('data-close'))), btn);
});
});
}
function schedulePositionPoll() {
var nextMs = 0;
if (hasSlTpMonitoring && isTradingSession) {
nextMs = 1000;
} else if (!ctpConnected) {
nextMs = 5000;
}
if (nextMs === pollIntervalMs && pollTimer) return;
pollIntervalMs = nextMs;
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (nextMs > 0) {
pollTimer = setInterval(pollPositions, nextMs);
}
/* 持仓改由后台 SSE 推送,保留空函数兼容旧调用 */
}
function updateSessionUi() {
@@ -654,71 +719,35 @@
return r.json();
})
.then(function (data) {
var cap = document.getElementById('cap-display');
if (cap && data.capital != null) cap.textContent = Number(data.capital).toFixed(2);
var connected = data.ctp_status && data.ctp_status.connected;
var connecting = data.ctp_status && data.ctp_status.connecting;
ctpConnected = !!connected;
isTradingSession = !!data.trading_session;
updateCtpBadge(!!connected, !!connecting);
var riskBadge = document.getElementById('risk-badge');
if (riskBadge && data.risk_status) {
riskBadge.textContent = data.risk_status.status_label || '';
riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss');
}
var rows = data.rows || [];
hasSlTpMonitoring = rows.some(function (row) {
return row.stop_loss != null || row.take_profit != null;
});
schedulePositionPoll();
updateSessionUi();
if (!connected) {
if (connecting) {
list.innerHTML = '<div class="empty-hint">CTP 连接中,请稍候…</div>';
return;
}
list.innerHTML = '<div class="empty-hint">CTP 未连接,正在尝试自动重连…</div>';
tryAutoCtpReconnect();
return;
}
if (!rows.length) {
var pendingOnly = data.pending_orders || [];
if (pendingOnly.length) {
list.innerHTML = '<div class="empty-hint" style="margin-bottom:.75rem">柜台暂无持仓</div>' +
pendingOnly.map(function (p) {
var dismissBtn = p.monitor_id ?
'<button type="button" class="pos-dismiss-btn" data-monitor-id="' + p.monitor_id + '">取消</button>' : '';
return (
'<div class="pos-pending-item ' +
(p.order_kind === 'stop_loss' ? 'sl' : (p.order_kind === 'take_profit' ? 'tp' : 'ctp')) +
'"><span>' + (p.label || '挂单') + ' · ' + (p.symbol || p.symbol_code) + '</span>' +
'<span class="pos-pending-right"><strong>' + fmtNum(p.price) + '</strong> · ' +
(p.lots || 1) + ' 手' + dismissBtn + '</span></div>'
);
}).join('');
bindPendingDismiss(list);
} else {
list.innerHTML = '<div class="empty-hint">柜台暂无持仓。</div>';
}
return;
}
list.innerHTML = rows.map(buildPosCard).join('');
bindPendingDismiss(list);
bindSlTpButtons(list);
bindPlaceOrderButtons(list);
list.querySelectorAll('[data-close]').forEach(function (btn) {
btn.addEventListener('click', function () {
closePosition(JSON.parse(decodeURIComponent(btn.getAttribute('data-close'))), btn);
});
});
applyPositionsData(data);
})
.catch(function () {
if (list.innerHTML.indexOf('pos-card') < 0) {
if (!positionsRendered && list.innerHTML.indexOf('pos-card') < 0) {
list.innerHTML = '<div class="empty-hint text-loss">持仓加载失败</div>';
}
});
}
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.onerror = function () {
if (positionSource) {
positionSource.close();
positionSource = null;
}
setTimeout(connectPositionStream, 3000);
};
}
function renderRecommendations(data) {
if (!recommendList || !data) return;
updateRecommendMaxMaps(data);
@@ -811,14 +840,20 @@
runWhenReady(function () {
setPriceType('limit');
pollPositions();
var cached = loadPosCache();
if (cached) {
applyPositionsData(cached);
}
connectPositionStream();
connectRecommendStream();
fetch('/api/recommend/list')
.then(function (r) { return r.json(); })
.then(function (data) { if (data.ok) renderRecommendations(data); })
.catch(function () {});
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') pollPositions();
if (document.visibilityState === 'visible' && !positionSource) {
connectPositionStream();
}
});
updateSessionUi();
scheduleQuote();