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 ( -