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 */}
|
{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)}
|
{filteredPositions.map((position) => (
))}
{/* 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}
)}
)
}