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;
}