diff --git a/.env.example b/.env.example index b7e367e..2f64cfe 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,5 @@ BAN_COOLDOWN_SEC=90 CANDIDATE_POOL=150 TODAY_DATA_MODE=ticker24h YESTERDAY_DATA_MODE=klines +CHART_KLINE_LIMIT=300 +CHART_CACHE_MINUTES=60 diff --git a/DEPLOY.md b/DEPLOY.md index 1ca4ce1..0aaa5ee 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -286,7 +286,7 @@ git pull | `cannot pull with rebase: unstaged changes` | 执行 `git stash` 后重试;或 `DEPLOY_SKIP_GIT_PULL=1 ./deploy/pm2-deploy.sh` 跳过拉取 | | `No module named pip` | 执行 `sudo apt install -y python3-venv` 后重新 `./deploy/pm2-deploy.sh`(脚本会用 .venv) | | Web 无数据 | 检查能否访问币安;国内服务器尝试 `PROXY_ENABLED=true` | -| 大量 `418 I'm a teapot` | IP 被封禁;**不要反复 restart**(会加长封禁)。等待日志中的 cooldown 秒数(如 734s)后再启动;今日刷新已改为 `TODAY_DATA_MODE=ticker24h`(仅 1 次 API) | +| 大量 `418 I'm a teapot` | IP 被封禁;**不要反复 restart**。日 K 已存 SQLite,图表优先读本地;仅过期或首次才请求币安 | | 企微收不到 | 检查 `WECOM_WEBHOOK_URL`;`curl -X POST .../api/push/test` | | 08:10 未推送 | 确认容器/PM2 在 08:10 前已运行;查日志 | | 端口占用 | `ss -tlnp \| grep 21450` 或改 `.env` 中 `PORT` | diff --git a/README.md b/README.md index c9622e7..d45a910 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ python run.py | `PROXY_FOR` | 代理范围 binance/wecom/all | binance | | `MAX_CONCURRENCY` | 币安 K 线并发数(过大易 418 封禁) | 3 | | `CANDIDATE_POOL` | 预筛候选合约数(按 24h 成交额) | 150 | +| `CHART_KLINE_LIMIT` | 日 K 存储/展示根数 | 300 | +| `CHART_CACHE_MINUTES` | 本地日 K 视为新鲜的时间(分钟内不请求币安) | 60 | ## API | 方法 | 路径 | 说明 | diff --git a/backend/app/binance.py b/backend/app/binance.py index 4071929..25f35a4 100644 --- a/backend/app/binance.py +++ b/backend/app/binance.py @@ -212,5 +212,24 @@ class BinanceFuturesClient: sym_set = set(symbols) return {t["symbol"]: float(t["price"]) for t in tickers if t["symbol"] in sym_set} + async def get_daily_klines(self, symbol: str, limit: int = 300) -> list[dict]: + raw = await self._get( + "/fapi/v1/klines", + {"symbol": symbol.upper(), "interval": "1d", "limit": min(limit, 1500)}, + ) + candles = [] + for k in raw or []: + candles.append( + { + "time": int(k[0]), + "open": float(k[1]), + "high": float(k[2]), + "low": float(k[3]), + "close": float(k[4]), + "volume": float(k[5]), + } + ) + return candles + binance_client = BinanceFuturesClient() diff --git a/backend/app/config.py b/backend/app/config.py index 9282736..44db9d0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -29,6 +29,8 @@ class Settings(BaseSettings): # today: ticker24h=仅1次API(滚动24h); yesterday: klines=按8:00切日精确统计 today_data_mode: str = "ticker24h" yesterday_data_mode: str = "klines" + chart_kline_limit: int = 300 + chart_cache_minutes: int = 60 # 代理默认关闭;仅当 PROXY_ENABLED=true 时生效 proxy_enabled: bool = False proxy_url: str = "socks5h://192.168.8.4:1081" diff --git a/backend/app/db.py b/backend/app/db.py index 77049a9..5125315 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -46,6 +46,28 @@ def init_db() -> None: success INTEGER NOT NULL, message TEXT ); + + CREATE TABLE IF NOT EXISTS daily_klines ( + symbol TEXT NOT NULL, + open_time INTEGER NOT NULL, + open REAL NOT NULL, + high REAL NOT NULL, + low REAL NOT NULL, + close REAL NOT NULL, + volume REAL NOT NULL, + quote_volume REAL NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (symbol, open_time) + ); + + CREATE INDEX IF NOT EXISTS idx_daily_klines_symbol + ON daily_klines(symbol, open_time); + + CREATE TABLE IF NOT EXISTS kline_meta ( + symbol TEXT PRIMARY KEY, + last_fetch_at TEXT NOT NULL, + bar_count INTEGER NOT NULL + ); """ ) @@ -108,6 +130,92 @@ def log_push(period_start: str, period_end: str, success: bool, message: str = " ) +def save_daily_klines(symbol: str, candles: list[dict[str, Any]]) -> None: + sym = symbol.upper() + now = datetime.now().isoformat() + with get_conn() as conn: + for c in candles: + conn.execute( + """ + INSERT INTO daily_klines ( + symbol, open_time, open, high, low, close, volume, quote_volume, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(symbol, open_time) DO UPDATE SET + open = excluded.open, + high = excluded.high, + low = excluded.low, + close = excluded.close, + volume = excluded.volume, + quote_volume = excluded.quote_volume, + updated_at = excluded.updated_at + """, + ( + sym, + int(c["time"]), + float(c["open"]), + float(c["high"]), + float(c["low"]), + float(c["close"]), + float(c.get("volume", 0)), + float(c.get("quote_volume", 0)), + now, + ), + ) + conn.execute( + """ + INSERT INTO kline_meta (symbol, last_fetch_at, bar_count) + VALUES (?, ?, ?) + ON CONFLICT(symbol) DO UPDATE SET + last_fetch_at = excluded.last_fetch_at, + bar_count = excluded.bar_count + """, + (sym, now, len(candles)), + ) + + +def get_daily_klines_from_db(symbol: str, limit: int) -> list[dict[str, Any]]: + sym = symbol.upper() + with get_conn() as conn: + rows = conn.execute( + """ + SELECT open_time, open, high, low, close, volume, quote_volume + FROM daily_klines + WHERE symbol = ? + ORDER BY open_time DESC + LIMIT ? + """, + (sym, limit), + ).fetchall() + rows = list(reversed(rows)) + return [ + { + "time": int(r["open_time"]), + "open": float(r["open"]), + "high": float(r["high"]), + "low": float(r["low"]), + "close": float(r["close"]), + "volume": float(r["volume"]), + "quote_volume": float(r["quote_volume"]), + } + for r in rows + ] + + +def get_kline_meta(symbol: str) -> dict[str, Any] | None: + sym = symbol.upper() + with get_conn() as conn: + row = conn.execute( + "SELECT last_fetch_at, bar_count FROM kline_meta WHERE symbol = ?", + (sym,), + ).fetchone() + if not row: + return None + return { + "last_fetch_at": row["last_fetch_at"], + "bar_count": row["bar_count"], + } + + def was_pushed_today(period_start: str, period_end: str) -> bool: with get_conn() as conn: row = conn.execute( diff --git a/backend/app/kline_store.py b/backend/app/kline_store.py new file mode 100644 index 0000000..aa57802 --- /dev/null +++ b/backend/app/kline_store.py @@ -0,0 +1,93 @@ +"""日 K 线:优先 SQLite 本地库,不足或过期再请求币安。""" + +import logging +from datetime import datetime + +from .binance import binance_client +from .config import settings +from .db import get_daily_klines_from_db, get_kline_meta, save_daily_klines +from .exceptions import BinanceRateLimitedError + +logger = logging.getLogger(__name__) + + +def _is_db_fresh(symbol: str, min_bars: int) -> bool: + meta = get_kline_meta(symbol) + if not meta or meta.get("bar_count", 0) < min_bars: + return False + try: + last_fetch = datetime.fromisoformat(meta["last_fetch_at"]) + except ValueError: + return False + age = (datetime.now() - last_fetch).total_seconds() + return age < settings.chart_cache_minutes * 60 + + +async def sync_daily_klines(symbol: str, limit: int | None = None) -> list[dict]: + """从币安拉取并写入本地库。""" + sym = symbol.upper() + n = min(limit or settings.chart_kline_limit, 1500) + candles = await binance_client.get_daily_klines(sym, n) + save_daily_klines(sym, candles) + logger.info("Saved %d daily klines for %s to DB", len(candles), sym) + return candles + + +async def get_daily_candles( + symbol: str, + limit: int | None = None, + force_refresh: bool = False, +) -> tuple[list[dict], str]: + """ + 返回 (candles, source)。 + source: db | db_stale | binance + """ + sym = symbol.upper().strip() + n = min(limit or settings.chart_kline_limit, 1500) + min_bars = min(n, 50) + + if not force_refresh and _is_db_fresh(sym, min_bars): + candles = get_daily_klines_from_db(sym, n) + if len(candles) >= min_bars: + return candles, "db" + + stored = get_daily_klines_from_db(sym, n) + if binance_client.is_rate_limited(): + if stored: + logger.warning("Rate limited, serve stale DB klines for %s", sym) + return stored, "db_stale" + raise BinanceRateLimitedError(binance_client.rate_limit_remaining_sec(), sym) + + try: + candles = await sync_daily_klines(sym, n) + return candles, "binance" + except BinanceRateLimitedError: + if stored: + return stored, "db_stale" + raise + except Exception: + if stored: + return stored, "db_stale" + raise + + +async def prefetch_symbols(symbols: list[str]) -> None: + """后台预拉 Top 币种日 K 入库(串行,避免 418)。""" + seen: set[str] = set() + for raw in symbols: + sym = raw.upper().strip() + if not sym or sym in seen or not sym.endswith("USDT"): + continue + seen.add(sym) + if _is_db_fresh(sym, min(50, settings.chart_kline_limit)): + continue + if binance_client.is_rate_limited(): + logger.warning("Prefetch stopped — rate limited") + break + try: + await sync_daily_klines(sym) + except BinanceRateLimitedError: + logger.warning("Prefetch rate limited at %s", sym) + break + except Exception as e: + logger.warning("Prefetch %s failed: %s", sym, e) diff --git a/backend/app/main.py b/backend/app/main.py index d3b86fc..2a7b4fa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,9 @@ from fastapi.staticfiles import StaticFiles from .aggregator import aggregate_period, enrich_snapshot_meta from .config import ROOT_DIR, settings +from .kline_store import get_daily_candles, sync_daily_klines from .db import get_latest_snapshot, init_db, log_push +from .exceptions import BinanceRateLimitedError from .periods import get_today_period, get_yesterday_period from .scheduler import job_finalize_yesterday, job_push_wecom, job_refresh_today, start_scheduler, startup_tasks, stop_scheduler from .state import get_today_cache @@ -128,3 +130,36 @@ async def api_refresh_yesterday(): async def api_refresh_today(): await job_refresh_today() return get_today_cache() or {"message": "done"} + + +@app.get("/api/chart/{symbol}/daily") +async def api_chart_daily(symbol: str, limit: int | None = None, refresh: bool = False): + """合约日 K 线:优先读本地 SQLite,过期再拉币安入库。""" + sym = symbol.upper().strip() + if not sym.endswith("USDT"): + raise HTTPException(400, "invalid symbol") + try: + candles, source = await get_daily_candles(sym, limit, force_refresh=refresh) + return { + "symbol": sym, + "interval": "1d", + "limit": len(candles), + "candles": candles, + "source": source, + } + except BinanceRateLimitedError as e: + raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e + except Exception as e: + logger.error("chart %s failed: %s", sym, e) + raise HTTPException(502, "K线获取失败") from e + + +@app.post("/api/chart/{symbol}/daily/refresh") +async def api_chart_daily_refresh(symbol: str, limit: int | None = None): + """强制从币安同步日 K 到本地库。""" + sym = symbol.upper().strip() + try: + candles = await sync_daily_klines(sym, limit) + return {"symbol": sym, "saved": len(candles), "source": "binance"} + except BinanceRateLimitedError as e: + raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 6316b05..06cc2a9 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -11,6 +11,7 @@ from .db import get_latest_snapshot, init_db, log_push, save_snapshot, was_pushe from .exceptions import BinanceRateLimitedError from .periods import get_today_period, get_yesterday_period, now_shanghai from .state import get_today_cache, set_today_cache +from .kline_store import prefetch_symbols from .wecom import build_markdown, send_wecom_markdown logger = logging.getLogger(__name__) @@ -53,6 +54,9 @@ async def job_finalize_yesterday() -> None: ) save_snapshot("yesterday", start, end, items) logger.info("Yesterday snapshot saved: %d items", len(items)) + syms = [x["symbol"] for x in items if x.get("symbol")] + if syms: + await prefetch_symbols(syms) except BinanceRateLimitedError as e: logger.error("Finalize yesterday rate limited %ss", e.retry_after_sec) except Exception as e: @@ -113,6 +117,9 @@ async def job_refresh_today() -> None: save_snapshot("today", start, end, items) set_today_cache(meta) logger.info("Today cache refreshed: %d items", len(items)) + syms = [x["symbol"] for x in items if x.get("symbol")] + if syms: + await prefetch_symbols(syms) except BinanceRateLimitedError as e: logger.error("Refresh today rate limited %ss — use cache", e.retry_after_sec) _restore_today_from_db() diff --git a/web/app.js b/web/app.js index 591a8ac..40c0417 100644 --- a/web/app.js +++ b/web/app.js @@ -88,7 +88,7 @@ function renderTable(tableId, tbody) { const items = sortItems(state.items, state.sortKey, state.sortDir); if (!items.length) { - tbody.innerHTML = '暂无数据'; + tbody.innerHTML = '暂无数据'; updateSortHeaders(tableId); return; } @@ -104,7 +104,13 @@ function renderTable(tableId, tbody) { : idx + 1; return ` ${displayRank} - ${row.symbol} + ${row.symbol} + +
+ + +
+ ${row.quote_volume_fmt || row.quote_volume} ${row.price_change_pct_fmt || pct.toFixed(2) + "%"} ${renderTags(row)} @@ -113,6 +119,7 @@ function renderTable(tableId, tbody) { .join(""); updateSortHeaders(tableId); + enqueueCharts(tbody); } function setTableData(tableId, data) { @@ -206,7 +213,7 @@ document.querySelectorAll("[data-reset]").forEach((btn) => { async function loadYesterday() { const body = document.getElementById("yesterday-body"); - body.innerHTML = '加载中…'; + body.innerHTML = '加载中…'; try { const res = await fetch("/api/yesterday/top30"); const data = await res.json(); @@ -218,7 +225,7 @@ async function loadYesterday() { "更新: " + (data.updated_at || "").replace("T", " ").slice(0, 19); setTableData("yesterday", data); } catch (e) { - body.innerHTML = `加载失败: ${e.message}`; + body.innerHTML = `加载失败: ${e.message}`; } } @@ -236,7 +243,7 @@ async function loadToday() { setTableData("today", data); document.getElementById("status").textContent = "今日数据已刷新"; } catch (e) { - body.innerHTML = `加载失败: ${e.message}`; + body.innerHTML = `加载失败: ${e.message}`; document.getElementById("status").textContent = e.message; } } diff --git a/web/charts.js b/web/charts.js new file mode 100644 index 0000000..7efcf9d --- /dev/null +++ b/web/charts.js @@ -0,0 +1,171 @@ +/** 迷你日 K 线图(Canvas) + 限速队列 */ + +const chartDataCache = new Map(); +const chartQueue = []; +let chartQueueRunning = false; +const CHART_FETCH_GAP_MS = 120; + +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)); +} + +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 { + let candles = chartDataCache.get(symbol); + let source = "cache"; + if (!candles) { + const res = await fetch(`/api/chart/${symbol}/daily?limit=300`); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || res.statusText); + } + const data = await res.json(); + candles = data.candles || []; + source = data.source || "db"; + chartDataCache.set(symbol, candles); + } + if (!candles.length) throw new Error("无K线数据"); + drawCandlestickChart(canvas, candles); + 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})`; + } catch (e) { + if (status) status.textContent = "—"; + box.title = `${symbol}: ${e.message}`; + drawEmptyChart(canvas); + } 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) { + modal = document.createElement("div"); + modal.id = "chart-modal"; + modal.className = "chart-modal hidden"; + modal.innerHTML = ` +
+ +

+ +
`; + document.body.appendChild(modal); + modal.querySelector(".chart-modal-close").onclick = () => + modal.classList.add("hidden"); + modal.addEventListener("click", (e) => { + if (e.target === modal) modal.classList.add("hidden"); + }); + } + + document.body.addEventListener("click", (e) => { + const box = e.target.closest(".mini-chart[data-symbol]"); + if (!box || box.dataset.loaded !== "1") return; + const symbol = box.dataset.symbol; + const candles = chartDataCache.get(symbol); + if (!candles) return; + modal.classList.remove("hidden"); + document.getElementById("chart-modal-title").textContent = + `${symbol} · 日K ${candles.length}根`; + drawCandlestickChart( + document.getElementById("chart-modal-canvas"), + candles + ); + }); +} + +setupChartModal(); diff --git a/web/index.html b/web/index.html index 1fdf472..98a3e08 100644 --- a/web/index.html +++ b/web/index.html @@ -9,7 +9,7 @@

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

-

北京时间 08:00 切日 · Top30 · 高亮:≥1000万 USDT / |涨跌|≥5% · 点击表头可排序

+

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

@@ -28,6 +28,7 @@ 排名 合约 + 日线图 成交额 (USDT) 涨跌幅 标记 @@ -54,6 +55,7 @@ 排名 合约 + 日线图 成交额 (USDT) 涨跌幅 标记 @@ -69,6 +71,7 @@ + diff --git a/web/style.css b/web/style.css index 2273a3c..55db559 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: 1100px; + max-width: 1280px; margin-inline: auto; } @@ -218,3 +218,89 @@ button:hover { .error { color: var(--down); } + +.symbol-cell { + white-space: nowrap; + min-width: 88px; +} + +.chart-col { + min-width: 320px; + color: var(--muted); + font-size: 0.8rem; +} + +.chart-cell { + padding: 0.35rem 0.5rem !important; + vertical-align: middle; +} + +.mini-chart { + position: relative; + width: 300px; + height: 64px; + cursor: zoom-in; + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--border); + background: #121820; +} + +.mini-chart canvas { + display: block; + width: 300px; + height: 64px; +} + +.chart-status { + position: absolute; + right: 4px; + bottom: 2px; + font-size: 0.65rem; + color: var(--muted); + pointer-events: none; +} + +.chart-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.72); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.chart-modal.hidden { + display: none; +} + +.chart-modal-inner { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem 1.25rem; + max-width: 95vw; +} + +.chart-modal-inner h3 { + margin: 0 0 0.75rem; + font-size: 1rem; +} + +.chart-modal-close { + float: right; + background: transparent; + border: none; + color: var(--text); + font-size: 1.5rem; + cursor: pointer; + line-height: 1; +} + +#chart-modal-canvas { + display: block; + max-width: 100%; + border-radius: 6px; + background: #121820; +}