212 lines
7.5 KiB
TypeScript
212 lines
7.5 KiB
TypeScript
"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" },
|
||
},
|
||
localization: {
|
||
timeFormatter: (ts: number) => {
|
||
// UTC+8 北京时间
|
||
const d = new Date((ts + 8 * 3600) * 1000);
|
||
const Y = d.getUTCFullYear();
|
||
const M = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||
const D = String(d.getUTCDate()).padStart(2, "0");
|
||
const h = String(d.getUTCHours()).padStart(2, "0");
|
||
const m = String(d.getUTCMinutes()).padStart(2, "0");
|
||
return `${Y}-${M}-${D} ${h}:${m}`;
|
||
},
|
||
},
|
||
timeScale: {
|
||
borderColor: "#e2e8f0",
|
||
timeVisible: true,
|
||
secondsVisible: false,
|
||
tickMarkFormatter: (ts: number) => {
|
||
const d = new Date((ts + 8 * 3600) * 1000);
|
||
const h = String(d.getUTCHours()).padStart(2, "0");
|
||
const m = String(d.getUTCMinutes()).padStart(2, "0");
|
||
return `${h}:${m}`;
|
||
},
|
||
},
|
||
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("1h");
|
||
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();
|
||
// 隐藏 TradingView 水印
|
||
rateRef.current.querySelectorAll("a").forEach(a => a.style.display = "none");
|
||
|
||
// 价格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();
|
||
// 隐藏 TradingView 水印
|
||
priceRef.current.querySelectorAll("a").forEach(a => a.style.display = "none");
|
||
|
||
} 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">原始值×10000 · 绿涨红跌 · 代表多头情绪强弱</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表(每2秒一条),在前端聚合为各时间周期K线,每30秒自动刷新。数据仅从露露服务器启动后开始积累,历史越长越有价值。
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|