diff --git a/store/position.go b/store/position.go index 91728839..ba9ebff2 100644 --- a/store/position.go +++ b/store/position.go @@ -668,19 +668,25 @@ func calculateSharpeRatioFromPnls(pnls []float64) float64 { } // calculateMaxDrawdownFromPnls calculates maximum drawdown +// Uses a virtual starting equity of 10000 to calculate percentage drawdown func calculateMaxDrawdownFromPnls(pnls []float64) float64 { if len(pnls) == 0 { return 0 } - var cumulative, peak, maxDD float64 + // Use virtual starting equity for percentage calculation + const startingEquity = 10000.0 + equity := startingEquity + peak := startingEquity + var maxDD float64 + for _, pnl := range pnls { - cumulative += pnl - if cumulative > peak { - peak = cumulative + equity += pnl + if equity > peak { + peak = equity } if peak > 0 { - dd := (peak - cumulative) / peak * 100 + dd := (peak - equity) / peak * 100 if dd > maxDD { maxDD = dd } diff --git a/trader/hyperliquid_sync_test.go b/trader/hyperliquid_sync_test.go index 065e37b0..92683e7f 100644 --- a/trader/hyperliquid_sync_test.go +++ b/trader/hyperliquid_sync_test.go @@ -2,6 +2,7 @@ package trader import ( "database/sql" + "math" "nofx/store" "testing" "time" @@ -382,7 +383,8 @@ func TestHyperliquidBugScenario(t *testing.T) { } expectedTotalPnL := 4.72 + 5.0 // Sum of both close trades - if totalPnL != expectedTotalPnL { + // Use tolerance for floating point comparison + if math.Abs(totalPnL-expectedTotalPnL) > 0.01 { t.Errorf("Expected total PnL %.2f, got %.2f", expectedTotalPnL, totalPnL) } } diff --git a/web/src/components/PositionHistory.tsx b/web/src/components/PositionHistory.tsx new file mode 100644 index 00000000..4cf2d349 --- /dev/null +++ b/web/src/components/PositionHistory.tsx @@ -0,0 +1,842 @@ +import { useState, useEffect, useMemo } from 'react' +import { api } from '../lib/api' +import { useLanguage } from '../contexts/LanguageContext' +import { t } from '../i18n/translations' +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, + formula, +}: { + title: string + value: string | number + suffix?: string + color?: string + icon: string + subtitle?: string + formula?: string +}) { + const [showTooltip, setShowTooltip] = useState(false) + + return ( +
+
+ {icon} + + {title} + + {formula && ( +
+ setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + ? + + {showTooltip && ( +
+ {formula} +
+ )} +
+ )} +
+
+ + {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)} + + + {/* P&L */} + +
+ {isProfitable ? '+' : ''} + {formatNumber(realizedPnl)} +
+
+ {pnlPct >= 0 ? '+' : ''} + {pnlPct.toFixed(2)}% +
+ + + {/* Fee */} + + -{(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([]) + + // 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) + const data = await api.getPositionHistory(traderId, 200) + 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]) + + // 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 + const filteredPositions = 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]) + + // 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' + } + formula={language === 'zh' + ? `胜率 = 盈利交易数 / 总交易数 × 100%\n= ${stats.win_trades || 0} / ${stats.total_trades || 0} × 100%` + : `Win Rate = Winning Trades / Total Trades × 100%\n= ${stats.win_trades || 0} / ${stats.total_trades || 0} × 100%`} + /> + = 0 ? '+' : '') + formatNumber(stats.total_pnl || 0)} + color={(stats.total_pnl || 0) >= 0 ? '#0ECB81' : '#F6465D'} + subtitle={`${t('positionHistory.fee', language)}: -${formatNumber(stats.total_fee || 0)}`} + formula={language === 'zh' + ? '总盈亏 = Σ(每笔已平仓位的 realized_pnl)\n不含手续费' + : 'Total P&L = Σ(realized_pnl of each closed position)\nExcluding fees'} + /> + = 1.5 ? '#0ECB81' : (stats.profit_factor || 0) >= 1 ? '#F0B90B' : '#F6465D'} + subtitle={t('positionHistory.profitFactorDesc', language)} + formula={language === 'zh' + ? '盈利因子 = 总盈利 / 总亏损\n>1.5 优秀, >1 盈利, <1 亏损' + : 'Profit Factor = Total Profit / Total Loss\n>1.5 Excellent, >1 Profitable, <1 Loss'} + /> + = 1.5 ? '#0ECB81' : profitLossRatio >= 1 ? '#F0B90B' : '#F6465D'} + subtitle={t('positionHistory.plRatioDesc', language)} + formula={language === 'zh' + ? `盈亏比 = 平均盈利 / 平均亏损\n= ${formatNumber(stats.avg_win || 0)} / ${formatNumber(stats.avg_loss || 0)}` + : `P/L Ratio = Avg Win / Avg Loss\n= ${formatNumber(stats.avg_win || 0)} / ${formatNumber(stats.avg_loss || 0)}`} + /> +
+ )} + + {/* Overall Stats - Row 2: Advanced Metrics */} + {stats && ( +
+ = 1 ? '#0ECB81' : (stats.sharpe_ratio || 0) >= 0 ? '#F0B90B' : '#F6465D'} + subtitle={t('positionHistory.sharpeRatioDesc', language)} + formula={language === 'zh' + ? '夏普比率 = 平均收益 / 收益标准差\n衡量风险调整后的收益\n>1 良好, >2 优秀' + : 'Sharpe Ratio = Mean Return / Std Dev\nMeasures risk-adjusted return\n>1 Good, >2 Excellent'} + /> + + + + = 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)} + formula={language === 'zh' + ? `净盈亏 = 总盈亏 - 手续费\n= ${formatNumber(stats.total_pnl || 0)} - ${formatNumber(stats.total_fee || 0)}` + : `Net P&L = Total P&L - Fees\n= ${formatNumber(stats.total_pnl || 0)} - ${formatNumber(stats.total_fee || 0)}`} + /> +
+ )} + + {/* 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.pnl', language)} + + {t('positionHistory.fee', language)} + + {t('positionHistory.duration', language)} + + {t('positionHistory.closedAt', language)} +
+
+ + {/* Footer */} +
+ + {t('positionHistory.showingPositions', language, { count: filteredPositions.length, total: positions.length })} + + {filteredPositions.length > 0 && ( + + {t('positionHistory.totalPnL', language)}:{' '} + sum + (p.realized_pnl || 0), 0) >= 0 + ? '#0ECB81' + : '#F6465D', + }} + > + {filteredPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0) >= 0 + ? '+' + : ''} + {formatNumber( + filteredPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0) + )} + + + )} +
+
+
+ ) +}