arbitrage-engine/frontend/app/page.tsx

321 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { createChart, ColorType, CandlestickSeries } from "lightweight-charts";
import { api, RatesResponse, StatsResponse, HistoryResponse, HistoryPoint, SignalHistoryItem, KBar, YtdStatsResponse } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import RateCard from "@/components/RateCard";
import StatsCard from "@/components/StatsCard";
import Link from "next/link";
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>
);
}
// ─── 未登录遮挡组件 ──────────────────────────────────────────────
function AuthGate({ children }: { children: React.ReactNode }) {
const { isLoggedIn, loading } = useAuth();
if (loading) return <div className="text-center text-slate-400 py-12">...</div>;
if (!isLoggedIn) {
return (
<div className="relative">
<div className="filter blur-sm pointer-events-none select-none opacity-60">
{children}
</div>
<div className="absolute inset-0 flex items-center justify-center bg-white/70 rounded-xl">
<div className="text-center space-y-3">
<div className="text-4xl">🔒</div>
<p className="text-slate-600 font-medium"></p>
<div className="flex gap-2 justify-center">
<Link href="/login" className="text-sm bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors"></Link>
<Link href="/register" className="text-sm border border-slate-300 text-slate-600 hover:border-blue-400 px-4 py-2 rounded-lg transition-colors"></Link>
</div>
</div>
</div>
</div>
);
}
return <>{children}</>;
}
// ─── 主仪表盘 ────────────────────────────────────────────────────
export default function Dashboard() {
const { isLoggedIn } = useAuth();
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 [ytd, setYtd] = useState<YtdStatsResponse | null>(null);
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 () => {
if (!isLoggedIn) return;
try {
const [s, h, sig, y] = await Promise.all([api.stats(), api.history(), api.signalsHistory(), api.statsYtd()]);
setStats(s); setHistory(h); setSignals(sig.items || []); setYtd(y);
} catch {}
}, [isLoggedIn]);
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} ytd={ytd?.BTC ?? null} />
<RateCard asset="ETH" data={rates?.ETH ?? null} ytd={ytd?.ETH ?? null} />
</div>
{/* 统计卡片 */}
<AuthGate>
{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">&gt;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>
</AuthGate>
</div>
);
}