diff --git a/backend/app/binance.py b/backend/app/binance.py index 25f35a4..a776539 100644 --- a/backend/app/binance.py +++ b/backend/app/binance.py @@ -227,6 +227,7 @@ class BinanceFuturesClient: "low": float(k[3]), "close": float(k[4]), "volume": float(k[5]), + "quote_volume": float(k[7]), } ) return candles diff --git a/web/app.js b/web/app.js index 40c0417..f9b5c47 100644 --- a/web/app.js +++ b/web/app.js @@ -107,7 +107,7 @@ function renderTable(tableId, tbody) { ${row.symbol}
- +
diff --git a/web/charts.js b/web/charts.js index 7efcf9d..99698e1 100644 --- a/web/charts.js +++ b/web/charts.js @@ -1,10 +1,23 @@ -/** 迷你日 K 线图(Canvas) + 限速队列 */ +/** 日 K + 成交量(Canvas 高清) */ const chartDataCache = new Map(); const chartQueue = []; let chartQueueRunning = false; const CHART_FETCH_GAP_MS = 120; +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 }; +const MODAL_SIZE = { w: 1280, h: 720 }; + function enqueueCharts(root) { root.querySelectorAll(".mini-chart[data-symbol]").forEach((box) => { const symbol = box.dataset.symbol; @@ -30,6 +43,136 @@ 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 ? MODAL_SIZE : 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); + + if (large) { + ctx.strokeStyle = COLORS.grid; + ctx.lineWidth = 1; + for (let i = 0; i <= 4; i++) { + const y = pad.t + (priceH * i) / 4; + ctx.beginPath(); + ctx.moveTo(pad.l, y); + ctx.lineTo(w - pad.r, y); + ctx.stroke(); + const price = pMax - (pRange * i) / 4; + ctx.fillStyle = COLORS.text; + ctx.font = "11px Segoe UI, system-ui, sans-serif"; + ctx.textAlign = "right"; + ctx.fillText(price.toPrecision(6), pad.l - 8, y + 4); + } + ctx.fillStyle = COLORS.text; + ctx.font = "12px Segoe UI, system-ui, sans-serif"; + ctx.textAlign = "left"; + ctx.fillText("价格", pad.l, pad.t - 4); + ctx.fillText("成交量", pad.l, volTop - 4); + } + + 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, large = false) { + if (!canvas) return; + const size = large ? MODAL_SIZE : MINI_SIZE; + const { ctx, w, h } = setupCanvas(canvas, size.w, 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 loadMiniChart(box) { const symbol = box.dataset.symbol; if (!symbol) return; @@ -53,85 +196,21 @@ async function loadMiniChart(box) { chartDataCache.set(symbol, candles); } if (!candles.length) throw new Error("无K线数据"); - drawCandlestickChart(canvas, candles); + drawCandlestickChart(canvas, candles, { large: false }); box.dataset.loaded = "1"; const srcLabel = source === "db" ? "本地" : source === "db_stale" ? "本地(旧)" : source === "cache" ? "缓存" : "同步"; if (status) status.textContent = `${candles.length}日·${srcLabel}`; - box.title = `${symbol} 最近${candles.length}根日K (${srcLabel})`; + box.title = `${symbol} 日K+量 ${candles.length}根 (${srcLabel}),点击放大`; } catch (e) { if (status) status.textContent = "—"; box.title = `${symbol}: ${e.message}`; - drawEmptyChart(canvas); + drawEmptyChart(canvas, false); } finally { box.dataset.loading = "0"; } } -function drawEmptyChart(canvas) { - if (!canvas) return; - const ctx = canvas.getContext("2d"); - const w = canvas.width; - const h = canvas.height; - ctx.clearRect(0, 0, w, h); - ctx.fillStyle = "#3a4558"; - ctx.fillRect(0, 0, w, h); - ctx.fillStyle = "#8b9cb3"; - ctx.font = "11px sans-serif"; - ctx.fillText("暂无", 8, h / 2 + 4); -} - -function drawCandlestickChart(canvas, candles) { - if (!canvas || !candles.length) return; - const ctx = canvas.getContext("2d"); - const w = canvas.width; - const h = canvas.height; - const pad = { t: 4, r: 4, b: 4, l: 4 }; - const plotW = w - pad.l - pad.r; - const plotH = h - pad.t - pad.b; - - let min = Infinity; - let max = -Infinity; - for (const c of candles) { - min = Math.min(min, c.low); - max = Math.max(max, c.high); - } - const range = max - min || 1; - const n = candles.length; - const step = plotW / n; - const bodyW = Math.max(1, step * 0.65); - - ctx.clearRect(0, 0, w, h); - ctx.fillStyle = "#121820"; - ctx.fillRect(0, 0, w, h); - - const yOf = (price) => pad.t + plotH * (1 - (price - min) / range); - - 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 yHigh = yOf(c.high); - const yLow = yOf(c.low); - const yOpen = yOf(c.open); - const yClose = yOf(c.close); - const color = up ? "#0ecb81" : "#f6465d"; - - ctx.strokeStyle = color; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(x, yHigh); - ctx.lineTo(x, yLow); - ctx.stroke(); - - const top = Math.min(yOpen, yClose); - const bodyH = Math.max(1, Math.abs(yClose - yOpen)); - ctx.fillStyle = color; - ctx.fillRect(x - bodyW / 2, top, bodyW, bodyH); - } -} - -/** 点击放大 */ function setupChartModal() { let modal = document.getElementById("chart-modal"); if (!modal) { @@ -142,7 +221,10 @@ function setupChartModal() {

- +

日K + 成交量 · 300根 · 滚轮可缩放页面

+
+ +
`; document.body.appendChild(modal); modal.querySelector(".chart-modal-close").onclick = () => @@ -150,6 +232,9 @@ function setupChartModal() { modal.addEventListener("click", (e) => { if (e.target === modal) modal.classList.add("hidden"); }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") modal.classList.add("hidden"); + }); } document.body.addEventListener("click", (e) => { @@ -160,11 +245,9 @@ function setupChartModal() { if (!candles) return; modal.classList.remove("hidden"); document.getElementById("chart-modal-title").textContent = - `${symbol} · 日K ${candles.length}根`; - drawCandlestickChart( - document.getElementById("chart-modal-canvas"), - candles - ); + `${symbol} · 日K + 成交量(${candles.length} 根)`; + const canvas = document.getElementById("chart-modal-canvas"); + drawCandlestickChart(canvas, candles, { large: true }); }); } diff --git a/web/index.html b/web/index.html index 98a3e08..6fa8f2b 100644 --- a/web/index.html +++ b/web/index.html @@ -9,7 +9,7 @@

币安 U本位合约 · 成交额排名

-

北京时间 08:00 切日 · Top30 · 高亮:≥1000万 USDT / |涨跌|≥5% · 合约右侧 300 日K · 点击图表放大

+

北京时间 08:00 切日 · Top30 · 合约右侧 300 日K+成交量 · 点击图表放大查看

diff --git a/web/style.css b/web/style.css index 55db559..4944180 100644 --- a/web/style.css +++ b/web/style.css @@ -22,7 +22,7 @@ body { color: var(--text); line-height: 1.5; padding: 1.5rem; - max-width: 1280px; + max-width: 1380px; margin-inline: auto; } @@ -225,7 +225,7 @@ button:hover { } .chart-col { - min-width: 320px; + min-width: 400px; color: var(--muted); font-size: 0.8rem; } @@ -237,19 +237,17 @@ button:hover { .mini-chart { position: relative; - width: 300px; - height: 64px; + width: 380px; + height: 100px; cursor: zoom-in; - border-radius: 4px; + border-radius: 6px; overflow: hidden; border: 1px solid var(--border); - background: #121820; + background: #0d1118; } .mini-chart canvas { display: block; - width: 300px; - height: 64px; } .chart-status { @@ -278,14 +276,29 @@ button:hover { .chart-modal-inner { background: var(--panel); border: 1px solid var(--border); - border-radius: 10px; - padding: 1rem 1.25rem; - max-width: 95vw; + border-radius: 12px; + padding: 1.25rem 1.5rem 1.5rem; + max-width: 96vw; + max-height: 96vh; + overflow: auto; } .chart-modal-inner h3 { + margin: 0 0 0.25rem; + font-size: 1.15rem; +} + +.chart-modal-hint { margin: 0 0 0.75rem; - font-size: 1rem; + font-size: 0.8rem; + color: var(--muted); +} + +.chart-modal-canvas-wrap { + overflow: auto; + border-radius: 8px; + border: 1px solid var(--border); + background: #0d1118; } .chart-modal-close { @@ -300,7 +313,5 @@ button:hover { #chart-modal-canvas { display: block; - max-width: 100%; - border-radius: 6px; - background: #121820; + background: #0d1118; }