import { useState } from 'react' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, } from 'recharts' import useSWR from 'swr' import { api } from '../../lib/api' import { useLanguage } from '../../contexts/LanguageContext' import { useAuth } from '../../contexts/AuthContext' import { t } from '../../i18n/translations' import { AlertTriangle, BarChart3, DollarSign, Percent, TrendingUp as ArrowUp, TrendingDown as ArrowDown, } from 'lucide-react' interface EquityPoint { timestamp: string total_equity: number pnl: number pnl_pct: number cycle_number: number } interface EquityChartProps { traderId?: string embedded?: boolean // Embedded mode (does not show the outer card) } export function EquityChart({ traderId, embedded = false }: EquityChartProps) { const { language } = useLanguage() const { user, token } = useAuth() const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar') const { data: history, error, isLoading } = useSWR( user && token && traderId ? `equity-history-${traderId}` : null, () => api.getEquityHistory(traderId, true), { refreshInterval: 30000, // Refresh every 30s (historical data updates less frequently) revalidateOnFocus: false, dedupingInterval: 20000, } ) const { data: account } = useSWR( user && token && traderId ? `account-${traderId}` : null, () => api.getAccount(traderId, true), { refreshInterval: 15000, // Refresh every 15s (matches backend cache) revalidateOnFocus: false, dedupingInterval: 10000, } ) // Loading state - show skeleton if (isLoading) { return (
{!embedded && (

{t('accountEquityCurve', language)}

)}
) } if (error) { return (
{t('loadingError', language)}
{error.message}
) } // Filter out invalid data: points where total_equity is 0 or less than 1 (caused by API failures) const validHistory = history?.filter((point) => point.total_equity > 1) || [] if (!validHistory || validHistory.length === 0) { return (
{!embedded && (

{t('accountEquityCurve', language)}

)}
{t('noHistoricalData', language)}
{t('dataWillAppear', language)}
) } // Limit to the most recent data points (performance optimization) // If there are more than 2000 points, only show the most recent 2000 const MAX_DISPLAY_POINTS = 2000 const displayHistory = validHistory.length > MAX_DISPLAY_POINTS ? validHistory.slice(-MAX_DISPLAY_POINTS) : validHistory // Compute the initial balance (prefer the configured value from account, fall back to deriving from history) const initialBalance = account?.initial_balance || // Read the real initial balance from the trader config (validHistory[0] ? validHistory[0].total_equity - validHistory[0].pnl : undefined) || // Fallback: equity - pnl 1000 // Default value (matches the default config used when creating a trader) // Transform the data format const chartData = displayHistory.map((point, index) => { const pnl = point.total_equity - initialBalance const pnlPct = ((pnl / initialBalance) * 100).toFixed(2) return { time: new Date(point.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', }), value: displayMode === 'dollar' ? point.total_equity : parseFloat(pnlPct), cycle: point.cycle_number ?? index + 1, raw_equity: point.total_equity, raw_pnl: pnl, raw_pnl_pct: parseFloat(pnlPct), } }) const currentValue = chartData[chartData.length - 1] const isProfit = currentValue.raw_pnl >= 0 // Compute the Y-axis range const calculateYDomain = () => { if (displayMode === 'percent') { // Percent mode: find the min/max values, leave a 20% margin const values = chartData.map((d) => d.value) const minVal = Math.min(...values) const maxVal = Math.max(...values) const range = Math.max(Math.abs(maxVal), Math.abs(minVal)) const padding = Math.max(range * 0.2, 1) // Leave at least a 1% margin return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)] } else { // Dollar mode: anchor on the initial balance, leave a 10% margin above and below const values = chartData.map((d) => d.value) const minVal = Math.min(...values, initialBalance) const maxVal = Math.max(...values, initialBalance) const range = maxVal - minVal const padding = Math.max(range * 0.15, initialBalance * 0.01) // Leave at least a 1% margin return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)] } } // Custom Tooltip - Binance Style const CustomTooltip = ({ active, payload }: any) => { if (active && payload && payload.length) { const data = payload[0].payload return (
Cycle #{data.cycle != null ? data.cycle : '—'}
{data.raw_equity.toFixed(2)} USDT
= 0 ? '#2E8B57' : '#D6433A' }} > {data.raw_pnl >= 0 ? '+' : ''} {data.raw_pnl.toFixed(2)} USDT ({data.raw_pnl_pct >= 0 ? '+' : ''} {data.raw_pnl_pct}%)
) } return null } return (
{/* Header */}
{!embedded && (

{t('accountEquityCurve', language)}

)}
{account?.total_equity.toFixed(2) || '0.00'} USDT
{isProfit ? ( ) : ( )} {isProfit ? '+' : ''} {currentValue.raw_pnl_pct}% ({isProfit ? '+' : ''} {currentValue.raw_pnl.toFixed(2)} USDT)
{/* Display Mode Toggle */}
{/* Chart */}
{/* NOFX Watermark */}
NOFX
displayMode === 'dollar' ? `$${value.toFixed(0)}` : `${value}%` } /> } /> 50 ? false : { fill: '#E0483B', r: 3 }} activeDot={{ r: 6, fill: '#E0483B', stroke: '#F1ECE2', strokeWidth: 2, }} connectNulls={true} />
{/* Footer Stats */}
{t('initialBalance', language)}
{initialBalance.toFixed(2)} USDT
{t('currentEquity', language)}
{currentValue.raw_equity.toFixed(2)} USDT
{t('historicalCycles', language)}
{validHistory.length} {t('cycles', language)}
{t('displayRange', language)}
{validHistory.length > MAX_DISPLAY_POINTS ? `${t('recent', language)} ${MAX_DISPLAY_POINTS}` : t('allData', language)}
) }