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