import { useState, useEffect, useMemo } from 'react' import { api } from '../lib/api' import { useLanguage } from '../contexts/LanguageContext' import { t } from '../i18n/translations' import { MetricTooltip } from './MetricTooltip' import type { HistoricalPosition, TraderStats, SymbolStats, DirectionStats, } from '../types' interface PositionHistoryProps { traderId: string } // Format number with proper decimals function formatNumber(value: number, decimals: number = 2): string { if (Math.abs(value) >= 1000000) { return (value / 1000000).toFixed(2) + 'M' } if (Math.abs(value) >= 1000) { return (value / 1000).toFixed(2) + 'K' } return value.toFixed(decimals) } // Format price with proper decimals function formatPrice(price: number): string { if (!price || price === 0) return '-' if (price >= 1000) return price.toFixed(2) if (price >= 1) return price.toFixed(4) return price.toFixed(6) } // Format duration from minutes function formatDuration(minutes: number): string { if (!minutes || minutes <= 0) return '-' if (minutes < 60) return `${minutes.toFixed(0)}m` if (minutes < 1440) return `${(minutes / 60).toFixed(1)}h` return `${(minutes / 1440).toFixed(1)}d` } // Format date function formatDate(dateStr: string): string { if (!dateStr) return '-' const date = new Date(dateStr) if (isNaN(date.getTime())) return '-' return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }) } // Stats Card Component with formula tooltip function StatCard({ title, value, suffix, color, icon, subtitle, metricKey, language = 'en', }: { title: string value: string | number suffix?: string color?: string icon: string subtitle?: string metricKey?: string language?: string }) { return (
{icon} {title} {metricKey && ( )}
{value} {suffix && ( {suffix} )}
{subtitle && (
{subtitle}
)}
) } // Symbol Stats Row function SymbolStatsRow({ stat }: { stat: SymbolStats }) { const totalPnl = stat.total_pnl || 0 const winRate = stat.win_rate || 0 const pnlColor = totalPnl >= 0 ? '#0ECB81' : '#F6465D' const winRateColor = winRate >= 60 ? '#0ECB81' : winRate >= 40 ? '#F0B90B' : '#F6465D' return (
{(stat.symbol || '').replace('USDT', '')} {stat.total_trades || 0} trades
Win Rate
{winRate.toFixed(1)}%
P&L
{totalPnl >= 0 ? '+' : ''} {formatNumber(totalPnl)}
) } // Direction Stats Card function DirectionStatsCard({ stat, language }: { stat: DirectionStats; language: 'en' | 'zh' }) { const isLong = (stat.side || '').toLowerCase() === 'long' const iconColor = isLong ? '#0ECB81' : '#F6465D' const totalPnl = stat.total_pnl || 0 const winRate = stat.win_rate || 0 const tradeCount = stat.trade_count || 0 const avgPnl = stat.avg_pnl || 0 const pnlColor = totalPnl >= 0 ? '#0ECB81' : '#F6465D' return (
{isLong ? '๐Ÿ“ˆ' : '๐Ÿ“‰'} {stat.side || 'Unknown'}
{t('positionHistory.trades', language)}
{tradeCount}
{t('positionHistory.winRate', language)}
= 60 ? '#0ECB81' : winRate >= 40 ? '#F0B90B' : '#F6465D', }} > {winRate.toFixed(1)}%
{t('positionHistory.totalPnL', language)}
{totalPnl >= 0 ? '+' : ''} {formatNumber(totalPnl)}
{t('positionHistory.avgPnL', language)}
= 0 ? '#0ECB81' : '#F6465D' }}> {avgPnl >= 0 ? '+' : ''} {formatNumber(avgPnl)}
) } // Position Row Component function PositionRow({ position }: { position: HistoricalPosition }) { const side = position.side || '' const isLong = side.toUpperCase() === 'LONG' const realizedPnl = position.realized_pnl || 0 const isProfitable = realizedPnl >= 0 const sideColor = isLong ? '#0ECB81' : '#F6465D' const pnlColor = isProfitable ? '#0ECB81' : '#F6465D' // Calculate holding time const entryTime = position.entry_time ? new Date(position.entry_time).getTime() : 0 const exitTime = position.exit_time ? new Date(position.exit_time).getTime() : 0 const holdingMinutes = entryTime && exitTime && exitTime > entryTime ? (exitTime - entryTime) / 60000 : 0 // Calculate PnL percentage based on entry price const entryPrice = position.entry_price || 0 const exitPrice = position.exit_price || 0 let pnlPct = 0 if (entryPrice > 0) { if (isLong) { pnlPct = ((exitPrice - entryPrice) / entryPrice) * 100 } else { pnlPct = ((entryPrice - exitPrice) / entryPrice) * 100 } } // Use entry_quantity for display (original position size) const displayQty = position.entry_quantity || position.quantity || 0 return ( {/* Symbol */}
{(position.symbol || '').replace('USDT', '')} {side}
{/* Entry Price */} {formatPrice(entryPrice)} {/* Exit Price */} {formatPrice(exitPrice)} {/* Quantity */} {displayQty.toFixed(4)} {/* Position Value (Entry Price * Quantity) */} {formatNumber(entryPrice * displayQty)} {/* P&L */}
{isProfitable ? '+' : ''} {formatNumber(realizedPnl)}
{pnlPct >= 0 ? '+' : ''} {pnlPct.toFixed(2)}%
{/* Fee - show more precision for small fees */} -{((position.fee || 0) < 0.01 && (position.fee || 0) > 0) ? (position.fee || 0).toFixed(4) : (position.fee || 0).toFixed(2)} {/* Duration */} {formatDuration(holdingMinutes)} {/* Exit Time */} {formatDate(position.exit_time)} ) } export function PositionHistory({ traderId }: PositionHistoryProps) { const { language } = useLanguage() const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [positions, setPositions] = useState([]) const [stats, setStats] = useState(null) const [symbolStats, setSymbolStats] = useState([]) const [directionStats, setDirectionStats] = useState([]) // Pagination state const [pageSize, setPageSize] = useState(20) const [currentPage, setCurrentPage] = useState(1) // Filter state const [filterSymbol, setFilterSymbol] = useState('all') const [filterSide, setFilterSide] = useState('all') const [sortBy, setSortBy] = useState<'time' | 'pnl' | 'pnl_pct'>('time') const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') useEffect(() => { const fetchData = async () => { try { setLoading(true) setError(null) // Fetch more data than needed to support filtering, but respect pageSize for initial load const data = await api.getPositionHistory(traderId, Math.max(200, pageSize * 5)) setPositions(data.positions || []) setStats(data.stats) setSymbolStats(data.symbol_stats || []) setDirectionStats(data.direction_stats || []) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load history') } finally { setLoading(false) } } if (traderId) { fetchData() } }, [traderId, pageSize]) // Get unique symbols for filter const uniqueSymbols = useMemo(() => { const symbols = new Set(positions.map((p) => p.symbol)) return Array.from(symbols).sort() }, [positions]) // Filtered and sorted positions (before pagination) const filteredAndSortedPositions = useMemo(() => { let result = [...positions] // Apply filters if (filterSymbol !== 'all') { result = result.filter((p) => p.symbol === filterSymbol) } if (filterSide !== 'all') { result = result.filter( (p) => (p.side || '').toUpperCase() === filterSide.toUpperCase() ) } // Apply sorting result.sort((a, b) => { let comparison = 0 switch (sortBy) { case 'time': comparison = new Date(a.exit_time || 0).getTime() - new Date(b.exit_time || 0).getTime() break case 'pnl': comparison = (a.realized_pnl || 0) - (b.realized_pnl || 0) break case 'pnl_pct': { const aPrice = a.entry_price || 1 const bPrice = b.entry_price || 1 const aPct = ((a.exit_price || 0) - aPrice) / aPrice * 100 const bPct = ((b.exit_price || 0) - bPrice) / bPrice * 100 comparison = aPct - bPct break } } return sortOrder === 'desc' ? -comparison : comparison }) return result }, [positions, filterSymbol, filterSide, sortBy, sortOrder]) // Pagination calculations const totalFilteredCount = filteredAndSortedPositions.length const totalPages = Math.ceil(totalFilteredCount / pageSize) // Reset to page 1 when filters change useEffect(() => { setCurrentPage(1) }, [filterSymbol, filterSide, sortBy, sortOrder, pageSize]) // Paginated positions (for display) const paginatedPositions = useMemo(() => { const startIndex = (currentPage - 1) * pageSize return filteredAndSortedPositions.slice(startIndex, startIndex + pageSize) }, [filteredAndSortedPositions, currentPage, pageSize]) // For backwards compatibility, keep filteredPositions as the paginated result const filteredPositions = paginatedPositions // Calculate profit/loss ratio (avg win / avg loss) const profitLossRatio = useMemo(() => { if (!stats) return 0 const avgWin = stats.avg_win || 0 const avgLoss = stats.avg_loss || 0 if (avgLoss === 0) return avgWin > 0 ? Infinity : 0 return avgWin / avgLoss }, [stats]) if (loading) { return (
{t('positionHistory.loading', language)}
) } if (error) { return (
{error}
) } if (positions.length === 0) { return (
๐Ÿ“Š
{t('positionHistory.noHistory', language)}
{t('positionHistory.noHistoryDesc', language)}
) } return (
{/* Overall Stats - Row 1: Core Metrics */} {stats && (
= 60 ? '#0ECB81' : (stats.win_rate || 0) >= 40 ? '#F0B90B' : '#F6465D' } metricKey="win_rate" language={language} /> = 0 ? '+' : '') + formatNumber(stats.total_pnl || 0)} color={(stats.total_pnl || 0) >= 0 ? '#0ECB81' : '#F6465D'} subtitle={`${t('positionHistory.fee', language)}: -${formatNumber(stats.total_fee || 0)}`} metricKey="total_return" language={language} /> = 1.5 ? '#0ECB81' : (stats.profit_factor || 0) >= 1 ? '#F0B90B' : '#F6465D'} subtitle={t('positionHistory.profitFactorDesc', language)} metricKey="profit_factor" language={language} /> = 1.5 ? '#0ECB81' : profitLossRatio >= 1 ? '#F0B90B' : '#F6465D'} subtitle={t('positionHistory.plRatioDesc', language)} metricKey="expectancy" language={language} />
)} {/* Overall Stats - Row 2: Advanced Metrics */} {stats && (
= 1 ? '#0ECB81' : (stats.sharpe_ratio || 0) >= 0 ? '#F0B90B' : '#F6465D'} subtitle={t('positionHistory.sharpeRatioDesc', language)} metricKey="sharpe_ratio" language={language} /> = 0 ? '+' : '') + formatNumber((stats.total_pnl || 0) - (stats.total_fee || 0))} color={(stats.total_pnl || 0) - (stats.total_fee || 0) >= 0 ? '#0ECB81' : '#F6465D'} subtitle={t('positionHistory.netPnLDesc', language)} language={language} />
)} {/* Direction Stats */} {directionStats.length > 0 && (
{directionStats.map((stat) => ( ))}
)} {/* Symbol Performance */} {symbolStats.length > 0 && (
๐Ÿ… {t('positionHistory.symbolPerformance', language)}
{symbolStats.slice(0, 10).map((stat) => ( ))}
)} {/* Position List */}
{/* Filters */}
{t('positionHistory.symbol', language)}:
{t('positionHistory.side', language)}:
{['all', 'LONG', 'SHORT'].map((side) => ( ))}
{t('positionHistory.sort', language)}:
{/* Table */}
{filteredPositions.map((position) => ( ))}
{t('positionHistory.symbol', language)} {t('positionHistory.entry', language)} {t('positionHistory.exit', language)} {t('positionHistory.qty', language)} {t('positionHistory.value', language)} {t('positionHistory.pnl', language)} {t('positionHistory.fee', language)} {t('positionHistory.duration', language)} {t('positionHistory.closedAt', language)}
{/* Footer with Pagination */}
{/* Left: Count info */}
{t('positionHistory.showingPositions', language, { count: totalFilteredCount, total: positions.length })} {totalFilteredCount > 0 && ( {t('positionHistory.totalPnL', language)}:{' '} sum + (p.realized_pnl || 0), 0) >= 0 ? '#0ECB81' : '#F6465D', }} > {filteredAndSortedPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0) >= 0 ? '+' : ''} {formatNumber( filteredAndSortedPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0) )} )}
{/* Right: Pagination controls */}
{/* Page size selector */}
{language === 'zh' ? 'ๆฏ้กต' : 'Per page'}:
{/* Page navigation */} {totalPages > 1 && (
{currentPage} / {totalPages}
)}
) }