/** 多周期 K 线 · SQLite 后端 + localStorage · 弹窗大图 Lightweight Charts */ const CHART_INTERVALS = ["5m", "15m", "30m", "1h", "4h", "1d", "1w"]; const INTERVAL_LIMITS = { "5m": 1000, "15m": 1000, "30m": 1000, "1h": 1000, "4h": 1000, "1d": 500, "1w": 500, }; const chartDataCache = new Map(); const chartQueue = []; let chartQueueRunning = false; const CHART_FETCH_GAP_MS = 120; const LS_KLINE_PREFIX = "ba_kline_"; const KLINE_TTL_MS = 60 * 60 * 1000; const COLORS = { bg: "#0d1118", grid: "#2a3548", up: "#0ecb81", down: "#f6465d", volUp: "rgba(14, 203, 129, 0.55)", volDown: "rgba(246, 70, 93, 0.55)", text: "#8b9cb3", }; const MINI_SIZE = { w: 380, h: 100 }; /** 弹窗 K 线区域固定尺寸(带鱼屏居中大图) */ const MODAL_CHART_SIZE = { w: 1920, h: 1080 }; const DEFAULT_MINI_INTERVAL = "1d"; let chartModalSymbol = ""; let chartModalInterval = "1d"; let lwcChart = null; let lwcCandleSeries = null; let lwcVolumeSeries = null; let lwcResizeObserver = null; let lwcPriceLines = []; const symbolPriceMeta = new Map(); let lwcModalCandles = []; let lwcModalInterval = "1d"; let lwcModalPriceMeta = { tick_size: "0.01", price_precision: 2 }; let lwcOnVisibleRangeChange = null; function cacheKey(symbol, interval) { return `${symbol}:${interval}`; } function limitForInterval(interval) { return INTERVAL_LIMITS[interval] || 500; } function modalChartSize() { return { w: Math.min(MODAL_CHART_SIZE.w, window.innerWidth - 32), h: Math.min(MODAL_CHART_SIZE.h, window.innerHeight - 32), }; } function loadKlineFromLS(symbol, interval) { try { const raw = localStorage.getItem(LS_KLINE_PREFIX + symbol + "_" + interval); if (!raw) return null; const obj = JSON.parse(raw); if (!obj?.candles?.length || Date.now() - (obj.ts || 0) > KLINE_TTL_MS) return null; return obj; } catch { return null; } } function saveKlineToLS(symbol, interval, candles, source, priceMeta) { try { localStorage.setItem( LS_KLINE_PREFIX + symbol + "_" + interval, JSON.stringify({ ts: Date.now(), candles, source, interval, tick_size: priceMeta?.tick_size, price_precision: priceMeta?.price_precision, }) ); } catch { /* quota */ } } function sourceLabel(source) { if (source === "browser") return "浏览器"; if (source === "db") return "本地"; if (source === "db_stale") return "本地(旧)"; if (source === "memory") return "缓存"; return "同步"; } function parseMinMove(tickSize) { const n = Number(tickSize); return Number.isFinite(n) && n > 0 ? n : 0.01; } function formatPrice(price, precision) { return Number(price).toFixed(precision); } function rememberPriceMeta(symbol, meta) { if (!meta?.tick_size) return null; const priceMeta = { tick_size: meta.tick_size, price_precision: Number(meta.price_precision ?? 2), }; symbolPriceMeta.set(symbol, priceMeta); return priceMeta; } function getPriceMeta(symbol, fallback) { return ( symbolPriceMeta.get(symbol) || (fallback?.tick_size ? rememberPriceMeta(symbol, fallback) : null) || { tick_size: "0.01", price_precision: 2, } ); } function findCandleExtremes(candles, interval) { let maxHigh = -Infinity; let minLow = Infinity; let highTime = null; let lowTime = null; for (const c of candles) { if (c.high > maxHigh) { maxHigh = c.high; highTime = toLwcTime(c.time, interval); } if (c.low < minLow) { minLow = c.low; lowTime = toLwcTime(c.time, interval); } } return { maxHigh, minLow, highTime, lowTime }; } function candlesInLogicalRange(candles, range) { if (!range || !candles.length) return candles; const from = Math.max(0, Math.floor(range.from)); const to = Math.min(candles.length - 1, Math.ceil(range.to)); if (from > to) return []; return candles.slice(from, to + 1); } function updateHighLowForVisibleWindow() { if (!lwcChart || !lwcCandleSeries || !lwcModalCandles.length) return; const range = lwcChart.timeScale().getVisibleLogicalRange(); const visible = candlesInLogicalRange(lwcModalCandles, range); const subset = visible.length ? visible : lwcModalCandles; applyHighLowAnnotations(subset, lwcModalInterval, lwcModalPriceMeta); } function bindVisibleRangeHighLow() { if (!lwcChart) return; unbindVisibleRangeHighLow(); lwcOnVisibleRangeChange = () => { updateHighLowForVisibleWindow(); }; lwcChart.timeScale().subscribeVisibleLogicalRangeChange(lwcOnVisibleRangeChange); } function unbindVisibleRangeHighLow() { if (lwcChart && lwcOnVisibleRangeChange) { lwcChart.timeScale().unsubscribeVisibleLogicalRangeChange(lwcOnVisibleRangeChange); } lwcOnVisibleRangeChange = null; } function toLwcTime(ms, interval) { if (interval === "1d" || interval === "1w") { const d = new Date(ms); return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate(), }; } return Math.floor(ms / 1000); } function candlesToLwc(candles, interval) { const ohlc = []; const vol = []; for (const c of candles) { const t = toLwcTime(c.time, interval); const up = c.close >= c.open; ohlc.push({ time: t, open: c.open, high: c.high, low: c.low, close: c.close, }); vol.push({ time: t, value: Number(c.quote_volume || c.volume || 0), color: up ? COLORS.volUp : COLORS.volDown, }); } return { ohlc, vol }; } function enqueueCharts(root) { root.querySelectorAll(".mini-chart[data-symbol]").forEach((box) => { const symbol = box.dataset.symbol; if (!symbol || box.dataset.loaded === "1" || box.dataset.loading === "1") return; chartQueue.push(box); }); runChartQueue(); } async function runChartQueue() { if (chartQueueRunning) return; chartQueueRunning = true; while (chartQueue.length) { const box = chartQueue.shift(); if (!box || !box.isConnected) continue; await loadMiniChart(box); await sleep(CHART_FETCH_GAP_MS); } chartQueueRunning = false; } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function volOf(c) { return Number(c.quote_volume || c.volume || 0); } function setupCanvas(canvas, displayW, displayH) { const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.style.width = `${displayW}px`; canvas.style.height = `${displayH}px`; const pw = Math.floor(displayW * dpr); const ph = Math.floor(displayH * dpr); if (canvas.width !== pw || canvas.height !== ph) { canvas.width = pw; canvas.height = ph; } const ctx = canvas.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return { ctx, w: displayW, h: displayH }; } function drawCandlestickChart(canvas, candles, options = {}) { if (!canvas || !candles.length) return; const large = options.large === true; const size = large ? modalChartSize() : MINI_SIZE; const volRatio = large ? 0.22 : 0.32; const pad = large ? { t: 16, r: 16, b: 28, l: 56 } : { t: 6, r: 6, b: 14, l: 6 }; const { ctx, w, h } = setupCanvas(canvas, size.w, size.h); const priceH = (h - pad.t - pad.b) * (1 - volRatio); const volH = (h - pad.t - pad.b) * volRatio; const volTop = pad.t + priceH + (large ? 8 : 4); const plotW = w - pad.l - pad.r; const n = candles.length; const step = plotW / n; let pMin = Infinity; let pMax = -Infinity; let vMax = 0; for (const c of candles) { pMin = Math.min(pMin, c.low); pMax = Math.max(pMax, c.high); vMax = Math.max(vMax, volOf(c)); } const pRange = pMax - pMin || 1; vMax = vMax || 1; const yPrice = (p) => pad.t + priceH * (1 - (p - pMin) / pRange); const yVol = (v) => volTop + volH * (1 - v / vMax); ctx.clearRect(0, 0, w, h); ctx.fillStyle = COLORS.bg; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = COLORS.grid; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(pad.l, volTop - 2); ctx.lineTo(w - pad.r, volTop - 2); ctx.stroke(); const bodyW = Math.max(large ? 2 : 1, step * (large ? 0.72 : 0.68)); for (let i = 0; i < n; i++) { const c = candles[i]; const up = c.close >= c.open; const x = pad.l + i * step + step / 2; const color = up ? COLORS.up : COLORS.down; const volColor = up ? COLORS.volUp : COLORS.volDown; const yHigh = yPrice(c.high); const yLow = yPrice(c.low); const yOpen = yPrice(c.open); const yClose = yPrice(c.close); ctx.strokeStyle = color; ctx.lineWidth = large ? 1.5 : 1; ctx.beginPath(); ctx.moveTo(x, yHigh); ctx.lineTo(x, yLow); ctx.stroke(); const top = Math.min(yOpen, yClose); const bodyHeight = Math.max(large ? 2 : 1, Math.abs(yClose - yOpen)); ctx.fillStyle = color; ctx.fillRect(x - bodyW / 2, top, bodyW, bodyHeight); const v = volOf(c); const barH = volH * (v / vMax); if (barH > 0.5) { ctx.fillStyle = volColor; ctx.fillRect(x - bodyW / 2, yVol(v), bodyW, barH); } } } function drawEmptyChart(canvas) { if (!canvas) return; const { ctx, w, h } = setupCanvas(canvas, MINI_SIZE.w, MINI_SIZE.h); ctx.fillStyle = "#1a2332"; ctx.fillRect(0, 0, w, h); ctx.fillStyle = COLORS.text; ctx.font = "13px sans-serif"; ctx.fillText("暂无数据", w / 2 - 28, h / 2); } async function fetchKlines(symbol, interval = DEFAULT_MINI_INTERVAL) { const key = cacheKey(symbol, interval); let cached = chartDataCache.get(key); if (cached) return cached; const ls = loadKlineFromLS(symbol, interval); if (ls) { const priceMeta = rememberPriceMeta(symbol, ls); const result = { candles: ls.candles, source: ls.source || "browser", interval, tick_size: priceMeta?.tick_size, price_precision: priceMeta?.price_precision, }; chartDataCache.set(key, result); return result; } const limit = limitForInterval(interval); const res = await fetch(`/api/chart/${symbol}?interval=${interval}&limit=${limit}`); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || res.statusText); } const data = await res.json(); const priceMeta = rememberPriceMeta(symbol, data); const result = { candles: data.candles || [], source: data.source || "db", interval, tick_size: priceMeta.tick_size, price_precision: priceMeta.price_precision, }; chartDataCache.set(key, result); saveKlineToLS(symbol, interval, result.candles, result.source, priceMeta); return result; } async function loadMiniChart(box) { const symbol = box.dataset.symbol; if (!symbol) return; box.dataset.loading = "1"; const canvas = box.querySelector("canvas"); const status = box.querySelector(".chart-status"); if (status) status.textContent = "加载…"; try { const { candles, source } = await fetchKlines(symbol, DEFAULT_MINI_INTERVAL); if (!candles.length) throw new Error("无K线数据"); drawCandlestickChart(canvas, candles, { large: false }); box.dataset.loaded = "1"; if (status) status.textContent = `${candles.length}日·${sourceLabel(source)}`; box.title = `${symbol} 日K ${candles.length}根 (${sourceLabel(source)}),点击查看大图`; } catch (e) { if (status) status.textContent = "—"; box.title = `${symbol}: ${e.message}`; drawEmptyChart(canvas); } finally { box.dataset.loading = "0"; } } function destroyLwcChart() { unbindVisibleRangeHighLow(); clearHighLowAnnotations(); lwcModalCandles = []; if (lwcResizeObserver) { lwcResizeObserver.disconnect(); lwcResizeObserver = null; } if (lwcChart) { lwcChart.remove(); lwcChart = null; lwcCandleSeries = null; lwcVolumeSeries = null; } } function clearHighLowAnnotations() { if (lwcCandleSeries) { lwcPriceLines.forEach((line) => { try { lwcCandleSeries.removePriceLine(line); } catch { /* already removed */ } }); lwcCandleSeries.setMarkers([]); } lwcPriceLines = []; } function applySeriesPriceFormat(priceMeta) { if (!lwcCandleSeries) return; const precision = priceMeta.price_precision; const minMove = parseMinMove(priceMeta.tick_size); lwcCandleSeries.applyOptions({ priceFormat: { type: "price", precision, minMove, }, }); } function applyHighLowAnnotations(candles, interval, priceMeta) { if (!lwcCandleSeries || !candles.length) return; clearHighLowAnnotations(); const { maxHigh, minLow, highTime, lowTime } = findCandleExtremes(candles, interval); if (!Number.isFinite(maxHigh) || !Number.isFinite(minLow)) return; const precision = priceMeta.price_precision; const highText = formatPrice(maxHigh, precision); const lowText = formatPrice(minLow, precision); lwcPriceLines.push( lwcCandleSeries.createPriceLine({ price: maxHigh, color: COLORS.up, lineWidth: 1, lineStyle: LightweightCharts.LineStyle.Dotted, axisLabelVisible: true, title: `最高 ${highText}`, }), lwcCandleSeries.createPriceLine({ price: minLow, color: COLORS.down, lineWidth: 1, lineStyle: LightweightCharts.LineStyle.Dotted, axisLabelVisible: true, title: `最低 ${lowText}`, }) ); const markers = []; if (highTime != null) { markers.push({ time: highTime, position: "aboveBar", color: COLORS.up, shape: "circle", text: `高 ${highText}`, }); } if (lowTime != null) { markers.push({ time: lowTime, position: "belowBar", color: COLORS.down, shape: "circle", text: `低 ${lowText}`, }); } lwcCandleSeries.setMarkers(markers); } function ensureLwcChart(container) { if (typeof LightweightCharts === "undefined") { container.innerHTML = '
图表库加载失败
'; return null; } destroyLwcChart(); const { w, h } = modalChartSize(); container.style.width = `${w}px`; container.style.height = `${h}px`; lwcChart = LightweightCharts.createChart(container, { width: w, height: h, layout: { background: { color: COLORS.bg }, textColor: COLORS.text, }, grid: { vertLines: { visible: false }, horzLines: { visible: false }, }, crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, rightPriceScale: { borderColor: COLORS.grid }, timeScale: { borderColor: COLORS.grid, timeVisible: true, secondsVisible: false, }, }); lwcCandleSeries = lwcChart.addCandlestickSeries({ upColor: COLORS.up, downColor: COLORS.down, borderUpColor: COLORS.up, borderDownColor: COLORS.down, wickUpColor: COLORS.up, wickDownColor: COLORS.down, }); lwcVolumeSeries = lwcChart.addHistogramSeries({ priceFormat: { type: "volume" }, priceScaleId: "", }); lwcVolumeSeries.priceScale().applyOptions({ scaleMargins: { top: 0.82, bottom: 0 }, }); lwcResizeObserver = new ResizeObserver(() => { if (!lwcChart || !container.isConnected) return; const rect = container.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { lwcChart.applyOptions({ width: rect.width, height: rect.height }); } }); lwcResizeObserver.observe(container); bindVisibleRangeHighLow(); return lwcChart; } function renderLwcChart(candles, interval, priceMeta) { const container = document.getElementById("chart-modal-container"); if (!container) return; if (!lwcChart) ensureLwcChart(container); if (!lwcCandleSeries || !lwcVolumeSeries) return; const meta = getPriceMeta(chartModalSymbol, priceMeta); applySeriesPriceFormat(meta); lwcModalCandles = candles; lwcModalInterval = interval; lwcModalPriceMeta = meta; const { ohlc, vol } = candlesToLwc(candles, interval); lwcCandleSeries.setData(ohlc); lwcVolumeSeries.setData(vol); lwcChart.timeScale().fitContent(); requestAnimationFrame(() => updateHighLowForVisibleWindow()); } function updateIntervalTabs() { document.querySelectorAll(".chart-interval-btn").forEach((btn) => { btn.classList.toggle("active", btn.dataset.interval === chartModalInterval); }); } function updateModalMeta(candles, source, interval) { const title = document.getElementById("chart-modal-title"); const hint = document.getElementById("chart-modal-hint"); if (title) { title.textContent = `${chartModalSymbol} · ${interval.toUpperCase()} K线`; } if (hint) { hint.textContent = `${candles.length} 根 · ${sourceLabel(source)} · 最高/最低随可见窗口 · 滚轮缩放 · Esc 关闭`; } } async function loadModalChart(interval) { chartModalInterval = interval; updateIntervalTabs(); const container = document.getElementById("chart-modal-container"); const hint = document.getElementById("chart-modal-hint"); if (hint) hint.textContent = "加载中…"; try { const { candles, source, tick_size, price_precision } = await fetchKlines( chartModalSymbol, interval ); if (!candles.length) throw new Error("无K线数据"); renderLwcChart(candles, interval, { tick_size, price_precision }); updateModalMeta(candles, source, interval); } catch (e) { if (hint) hint.textContent = `加载失败: ${e.message}`; destroyLwcChart(); if (container) { container.innerHTML = `${e.message}
`; } } } function closeChartModal() { const modal = document.getElementById("chart-modal"); if (!modal) return; modal.classList.add("hidden"); destroyLwcChart(); chartModalSymbol = ""; } async function openChartModal(symbol) { const key = cacheKey(symbol, DEFAULT_MINI_INTERVAL); const cached = chartDataCache.get(key); if (!cached?.candles?.length) { try { await fetchKlines(symbol, DEFAULT_MINI_INTERVAL); } catch { return; } } chartModalSymbol = symbol; chartModalInterval = DEFAULT_MINI_INTERVAL; const modal = document.getElementById("chart-modal"); modal.classList.remove("hidden"); const container = document.getElementById("chart-modal-container"); if (container) container.innerHTML = ""; await loadModalChart(DEFAULT_MINI_INTERVAL); } function setupChartModal() { let modal = document.getElementById("chart-modal"); if (!modal) { modal = document.createElement("div"); modal.id = "chart-modal"; modal.className = "chart-modal hidden"; modal.innerHTML = `