288 lines
14 KiB
TypeScript
288 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback, useRef } from "react";
|
||
import { createChart, ColorType, CandlestickSeries } from "lightweight-charts";
|
||
import { api, RatesResponse, StatsResponse, HistoryResponse, HistoryPoint, SignalHistoryItem, KBar } from "@/lib/api";
|
||
import RateCard from "@/components/RateCard";
|
||
import StatsCard from "@/components/StatsCard";
|
||
import {
|
||
LineChart, Line, XAxis, YAxis, Tooltip, Legend,
|
||
ResponsiveContainer, ReferenceLine
|
||
} from "recharts";
|
||
|
||
// ─── K线子组件 ──────────────────────────────────────────────────
|
||
const INTERVALS = [
|
||
{ label: "1m", value: "1m" }, { label: "5m", value: "5m" },
|
||
{ label: "30m", value: "30m" }, { label: "1h", value: "1h" },
|
||
{ label: "4h", value: "4h" }, { label: "8h", value: "8h" },
|
||
{ label: "日", value: "1d" }, { label: "周", value: "1w" }, { label: "月", value: "1M" },
|
||
];
|
||
|
||
function bjtTimeFormatter(ts: number) {
|
||
const d = new Date((ts + 8 * 3600) * 1000);
|
||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth()+1).padStart(2,"0")}-${String(d.getUTCDate()).padStart(2,"0")} ${String(d.getUTCHours()).padStart(2,"0")}:${String(d.getUTCMinutes()).padStart(2,"0")}`;
|
||
}
|
||
|
||
function baseChartOpts(height: number) {
|
||
return {
|
||
layout: { background: { type: ColorType.Solid, color: "#ffffff" }, textColor: "#64748b", fontSize: 11 },
|
||
grid: { vertLines: { color: "#f1f5f9" }, horzLines: { color: "#f1f5f9" } },
|
||
localization: { timeFormatter: bjtTimeFormatter },
|
||
timeScale: {
|
||
borderColor: "#e2e8f0", timeVisible: true, secondsVisible: false,
|
||
tickMarkFormatter: (ts: number) => {
|
||
const d = new Date((ts + 8 * 3600) * 1000);
|
||
return `${String(d.getUTCHours()).padStart(2,"0")}:${String(d.getUTCMinutes()).padStart(2,"0")}`;
|
||
},
|
||
},
|
||
rightPriceScale: { borderColor: "#e2e8f0" },
|
||
height,
|
||
};
|
||
}
|
||
|
||
function MiniKChart({ symbol, interval, mode, colors }: {
|
||
symbol: string; interval: string; mode: "rate"|"price";
|
||
colors: { up: string; down: string };
|
||
}) {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
const chartRef = useRef<ReturnType<typeof createChart> | null>(null);
|
||
|
||
const render = useCallback(async () => {
|
||
try {
|
||
const json = await api.kline(symbol, interval);
|
||
const bars: KBar[] = json.data || [];
|
||
if (!ref.current) return;
|
||
chartRef.current?.remove();
|
||
const chart = createChart(ref.current, baseChartOpts(220));
|
||
chartRef.current = chart;
|
||
const series = chart.addSeries(CandlestickSeries, {
|
||
upColor: colors.up, downColor: colors.down,
|
||
borderUpColor: colors.up, borderDownColor: colors.down,
|
||
wickUpColor: colors.up, wickDownColor: colors.down,
|
||
});
|
||
series.setData(mode === "rate"
|
||
? bars.map(b => ({ time: b.time as any, open: b.open, high: b.high, low: b.low, close: b.close }))
|
||
: bars.map(b => ({ time: b.time as any, open: b.price_open, high: b.price_high, low: b.price_low, close: b.price_close }))
|
||
);
|
||
chart.timeScale().fitContent();
|
||
ref.current.querySelectorAll("a").forEach(a => (a as HTMLElement).style.display = "none");
|
||
} catch {}
|
||
}, [symbol, interval, mode, colors.up, colors.down]);
|
||
|
||
useEffect(() => {
|
||
render();
|
||
const iv = window.setInterval(render, 30_000);
|
||
return () => { window.clearInterval(iv); chartRef.current?.remove(); chartRef.current = null; };
|
||
}, [render]);
|
||
|
||
return <div ref={ref} className="w-full" style={{ height: 220 }} />;
|
||
}
|
||
|
||
function IntervalPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||
return (
|
||
<div className="flex flex-wrap gap-1 text-xs">
|
||
{INTERVALS.map(iv => (
|
||
<button key={iv.value} onClick={() => onChange(iv.value)}
|
||
className={`px-2 py-1 rounded border transition-colors ${value === iv.value ? "bg-slate-800 text-white border-slate-800" : "border-slate-200 text-slate-500 hover:border-slate-400"}`}>
|
||
{iv.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── 主仪表盘 ────────────────────────────────────────────────────
|
||
export default function Dashboard() {
|
||
const [rates, setRates] = useState<RatesResponse | null>(null);
|
||
const [stats, setStats] = useState<StatsResponse | null>(null);
|
||
const [history, setHistory] = useState<HistoryResponse | null>(null);
|
||
const [signals, setSignals] = useState<SignalHistoryItem[]>([]);
|
||
const [status, setStatus] = useState<"loading"|"running"|"error">("loading");
|
||
const [lastUpdate, setLastUpdate] = useState("");
|
||
const [symbol, setSymbol] = useState<"BTC"|"ETH">("BTC");
|
||
const [rateInterval, setRateInterval] = useState("1h");
|
||
const [priceInterval, setPriceInterval] = useState("1h");
|
||
|
||
const fetchRates = useCallback(async () => {
|
||
try { setRates(await api.rates()); setStatus("running"); setLastUpdate(new Date().toLocaleTimeString("zh-CN")); }
|
||
catch { setStatus("error"); }
|
||
}, []);
|
||
|
||
const fetchSlow = useCallback(async () => {
|
||
try {
|
||
const [s, h, sig] = await Promise.all([api.stats(), api.history(), api.signalsHistory()]);
|
||
setStats(s); setHistory(h); setSignals(sig.items || []);
|
||
} catch {}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchRates(); fetchSlow();
|
||
const r = setInterval(fetchRates, 2_000);
|
||
const s = setInterval(fetchSlow, 120_000);
|
||
return () => { clearInterval(r); clearInterval(s); };
|
||
}, [fetchRates, fetchSlow]);
|
||
|
||
// 历史图数据
|
||
const btcMap = new Map((history?.BTC ?? []).map((p: HistoryPoint) => [p.timestamp.slice(0,13), p.fundingRate * 100]));
|
||
const ethMap = new Map((history?.ETH ?? []).map((p: HistoryPoint) => [p.timestamp.slice(0,13), p.fundingRate * 100]));
|
||
const allTimes = Array.from(new Set([...btcMap.keys(), ...ethMap.keys()])).sort();
|
||
const historyChart = allTimes.slice(-42).map(t => ({ time: t.slice(5).replace("T"," "), BTC: btcMap.get(t) ?? null, ETH: ethMap.get(t) ?? null }));
|
||
const tableRows = allTimes.slice().reverse().slice(0,30).map(t => ({ time: t.replace("T"," "), btc: btcMap.get(t), eth: ethMap.get(t) }));
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{/* 标题 */}
|
||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||
<div>
|
||
<h1 className="text-xl font-bold text-slate-900">资金费率套利监控</h1>
|
||
<p className="text-slate-500 text-xs mt-0.5">实时监控 BTC / ETH 永续合约资金费率</p>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<span className={`w-2 h-2 rounded-full ${status==="running"?"bg-emerald-500 animate-pulse":status==="error"?"bg-red-500":"bg-slate-300"}`} />
|
||
<span className={status==="running"?"text-emerald-600":status==="error"?"text-red-600":"text-slate-400"}>
|
||
{status==="running"?"运行中":status==="error"?"连接失败":"加载中..."}
|
||
</span>
|
||
{lastUpdate && <span className="text-slate-400 text-xs">更新于 {lastUpdate}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 费率卡片 */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<RateCard asset="BTC" data={rates?.BTC ?? null} />
|
||
<RateCard asset="ETH" data={rates?.ETH ?? null} />
|
||
</div>
|
||
|
||
{/* 统计卡片 */}
|
||
{stats && (
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<StatsCard title="BTC 套利" mean7d={stats.BTC.mean7d} annualized={stats.BTC.annualized} accent="blue" />
|
||
<StatsCard title="ETH 套利" mean7d={stats.ETH.mean7d} annualized={stats.ETH.annualized} accent="indigo" />
|
||
<StatsCard title="50/50 组合" mean7d={stats.combo.mean7d} annualized={stats.combo.annualized} accent="green" />
|
||
</div>
|
||
)}
|
||
|
||
{/* K线 */}
|
||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-4 space-y-4">
|
||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||
<h2 className="font-semibold text-slate-800 text-sm">K 线图</h2>
|
||
<div className="flex gap-1.5">
|
||
{(["BTC","ETH"] as const).map(s => (
|
||
<button key={s} onClick={() => setSymbol(s)}
|
||
className={`px-3 py-1 rounded-lg border text-xs 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>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 费率K */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
|
||
<span className="text-xs text-slate-500 font-medium">{symbol} 资金费率(万分之)</span>
|
||
<IntervalPicker value={rateInterval} onChange={setRateInterval} />
|
||
</div>
|
||
<MiniKChart symbol={symbol} interval={rateInterval} mode="rate" colors={{ up:"#16a34a", down:"#dc2626" }} />
|
||
</div>
|
||
|
||
{/* 价格K */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
|
||
<span className="text-xs text-slate-500 font-medium">{symbol} 标记价格(USD)</span>
|
||
<IntervalPicker value={priceInterval} onChange={setPriceInterval} />
|
||
</div>
|
||
<MiniKChart symbol={symbol} interval={priceInterval} mode="price" colors={{ up:"#2563eb", down:"#7c3aed" }} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* 历史费率走势 */}
|
||
{historyChart.length > 0 && (
|
||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-4">
|
||
<h2 className="font-semibold text-slate-800 text-sm mb-3">历史费率走势(过去7天)</h2>
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<LineChart data={historyChart} margin={{ top:4, right:8, bottom:4, left:8 }}>
|
||
<XAxis dataKey="time" tick={{ fill:"#94a3b8", fontSize:10 }} tickLine={false} interval="preserveStartEnd" />
|
||
<YAxis tick={{ fill:"#94a3b8", fontSize:10 }} tickLine={false} axisLine={false} tickFormatter={v=>`${v.toFixed(3)}%`} width={58} />
|
||
<Tooltip formatter={v=>[`${Number(v).toFixed(4)}%`]} contentStyle={{ background:"#fff", border:"1px solid #e2e8f0", borderRadius:8, fontSize:12 }} />
|
||
<Legend wrapperStyle={{ fontSize:12 }} />
|
||
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="4 2" />
|
||
<Line type="monotone" dataKey="BTC" stroke="#2563eb" strokeWidth={1.5} dot={false} connectNulls />
|
||
<Line type="monotone" dataKey="ETH" stroke="#7c3aed" strokeWidth={1.5} dot={false} connectNulls />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)}
|
||
|
||
{/* 历史明细 + 信号历史 并排 */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
{/* 历史明细 */}
|
||
{tableRows.length > 0 && (
|
||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||
<div className="px-4 py-3 border-b border-slate-100">
|
||
<h2 className="font-semibold text-slate-800 text-sm">历史费率明细(最近30条)</h2>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="bg-slate-50 border-b border-slate-100">
|
||
<th className="text-left px-4 py-2 text-slate-500 font-medium">时间</th>
|
||
<th className="text-right px-4 py-2 text-blue-600 font-medium">BTC</th>
|
||
<th className="text-right px-4 py-2 text-violet-600 font-medium">ETH</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{tableRows.map((row, i) => (
|
||
<tr key={i} className="border-b border-slate-50 hover:bg-slate-50">
|
||
<td className="px-4 py-1.5 text-slate-400 font-mono">{row.time}</td>
|
||
<td className={`px-4 py-1.5 text-right font-mono ${row.btc==null?"text-slate-300":row.btc>=0?"text-emerald-600":"text-red-500"}`}>
|
||
{row.btc!=null?`${row.btc.toFixed(4)}%`:"--"}
|
||
</td>
|
||
<td className={`px-4 py-1.5 text-right font-mono ${row.eth==null?"text-slate-300":row.eth>=0?"text-emerald-600":"text-red-500"}`}>
|
||
{row.eth!=null?`${row.eth.toFixed(4)}%`:"--"}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 信号历史 */}
|
||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||
<div className="px-4 py-3 border-b border-slate-100">
|
||
<h2 className="font-semibold text-slate-800 text-sm">信号历史</h2>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="bg-slate-50 border-b border-slate-100">
|
||
<th className="text-left px-4 py-2 text-slate-500 font-medium">时间</th>
|
||
<th className="text-left px-4 py-2 text-slate-500 font-medium">币种</th>
|
||
<th className="text-right px-4 py-2 text-slate-500 font-medium">年化</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{signals.length === 0 ? (
|
||
<tr><td colSpan={3} className="px-4 py-6 text-center text-slate-400">暂无信号(年化>10%时自动触发)</td></tr>
|
||
) : signals.map(row => (
|
||
<tr key={row.id} className="border-b border-slate-50 hover:bg-slate-50">
|
||
<td className="px-4 py-1.5 text-slate-400 font-mono">{new Date(row.sent_at).toLocaleString("zh-CN")}</td>
|
||
<td className="px-4 py-1.5 font-medium text-slate-700">{row.symbol}</td>
|
||
<td className="px-4 py-1.5 text-right font-mono text-blue-600">{row.annualized}%</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 策略说明 */}
|
||
<div className="rounded-lg border border-blue-100 bg-blue-50 px-4 py-3 text-xs text-slate-600">
|
||
<span className="text-blue-600 font-medium">策略原理:</span>
|
||
持有现货多头 + 永续空头,每8小时收取资金费率,赚取无方向风险的稳定收益。
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|