增加K线
This commit is contained in:
+180
-21
@@ -52,6 +52,14 @@ let lwcOnCrosshairMove = null;
|
|||||||
let lwcOnChartClick = null;
|
let lwcOnChartClick = null;
|
||||||
let lwcPinnedCandleTime = null;
|
let lwcPinnedCandleTime = null;
|
||||||
|
|
||||||
|
const BINANCE_KLINE_WS = "wss://fstream.binance.com/ws";
|
||||||
|
let klineWs = null;
|
||||||
|
let klineWsSymbol = "";
|
||||||
|
let klineWsInterval = "";
|
||||||
|
let klineWsReconnectTimer = null;
|
||||||
|
let modalMetaBase = { count: 0, source: "db", interval: "1d" };
|
||||||
|
let highLowWsRaf = 0;
|
||||||
|
|
||||||
function cacheKey(symbol, interval) {
|
function cacheKey(symbol, interval) {
|
||||||
return `${symbol}:${interval}`;
|
return `${symbol}:${interval}`;
|
||||||
}
|
}
|
||||||
@@ -98,6 +106,7 @@ function saveKlineToLS(symbol, interval, candles, source, priceMeta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sourceLabel(source) {
|
function sourceLabel(source) {
|
||||||
|
if (source === "ws") return "实时";
|
||||||
if (source === "browser") return "浏览器";
|
if (source === "browser") return "浏览器";
|
||||||
if (source === "db") return "本地";
|
if (source === "db") return "本地";
|
||||||
if (source === "db_stale") return "本地(旧)";
|
if (source === "db_stale") return "本地(旧)";
|
||||||
@@ -365,6 +374,151 @@ function candlesToLwc(candles, interval) {
|
|||||||
return { ohlc, vol };
|
return { ohlc, vol };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upsertModalCandleFromWs(k) {
|
||||||
|
const candle = {
|
||||||
|
time: Number(k.t),
|
||||||
|
open: Number(k.o),
|
||||||
|
high: Number(k.h),
|
||||||
|
low: Number(k.l),
|
||||||
|
close: Number(k.c),
|
||||||
|
volume: Number(k.v),
|
||||||
|
quote_volume: Number(k.q),
|
||||||
|
};
|
||||||
|
const idx = lwcModalCandles.findIndex((c) => c.time === candle.time);
|
||||||
|
if (idx >= 0) {
|
||||||
|
lwcModalCandles[idx] = candle;
|
||||||
|
} else if (
|
||||||
|
!lwcModalCandles.length ||
|
||||||
|
candle.time > lwcModalCandles[lwcModalCandles.length - 1].time
|
||||||
|
) {
|
||||||
|
lwcModalCandles.push(candle);
|
||||||
|
const max = limitForInterval(lwcModalInterval);
|
||||||
|
while (lwcModalCandles.length > max) lwcModalCandles.shift();
|
||||||
|
}
|
||||||
|
return candle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRealtimeCandle(candle) {
|
||||||
|
if (!lwcCandleSeries || !lwcVolumeSeries) return;
|
||||||
|
const t = toLwcTime(candle.time, lwcModalInterval);
|
||||||
|
const up = candle.close >= candle.open;
|
||||||
|
lwcCandleSeries.update({
|
||||||
|
time: t,
|
||||||
|
open: candle.open,
|
||||||
|
high: candle.high,
|
||||||
|
low: candle.low,
|
||||||
|
close: candle.close,
|
||||||
|
});
|
||||||
|
lwcVolumeSeries.update({
|
||||||
|
time: t,
|
||||||
|
value: Number(candle.quote_volume || candle.volume || 0),
|
||||||
|
color: up ? COLORS.volUp : COLORS.volDown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncChartCacheFromModal() {
|
||||||
|
const key = cacheKey(chartModalSymbol, lwcModalInterval);
|
||||||
|
const cached = chartDataCache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
cached.candles = lwcModalCandles.slice();
|
||||||
|
cached.source = "ws";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHighLowFromWs() {
|
||||||
|
if (highLowWsRaf) return;
|
||||||
|
highLowWsRaf = requestAnimationFrame(() => {
|
||||||
|
highLowWsRaf = 0;
|
||||||
|
updateHighLowForVisibleWindow();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWsKlineUpdate(k) {
|
||||||
|
const candle = upsertModalCandleFromWs(k);
|
||||||
|
applyRealtimeCandle(candle);
|
||||||
|
syncChartCacheFromModal();
|
||||||
|
if (lwcPinnedCandleTime == null) {
|
||||||
|
renderOhlcPanel(candle, "实时");
|
||||||
|
}
|
||||||
|
scheduleHighLowFromWs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKlineWsLive() {
|
||||||
|
return klineWs?.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModalHint() {
|
||||||
|
const hint = document.getElementById("chart-modal-hint");
|
||||||
|
if (!hint) return;
|
||||||
|
const src = isKlineWsLive() ? "实时 WS" : sourceLabel(modalMetaBase.source);
|
||||||
|
hint.textContent = `${modalMetaBase.count} 根 · ${src} · 十字线看当前 · 点击选中 · Esc 关闭`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectKlineWs() {
|
||||||
|
if (klineWsReconnectTimer) {
|
||||||
|
clearTimeout(klineWsReconnectTimer);
|
||||||
|
klineWsReconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (klineWs) {
|
||||||
|
klineWs.onopen = null;
|
||||||
|
klineWs.onmessage = null;
|
||||||
|
klineWs.onclose = null;
|
||||||
|
klineWs.onerror = null;
|
||||||
|
try {
|
||||||
|
klineWs.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
klineWs = null;
|
||||||
|
}
|
||||||
|
klineWsSymbol = "";
|
||||||
|
klineWsInterval = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleKlineWsReconnect() {
|
||||||
|
if (!chartModalSymbol || klineWsReconnectTimer) return;
|
||||||
|
klineWsReconnectTimer = setTimeout(() => {
|
||||||
|
klineWsReconnectTimer = null;
|
||||||
|
if (chartModalSymbol) connectKlineWs(chartModalSymbol, chartModalInterval);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectKlineWs(symbol, interval) {
|
||||||
|
disconnectKlineWs();
|
||||||
|
const sym = symbol.toUpperCase();
|
||||||
|
const iv = interval.toLowerCase();
|
||||||
|
klineWsSymbol = sym;
|
||||||
|
klineWsInterval = iv;
|
||||||
|
|
||||||
|
const ws = new WebSocket(`${BINANCE_KLINE_WS}/${sym.toLowerCase()}@kline_${iv}`);
|
||||||
|
klineWs = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => updateModalHint();
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
if (msg.e !== "kline" || !msg.k) return;
|
||||||
|
if (msg.k.s !== sym || msg.k.i !== iv) return;
|
||||||
|
onWsKlineUpdate(msg.k);
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (klineWs === ws) {
|
||||||
|
klineWs = null;
|
||||||
|
updateModalHint();
|
||||||
|
if (chartModalSymbol && klineWsSymbol === sym && klineWsInterval === iv) {
|
||||||
|
scheduleKlineWsReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => updateModalHint();
|
||||||
|
}
|
||||||
|
|
||||||
function enqueueCharts(root) {
|
function enqueueCharts(root) {
|
||||||
root.querySelectorAll(".mini-chart[data-symbol]").forEach((box) => {
|
root.querySelectorAll(".mini-chart[data-symbol]").forEach((box) => {
|
||||||
const symbol = box.dataset.symbol;
|
const symbol = box.dataset.symbol;
|
||||||
@@ -497,27 +651,30 @@ function drawEmptyChart(canvas) {
|
|||||||
ctx.fillText("暂无数据", w / 2 - 28, h / 2);
|
ctx.fillText("暂无数据", w / 2 - 28, h / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchKlines(symbol, interval = DEFAULT_MINI_INTERVAL) {
|
async function fetchKlines(symbol, interval = DEFAULT_MINI_INTERVAL, forceRefresh = false) {
|
||||||
const key = cacheKey(symbol, interval);
|
const key = cacheKey(symbol, interval);
|
||||||
let cached = chartDataCache.get(key);
|
if (!forceRefresh) {
|
||||||
if (cached) return cached;
|
const cached = chartDataCache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
const ls = loadKlineFromLS(symbol, interval);
|
const ls = loadKlineFromLS(symbol, interval);
|
||||||
if (ls) {
|
if (ls) {
|
||||||
const priceMeta = rememberPriceMeta(symbol, ls);
|
const priceMeta = rememberPriceMeta(symbol, ls);
|
||||||
const result = {
|
const result = {
|
||||||
candles: ls.candles,
|
candles: ls.candles,
|
||||||
source: ls.source || "browser",
|
source: ls.source || "browser",
|
||||||
interval,
|
interval,
|
||||||
tick_size: priceMeta?.tick_size,
|
tick_size: priceMeta?.tick_size,
|
||||||
price_precision: priceMeta?.price_precision,
|
price_precision: priceMeta?.price_precision,
|
||||||
};
|
};
|
||||||
chartDataCache.set(key, result);
|
chartDataCache.set(key, result);
|
||||||
return result;
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = limitForInterval(interval);
|
const limit = limitForInterval(interval);
|
||||||
const res = await fetch(`/api/chart/${symbol}?interval=${interval}&limit=${limit}`);
|
const refreshQ = forceRefresh ? "&refresh=true" : "";
|
||||||
|
const res = await fetch(`/api/chart/${symbol}?interval=${interval}&limit=${limit}${refreshQ}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(err.detail || res.statusText);
|
throw new Error(err.detail || res.statusText);
|
||||||
@@ -561,6 +718,7 @@ async function loadMiniChart(box) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function destroyLwcChart() {
|
function destroyLwcChart() {
|
||||||
|
disconnectKlineWs();
|
||||||
unbindVisibleRangeHighLow();
|
unbindVisibleRangeHighLow();
|
||||||
unbindChartInteractions();
|
unbindChartInteractions();
|
||||||
clearHighLowAnnotations();
|
clearHighLowAnnotations();
|
||||||
@@ -753,14 +911,12 @@ function updateIntervalTabs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateModalMeta(candles, source, interval) {
|
function updateModalMeta(candles, source, interval) {
|
||||||
|
modalMetaBase = { count: candles.length, source, interval };
|
||||||
const title = document.getElementById("chart-modal-title");
|
const title = document.getElementById("chart-modal-title");
|
||||||
const hint = document.getElementById("chart-modal-hint");
|
|
||||||
if (title) {
|
if (title) {
|
||||||
title.textContent = `${chartModalSymbol} · ${interval.toUpperCase()} K线`;
|
title.textContent = `${chartModalSymbol} · ${interval.toUpperCase()} K线`;
|
||||||
}
|
}
|
||||||
if (hint) {
|
updateModalHint();
|
||||||
hint.textContent = `${candles.length} 根 · ${sourceLabel(source)} · 十字线看当前 · 点击选中 · Esc 关闭`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadModalChart(interval) {
|
async function loadModalChart(interval) {
|
||||||
@@ -774,12 +930,15 @@ async function loadModalChart(interval) {
|
|||||||
try {
|
try {
|
||||||
const { candles, source, tick_size, price_precision } = await fetchKlines(
|
const { candles, source, tick_size, price_precision } = await fetchKlines(
|
||||||
chartModalSymbol,
|
chartModalSymbol,
|
||||||
interval
|
interval,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
if (!candles.length) throw new Error("无K线数据");
|
if (!candles.length) throw new Error("无K线数据");
|
||||||
renderLwcChart(candles, interval, { tick_size, price_precision });
|
renderLwcChart(candles, interval, { tick_size, price_precision });
|
||||||
updateModalMeta(candles, source, interval);
|
updateModalMeta(candles, source, interval);
|
||||||
|
connectKlineWs(chartModalSymbol, interval);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
disconnectKlineWs();
|
||||||
if (hint) hint.textContent = `加载失败: ${e.message}`;
|
if (hint) hint.textContent = `加载失败: ${e.message}`;
|
||||||
destroyLwcChart();
|
destroyLwcChart();
|
||||||
if (container) {
|
if (container) {
|
||||||
|
|||||||
Reference in New Issue
Block a user