import { useState, useEffect } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Legend, } from 'recharts'; import useSWR from 'swr'; import { api } from '../lib/api'; import type { CompetitionTraderData } from '../types'; interface ComparisonChartProps { traders: CompetitionTraderData[]; } export function ComparisonChart({ traders }: ComparisonChartProps) { const [combinedData, setCombinedData] = useState([]); // 获取所有trader的历史数据 const traderHistories = traders.map((trader) => { // eslint-disable-next-line react-hooks/rules-of-hooks return useSWR(`equity-history-${trader.trader_id}`, () => api.getEquityHistory(trader.trader_id), { refreshInterval: 10000 } ); }); useEffect(() => { // 等待所有数据加载完成 const allLoaded = traderHistories.every((h) => h.data); if (!allLoaded) return; // 合并所有trader的数据 - 使用cycle_number作为key确保数据对齐 const cycleMap = new Map(); traderHistories.forEach((history, index) => { const trader = traders[index]; history.data?.forEach((point: any) => { const cycleNumber = point.cycle_number || 0; const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', }); if (!cycleMap.has(cycleNumber)) { cycleMap.set(cycleNumber, { cycle: cycleNumber, time, timestamp: point.timestamp }); } const entry = cycleMap.get(cycleNumber); entry[`${trader.trader_id}_pnl_pct`] = point.total_pnl_pct; entry[`${trader.trader_id}_equity`] = point.total_equity; }); }); // 转换为数组并按cycle排序 const combined = Array.from(cycleMap.values()) .filter(item => { // 只保留所有trader都有数据的点 return traders.every(t => item[`${t.trader_id}_pnl_pct`] !== undefined); }) .sort((a, b) => a.cycle - b.cycle); setCombinedData(combined); }, [traderHistories.map((h) => h.data).join(',')]); const isLoading = traderHistories.some((h) => !h.data); if (isLoading) { return (
Loading comparison data...
); } if (combinedData.length === 0) { return (
📊
暂无历史数据
运行几个周期后将显示对比曲线
); } // 限制显示数据点 const MAX_DISPLAY_POINTS = 2000; const displayData = combinedData.length > MAX_DISPLAY_POINTS ? combinedData.slice(-MAX_DISPLAY_POINTS) : combinedData; // 计算Y轴范围 const calculateYDomain = () => { const allValues: number[] = []; displayData.forEach((point) => { traders.forEach((trader) => { const value = point[`${trader.trader_id}_pnl_pct`]; if (value !== undefined) { allValues.push(value); } }); }); if (allValues.length === 0) return [-5, 5]; const minVal = Math.min(...allValues); const maxVal = Math.max(...allValues); const range = Math.max(Math.abs(maxVal), Math.abs(minVal)); const padding = Math.max(range * 0.2, 1); // 至少留1%余量 return [ Math.floor(minVal - padding), Math.ceil(maxVal + padding) ]; }; // Trader颜色配置 - 使用更鲜艳对比度更高的颜色 const getTraderColor = (traderId: string) => { const trader = traders.find((t) => t.trader_id === traderId); if (trader?.ai_model === 'qwen') { return '#c084fc'; // purple-400 (更亮) } else { return '#60a5fa'; // blue-400 (更亮) } }; // 自定义Tooltip - Binance Style const CustomTooltip = ({ active, payload }: any) => { if (active && payload && payload.length) { const data = payload[0].payload; return (
Cycle #{data.cycle} - {data.time}
{traders.map((trader) => { const pnlPct = data[`${trader.trader_id}_pnl_pct`]; const equity = data[`${trader.trader_id}_equity`]; if (pnlPct === undefined) return null; return (
{trader.trader_name}
= 0 ? '#0ECB81' : '#F6465D' }}> {pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}% ({equity?.toFixed(2)} USDT)
); })}
); } return null; }; // 计算当前差距 const currentGap = displayData.length > 0 ? (() => { const lastPoint = displayData[displayData.length - 1]; const values = traders.map(t => lastPoint[`${t.trader_id}_pnl_pct`] || 0); return Math.abs(values[0] - values[1]); })() : 0; return (
{traders.map((trader) => ( ))} `${value.toFixed(1)}%`} width={60} /> } /> {traders.map((trader, index) => ( ))} { const traderId = traders.find((t) => value === t.trader_name)?.trader_id; const trader = traders.find((t) => t.trader_id === traderId); return ( {trader?.trader_name} ({trader?.ai_model.toUpperCase()}) ); }} />
{/* Stats */}
对比模式
PnL %
数据点数
{combinedData.length} 个
当前差距
1 ? '#F0B90B' : '#EAECEF' }}> {currentGap.toFixed(2)}%
显示范围
{combinedData.length > MAX_DISPLAY_POINTS ? `最近 ${MAX_DISPLAY_POINTS}` : '全部数据'}
); }