import { useMemo, useState } from 'react' import { Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Legend, Area, ComposedChart, } 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, TrendingUp, TrendingDown, Zap } from 'lucide-react' // Time period options: 1D, 3D, 7D, 30D, All const TIME_PERIODS = [ { key: '1d', hours: 24 }, { key: '3d', hours: 72 }, { key: '7d', hours: 168 }, { key: '30d', hours: 720 }, { key: 'all', hours: 0 }, ] as const interface ComparisonChartProps { traders: CompetitionTraderData[] } export function ComparisonChart({ traders }: ComparisonChartProps) { const { language } = useLanguage() const [selectedPeriod, setSelectedPeriod] = useState('7d') // Default to 7 days // Get hours for selected period const selectedHours = TIME_PERIODS.find(p => p.key === selectedPeriod)?.hours || 0 // Generate unique key for SWR (include period and hours) const tradersKey = traders .map((t) => t.trader_id) .sort() .join(',') const { data: allTraderHistories, isLoading } = useSWR( traders.length > 0 ? `equity-histories-${tradersKey}-${selectedHours}` : null, async () => { console.log('Fetching equity history with hours:', selectedHours) const traderIds = traders.map((trader) => trader.trader_id) const batchData = await api.getEquityHistoryBatch(traderIds, selectedHours) console.log('Received data points:', Object.values(batchData.histories || {}).map((h: any) => h?.length)) return traders.map((trader) => { const history = batchData.histories?.[trader.trader_id] || [] // If backend doesn't return total_pnl_pct, calculate it from equity if (history.length > 0 && history[0].total_pnl_pct === undefined) { const initialEquity = history[0].total_equity history.forEach((point: any) => { point.total_pnl_pct = initialEquity > 0 ? ((point.total_equity - initialEquity) / initialEquity) * 100 : 0 }) } return history }) }, { refreshInterval: 30000, revalidateOnFocus: false, dedupingInterval: 0, // No deduping for immediate response keepPreviousData: false, } ) const traderHistories = useMemo(() => { if (!allTraderHistories) { return traders.map(() => ({ data: undefined })) } return allTraderHistories.map((data) => ({ data })) }, [allTraderHistories, traders.length]) const combinedData = useMemo(() => { const allLoaded = traderHistories.every((h) => h.data) if (!allLoaded) return [] const timestampMap = new Map< string, { timestamp: string time: string traders: Map } >() // Helper function to normalize timestamp to nearest minute const normalizeTimestamp = (ts: string): string => { const date = new Date(ts) date.setSeconds(0, 0) // Round to minute return date.toISOString() } traderHistories.forEach((history, index) => { const trader = traders[index] if (!history.data) return history.data.forEach((point: any) => { // Normalize timestamp to nearest minute so different traders' data aligns const normalizedTs = normalizeTimestamp(point.timestamp) if (!timestampMap.has(normalizedTs)) { const date = new Date(normalizedTs) // Format time based on selected period let time: string if (selectedHours <= 24) { // 1 day: show HH:mm time = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) } else if (selectedHours <= 72) { // 3 days: show MM/DD HH:mm time = `${date.getMonth() + 1}/${date.getDate()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}` } else { // 7+ days: show MM/DD time = `${date.getMonth() + 1}/${date.getDate()}` } timestampMap.set(normalizedTs, { timestamp: normalizedTs, time, traders: new Map(), }) } // Use latest value if multiple points fall in same minute const existing = timestampMap.get(normalizedTs)!.traders.get(trader.trader_id) if (!existing || new Date(point.timestamp) > new Date(existing.originalTs || '')) { timestampMap.get(normalizedTs)!.traders.set(trader.trader_id, { pnl_pct: point.total_pnl_pct || 0, equity: point.total_equity, originalTs: point.timestamp, }) } }) }) const sortedEntries = Array.from(timestampMap.entries()) .sort(([tsA], [tsB]) => new Date(tsA).getTime() - new Date(tsB).getTime()) // Track last known values for each trader to fill gaps const lastKnown: Map = new Map() const combined = sortedEntries.map(([ts, data], index) => { const entry: any = { index: index + 1, time: data.time, timestamp: ts, } traders.forEach((trader) => { const traderData = data.traders.get(trader.trader_id) if (traderData) { // Update last known value lastKnown.set(trader.trader_id, { pnl_pct: traderData.pnl_pct, equity: traderData.equity, }) entry[`${trader.trader_id}_pnl_pct`] = traderData.pnl_pct entry[`${trader.trader_id}_equity`] = traderData.equity } else { // Use last known value to fill gap const last = lastKnown.get(trader.trader_id) if (last) { entry[`${trader.trader_id}_pnl_pct`] = last.pnl_pct entry[`${trader.trader_id}_equity`] = last.equity } } }) return entry }) return combined }, [allTraderHistories, traders, selectedHours]) // Get trader color const traderColor = (traderId: string) => getTraderColor(traders, traderId) if (isLoading) { return (
{t('loadingChartData', language) || 'Loading chart data...'}
) } if (combinedData.length === 0) { return (
{t('noHistoricalData', language)}
{t('dataWillAppear', language)}
) } const MAX_DISPLAY_POINTS = 500 const displayData = combinedData.length > MAX_DISPLAY_POINTS ? combinedData.slice(-MAX_DISPLAY_POINTS) : combinedData // Calculate Y axis domain with better padding const calculateYDomain = () => { const allValues: number[] = [] displayData.forEach((point) => { traders.forEach((trader) => { const value = point[`${trader.trader_id}_pnl_pct`] if (value !== undefined && !isNaN(value)) { allValues.push(value) } }) }) if (allValues.length === 0) return [-2, 2] const minVal = Math.min(...allValues) const maxVal = Math.max(...allValues) const range = maxVal - minVal // Use actual data range with 20% padding on each side // This ensures both lines are clearly visible const padding = Math.max(range * 0.2, 2) // At least 2% padding return [ Math.floor((minVal - padding) * 10) / 10, Math.ceil((maxVal + padding) * 10) / 10 ] } // Custom Tooltip const CustomTooltip = ({ active, payload }: any) => { if (active && payload && payload.length) { const data = payload[0].payload const date = new Date(data.timestamp) const dateStr = date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) return (
{dateStr} {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 const isPositive = pnlPct >= 0 return (
{trader.trader_name}
{isPositive ? : } {isPositive ? '+' : ''}{pnlPct.toFixed(2)}%
${equity?.toFixed(2)}
) })}
) } return null } // Calculate stats - find each trader's last available data point const traderStats = traders.map(trader => { // Find the last data point that has data for this trader let currentPnl = 0 let currentEquity = 0 for (let i = displayData.length - 1; i >= 0; i--) { const pnl = displayData[i]?.[`${trader.trader_id}_pnl_pct`] if (pnl !== undefined) { currentPnl = pnl currentEquity = displayData[i]?.[`${trader.trader_id}_equity`] || 0 break } } return { ...trader, currentPnl, currentEquity } }).sort((a, b) => b.currentPnl - a.currentPnl) const leader = traderStats[0] const gap = traderStats.length > 1 ? Math.abs(traderStats[0].currentPnl - traderStats[1].currentPnl).toFixed(2) : '0.00' return (
{/* Time Period Selector + Mini Stats Bar */}
{/* Time Period Buttons */}
{TIME_PERIODS.map((period) => ( ))}
{/* Mini Stats Bar */}
{traderStats.slice(0, 3).map((trader, idx) => (
{trader.trader_name} = 0 ? '#0ECB81' : '#F6465D' }}> {trader.currentPnl >= 0 ? '+' : ''}{trader.currentPnl.toFixed(2)}%
))}
{/* Chart */}
{/* Watermark */}
NOFX
{traders.map((trader) => ( ))} {/* Glow filter */} `${value.toFixed(1)}%`} width={50} /> } /> {/* Zero reference line */} {/* Area fills for top 2 traders */} {traders.slice(0, 2).map((trader) => ( ))} {/* Lines for all traders */} {traders.map((trader, idx) => ( ))} { // Filter out Area entries (they use raw dataKey containing _pnl_pct) const filteredPayload = payload?.filter( (entry: any) => entry.value && !entry.value.includes('_pnl_pct') ) || [] return (
{filteredPayload.map((entry: any, index: number) => { const trader = traders.find((t) => t.trader_name === entry.value) // Find this trader's last available PnL from traderStats const traderStat = traderStats.find((t) => t.trader_id === trader?.trader_id) const pnl = traderStat?.currentPnl || 0 return (
{entry.value} = 0 ? '#0ECB81' : '#F6465D', marginLeft: '6px', fontFamily: 'monospace' }}> ({pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}%)
) })}
) }} />
{/* Bottom Stats */}
{t('leader', language)}
{leader?.trader_name || '-'}
{t('leadPnL', language) || 'Lead PnL'}
= 0 ? '#0ECB81' : '#F6465D' }}> {(leader?.currentPnl || 0) >= 0 ? '+' : ''}{(leader?.currentPnl || 0).toFixed(2)}%
{t('currentGap', language)}
{gap}%
{t('dataPoints', language)}
{displayData.length}
) }