feat(hub): background chart poll with SSE for positions and market watch

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-04 12:39:26 +08:00
parent 9d12323ce6
commit 6f8f0968c8
9 changed files with 591 additions and 10 deletions
+123 -3
View File
@@ -1,8 +1,10 @@
/**
* 中控行情区:K 线 + 成交量;默认最新 OHLCV5s 自动刷新;价格轴「自动」。
* 中控行情区:K 线 + 成交量;Hub 后台轮询 + SSE 推送;价格轴「自动」。
*/
(function () {
const AUTO_REFRESH_MS = 5000;
const CHART_WATCH_HEARTBEAT_MS = 25000;
const CHART_SSE_FALLBACK_MS = 30000;
const DEFAULT_VISIBLE_BARS = 200;
const RIGHT_OFFSET_BARS = 10;
const CANDLE_SCALE_BOTTOM = 0.26;
@@ -128,6 +130,11 @@
let loadToken = 0;
let marketInited = false;
let refreshTimer = null;
let chartWatchTimer = null;
let chartEventSource = null;
let chartSseReconnectTimer = null;
let localChartVersion = 0;
let localSeriesVersion = 0;
let lastViewKey = "";
let currentTf = "1d";
let priceTagTimer = null;
@@ -1529,18 +1536,117 @@
if (elTf && !elTf.value) elTf.value = "1d";
}
function currentViewSeriesKey() {
const exKey = (elExchange && elExchange.value) || "";
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
const tf = (elTf && elTf.value) || "1d";
if (!exKey || !sym) return "";
return exKey + "|" + sym + "|" + tf;
}
function postChartWatch() {
const exKey = (elExchange && elExchange.value) || "";
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
const tf = (elTf && elTf.value) || "1d";
if (!exKey || !sym) return Promise.resolve();
return fetch("/api/chart/watch", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ exchange_key: exKey, symbol: sym, timeframe: tf }),
}).catch(function () {});
}
function postChartUnwatch() {
const exKey = (elExchange && elExchange.value) || "";
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
const tf = (elTf && elTf.value) || "1d";
if (!exKey || !sym) return Promise.resolve();
return fetch("/api/chart/unwatch", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ exchange_key: exKey, symbol: sym, timeframe: tf }),
}).catch(function () {});
}
function closeChartStream() {
if (chartEventSource) {
chartEventSource.close();
chartEventSource = null;
}
}
function connectChartStream() {
closeChartStream();
const page = document.getElementById("page-market");
if (!page || page.classList.contains("hidden")) return;
chartEventSource = new EventSource("/api/chart/stream");
chartEventSource.addEventListener("chart", function (ev) {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.chart_version) || 0;
const series = st.series || {};
const vKey = currentViewSeriesKey();
const sVer = vKey && series[vKey] ? Number(series[vKey].series_version) || 0 : 0;
const seriesChanged = vKey && sVer > 0 && sVer !== localSeriesVersion;
if (seriesChanged) {
localSeriesVersion = sVer;
localChartVersion = ver;
loadChart(false, { autoTick: true });
} else if (ver !== localChartVersion) {
localChartVersion = ver;
}
} catch (_) {}
});
chartEventSource.onerror = function () {
closeChartStream();
if (chartSseReconnectTimer) clearTimeout(chartSseReconnectTimer);
chartSseReconnectTimer = setTimeout(function () {
const p = document.getElementById("page-market");
if (p && !p.classList.contains("hidden")) connectChartStream();
}, 8000);
};
}
function startChartWatchHeartbeat() {
stopChartWatchHeartbeat();
void postChartWatch();
chartWatchTimer = setInterval(function () {
const page = document.getElementById("page-market");
if (!page || page.classList.contains("hidden")) return;
void postChartWatch();
}, CHART_WATCH_HEARTBEAT_MS);
}
function stopChartWatchHeartbeat() {
if (chartWatchTimer) clearInterval(chartWatchTimer);
chartWatchTimer = null;
}
function startAutoRefresh() {
stopAutoRefresh();
refreshTimer = setInterval(function () {
const page = document.getElementById("page-market");
if (!page || page.classList.contains("hidden")) return;
loadChart(false, { autoTick: true });
}, AUTO_REFRESH_MS);
}, CHART_SSE_FALLBACK_MS);
}
function stopAutoRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
if (chartSseReconnectTimer) {
clearTimeout(chartSseReconnectTimer);
chartSseReconnectTimer = null;
}
}
function stopChartLive() {
stopAutoRefresh();
stopChartWatchHeartbeat();
closeChartStream();
void postChartUnwatch();
}
async function loadMeta() {
@@ -1563,6 +1669,10 @@
async function loadChart(force, options) {
options = options || {};
const autoTick = !!options.autoTick;
if (!autoTick) {
localSeriesVersion = 0;
void postChartWatch();
}
if (!ensureChart()) return;
const exKey = (elExchange && elExchange.value) || "";
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
@@ -1645,7 +1755,9 @@
(data.from_cache || 0) +
" / 新拉 " +
(data.fetched || 0) +
")· 每 5s 刷新";
")· 后台 " +
(data.chart_poll_interval_sec || 5) +
"s 轮询 · SSE";
if (data.stale && data.stale_message) {
hint += " · 缓存:" + data.stale_message;
}
@@ -1654,6 +1766,8 @@
elStatus.textContent = hint;
}
if (elUpdated) elUpdated.textContent = "数据 " + (data.updated_at || "--");
if (data.series_version != null) localSeriesVersion = Number(data.series_version) || localSeriesVersion;
if (data.chart_version != null) localChartVersion = Number(data.chart_version) || localChartVersion;
tickLiveClock();
} catch (e) {
if (myToken !== loadToken) return;
@@ -1778,6 +1892,8 @@
readQuery();
}
focusMarketChartArea();
connectChartStream();
startChartWatchHeartbeat();
startAutoRefresh();
await loadChart(false);
startPriceTagTimer();
@@ -1790,7 +1906,10 @@
if (elSymbol && sym) elSymbol.value = String(sym).trim().toUpperCase();
if (tf && elTf) elTf.value = tf;
lastViewKey = "";
localSeriesVersion = 0;
updateExchangeDisplay();
connectChartStream();
startChartWatchHeartbeat();
startAutoRefresh();
await loadChart(false);
startPriceTagTimer();
@@ -1800,6 +1919,7 @@
},
startAutoRefresh: startAutoRefresh,
stopAutoRefresh: stopAutoRefresh,
stopChartLive: stopChartLive,
stopPriceTagTimer: stopPriceTagTimer,
};