mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
fix: remove leverage from position history and fix max drawdown calculation
- Remove leverage column from position history table - Fix max drawdown calculation using virtual starting equity - Add formula tooltips for all statistics metrics - Remove leverage parameter from pnlPct calculation
This commit is contained in:
+11
-5
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="rounded-lg p-4 transition-all duration-200 hover:scale-[1.02]"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1E2329 0%, #181C21 100%)',
|
||||
border: '1px solid #2B3139',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">{icon}</span>
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{title}
|
||||
</span>
|
||||
{formula && (
|
||||
<div className="relative">
|
||||
<span
|
||||
className="cursor-help text-xs px-1 rounded"
|
||||
style={{ color: '#848E9C', background: '#2B3139' }}
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
?
|
||||
</span>
|
||||
{showTooltip && (
|
||||
<div
|
||||
className="absolute z-50 left-0 top-6 p-2 rounded text-xs whitespace-pre-wrap min-w-[200px] max-w-[300px]"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||
}}
|
||||
>
|
||||
{formula}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
className="text-xl font-bold font-mono"
|
||||
style={{ color: color || '#EAECEF' }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{suffix && (
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className="flex items-center justify-between p-3 rounded-lg transition-all duration-200 hover:bg-white/5"
|
||||
style={{ borderBottom: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{(stat.symbol || '').replace('USDT', '')}
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{stat.total_trades || 0} trades
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
Win Rate
|
||||
</div>
|
||||
<div className="font-mono font-semibold" style={{ color: winRateColor }}>
|
||||
{winRate.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right min-w-[80px]">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
P&L
|
||||
</div>
|
||||
<div className="font-mono font-semibold" style={{ color: pnlColor }}>
|
||||
{totalPnl >= 0 ? '+' : ''}
|
||||
{formatNumber(totalPnl)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className="rounded-lg p-4"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1E2329 0%, #181C21 100%)',
|
||||
border: `1px solid ${iconColor}33`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xl">{isLong ? '📈' : '📉'}</span>
|
||||
<span
|
||||
className="font-bold uppercase"
|
||||
style={{ color: iconColor }}
|
||||
>
|
||||
{stat.side || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{t('positionHistory.trades', language)}
|
||||
</div>
|
||||
<div className="font-mono font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{tradeCount}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{t('positionHistory.winRate', language)}
|
||||
</div>
|
||||
<div
|
||||
className="font-mono font-semibold"
|
||||
style={{
|
||||
color:
|
||||
winRate >= 60
|
||||
? '#0ECB81'
|
||||
: winRate >= 40
|
||||
? '#F0B90B'
|
||||
: '#F6465D',
|
||||
}}
|
||||
>
|
||||
{winRate.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{t('positionHistory.totalPnL', language)}
|
||||
</div>
|
||||
<div className="font-mono font-semibold" style={{ color: pnlColor }}>
|
||||
{totalPnl >= 0 ? '+' : ''}
|
||||
{formatNumber(totalPnl)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{t('positionHistory.avgPnL', language)}
|
||||
</div>
|
||||
<div className="font-mono font-semibold" style={{ color: avgPnl >= 0 ? '#0ECB81' : '#F6465D' }}>
|
||||
{avgPnl >= 0 ? '+' : ''}
|
||||
{formatNumber(avgPnl)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<tr
|
||||
className="transition-all duration-200 hover:bg-white/5"
|
||||
style={{ borderBottom: '1px solid #2B3139' }}
|
||||
>
|
||||
{/* Symbol */}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{(position.symbol || '').replace('USDT', '')}
|
||||
</span>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs font-semibold uppercase"
|
||||
style={{
|
||||
background: `${sideColor}22`,
|
||||
color: sideColor,
|
||||
border: `1px solid ${sideColor}44`,
|
||||
}}
|
||||
>
|
||||
{side}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Entry Price */}
|
||||
<td className="py-3 px-4 text-right font-mono" style={{ color: '#EAECEF' }}>
|
||||
{formatPrice(entryPrice)}
|
||||
</td>
|
||||
|
||||
{/* Exit Price */}
|
||||
<td className="py-3 px-4 text-right font-mono" style={{ color: '#EAECEF' }}>
|
||||
{formatPrice(exitPrice)}
|
||||
</td>
|
||||
|
||||
{/* Quantity */}
|
||||
<td className="py-3 px-4 text-right font-mono" style={{ color: '#848E9C' }}>
|
||||
{displayQty.toFixed(4)}
|
||||
</td>
|
||||
|
||||
{/* P&L */}
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="font-mono font-semibold" style={{ color: pnlColor }}>
|
||||
{isProfitable ? '+' : ''}
|
||||
{formatNumber(realizedPnl)}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: pnlColor }}>
|
||||
{pnlPct >= 0 ? '+' : ''}
|
||||
{pnlPct.toFixed(2)}%
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Fee */}
|
||||
<td className="py-3 px-4 text-right font-mono text-xs" style={{ color: '#848E9C' }}>
|
||||
-{(position.fee || 0).toFixed(2)}
|
||||
</td>
|
||||
|
||||
{/* Duration */}
|
||||
<td className="py-3 px-4 text-center text-sm" style={{ color: '#848E9C' }}>
|
||||
{formatDuration(holdingMinutes)}
|
||||
</td>
|
||||
|
||||
{/* Exit Time */}
|
||||
<td className="py-3 px-4 text-right text-xs" style={{ color: '#848E9C' }}>
|
||||
{formatDate(position.exit_time)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
const { language } = useLanguage()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [positions, setPositions] = useState<HistoricalPosition[]>([])
|
||||
const [stats, setStats] = useState<TraderStats | null>(null)
|
||||
const [symbolStats, setSymbolStats] = useState<SymbolStats[]>([])
|
||||
const [directionStats, setDirectionStats] = useState<DirectionStats[]>([])
|
||||
|
||||
// Filter state
|
||||
const [filterSymbol, setFilterSymbol] = useState<string>('all')
|
||||
const [filterSide, setFilterSide] = useState<string>('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 (
|
||||
<div
|
||||
className="flex items-center justify-center p-12"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
<div className="animate-spin mr-3">
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{t('positionHistory.loading', language)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg p-6 text-center"
|
||||
style={{
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
border: '1px solid rgba(246, 70, 93, 0.3)',
|
||||
color: '#F6465D',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (positions.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg p-12 text-center"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1E2329 0%, #181C21 100%)',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
>
|
||||
<div className="text-4xl mb-4">📊</div>
|
||||
<div className="text-lg font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('positionHistory.noHistory', language)}
|
||||
</div>
|
||||
<div style={{ color: '#848E9C' }}>
|
||||
{t('positionHistory.noHistoryDesc', language)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Overall Stats - Row 1: Core Metrics */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<StatCard
|
||||
icon="📊"
|
||||
title={t('positionHistory.totalTrades', language)}
|
||||
value={stats.total_trades || 0}
|
||||
subtitle={t('positionHistory.winLoss', language, { win: stats.win_trades || 0, loss: stats.loss_trades || 0 })}
|
||||
formula={language === 'zh'
|
||||
? '总交易次数 = 所有已平仓位数量'
|
||||
: 'Total Trades = Count of all closed positions'}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🎯"
|
||||
title={t('positionHistory.winRate', language)}
|
||||
value={(stats.win_rate || 0).toFixed(1)}
|
||||
suffix="%"
|
||||
color={
|
||||
(stats.win_rate || 0) >= 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%`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="💰"
|
||||
title={t('positionHistory.totalPnL', language)}
|
||||
value={((stats.total_pnl || 0) >= 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'}
|
||||
/>
|
||||
<StatCard
|
||||
icon="📈"
|
||||
title={t('positionHistory.profitFactor', language)}
|
||||
value={(stats.profit_factor || 0).toFixed(2)}
|
||||
color={(stats.profit_factor || 0) >= 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'}
|
||||
/>
|
||||
<StatCard
|
||||
icon="⚖️"
|
||||
title={t('positionHistory.plRatio', language)}
|
||||
value={profitLossRatio === Infinity ? '∞' : profitLossRatio.toFixed(2)}
|
||||
color={profitLossRatio >= 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)}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overall Stats - Row 2: Advanced Metrics */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<StatCard
|
||||
icon="📉"
|
||||
title={t('positionHistory.sharpeRatio', language)}
|
||||
value={(stats.sharpe_ratio || 0).toFixed(2)}
|
||||
color={(stats.sharpe_ratio || 0) >= 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'}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🔻"
|
||||
title={t('positionHistory.maxDrawdown', language)}
|
||||
value={(stats.max_drawdown_pct || 0).toFixed(1)}
|
||||
suffix="%"
|
||||
color={(stats.max_drawdown_pct || 0) <= 10 ? '#0ECB81' : (stats.max_drawdown_pct || 0) <= 20 ? '#F0B90B' : '#F6465D'}
|
||||
formula={language === 'zh'
|
||||
? '最大回撤 = (峰值 - 谷值) / 峰值 × 100%\n基于虚拟起始资金10000计算\n衡量最大亏损幅度'
|
||||
: 'Max Drawdown = (Peak - Trough) / Peak × 100%\nBased on virtual starting equity of 10000\nMeasures largest loss from peak'}
|
||||
/>
|
||||
<StatCard
|
||||
icon="🏆"
|
||||
title={t('positionHistory.avgWin', language)}
|
||||
value={'+' + formatNumber(stats.avg_win || 0)}
|
||||
color="#0ECB81"
|
||||
formula={language === 'zh'
|
||||
? `平均盈利 = 总盈利 / 盈利交易数\n盈利交易数: ${stats.win_trades || 0}`
|
||||
: `Avg Win = Total Profit / Winning Trades\nWinning Trades: ${stats.win_trades || 0}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="💸"
|
||||
title={t('positionHistory.avgLoss', language)}
|
||||
value={'-' + formatNumber(stats.avg_loss || 0)}
|
||||
color="#F6465D"
|
||||
formula={language === 'zh'
|
||||
? `平均亏损 = 总亏损 / 亏损交易数\n亏损交易数: ${stats.loss_trades || 0}`
|
||||
: `Avg Loss = Total Loss / Losing Trades\nLosing Trades: ${stats.loss_trades || 0}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="💵"
|
||||
title={t('positionHistory.netPnL', language)}
|
||||
value={((stats.total_pnl || 0) - (stats.total_fee || 0) >= 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)}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Direction Stats */}
|
||||
{directionStats.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{directionStats.map((stat) => (
|
||||
<DirectionStatsCard key={stat.side} stat={stat} language={language} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Symbol Performance */}
|
||||
{symbolStats.length > 0 && (
|
||||
<div
|
||||
className="rounded-lg p-4"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1E2329 0%, #181C21 100%)',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-lg">🏅</span>
|
||||
<span className="font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{t('positionHistory.symbolPerformance', language)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{symbolStats.slice(0, 10).map((stat) => (
|
||||
<SymbolStatsRow key={stat.symbol} stat={stat} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Position List */}
|
||||
<div
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1E2329 0%, #181C21 100%)',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
>
|
||||
{/* Filters */}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-4 p-4"
|
||||
style={{ borderBottom: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('positionHistory.symbol', language)}:
|
||||
</span>
|
||||
<select
|
||||
value={filterSymbol}
|
||||
onChange={(e) => setFilterSymbol(e.target.value)}
|
||||
className="rounded px-3 py-1.5 text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
<option value="all">{t('positionHistory.allSymbols', language)}</option>
|
||||
{uniqueSymbols.map((symbol) => (
|
||||
<option key={symbol} value={symbol}>
|
||||
{(symbol || '').replace('USDT', '')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('positionHistory.side', language)}:
|
||||
</span>
|
||||
<div className="flex rounded overflow-hidden" style={{ border: '1px solid #2B3139' }}>
|
||||
{['all', 'LONG', 'SHORT'].map((side) => (
|
||||
<button
|
||||
key={side}
|
||||
onClick={() => setFilterSide(side)}
|
||||
className="px-3 py-1.5 text-sm capitalize transition-colors"
|
||||
style={{
|
||||
background: filterSide === side ? '#2B3139' : 'transparent',
|
||||
color: filterSide === side ? '#EAECEF' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
{side === 'all' ? t('positionHistory.all', language) : side}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('positionHistory.sort', language)}:
|
||||
</span>
|
||||
<select
|
||||
value={`${sortBy}-${sortOrder}`}
|
||||
onChange={(e) => {
|
||||
const [by, order] = e.target.value.split('-') as [
|
||||
'time' | 'pnl' | 'pnl_pct',
|
||||
'asc' | 'desc',
|
||||
]
|
||||
setSortBy(by)
|
||||
setSortOrder(order)
|
||||
}}
|
||||
className="rounded px-3 py-1.5 text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
<option value="time-desc">{t('positionHistory.latestFirst', language)}</option>
|
||||
<option value="time-asc">{t('positionHistory.oldestFirst', language)}</option>
|
||||
<option value="pnl-desc">{t('positionHistory.highestPnL', language)}</option>
|
||||
<option value="pnl-asc">{t('positionHistory.lowestPnL', language)}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr style={{ background: '#0B0E11' }}>
|
||||
<th
|
||||
className="py-3 px-4 text-left text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('positionHistory.symbol', language)}
|
||||
</th>
|
||||
<th
|
||||
className="py-3 px-4 text-right text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('positionHistory.entry', language)}
|
||||
</th>
|
||||
<th
|
||||
className="py-3 px-4 text-right text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('positionHistory.exit', language)}
|
||||
</th>
|
||||
<th
|
||||
className="py-3 px-4 text-right text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('positionHistory.qty', language)}
|
||||
</th>
|
||||
<th
|
||||
className="py-3 px-4 text-right text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('positionHistory.pnl', language)}
|
||||
</th>
|
||||
<th
|
||||
className="py-3 px-4 text-right text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('positionHistory.fee', language)}
|
||||
</th>
|
||||
<th
|
||||
className="py-3 px-4 text-center text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('positionHistory.duration', language)}
|
||||
</th>
|
||||
<th
|
||||
className="py-3 px-4 text-right text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('positionHistory.closedAt', language)}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredPositions.map((position) => (
|
||||
<PositionRow key={position.id} position={position} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4 text-sm"
|
||||
style={{ borderTop: '1px solid #2B3139', color: '#848E9C' }}
|
||||
>
|
||||
<span>
|
||||
{t('positionHistory.showingPositions', language, { count: filteredPositions.length, total: positions.length })}
|
||||
</span>
|
||||
{filteredPositions.length > 0 && (
|
||||
<span>
|
||||
{t('positionHistory.totalPnL', language)}:{' '}
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
filteredPositions.reduce((sum, p) => 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)
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user