feat: kline page with lightweight-charts + /api/kline OHLC aggregation endpoint

This commit is contained in:
root 2026-02-27 06:36:19 +00:00
parent c6801e061c
commit 072592145f
4 changed files with 252 additions and 5 deletions

View File

@ -126,8 +126,63 @@ async def get_snapshots(hours: int = 24, limit: int = 5000):
"hours": hours, "hours": hours,
"data": [dict(r) for r in rows] "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") @app.get("/api/history")

190
frontend/app/kline/page.tsx Normal file
View File

@ -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<HTMLDivElement>(null);
const priceRef = useRef<HTMLDivElement>(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<ReturnType<typeof createChart> | null>(null);
const priceChartRef = useRef<ReturnType<typeof createChart> | 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 (
<div className="space-y-5">
{/* 标题+控制栏 */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-slate-900"> K 线</h1>
<p className="text-slate-500 text-sm mt-1">
2 · <span className="text-blue-600 font-medium">{count.toLocaleString()}</span> K线
</p>
</div>
<div className="flex flex-wrap gap-2 text-sm">
{/* 币种 */}
{(["BTC", "ETH"] as const).map(s => (
<button key={s} onClick={() => setSymbol(s)}
className={`px-3 py-1.5 rounded-lg border font-medium transition-colors ${symbol === s ? "bg-blue-600 text-white border-blue-600" : "border-slate-200 text-slate-600 hover:border-blue-400"}`}>
{s}
</button>
))}
<span className="w-px bg-slate-200 mx-1" />
{/* 周期 */}
{INTERVALS.map(iv => (
<button key={iv.value} onClick={() => setInterval(iv.value)}
className={`px-3 py-1.5 rounded-lg border transition-colors ${interval === iv.value ? "bg-slate-800 text-white border-slate-800" : "border-slate-200 text-slate-600 hover:border-slate-400"}`}>
{iv.label}
</button>
))}
</div>
</div>
{loading && <div className="text-slate-400 py-8 text-center">...</div>}
{/* 费率K线 */}
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-slate-700 font-semibold text-sm">{symbol} K 线%</h2>
<span className="text-xs text-slate-400">绿 · </span>
</div>
<div ref={rateRef} className="w-full" style={{ height: 260 }} />
</div>
{/* 价格K线 */}
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-slate-700 font-semibold text-sm">{symbol} K 线USD</h2>
<span className="text-xs text-slate-400"> · </span>
</div>
<div ref={priceRef} className="w-full" style={{ height: 260 }} />
</div>
<div className="rounded-lg border border-blue-100 bg-blue-50 px-5 py-3 text-sm text-slate-600">
<span className="text-blue-600 font-medium"></span>
rate_snapshots表2K线30
</div>
</div>
);
}

View File

@ -4,7 +4,8 @@ import { useState } from "react";
const navLinks = [ const navLinks = [
{ href: "/", label: "仪表盘" }, { href: "/", label: "仪表盘" },
{ href: "/live", label: "实时变动" }, { href: "/kline", label: "K线" },
{ href: "/live", label: "实时" },
{ href: "/history", label: "历史" }, { href: "/history", label: "历史" },
{ href: "/signals", label: "信号" }, { href: "/signals", label: "信号" },
{ href: "/about", label: "说明" }, { href: "/about", label: "说明" },

View File

@ -16,7 +16,8 @@
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0",
"lightweight-charts": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",