arbitrage-engine/frontend/app/page.tsx
fanziqi ad60a53262 review: add code audit annotations and REVIEW.md for v5.1
P0 issues annotated (critical, must fix before live trading):
- signal_engine.py: cooldown blocks reverse-signal position close
- paper_monitor.py + signal_engine.py: pnl_r 2x inflated for TP scenarios
- signal_engine.py: entry price uses 30min VWAP instead of real-time price
- paper_monitor.py + signal_engine.py: concurrent write race on paper_trades

P1 issues annotated (long-term stability):
- db.py: ensure_partitions uses timedelta(30d) causing missed monthly partitions
- signal_engine.py: float precision drift in buy_vol/sell_vol accumulation
- market_data_collector.py: single bare connection with no reconnect logic
- db.py: get_sync_pool initialization not thread-safe
- signal_engine.py: recent_large_trades deque has no maxlen

P2/P3 issues annotated across backend and frontend:
- coinbase_premium KeyError for XRP/SOL symbols
- liquidation_collector: redundant elif condition in aggregation logic
- auth.py: JWT secret hardcoded default, login rate-limit absent
- Frontend: concurrent refresh token race, AuthContext not synced on failure
- Frontend: universal catch{} swallows all API errors silently
- Frontend: serial API requests in LatestSignals, market-indicators over-polling

docs/REVIEW.md: comprehensive audit report with all 34 issues (P0×4, P1×5,
P2×6, P3×4 backend + FE-P1×4, FE-P2×8, FE-P3×3 frontend), fix suggestions
and prioritized remediation roadmap.
2026-03-01 17:14:52 +08:00

334 lines
17 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;
// [REVIEW] FE-P2-1 | 每次调用都销毁并重建 lightweight-charts 实例
// setInterval 每30秒触发一次导致图表闪烁destroy→create→setData 有可见延迟)
// 修复:只在 symbol/interval 变化时重建图表,仅数据更新时调用 series.setData()
// 可用 useRef 存储 series 引用if (!chartRef.current) { 初始化 } else { series.setData() }
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 {}
// [REVIEW] FE-P2-2 | async render 未检查组件挂载状态
// cleanup 执行 chartRef.current = null 后,如果 fetch 仍在 in-flight
// render() 继续执行到 if (!ref.current) return 会安全退出
// 但若 ref.current 在 cleanup 后仍不为 null极短窗口chart.remove 已调用
// 则 series.setData 会访问已销毁的 chart 对象
// 修复:在 useEffect 中用 let mounted = true; cleanup: mounted = false; 在 async 中判断
}, [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 {
// [REVIEW] FE-P1-3 | 4个并行API中任一失败整批都被丢弃用户无提示
// Promise.all 失败则所有结果丢失,建议改用 Promise.allSettled
}
}, [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>
);
}