import { useMemo } 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' import { getTraderColor } from '../utils/traderColors' import { useLanguage } from '../contexts/LanguageContext' import { t } from '../i18n/translations' import { BarChart3 } from 'lucide-react' interface ComparisonChartProps { traders: CompetitionTraderData[] } export function ComparisonChart({ traders }: ComparisonChartProps) { const { language } = useLanguage() // 获取所有trader的历史数据 - 使用单个useSWR并发请求所有trader数据 // 生成唯一的key,当traders变化时会触发重新请求 const tradersKey = traders .map((t) => t.trader_id) .sort() .join(',') const { data: allTraderHistories, isLoading } = useSWR( traders.length > 0 ? `all-equity-histories-${tradersKey}` : null, async () => { // 使用批量API一次性获取所有trader的历史数据 const traderIds = traders.map((trader) => trader.trader_id) const batchData = await api.getEquityHistoryBatch(traderIds) // 转换为原格式,保持与原有代码兼容 return traders.map((trader) => { return batchData.histories[trader.trader_id] || [] }) }, { refreshInterval: 30000, // 30秒刷新(对比图表数据更新频率较低) revalidateOnFocus: false, dedupingInterval: 20000, } ) // 将数据转换为与原格式兼容的结构 const traderHistories = useMemo(() => { if (!allTraderHistories) { return traders.map(() => ({ data: undefined })) } return allTraderHistories.map((data) => ({ data })) }, [allTraderHistories, traders.length]) // 使用useMemo自动处理数据合并,直接使用data对象作为依赖 const combinedData = useMemo(() => { // 等待所有数据加载完成 const allLoaded = traderHistories.every((h) => h.data) if (!allLoaded) return [] console.log(`[${new Date().toISOString()}] Recalculating chart data...`) // 新方案:按时间戳分组,不再依赖 cycle_number(因为后端会重置) // 收集所有时间戳 const timestampMap = new Map< string, { timestamp: string time: string traders: Map } >() traderHistories.forEach((history, index) => { const trader = traders[index] if (!history.data) return console.log( `Trader ${trader.trader_id}: ${history.data.length} data points` ) history.data.forEach((point: any) => { const ts = point.timestamp if (!timestampMap.has(ts)) { const time = new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', }) timestampMap.set(ts, { timestamp: ts, time, traders: new Map(), }) } // 计算盈亏百分比:从total_pnl和balance计算 // 假设初始余额 = balance - total_pnl const initialBalance = point.balance - point.total_pnl const pnlPct = initialBalance > 0 ? (point.total_pnl / initialBalance) * 100 : 0 timestampMap.get(ts)!.traders.set(trader.trader_id, { pnl_pct: pnlPct, equity: point.total_equity, }) }) }) // 按时间戳排序,转换为数组 const combined = Array.from(timestampMap.entries()) .sort(([tsA], [tsB]) => new Date(tsA).getTime() - new Date(tsB).getTime()) .map(([ts, data], index) => { const entry: any = { index: index + 1, // 使用序号代替cycle time: data.time, timestamp: ts, } traders.forEach((trader) => { const traderData = data.traders.get(trader.trader_id) if (traderData) { entry[`${trader.trader_id}_pnl_pct`] = traderData.pnl_pct entry[`${trader.trader_id}_equity`] = traderData.equity } }) return entry }) if (combined.length > 0) { const lastPoint = combined[combined.length - 1] console.log( `Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}` ) } return combined }, [allTraderHistories, traders]) if (isLoading) { return (
Loading comparison data...
) } if (combinedData.length === 0) { return (
{t('noHistoricalData', language)}
{t('dataWillAppear', language)}
) } // 限制显示数据点 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)] } // 使用统一的颜色分配逻辑(与Leaderboard保持一致) const traderColor = (traderId: string) => getTraderColor(traders, traderId) // 自定义Tooltip - Binance Style const CustomTooltip = ({ active, payload }: any) => { if (active && payload && payload.length) { const data = payload[0].payload return (
{data.time} - #{data.index}
{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 (
{/* NOFX Watermark */}
NOFX
{traders.map((trader) => ( ))} `${value.toFixed(1)}%`} width={60} /> } /> {traders.map((trader) => ( ))} { 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 */}
{t('comparisonMode', language)}
PnL %
{t('dataPoints', language)}
{t('count', language, { count: combinedData.length })}
{t('currentGap', language)}
1 ? '#F0B90B' : '#EAECEF' }} > {currentGap.toFixed(2)}%
{t('displayRange', language)}
{combinedData.length > MAX_DISPLAY_POINTS ? `${t('recent', language)} ${MAX_DISPLAY_POINTS}` : t('allData', language)}
) }