Refactor market K-line storage with tiered retention and chunked loading.
Store 1m/5m/1h/12h/1d/1w with per-timeframe policies, aggregate 15m and 2h/4h on read, and support left-pan history fetches via before_ms. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,40 @@
|
||||
const CHART_WATCH_HEARTBEAT_MS = 25000;
|
||||
const CHART_SSE_FALLBACK_MS = 30000;
|
||||
const DEFAULT_VISIBLE_BARS = 200;
|
||||
const CHART_LOAD_LEFT_THRESHOLD = 25;
|
||||
const CHART_INITIAL_LIMITS = {
|
||||
"1m": 300,
|
||||
"5m": 300,
|
||||
"15m": 300,
|
||||
"1h": 200,
|
||||
"2h": 200,
|
||||
"4h": 200,
|
||||
"12h": 200,
|
||||
"1d": 200,
|
||||
"1w": 150,
|
||||
};
|
||||
const CHART_CHUNK_LIMITS = {
|
||||
"1m": 500,
|
||||
"5m": 500,
|
||||
"15m": 500,
|
||||
"1h": 300,
|
||||
"2h": 300,
|
||||
"4h": 300,
|
||||
"12h": 200,
|
||||
"1d": 200,
|
||||
"1w": 150,
|
||||
};
|
||||
const CHART_MEMORY_CAPS = {
|
||||
"1m": 5000,
|
||||
"5m": 5000,
|
||||
"15m": 5000,
|
||||
"1h": 1000,
|
||||
"2h": 1000,
|
||||
"4h": 1000,
|
||||
"12h": 1000,
|
||||
"1d": 1000,
|
||||
"1w": 500,
|
||||
};
|
||||
const RIGHT_OFFSET_BARS = 10;
|
||||
const CANDLE_SCALE_BOTTOM = 0.26;
|
||||
const VOLUME_SCALE_TOP = 0.73;
|
||||
@@ -141,6 +175,8 @@
|
||||
let localSeriesVersion = 0;
|
||||
let lastViewKey = "";
|
||||
let currentTf = "1d";
|
||||
let exhaustedLeft = false;
|
||||
let loadingLeft = false;
|
||||
let priceTagTimer = null;
|
||||
let tfDigitBuf = "";
|
||||
let tfDigitTimer = null;
|
||||
@@ -1914,9 +1950,13 @@
|
||||
paintOhlcv(bar);
|
||||
});
|
||||
|
||||
chart.timeScale().subscribeVisibleLogicalRangeChange(function () {
|
||||
chart.timeScale().subscribeVisibleLogicalRangeChange(function (range) {
|
||||
updateVisibleRangeMarkers();
|
||||
updatePriceTag();
|
||||
if (!range || loadingLeft || exhaustedLeft || !lastCandles.length) return;
|
||||
if (range.from < CHART_LOAD_LEFT_THRESHOLD) {
|
||||
void loadOlderCandles();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
@@ -1939,6 +1979,169 @@
|
||||
return (exKey || "") + "|" + (sym || "") + "|" + (tf || "");
|
||||
}
|
||||
|
||||
function chartInitialLimit(tf) {
|
||||
return CHART_INITIAL_LIMITS[tf] || 200;
|
||||
}
|
||||
|
||||
function chartChunkLimit(tf) {
|
||||
return CHART_CHUNK_LIMITS[tf] || 200;
|
||||
}
|
||||
|
||||
function chartMemoryCap(tf) {
|
||||
return CHART_MEMORY_CAPS[tf] || 1000;
|
||||
}
|
||||
|
||||
function resetChartHistoryState() {
|
||||
exhaustedLeft = false;
|
||||
loadingLeft = false;
|
||||
}
|
||||
|
||||
function mergeCandles(existing, incoming, opts) {
|
||||
opts = opts || {};
|
||||
const prepend = !!opts.prepend;
|
||||
const byTime = {};
|
||||
(existing || []).forEach(function (c) {
|
||||
if (c && c.time != null) byTime[c.time] = c;
|
||||
});
|
||||
(incoming || []).forEach(function (c) {
|
||||
if (c && c.time != null) byTime[c.time] = c;
|
||||
});
|
||||
let merged = Object.keys(byTime)
|
||||
.map(function (t) {
|
||||
return Number(t);
|
||||
})
|
||||
.sort(function (a, b) {
|
||||
return a - b;
|
||||
})
|
||||
.map(function (t) {
|
||||
return byTime[t];
|
||||
});
|
||||
const cap = chartMemoryCap(currentTf);
|
||||
if (merged.length > cap) {
|
||||
merged = prepend ? merged.slice(0, cap) : merged.slice(-cap);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function applyCandlesToChart(candles, rangeShift) {
|
||||
lastCandles = alignCandlesToTick(candles);
|
||||
indexCandles(lastCandles);
|
||||
candleSeries.setData(lastCandles);
|
||||
volumeSeries.setData(buildVolumeData(lastCandles));
|
||||
applyChartRightGap();
|
||||
if (rangeShift && chart) {
|
||||
const range = chart.timeScale().getVisibleLogicalRange();
|
||||
if (range) {
|
||||
chart.timeScale().setVisibleLogicalRange({
|
||||
from: range.from + rangeShift,
|
||||
to: range.to + rangeShift,
|
||||
});
|
||||
}
|
||||
}
|
||||
applyPriceAutoScale();
|
||||
updateVisibleRangeMarkers();
|
||||
try {
|
||||
updateIndicators();
|
||||
} catch (indErr) {}
|
||||
showLatestOhlcv();
|
||||
}
|
||||
|
||||
async function fetchChartChunk(params) {
|
||||
const qs = new URLSearchParams({
|
||||
exchange_key: params.exchange_key,
|
||||
symbol: params.symbol,
|
||||
timeframe: params.timeframe,
|
||||
limit: String(params.limit),
|
||||
});
|
||||
if (params.before_ms) qs.set("before_ms", String(params.before_ms));
|
||||
if (params.refresh) qs.set("refresh", "1");
|
||||
const r = await fetch("/api/chart/ohlcv?" + qs.toString(), { credentials: "same-origin" });
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
throw new Error(data.detail || data.msg || "请求失败");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadOlderCandles() {
|
||||
if (loadingLeft || exhaustedLeft || !lastCandles.length) return;
|
||||
const exKey = (elExchange && elExchange.value) || "";
|
||||
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||||
const tf = (elTf && elTf.value) || "1d";
|
||||
if (!exKey || !sym) return;
|
||||
loadingLeft = true;
|
||||
const beforeMs = Number(lastCandles[0].time) * 1000;
|
||||
try {
|
||||
const data = await fetchChartChunk({
|
||||
exchange_key: exKey,
|
||||
symbol: sym,
|
||||
timeframe: tf,
|
||||
limit: chartChunkLimit(tf),
|
||||
before_ms: beforeMs,
|
||||
});
|
||||
if (data.exhausted) exhaustedLeft = true;
|
||||
const incoming = alignCandlesToTick(data.candles || []);
|
||||
if (!incoming.length) return;
|
||||
const shift = incoming.length;
|
||||
applyCandlesToChart(mergeCandles(lastCandles, incoming, { prepend: true }), shift);
|
||||
if (elStatus && !elStatus.classList.contains("err")) {
|
||||
elStatus.textContent =
|
||||
"已加载 " +
|
||||
lastCandles.length +
|
||||
" 根(向左 +" +
|
||||
incoming.length +
|
||||
(exhaustedLeft ? " · 已到最早" : "") +
|
||||
")";
|
||||
}
|
||||
} catch (e) {
|
||||
if (elStatus) {
|
||||
elStatus.className = "market-status warn";
|
||||
elStatus.textContent = "加载更早 K 线失败:" + String(e.message || e);
|
||||
}
|
||||
} finally {
|
||||
loadingLeft = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshChartTail() {
|
||||
const exKey = (elExchange && elExchange.value) || "";
|
||||
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||||
const tf = (elTf && elTf.value) || "1d";
|
||||
if (!exKey || !sym || !lastCandles.length) return;
|
||||
const myToken = loadToken;
|
||||
let savedRange = null;
|
||||
if (chart) savedRange = chart.timeScale().getVisibleLogicalRange();
|
||||
try {
|
||||
const data = await fetchChartChunk({
|
||||
exchange_key: exKey,
|
||||
symbol: sym,
|
||||
timeframe: tf,
|
||||
limit: chartChunkLimit(tf),
|
||||
});
|
||||
if (myToken !== loadToken) return;
|
||||
if (!data.ok || !data.candles || !data.candles.length) return;
|
||||
if (data.price_tick != null) {
|
||||
priceTick = data.price_tick;
|
||||
try {
|
||||
applyChartPriceFormat();
|
||||
} catch (fmtErr) {
|
||||
priceTick = null;
|
||||
applyChartPriceFormat();
|
||||
}
|
||||
}
|
||||
applyCandlesToChart(mergeCandles(lastCandles, alignCandlesToTick(data.candles), { prepend: false }), 0);
|
||||
if (savedRange) chart.timeScale().setVisibleLogicalRange(savedRange);
|
||||
if (posContext) {
|
||||
updateLivePosPnl();
|
||||
refreshPosPnlFromBoard();
|
||||
}
|
||||
if (data.series_version != null) localSeriesVersion = Number(data.series_version) || localSeriesVersion;
|
||||
if (data.chart_version != null) localChartVersion = Number(data.chart_version) || localChartVersion;
|
||||
if (elUpdated) elUpdated.textContent = "数据 " + (data.updated_at || "--");
|
||||
tickLiveClock();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function applyChartRightGap() {
|
||||
if (!chart) return;
|
||||
chart.timeScale().applyOptions({
|
||||
@@ -2073,7 +2276,7 @@
|
||||
if (seriesChanged) {
|
||||
localSeriesVersion = sVer;
|
||||
localChartVersion = ver;
|
||||
loadChart(false, { autoTick: true });
|
||||
refreshChartTail();
|
||||
} else if (posContext) {
|
||||
updateLivePosPnl();
|
||||
} else if (ver !== localChartVersion) {
|
||||
@@ -2111,7 +2314,7 @@
|
||||
refreshTimer = setInterval(function () {
|
||||
const page = document.getElementById("page-market");
|
||||
if (!page || page.classList.contains("hidden")) return;
|
||||
loadChart(false, { autoTick: true });
|
||||
refreshChartTail();
|
||||
}, CHART_SSE_FALLBACK_MS);
|
||||
}
|
||||
|
||||
@@ -2151,10 +2354,11 @@
|
||||
async function loadChart(force, options) {
|
||||
options = options || {};
|
||||
const autoTick = !!options.autoTick;
|
||||
if (!autoTick) {
|
||||
localSeriesVersion = 0;
|
||||
void postChartWatch();
|
||||
if (autoTick) {
|
||||
return refreshChartTail();
|
||||
}
|
||||
localSeriesVersion = 0;
|
||||
void postChartWatch();
|
||||
if (!ensureChart()) return;
|
||||
const exKey = (elExchange && elExchange.value) || "";
|
||||
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||||
@@ -2169,31 +2373,23 @@
|
||||
}
|
||||
const myToken = ++loadToken;
|
||||
const vKey = viewKey(exKey, sym, tf);
|
||||
const resetView = !!force || !autoTick || vKey !== lastViewKey;
|
||||
let savedRange = null;
|
||||
if (!resetView && chart) {
|
||||
savedRange = chart.timeScale().getVisibleLogicalRange();
|
||||
}
|
||||
if (!autoTick && elStatus) {
|
||||
const resetView = !!force || vKey !== lastViewKey;
|
||||
if (resetView) resetChartHistoryState();
|
||||
if (elStatus) {
|
||||
elStatus.className = "market-status";
|
||||
elStatus.textContent = "加载中…";
|
||||
}
|
||||
updateHeaderLabels(sym, tf);
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
exchange_key: exKey,
|
||||
symbol: sym,
|
||||
timeframe: tf,
|
||||
});
|
||||
if (force) qs.set("refresh", "1");
|
||||
|
||||
try {
|
||||
const r = await fetch("/api/chart/ohlcv?" + qs.toString(), { credentials: "same-origin" });
|
||||
const data = await r.json();
|
||||
const data = await fetchChartChunk({
|
||||
exchange_key: exKey,
|
||||
symbol: sym,
|
||||
timeframe: tf,
|
||||
limit: chartInitialLimit(tf),
|
||||
refresh: !!force,
|
||||
});
|
||||
if (myToken !== loadToken) return;
|
||||
if (!r.ok) {
|
||||
throw new Error(data.detail || data.msg || "请求失败");
|
||||
}
|
||||
if (!data.ok || !data.candles || !data.candles.length) {
|
||||
throw new Error(data.msg || "无 K 线");
|
||||
}
|
||||
@@ -2205,45 +2401,31 @@
|
||||
priceTick = null;
|
||||
applyChartPriceFormat();
|
||||
}
|
||||
lastCandles = alignCandlesToTick(data.candles);
|
||||
indexCandles(lastCandles);
|
||||
candleSeries.setData(lastCandles);
|
||||
volumeSeries.setData(buildVolumeData(lastCandles));
|
||||
applyChartRightGap();
|
||||
applyCandlesToChart(alignCandlesToTick(data.candles), 0);
|
||||
if (resetView) {
|
||||
lastViewKey = vKey;
|
||||
applyDefaultVisibleRange();
|
||||
} else if (savedRange) {
|
||||
chart.timeScale().setVisibleLogicalRange(savedRange);
|
||||
}
|
||||
applyPriceAutoScale();
|
||||
updateVisibleRangeMarkers();
|
||||
syncPosContextForView(exKey, sym);
|
||||
if (posContext) {
|
||||
updateLivePosPnl();
|
||||
refreshPosPnlFromBoard();
|
||||
}
|
||||
showLatestOhlcv();
|
||||
try {
|
||||
updateIndicators();
|
||||
} catch (indErr) {
|
||||
/* 指标序列 priceFormat 异常时不阻断主图 */
|
||||
}
|
||||
scheduleChartResize();
|
||||
|
||||
const limit = data.limit || lastCandles.length;
|
||||
let hint =
|
||||
"已加载 " +
|
||||
data.candles.length +
|
||||
" 根(目标 " +
|
||||
lastCandles.length +
|
||||
" 根(首屏 " +
|
||||
limit +
|
||||
")· 库 " +
|
||||
(data.from_cache || 0) +
|
||||
" / 新拉 " +
|
||||
(data.fetched || 0) +
|
||||
")· 后台 " +
|
||||
" · 左拖加载更多 · 后台 " +
|
||||
(data.chart_poll_interval_sec || 5) +
|
||||
"s 轮询 · SSE";
|
||||
"s";
|
||||
if (data.stale && data.stale_message) {
|
||||
hint += " · 缓存:" + data.stale_message;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user