mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 16:26:57 +08:00
feat: fix competition chart with accurate PnL calculation and improved UI
Backend: - Add GetByID method to TraderStore for fetching trader without userID - Calculate total_pnl_pct in equity history API using initial_balance - Falls back to first snapshot equity if initial_balance not set Frontend (ComparisonChart): - Redesign with modern UI: gradient fills, glow effects, rounded corners - Add mini stats bar showing all traders with current PnL - Improved tooltip with date, time, and trend icons - Better Y-axis domain calculation ensuring zero is visible - Bottom stats grid: Leader, Lead PnL, Gap, Data Points - Use ComposedChart with Area + Line for visual depth - Animated loading state with spinning indicator
This commit is contained in:
@@ -2248,6 +2248,19 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string) map[string]inter
|
|||||||
histories := make(map[string]interface{})
|
histories := make(map[string]interface{})
|
||||||
errors := make(map[string]string)
|
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 {
|
for _, traderID := range traderIDs {
|
||||||
if traderID == "" {
|
if traderID == "" {
|
||||||
continue
|
continue
|
||||||
@@ -2266,14 +2279,28 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string) map[string]inter
|
|||||||
continue
|
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))
|
history := make([]map[string]interface{}, 0, len(snapshots))
|
||||||
for _, snap := range 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{}{
|
history = append(history, map[string]interface{}{
|
||||||
"timestamp": snap.Timestamp,
|
"timestamp": snap.Timestamp,
|
||||||
"total_equity": snap.TotalEquity,
|
"total_equity": snap.TotalEquity,
|
||||||
"total_pnl": snap.UnrealizedPnL,
|
"total_pnl": snap.UnrealizedPnL,
|
||||||
"balance": snap.Balance,
|
"total_pnl_pct": pnlPct,
|
||||||
|
"balance": snap.Balance,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -337,6 +337,33 @@ func (s *TraderStore) getActiveOrDefaultStrategy(userID string) (*Strategy, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListAll gets all users' trader list
|
// 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) {
|
func (s *TraderStore) ListAll() ([]*Trader, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT id, user_id, name, ai_model_id, exchange_id, COALESCE(strategy_id, ''),
|
SELECT id, user_id, name, ai_model_id, exchange_id, COALESCE(strategy_id, ''),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
LineChart,
|
|
||||||
Line,
|
Line,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
@@ -9,6 +8,8 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
Legend,
|
Legend,
|
||||||
|
Area,
|
||||||
|
ComposedChart,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import useSWR from 'swr'
|
import useSWR from 'swr'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
@@ -16,7 +17,7 @@ import type { CompetitionTraderData } from '../types'
|
|||||||
import { getTraderColor } from '../utils/traderColors'
|
import { getTraderColor } from '../utils/traderColors'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import { t } from '../i18n/translations'
|
import { t } from '../i18n/translations'
|
||||||
import { BarChart3 } from 'lucide-react'
|
import { BarChart3, TrendingUp, TrendingDown, Zap } from 'lucide-react'
|
||||||
|
|
||||||
interface ComparisonChartProps {
|
interface ComparisonChartProps {
|
||||||
traders: CompetitionTraderData[]
|
traders: CompetitionTraderData[]
|
||||||
@@ -24,8 +25,8 @@ interface ComparisonChartProps {
|
|||||||
|
|
||||||
export function ComparisonChart({ traders }: ComparisonChartProps) {
|
export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
// 获取所有trader的历史数据 - 使用单个useSWR并发请求所有trader数据
|
|
||||||
// 生成唯一的key,当traders变化时会触发重新请求
|
// Generate unique key for SWR
|
||||||
const tradersKey = traders
|
const tradersKey = traders
|
||||||
.map((t) => t.trader_id)
|
.map((t) => t.trader_id)
|
||||||
.sort()
|
.sort()
|
||||||
@@ -34,23 +35,19 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
|||||||
const { data: allTraderHistories, isLoading } = useSWR(
|
const { data: allTraderHistories, isLoading } = useSWR(
|
||||||
traders.length > 0 ? `all-equity-histories-${tradersKey}` : null,
|
traders.length > 0 ? `all-equity-histories-${tradersKey}` : null,
|
||||||
async () => {
|
async () => {
|
||||||
// 使用批量API一次性获取所有trader的历史数据
|
|
||||||
const traderIds = traders.map((trader) => trader.trader_id)
|
const traderIds = traders.map((trader) => trader.trader_id)
|
||||||
const batchData = await api.getEquityHistoryBatch(traderIds)
|
const batchData = await api.getEquityHistoryBatch(traderIds)
|
||||||
|
|
||||||
// 转换为原格式,保持与原有代码兼容
|
|
||||||
return traders.map((trader) => {
|
return traders.map((trader) => {
|
||||||
return batchData.histories[trader.trader_id] || []
|
return batchData.histories[trader.trader_id] || []
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refreshInterval: 30000, // 30秒刷新(对比图表数据更新频率较低)
|
refreshInterval: 30000,
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
dedupingInterval: 20000,
|
dedupingInterval: 20000,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 将数据转换为与原格式兼容的结构
|
|
||||||
const traderHistories = useMemo(() => {
|
const traderHistories = useMemo(() => {
|
||||||
if (!allTraderHistories) {
|
if (!allTraderHistories) {
|
||||||
return traders.map(() => ({ data: undefined }))
|
return traders.map(() => ({ data: undefined }))
|
||||||
@@ -58,16 +55,10 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
|||||||
return allTraderHistories.map((data) => ({ data }))
|
return allTraderHistories.map((data) => ({ data }))
|
||||||
}, [allTraderHistories, traders.length])
|
}, [allTraderHistories, traders.length])
|
||||||
|
|
||||||
// 使用useMemo自动处理数据合并,直接使用data对象作为依赖
|
|
||||||
const combinedData = useMemo(() => {
|
const combinedData = useMemo(() => {
|
||||||
// 等待所有数据加载完成
|
|
||||||
const allLoaded = traderHistories.every((h) => h.data)
|
const allLoaded = traderHistories.every((h) => h.data)
|
||||||
if (!allLoaded) return []
|
if (!allLoaded) return []
|
||||||
|
|
||||||
console.log(`[${new Date().toISOString()}] Recalculating chart data...`)
|
|
||||||
|
|
||||||
// 新方案:按时间戳分组,不再依赖 cycle_number(因为后端会重置)
|
|
||||||
// 收集所有时间戳
|
|
||||||
const timestampMap = new Map<
|
const timestampMap = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -81,10 +72,6 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
|||||||
const trader = traders[index]
|
const trader = traders[index]
|
||||||
if (!history.data) return
|
if (!history.data) return
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Trader ${trader.trader_id}: ${history.data.length} data points`
|
|
||||||
)
|
|
||||||
|
|
||||||
history.data.forEach((point: any) => {
|
history.data.forEach((point: any) => {
|
||||||
const ts = point.timestamp
|
const ts = point.timestamp
|
||||||
|
|
||||||
@@ -100,7 +87,6 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接使用后端返回的盈亏百分比,不要在前端重新计算
|
|
||||||
timestampMap.get(ts)!.traders.set(trader.trader_id, {
|
timestampMap.get(ts)!.traders.set(trader.trader_id, {
|
||||||
pnl_pct: point.total_pnl_pct || 0,
|
pnl_pct: point.total_pnl_pct || 0,
|
||||||
equity: point.total_equity,
|
equity: point.total_equity,
|
||||||
@@ -108,12 +94,11 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 按时间戳排序,转换为数组
|
|
||||||
const combined = Array.from(timestampMap.entries())
|
const combined = Array.from(timestampMap.entries())
|
||||||
.sort(([tsA], [tsB]) => new Date(tsA).getTime() - new Date(tsB).getTime())
|
.sort(([tsA], [tsB]) => new Date(tsA).getTime() - new Date(tsB).getTime())
|
||||||
.map(([ts, data], index) => {
|
.map(([ts, data], index) => {
|
||||||
const entry: any = {
|
const entry: any = {
|
||||||
index: index + 1, // 使用序号代替cycle
|
index: index + 1,
|
||||||
time: data.time,
|
time: data.time,
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
}
|
}
|
||||||
@@ -129,340 +114,345 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
|||||||
return entry
|
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
|
return combined
|
||||||
}, [allTraderHistories, traders])
|
}, [allTraderHistories, traders])
|
||||||
|
|
||||||
|
// Get trader color
|
||||||
|
const traderColor = (traderId: string) => getTraderColor(traders, traderId)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-16" style={{ color: '#848E9C' }}>
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<div className="spinner mx-auto mb-4"></div>
|
<div className="relative">
|
||||||
<div className="text-sm font-semibold">Loading comparison data...</div>
|
<div className="w-16 h-16 border-4 border-t-transparent rounded-full animate-spin"
|
||||||
|
style={{ borderColor: '#F0B90B', borderTopColor: 'transparent' }} />
|
||||||
|
<TrendingUp className="w-6 h-6 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||||
|
style={{ color: '#F0B90B' }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm mt-4 font-medium" style={{ color: '#848E9C' }}>
|
||||||
|
{t('loadingChartData', language) || 'Loading chart data...'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (combinedData.length === 0) {
|
if (combinedData.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-16" style={{ color: '#848E9C' }}>
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<BarChart3 className="w-12 h-12 mx-auto mb-4 opacity-60" />
|
<div className="w-20 h-20 rounded-2xl flex items-center justify-center mb-4"
|
||||||
<div className="text-lg font-semibold mb-2">
|
style={{ background: 'rgba(240, 185, 11, 0.1)' }}>
|
||||||
|
<BarChart3 className="w-10 h-10" style={{ color: '#F0B90B', opacity: 0.6 }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold mb-2" style={{ color: '#EAECEF' }}>
|
||||||
{t('noHistoricalData', language)}
|
{t('noHistoricalData', language)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm">{t('dataWillAppear', language)}</div>
|
<div className="text-sm text-center max-w-xs" style={{ color: '#848E9C' }}>
|
||||||
|
{t('dataWillAppear', language)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 限制显示数据点
|
const MAX_DISPLAY_POINTS = 500
|
||||||
const MAX_DISPLAY_POINTS = 2000
|
|
||||||
const displayData =
|
const displayData =
|
||||||
combinedData.length > MAX_DISPLAY_POINTS
|
combinedData.length > MAX_DISPLAY_POINTS
|
||||||
? combinedData.slice(-MAX_DISPLAY_POINTS)
|
? combinedData.slice(-MAX_DISPLAY_POINTS)
|
||||||
: combinedData
|
: combinedData
|
||||||
|
|
||||||
// 计算Y轴范围
|
// Calculate Y axis domain with better padding
|
||||||
const calculateYDomain = () => {
|
const calculateYDomain = () => {
|
||||||
const allValues: number[] = []
|
const allValues: number[] = []
|
||||||
displayData.forEach((point) => {
|
displayData.forEach((point) => {
|
||||||
traders.forEach((trader) => {
|
traders.forEach((trader) => {
|
||||||
const value = point[`${trader.trader_id}_pnl_pct`]
|
const value = point[`${trader.trader_id}_pnl_pct`]
|
||||||
if (value !== undefined) {
|
if (value !== undefined && !isNaN(value)) {
|
||||||
allValues.push(value)
|
allValues.push(value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (allValues.length === 0) return [-5, 5]
|
if (allValues.length === 0) return [-2, 2]
|
||||||
|
|
||||||
const minVal = Math.min(...allValues)
|
const minVal = Math.min(...allValues)
|
||||||
const maxVal = Math.max(...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保持一致)
|
// Custom Tooltip
|
||||||
const traderColor = (traderId: string) => getTraderColor(traders, traderId)
|
|
||||||
|
|
||||||
// 自定义Tooltip - Binance Style
|
|
||||||
const CustomTooltip = ({ active, payload }: any) => {
|
const CustomTooltip = ({ active, payload }: any) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
const data = payload[0].payload
|
const data = payload[0].payload
|
||||||
|
const date = new Date(data.timestamp)
|
||||||
|
const dateStr = date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded p-3 shadow-xl"
|
className="rounded-xl p-4 shadow-2xl backdrop-blur-sm"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
style={{
|
||||||
|
background: 'rgba(30, 35, 41, 0.95)',
|
||||||
|
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||||
|
minWidth: '200px'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
<div className="flex items-center gap-2 mb-3 pb-2" style={{ borderBottom: '1px solid #2B3139' }}>
|
||||||
{data.time} - #{data.index}
|
<Zap className="w-3.5 h-3.5" style={{ color: '#F0B90B' }} />
|
||||||
|
<span className="text-xs font-medium" style={{ color: '#F0B90B' }}>
|
||||||
|
{dateStr} {data.time}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{traders.map((trader) => {
|
<div className="space-y-2.5">
|
||||||
const pnlPct = data[`${trader.trader_id}_pnl_pct`]
|
{traders.map((trader) => {
|
||||||
const equity = data[`${trader.trader_id}_equity`]
|
const pnlPct = data[`${trader.trader_id}_pnl_pct`]
|
||||||
if (pnlPct === undefined) return null
|
const equity = data[`${trader.trader_id}_equity`]
|
||||||
|
if (pnlPct === undefined) return null
|
||||||
|
const isPositive = pnlPct >= 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={trader.trader_id} className="mb-1.5 last:mb-0">
|
<div key={trader.trader_id} className="flex items-center justify-between gap-4">
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className="text-xs font-semibold mb-0.5"
|
<div className="w-2.5 h-2.5 rounded-full"
|
||||||
style={{ color: traderColor(trader.trader_id) }}
|
style={{ background: traderColor(trader.trader_id) }} />
|
||||||
>
|
<span className="text-xs font-medium truncate max-w-[100px]"
|
||||||
{trader.trader_name}
|
style={{ color: '#EAECEF' }}>
|
||||||
|
{trader.trader_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-bold mono flex items-center gap-1"
|
||||||
|
style={{ color: isPositive ? '#0ECB81' : '#F6465D' }}>
|
||||||
|
{isPositive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
||||||
|
{isPositive ? '+' : ''}{pnlPct.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] mono" style={{ color: '#5E6673' }}>
|
||||||
|
${equity?.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
)
|
||||||
className="text-sm mono font-bold"
|
})}
|
||||||
style={{ color: pnlPct >= 0 ? '#0ECB81' : '#F6465D' }}
|
</div>
|
||||||
>
|
|
||||||
{pnlPct >= 0 ? '+' : ''}
|
|
||||||
{pnlPct.toFixed(2)}%
|
|
||||||
<span
|
|
||||||
className="text-xs ml-2 font-normal"
|
|
||||||
style={{ color: '#848E9C' }}
|
|
||||||
>
|
|
||||||
({equity?.toFixed(2)} USDT)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算当前差距
|
// Calculate stats
|
||||||
const currentGap =
|
const lastPoint = displayData[displayData.length - 1]
|
||||||
displayData.length > 0
|
const traderStats = traders.map(trader => {
|
||||||
? (() => {
|
const currentPnl = lastPoint?.[`${trader.trader_id}_pnl_pct`] || 0
|
||||||
const lastPoint = displayData[displayData.length - 1]
|
const currentEquity = lastPoint?.[`${trader.trader_id}_equity`] || 0
|
||||||
const values = traders.map(
|
return { ...trader, currentPnl, currentEquity }
|
||||||
(t) => lastPoint[`${t.trader_id}_pnl_pct`] || 0
|
}).sort((a, b) => b.currentPnl - a.currentPnl)
|
||||||
)
|
|
||||||
return Math.abs(values[0] - values[1])
|
const leader = traderStats[0]
|
||||||
})()
|
const gap = traderStats.length > 1
|
||||||
: 0
|
? Math.abs(traderStats[0].currentPnl - traderStats[1].currentPnl).toFixed(2)
|
||||||
|
: '0.00'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<div
|
{/* Mini Stats Bar */}
|
||||||
style={{
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
borderRadius: '8px',
|
{traderStats.slice(0, 5).map((trader, idx) => (
|
||||||
overflow: 'hidden',
|
<div key={trader.trader_id}
|
||||||
position: 'relative',
|
className="flex items-center gap-2 px-3 py-1.5 rounded-full transition-all hover:scale-105"
|
||||||
}}
|
style={{
|
||||||
>
|
background: idx === 0 ? 'rgba(240, 185, 11, 0.15)' : 'rgba(43, 49, 57, 0.5)',
|
||||||
{/* NOFX Watermark */}
|
border: `1px solid ${idx === 0 ? 'rgba(240, 185, 11, 0.3)' : '#2B3139'}`
|
||||||
<div
|
}}>
|
||||||
style={{
|
<div className="w-2 h-2 rounded-full"
|
||||||
position: 'absolute',
|
style={{ background: traderColor(trader.trader_id) }} />
|
||||||
top: '20px',
|
<span className="text-xs font-medium truncate max-w-[80px]"
|
||||||
right: '20px',
|
style={{ color: '#EAECEF' }}>
|
||||||
fontSize: '24px',
|
{trader.trader_name}
|
||||||
fontWeight: 'bold',
|
</span>
|
||||||
color: 'rgba(240, 185, 11, 0.15)',
|
<span className="text-xs font-bold mono"
|
||||||
zIndex: 10,
|
style={{ color: trader.currentPnl >= 0 ? '#0ECB81' : '#F6465D' }}>
|
||||||
pointerEvents: 'none',
|
{trader.currentPnl >= 0 ? '+' : ''}{trader.currentPnl.toFixed(2)}%
|
||||||
fontFamily: 'monospace',
|
</span>
|
||||||
}}
|
</div>
|
||||||
>
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<div className="relative rounded-xl overflow-hidden"
|
||||||
|
style={{ background: 'linear-gradient(180deg, rgba(11, 14, 17, 0.8) 0%, rgba(11, 14, 17, 1) 100%)' }}>
|
||||||
|
{/* Watermark */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
fontSize: '80px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'rgba(240, 185, 11, 0.03)',
|
||||||
|
zIndex: 1,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
}}>
|
||||||
NOFX
|
NOFX
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={520}>
|
|
||||||
<LineChart
|
<ResponsiveContainer width="100%" height={420}>
|
||||||
|
<ComposedChart
|
||||||
data={displayData}
|
data={displayData}
|
||||||
margin={{ top: 20, right: 30, left: 20, bottom: 40 }}
|
margin={{ top: 20, right: 20, left: 10, bottom: 20 }}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
{traders.map((trader) => (
|
{traders.map((trader) => (
|
||||||
<linearGradient
|
<linearGradient
|
||||||
key={`gradient-${trader.trader_id}`}
|
key={`area-gradient-${trader.trader_id}`}
|
||||||
id={`gradient-${trader.trader_id}`}
|
id={`area-gradient-${trader.trader_id}`}
|
||||||
x1="0"
|
x1="0" y1="0" x2="0" y2="1"
|
||||||
y1="0"
|
|
||||||
x2="0"
|
|
||||||
y2="1"
|
|
||||||
>
|
>
|
||||||
<stop
|
<stop offset="0%" stopColor={traderColor(trader.trader_id)} stopOpacity={0.3} />
|
||||||
offset="5%"
|
<stop offset="100%" stopColor={traderColor(trader.trader_id)} stopOpacity={0} />
|
||||||
stopColor={traderColor(trader.trader_id)}
|
|
||||||
stopOpacity={0.9}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="95%"
|
|
||||||
stopColor={traderColor(trader.trader_id)}
|
|
||||||
stopOpacity={0.2}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
))}
|
))}
|
||||||
|
{/* Glow filter */}
|
||||||
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="coloredBlur"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E2329" vertical={false} />
|
||||||
|
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="time"
|
||||||
stroke="#5E6673"
|
stroke="#2B3139"
|
||||||
tick={{ fill: '#848E9C', fontSize: 11 }}
|
tick={{ fill: '#5E6673', fontSize: 10 }}
|
||||||
tickLine={{ stroke: '#2B3139' }}
|
tickLine={false}
|
||||||
interval={Math.floor(displayData.length / 12)}
|
axisLine={{ stroke: '#2B3139' }}
|
||||||
angle={-15}
|
interval={Math.max(Math.floor(displayData.length / 8), 1)}
|
||||||
textAnchor="end"
|
|
||||||
height={60}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<YAxis
|
<YAxis
|
||||||
stroke="#5E6673"
|
stroke="#2B3139"
|
||||||
tick={{ fill: '#848E9C', fontSize: 12 }}
|
tick={{ fill: '#5E6673', fontSize: 10 }}
|
||||||
tickLine={{ stroke: '#2B3139' }}
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
domain={calculateYDomain()}
|
domain={calculateYDomain()}
|
||||||
tickFormatter={(value) => `${value.toFixed(1)}%`}
|
tickFormatter={(value) => `${value.toFixed(1)}%`}
|
||||||
width={60}
|
width={50}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
|
||||||
|
{/* Zero reference line */}
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={0}
|
y={0}
|
||||||
stroke="#474D57"
|
stroke="#474D57"
|
||||||
strokeDasharray="5 5"
|
strokeDasharray="8 4"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1}
|
||||||
label={{
|
|
||||||
value: 'Break Even',
|
|
||||||
fill: '#848E9C',
|
|
||||||
fontSize: 11,
|
|
||||||
position: 'right',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{traders.map((trader) => (
|
{/* Area fills for top 2 traders */}
|
||||||
|
{traders.slice(0, 2).map((trader) => (
|
||||||
|
<Area
|
||||||
|
key={`area-${trader.trader_id}`}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={`${trader.trader_id}_pnl_pct`}
|
||||||
|
fill={`url(#area-gradient-${trader.trader_id})`}
|
||||||
|
stroke="none"
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Lines for all traders */}
|
||||||
|
{traders.map((trader, idx) => (
|
||||||
<Line
|
<Line
|
||||||
key={trader.trader_id}
|
key={trader.trader_id}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={`${trader.trader_id}_pnl_pct`}
|
dataKey={`${trader.trader_id}_pnl_pct`}
|
||||||
stroke={traderColor(trader.trader_id)}
|
stroke={traderColor(trader.trader_id)}
|
||||||
strokeWidth={3}
|
strokeWidth={idx === 0 ? 3 : 2}
|
||||||
dot={
|
dot={false}
|
||||||
displayData.length < 50
|
|
||||||
? { fill: traderColor(trader.trader_id), r: 3 }
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
activeDot={{
|
activeDot={{
|
||||||
r: 6,
|
r: 6,
|
||||||
fill: traderColor(trader.trader_id),
|
fill: traderColor(trader.trader_id),
|
||||||
stroke: '#fff',
|
stroke: '#0B0E11',
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
|
filter: 'url(#glow)',
|
||||||
}}
|
}}
|
||||||
name={trader.trader_name}
|
name={trader.trader_name}
|
||||||
connectNulls
|
connectNulls
|
||||||
|
style={{ filter: idx === 0 ? 'url(#glow)' : undefined }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Legend
|
<Legend
|
||||||
wrapperStyle={{ paddingTop: '20px' }}
|
wrapperStyle={{ paddingTop: '16px' }}
|
||||||
iconType="line"
|
iconType="circle"
|
||||||
formatter={(value, entry: any) => {
|
iconSize={8}
|
||||||
const traderId = traders.find(
|
formatter={(value) => {
|
||||||
(t) => value === t.trader_name
|
const trader = traders.find((t) => t.trader_name === value)
|
||||||
)?.trader_id
|
const pnl = trader ? lastPoint?.[`${trader.trader_id}_pnl_pct`] || 0 : 0
|
||||||
const trader = traders.find((t) => t.trader_id === traderId)
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span style={{ color: '#EAECEF', fontSize: '12px', fontWeight: 500 }}>
|
||||||
style={{
|
{value}
|
||||||
color: entry.color,
|
<span style={{
|
||||||
fontWeight: 600,
|
color: pnl >= 0 ? '#0ECB81' : '#F6465D',
|
||||||
fontSize: '14px',
|
marginLeft: '6px',
|
||||||
}}
|
fontFamily: 'monospace'
|
||||||
>
|
}}>
|
||||||
{trader?.trader_name} ({trader?.ai_model.toUpperCase()})
|
({pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}%)
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Bottom Stats */}
|
||||||
<div
|
<div className="grid grid-cols-4 gap-2">
|
||||||
className="mt-6 grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 pt-5"
|
<div className="p-3 rounded-lg text-center"
|
||||||
style={{ borderTop: '1px solid #2B3139' }}
|
style={{ background: 'rgba(240, 185, 11, 0.05)', border: '1px solid rgba(240, 185, 11, 0.1)' }}>
|
||||||
>
|
<div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: '#848E9C' }}>
|
||||||
<div
|
{t('leader', language)}
|
||||||
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
|
|
||||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-xs mb-1 uppercase tracking-wider"
|
|
||||||
style={{ color: '#848E9C' }}
|
|
||||||
>
|
|
||||||
{t('comparisonMode', language)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="text-sm font-bold truncate" style={{ color: '#F0B90B' }}>
|
||||||
className="text-sm md:text-base font-bold"
|
{leader?.trader_name || '-'}
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
PnL %
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="p-3 rounded-lg text-center" style={{ background: 'rgba(14, 203, 129, 0.05)' }}>
|
||||||
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
|
<div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: '#848E9C' }}>
|
||||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
{t('leadPnL', language) || 'Lead PnL'}
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-xs mb-1 uppercase tracking-wider"
|
|
||||||
style={{ color: '#848E9C' }}
|
|
||||||
>
|
|
||||||
{t('dataPoints', language)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="text-sm font-bold mono"
|
||||||
className="text-sm md:text-base font-bold mono"
|
style={{ color: (leader?.currentPnl || 0) >= 0 ? '#0ECB81' : '#F6465D' }}>
|
||||||
style={{ color: '#EAECEF' }}
|
{(leader?.currentPnl || 0) >= 0 ? '+' : ''}{(leader?.currentPnl || 0).toFixed(2)}%
|
||||||
>
|
|
||||||
{t('count', language, { count: combinedData.length })}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="p-3 rounded-lg text-center" style={{ background: 'rgba(96, 165, 250, 0.05)' }}>
|
||||||
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
|
<div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: '#848E9C' }}>
|
||||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-xs mb-1 uppercase tracking-wider"
|
|
||||||
style={{ color: '#848E9C' }}
|
|
||||||
>
|
|
||||||
{t('currentGap', language)}
|
{t('currentGap', language)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="text-sm font-bold mono" style={{ color: '#60a5fa' }}>
|
||||||
className="text-sm md:text-base font-bold mono"
|
{gap}%
|
||||||
style={{ color: currentGap > 1 ? '#F0B90B' : '#EAECEF' }}
|
|
||||||
>
|
|
||||||
{currentGap.toFixed(2)}%
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="p-3 rounded-lg text-center" style={{ background: 'rgba(139, 92, 246, 0.05)' }}>
|
||||||
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
|
<div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: '#848E9C' }}>
|
||||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
{t('dataPoints', language)}
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-xs mb-1 uppercase tracking-wider"
|
|
||||||
style={{ color: '#848E9C' }}
|
|
||||||
>
|
|
||||||
{t('displayRange', language)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="text-sm font-bold mono" style={{ color: '#8b5cf6' }}>
|
||||||
className="text-sm md:text-base font-bold mono"
|
{displayData.length}
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
{combinedData.length > MAX_DISPLAY_POINTS
|
|
||||||
? `${t('recent', language)} ${MAX_DISPLAY_POINTS}`
|
|
||||||
: t('allData', language)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user