From 072592145fc9a73bcff349e4b98af9e101f65ead Mon Sep 17 00:00:00 2001 From: root Date: Fri, 27 Feb 2026 06:36:19 +0000 Subject: [PATCH] feat: kline page with lightweight-charts + /api/kline OHLC aggregation endpoint --- backend/main.py | 59 +++++++++- frontend/app/kline/page.tsx | 190 +++++++++++++++++++++++++++++++++ frontend/components/Navbar.tsx | 3 +- frontend/package.json | 5 +- 4 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 frontend/app/kline/page.tsx diff --git a/backend/main.py b/backend/main.py index b322762..2218b5d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -126,8 +126,63 @@ async def get_snapshots(hours: int = 24, limit: int = 5000): "hours": hours, "data": [dict(r) for r in rows] } - set_cache("rates", result) - return result + + +@app.get("/api/kline") +async def get_kline(symbol: str = "BTC", interval: str = "5m", limit: int = 500): + """ + 从 rate_snapshots 聚合K线数据 + symbol: BTC | ETH + interval: 1m | 5m | 30m | 1h | 4h | 8h | 1d | 1w | 1M + 返回: [{time, open, high, low, close, price_open, price_high, price_low, price_close}] + """ + interval_secs = { + "1m": 60, "5m": 300, "30m": 1800, + "1h": 3600, "4h": 14400, "8h": 28800, + "1d": 86400, "1w": 604800, "1M": 2592000, + } + bar_secs = interval_secs.get(interval, 300) + rate_col = "btc_rate" if symbol.upper() == "BTC" else "eth_rate" + price_col = "btc_price" if symbol.upper() == "BTC" else "eth_price" + + # 查询足够多的原始数据(limit根K * bar_secs最多需要的时间范围) + since = int(time.time()) - bar_secs * limit + conn = sqlite3.connect(DB_PATH) + rows = conn.execute( + f"SELECT ts, {rate_col} as rate, {price_col} as price FROM rate_snapshots WHERE ts >= ? ORDER BY ts ASC", + (since,) + ).fetchall() + conn.close() + + if not rows: + return {"symbol": symbol, "interval": interval, "data": []} + + # 按bar_secs分组聚合OHLC + bars: dict = {} + for ts, rate, price in rows: + bar_ts = (ts // bar_secs) * bar_secs + if bar_ts not in bars: + bars[bar_ts] = { + "time": bar_ts, + "open": rate, "high": rate, "low": rate, "close": rate, + "price_open": price, "price_high": price, "price_low": price, "price_close": price, + } + else: + b = bars[bar_ts] + b["high"] = max(b["high"], rate) + b["low"] = min(b["low"], rate) + b["close"] = rate + b["price_high"] = max(b["price_high"], price) + b["price_low"] = min(b["price_low"], price) + b["price_close"] = price + + data = sorted(bars.values(), key=lambda x: x["time"])[-limit:] + # 转换为百分比(费率) + for b in data: + for k in ("open", "high", "low", "close"): + b[k] = round(b[k] * 100, 6) + + return {"symbol": symbol, "interval": interval, "count": len(data), "data": data} @app.get("/api/history") diff --git a/frontend/app/kline/page.tsx b/frontend/app/kline/page.tsx new file mode 100644 index 0000000..1cfcc3f --- /dev/null +++ b/frontend/app/kline/page.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import { createChart, ColorType, CandlestickSeries, LineSeries } from "lightweight-charts"; + +const INTERVALS = [ + { label: "1分钟", value: "1m" }, + { label: "5分钟", value: "5m" }, + { label: "30分钟", value: "30m" }, + { label: "1小时", value: "1h" }, + { label: "4小时", value: "4h" }, + { label: "8小时", value: "8h" }, + { label: "日线", value: "1d" }, + { label: "周线", value: "1w" }, + { label: "月线", value: "1M" }, +]; + +interface KBar { + time: number; + open: number; + high: number; + low: number; + close: number; + price_open: number; + price_high: number; + price_low: number; + price_close: number; +} + +function chartOptions(height: number) { + return { + layout: { + background: { type: ColorType.Solid, color: "#ffffff" }, + textColor: "#64748b", + fontSize: 11, + }, + grid: { + vertLines: { color: "#f1f5f9" }, + horzLines: { color: "#f1f5f9" }, + }, + crosshair: { mode: 1 }, + timeScale: { + borderColor: "#e2e8f0", + timeVisible: true, + secondsVisible: false, + }, + rightPriceScale: { borderColor: "#e2e8f0" }, + height, + }; +} + +export default function KlinePage() { + const rateRef = useRef(null); + const priceRef = useRef(null); + const [symbol, setSymbol] = useState<"BTC" | "ETH">("BTC"); + const [interval, setInterval] = useState("5m"); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(true); + const rateChartRef = useRef | null>(null); + const priceChartRef = useRef | null>(null); + + const fetchAndRender = useCallback(async () => { + try { + const r = await fetch(`/api/kline?symbol=${symbol}&interval=${interval}&limit=500`); + const json = await r.json(); + const bars: KBar[] = json.data || []; + setCount(json.count || 0); + + if (!rateRef.current || !priceRef.current) return; + + // 销毁旧图表 + rateChartRef.current?.remove(); + priceChartRef.current?.remove(); + + // 费率K线 + const rateChart = createChart(rateRef.current, chartOptions(260)); + rateChartRef.current = rateChart; + const rateSeries = rateChart.addSeries(CandlestickSeries, { + upColor: "#16a34a", + downColor: "#dc2626", + borderUpColor: "#16a34a", + borderDownColor: "#dc2626", + wickUpColor: "#16a34a", + wickDownColor: "#dc2626", + }); + rateSeries.setData(bars.map(b => ({ + time: b.time as any, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + }))); + rateChart.timeScale().fitContent(); + + // 价格K线 + const priceChart = createChart(priceRef.current, chartOptions(260)); + priceChartRef.current = priceChart; + const priceSeries = priceChart.addSeries(CandlestickSeries, { + upColor: "#2563eb", + downColor: "#7c3aed", + borderUpColor: "#2563eb", + borderDownColor: "#7c3aed", + wickUpColor: "#2563eb", + wickDownColor: "#7c3aed", + }); + priceSeries.setData(bars.map(b => ({ + time: b.time as any, + open: b.price_open, + high: b.price_high, + low: b.price_low, + close: b.price_close, + }))); + priceChart.timeScale().fitContent(); + + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }, [symbol, interval]); + + useEffect(() => { + setLoading(true); + fetchAndRender(); + const iv = window.setInterval(fetchAndRender, 30_000); + return () => { + window.clearInterval(iv); + rateChartRef.current?.remove(); + priceChartRef.current?.remove(); + rateChartRef.current = null; + priceChartRef.current = null; + }; + }, [fetchAndRender]); + + return ( +
+ {/* 标题+控制栏 */} +
+
+

费率 K 线

+

+ 本地2秒粒度数据聚合 · 已记录 {count.toLocaleString()} 根K线 +

+
+
+ {/* 币种 */} + {(["BTC", "ETH"] as const).map(s => ( + + ))} + + {/* 周期 */} + {INTERVALS.map(iv => ( + + ))} +
+
+ + {loading &&
加载中...
} + + {/* 费率K线 */} +
+
+

{symbol} 资金费率 K 线(%)

+ 绿涨红跌 · 代表多头情绪强弱 +
+
+
+ + {/* 价格K线 */} +
+
+

{symbol} 标记价格 K 线(USD)

+ 蓝涨紫跌 · 与费率对比观察趋势 +
+
+
+ +
+ 说明: + 数据来自本地rate_snapshots表(每2秒一条),在前端聚合为各时间周期K线,每30秒自动刷新。数据仅从露露服务器启动后开始积累,历史越长越有价值。 +
+
+ ); +} diff --git a/frontend/components/Navbar.tsx b/frontend/components/Navbar.tsx index 279d496..7c76f9d 100644 --- a/frontend/components/Navbar.tsx +++ b/frontend/components/Navbar.tsx @@ -4,7 +4,8 @@ import { useState } from "react"; const navLinks = [ { href: "/", label: "仪表盘" }, - { href: "/live", label: "实时变动" }, + { href: "/kline", label: "K线" }, + { href: "/live", label: "实时" }, { href: "/history", label: "历史" }, { href: "/signals", label: "信号" }, { href: "/about", label: "说明" }, diff --git a/frontend/package.json b/frontend/package.json index 8d2db76..3f1d7a1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,8 @@ "react": "19.2.3", "react-dom": "19.2.3", "recharts": "^3.7.0", - "tailwind-merge": "^3.5.0" + "tailwind-merge": "^3.5.0", + "lightweight-charts": "^5.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -28,4 +29,4 @@ "tailwindcss": "^4", "typescript": "^5" } -} +} \ No newline at end of file