diff --git a/api/server.go b/api/server.go index 7753d5da..f5cfd4e1 100644 --- a/api/server.go +++ b/api/server.go @@ -2248,6 +2248,19 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string) map[string]inter histories := make(map[string]interface{}) errors := make(map[string]string) + // Pre-fetch initial balances for all traders + initialBalances := make(map[string]float64) + for _, traderID := range traderIDs { + if traderID == "" { + continue + } + // Get trader's initial balance from database (use GetByID which doesn't require userID) + trader, err := s.store.Trader().GetByID(traderID) + if err == nil && trader != nil && trader.InitialBalance > 0 { + initialBalances[traderID] = trader.InitialBalance + } + } + for _, traderID := range traderIDs { if traderID == "" { continue @@ -2266,14 +2279,28 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string) map[string]inter continue } - // Build return rate historical data + // Get initial balance for calculating PnL percentage + initialBalance := initialBalances[traderID] + if initialBalance <= 0 { + // If no initial balance configured, use the first snapshot's equity as baseline + initialBalance = snapshots[0].TotalEquity + } + + // Build return rate historical data with PnL percentage history := make([]map[string]interface{}, 0, len(snapshots)) for _, snap := range snapshots { + // Calculate PnL percentage: (current_equity - initial_balance) / initial_balance * 100 + pnlPct := 0.0 + if initialBalance > 0 { + pnlPct = (snap.TotalEquity - initialBalance) / initialBalance * 100 + } + history = append(history, map[string]interface{}{ - "timestamp": snap.Timestamp, - "total_equity": snap.TotalEquity, - "total_pnl": snap.UnrealizedPnL, - "balance": snap.Balance, + "timestamp": snap.Timestamp, + "total_equity": snap.TotalEquity, + "total_pnl": snap.UnrealizedPnL, + "total_pnl_pct": pnlPct, + "balance": snap.Balance, }) } diff --git a/store/trader.go b/store/trader.go index d05bec9a..5a813bc7 100644 --- a/store/trader.go +++ b/store/trader.go @@ -337,6 +337,33 @@ func (s *TraderStore) getActiveOrDefaultStrategy(userID string) (*Strategy, erro } // ListAll gets all users' trader list +// GetByID gets a trader by ID without requiring userID (for public APIs) +func (s *TraderStore) GetByID(traderID string) (*Trader, error) { + var t Trader + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, user_id, name, ai_model_id, exchange_id, COALESCE(strategy_id, ''), + initial_balance, scan_interval_minutes, is_running, COALESCE(is_cross_margin, 1), + COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5), COALESCE(trading_symbols, ''), + COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''), + COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'), + created_at, updated_at + FROM traders WHERE id = ? + `, traderID).Scan( + &t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.StrategyID, + &t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.IsCrossMargin, + &t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols, + &t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt, + &t.SystemPromptTemplate, &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + return &t, nil +} + func (s *TraderStore) ListAll() ([]*Trader, error) { rows, err := s.db.Query(` SELECT id, user_id, name, ai_model_id, exchange_id, COALESCE(strategy_id, ''), diff --git a/web/src/components/ComparisonChart.tsx b/web/src/components/ComparisonChart.tsx index 554c9b50..c888fc6e 100644 --- a/web/src/components/ComparisonChart.tsx +++ b/web/src/components/ComparisonChart.tsx @@ -1,6 +1,5 @@ import { useMemo } from 'react' import { - LineChart, Line, XAxis, YAxis, @@ -9,6 +8,8 @@ import { ResponsiveContainer, ReferenceLine, Legend, + Area, + ComposedChart, } from 'recharts' import useSWR from 'swr' import { api } from '../lib/api' @@ -16,7 +17,7 @@ import type { CompetitionTraderData } from '../types' import { getTraderColor } from '../utils/traderColors' import { useLanguage } from '../contexts/LanguageContext' import { t } from '../i18n/translations' -import { BarChart3 } from 'lucide-react' +import { BarChart3, TrendingUp, TrendingDown, Zap } from 'lucide-react' interface ComparisonChartProps { traders: CompetitionTraderData[] @@ -24,8 +25,8 @@ interface ComparisonChartProps { export function ComparisonChart({ traders }: ComparisonChartProps) { const { language } = useLanguage() - // 获取所有trader的历史数据 - 使用单个useSWR并发请求所有trader数据 - // 生成唯一的key,当traders变化时会触发重新请求 + + // Generate unique key for SWR const tradersKey = traders .map((t) => t.trader_id) .sort() @@ -34,23 +35,19 @@ export function ComparisonChart({ traders }: ComparisonChartProps) { const { data: allTraderHistories, isLoading } = useSWR( traders.length > 0 ? `all-equity-histories-${tradersKey}` : null, async () => { - // 使用批量API一次性获取所有trader的历史数据 const traderIds = traders.map((trader) => trader.trader_id) const batchData = await api.getEquityHistoryBatch(traderIds) - - // 转换为原格式,保持与原有代码兼容 return traders.map((trader) => { return batchData.histories[trader.trader_id] || [] }) }, { - refreshInterval: 30000, // 30秒刷新(对比图表数据更新频率较低) + refreshInterval: 30000, revalidateOnFocus: false, dedupingInterval: 20000, } ) - // 将数据转换为与原格式兼容的结构 const traderHistories = useMemo(() => { if (!allTraderHistories) { return traders.map(() => ({ data: undefined })) @@ -58,16 +55,10 @@ export function ComparisonChart({ traders }: ComparisonChartProps) { return allTraderHistories.map((data) => ({ data })) }, [allTraderHistories, traders.length]) - // 使用useMemo自动处理数据合并,直接使用data对象作为依赖 const combinedData = useMemo(() => { - // 等待所有数据加载完成 const allLoaded = traderHistories.every((h) => h.data) if (!allLoaded) return [] - console.log(`[${new Date().toISOString()}] Recalculating chart data...`) - - // 新方案:按时间戳分组,不再依赖 cycle_number(因为后端会重置) - // 收集所有时间戳 const timestampMap = new Map< string, { @@ -81,10 +72,6 @@ export function ComparisonChart({ traders }: ComparisonChartProps) { const trader = traders[index] if (!history.data) return - console.log( - `Trader ${trader.trader_id}: ${history.data.length} data points` - ) - history.data.forEach((point: any) => { const ts = point.timestamp @@ -100,7 +87,6 @@ export function ComparisonChart({ traders }: ComparisonChartProps) { }) } - // 直接使用后端返回的盈亏百分比,不要在前端重新计算 timestampMap.get(ts)!.traders.set(trader.trader_id, { pnl_pct: point.total_pnl_pct || 0, equity: point.total_equity, @@ -108,12 +94,11 @@ export function ComparisonChart({ traders }: ComparisonChartProps) { }) }) - // 按时间戳排序,转换为数组 const combined = Array.from(timestampMap.entries()) .sort(([tsA], [tsB]) => new Date(tsA).getTime() - new Date(tsB).getTime()) .map(([ts, data], index) => { const entry: any = { - index: index + 1, // 使用序号代替cycle + index: index + 1, time: data.time, timestamp: ts, } @@ -129,340 +114,345 @@ export function ComparisonChart({ traders }: ComparisonChartProps) { return entry }) - if (combined.length > 0) { - const lastPoint = combined[combined.length - 1] - console.log( - `Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}` - ) - } - return combined }, [allTraderHistories, traders]) + // Get trader color + const traderColor = (traderId: string) => getTraderColor(traders, traderId) + if (isLoading) { return ( -
-
-
Loading comparison data...
+
+
+
+ +
+
+ {t('loadingChartData', language) || 'Loading chart data...'} +
) } if (combinedData.length === 0) { return ( -
- -
+
+
+ +
+
{t('noHistoricalData', language)}
-
{t('dataWillAppear', language)}
+
+ {t('dataWillAppear', language)} +
) } - // 限制显示数据点 - const MAX_DISPLAY_POINTS = 2000 + const MAX_DISPLAY_POINTS = 500 const displayData = combinedData.length > MAX_DISPLAY_POINTS ? combinedData.slice(-MAX_DISPLAY_POINTS) : combinedData - // 计算Y轴范围 + // Calculate Y axis domain with better padding const calculateYDomain = () => { const allValues: number[] = [] displayData.forEach((point) => { traders.forEach((trader) => { const value = point[`${trader.trader_id}_pnl_pct`] - if (value !== undefined) { + if (value !== undefined && !isNaN(value)) { allValues.push(value) } }) }) - if (allValues.length === 0) return [-5, 5] + if (allValues.length === 0) return [-2, 2] const minVal = Math.min(...allValues) const maxVal = Math.max(...allValues) - const range = Math.max(Math.abs(maxVal), Math.abs(minVal)) - const padding = Math.max(range * 0.2, 1) // 至少留1%余量 - return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)] + // Ensure zero is visible and add symmetric padding + const absMax = Math.max(Math.abs(maxVal), Math.abs(minVal), 0.5) + const padding = absMax * 0.3 + + return [ + Math.floor((Math.min(minVal, 0) - padding) * 10) / 10, + Math.ceil((Math.max(maxVal, 0) + padding) * 10) / 10 + ] } - // 使用统一的颜色分配逻辑(与Leaderboard保持一致) - const traderColor = (traderId: string) => getTraderColor(traders, traderId) - - // 自定义Tooltip - Binance Style + // Custom Tooltip const CustomTooltip = ({ active, payload }: any) => { if (active && payload && payload.length) { const data = payload[0].payload + const date = new Date(data.timestamp) + const dateStr = date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) + return (
-
- {data.time} - #{data.index} +
+ + + {dateStr} {data.time} +
- {traders.map((trader) => { - const pnlPct = data[`${trader.trader_id}_pnl_pct`] - const equity = data[`${trader.trader_id}_equity`] - if (pnlPct === undefined) return null +
+ {traders.map((trader) => { + const pnlPct = data[`${trader.trader_id}_pnl_pct`] + const equity = data[`${trader.trader_id}_equity`] + if (pnlPct === undefined) return null + const isPositive = pnlPct >= 0 - return ( -
-
- {trader.trader_name} + return ( +
+
+
+ + {trader.trader_name} + +
+
+
+ {isPositive ? : } + {isPositive ? '+' : ''}{pnlPct.toFixed(2)}% +
+
+ ${equity?.toFixed(2)} +
+
-
= 0 ? '#0ECB81' : '#F6465D' }} - > - {pnlPct >= 0 ? '+' : ''} - {pnlPct.toFixed(2)}% - - ({equity?.toFixed(2)} USDT) - -
-
- ) - })} + ) + })} +
) } return null } - // 计算当前差距 - const currentGap = - displayData.length > 0 - ? (() => { - const lastPoint = displayData[displayData.length - 1] - const values = traders.map( - (t) => lastPoint[`${t.trader_id}_pnl_pct`] || 0 - ) - return Math.abs(values[0] - values[1]) - })() - : 0 + // Calculate stats + const lastPoint = displayData[displayData.length - 1] + const traderStats = traders.map(trader => { + const currentPnl = lastPoint?.[`${trader.trader_id}_pnl_pct`] || 0 + const currentEquity = lastPoint?.[`${trader.trader_id}_equity`] || 0 + return { ...trader, currentPnl, currentEquity } + }).sort((a, b) => b.currentPnl - a.currentPnl) + + const leader = traderStats[0] + const gap = traderStats.length > 1 + ? Math.abs(traderStats[0].currentPnl - traderStats[1].currentPnl).toFixed(2) + : '0.00' return ( -
-
- {/* NOFX Watermark */} -
+
+ {/* Mini Stats Bar */} +
+ {traderStats.slice(0, 5).map((trader, idx) => ( +
+
+ + {trader.trader_name} + + = 0 ? '#0ECB81' : '#F6465D' }}> + {trader.currentPnl >= 0 ? '+' : ''}{trader.currentPnl.toFixed(2)}% + +
+ ))} +
+ + {/* Chart */} +
+ {/* Watermark */} +
NOFX
- - + {traders.map((trader) => ( - - + + ))} + {/* Glow filter */} + + + + + + + - + `${value.toFixed(1)}%`} - width={60} + width={50} /> } /> + {/* Zero reference line */} - {traders.map((trader) => ( + {/* Area fills for top 2 traders */} + {traders.slice(0, 2).map((trader) => ( + + ))} + + {/* Lines for all traders */} + {traders.map((trader, idx) => ( ))} { - const traderId = traders.find( - (t) => value === t.trader_name - )?.trader_id - const trader = traders.find((t) => t.trader_id === traderId) + wrapperStyle={{ paddingTop: '16px' }} + iconType="circle" + iconSize={8} + formatter={(value) => { + const trader = traders.find((t) => t.trader_name === value) + const pnl = trader ? lastPoint?.[`${trader.trader_id}_pnl_pct`] || 0 : 0 return ( - - {trader?.trader_name} ({trader?.ai_model.toUpperCase()}) + + {value} + = 0 ? '#0ECB81' : '#F6465D', + marginLeft: '6px', + fontFamily: 'monospace' + }}> + ({pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}%) + ) }} /> - +
- {/* Stats */} -
-
-
- {t('comparisonMode', language)} + {/* Bottom Stats */} +
+
+
+ {t('leader', language)}
-
- PnL % +
+ {leader?.trader_name || '-'}
-
-
- {t('dataPoints', language)} +
+
+ {t('leadPnL', language) || 'Lead PnL'}
-
- {t('count', language, { count: combinedData.length })} +
= 0 ? '#0ECB81' : '#F6465D' }}> + {(leader?.currentPnl || 0) >= 0 ? '+' : ''}{(leader?.currentPnl || 0).toFixed(2)}%
-
-
+
+
{t('currentGap', language)}
-
1 ? '#F0B90B' : '#EAECEF' }} - > - {currentGap.toFixed(2)}% +
+ {gap}%
-
-
- {t('displayRange', language)} +
+
+ {t('dataPoints', language)}
-
- {combinedData.length > MAX_DISPLAY_POINTS - ? `${t('recent', language)} ${MAX_DISPLAY_POINTS}` - : t('allData', language)} +
+ {displayData.length}