Files
nofx/web/src/components/charts/EquityChart.tsx
tinkle-community 110bf52908 feat: cream terminal redesign, English-only UI, autopilot launch fixes
- Redesign dashboard into a cream-paper + vermilion IBM Plex Mono terminal
  (live L2 order book, cost/liq map, WS K-line, signal matrix, orchestration
  topology, risk radar, execution log, current positions, equity curve)
- Convert all user-facing UI and backend strings/prompts from Chinese to
  English (multi-language retained, default English)
- Add /api/statistics/full endpoint + full-stats frontend wiring
- Fix Autopilot launch: reuse the existing trader instead of creating
  duplicates (eliminates repeat ~35s create cost and stale-trader 404s);
  launch sends 5m scan interval
- Fix unreadable toasts: cream theme with high-contrast text + per-type accent
- Silence background dashboard polls (getTraderConfig) to stop error-toast spam
2026-06-30 16:03:52 +08:00

477 lines
16 KiB
TypeScript

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<EquityPoint[]>(
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 (
<div className={embedded ? 'p-6' : 'binance-card p-6'}>
{!embedded && (
<h3 className="text-lg font-semibold mb-6" style={{ color: '#1A1813' }}>
{t('accountEquityCurve', language)}
</h3>
)}
<div className="animate-pulse">
<div className="skeleton h-64 w-full rounded"></div>
</div>
</div>
)
}
if (error) {
return (
<div className={embedded ? 'p-6' : 'binance-card p-6'}>
<div
className="flex items-center gap-3 p-4 rounded"
style={{
background: 'rgba(214, 67, 58, 0.1)',
border: '1px solid rgba(214, 67, 58, 0.2)',
}}
>
<AlertTriangle className="w-6 h-6" style={{ color: '#D6433A' }} />
<div>
<div className="font-semibold" style={{ color: '#D6433A' }}>
{t('loadingError', language)}
</div>
<div className="text-sm" style={{ color: '#8A8478' }}>
{error.message}
</div>
</div>
</div>
</div>
)
}
// 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 (
<div className={embedded ? 'p-6' : 'binance-card p-6'}>
{!embedded && (
<h3 className="text-lg font-semibold mb-6" style={{ color: '#1A1813' }}>
{t('accountEquityCurve', language)}
</h3>
)}
<div className="text-center py-16" style={{ color: '#8A8478' }}>
<div className="mb-4 flex justify-center opacity-50">
<BarChart3 className="w-16 h-16" />
</div>
<div className="text-lg font-semibold mb-2">
{t('noHistoricalData', language)}
</div>
<div className="text-sm">{t('dataWillAppear', language)}</div>
</div>
</div>
)
}
// 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 (
<div
className="rounded p-3 shadow-xl"
style={{ background: '#F7F4EC', border: '1px solid rgba(26, 24, 19, 0.14)' }}
>
<div className="text-xs mb-1" style={{ color: '#8A8478' }}>
Cycle #{data.cycle != null ? data.cycle : '—'}
</div>
<div className="font-bold mono" style={{ color: '#1A1813' }}>
{data.raw_equity.toFixed(2)} USDT
</div>
<div
className="text-sm mono font-bold"
style={{ color: data.raw_pnl >= 0 ? '#2E8B57' : '#D6433A' }}
>
{data.raw_pnl >= 0 ? '+' : ''}
{data.raw_pnl.toFixed(2)} USDT ({data.raw_pnl_pct >= 0 ? '+' : ''}
{data.raw_pnl_pct}%)
</div>
</div>
)
}
return null
}
return (
<div className={embedded ? 'p-3 sm:p-5' : 'binance-card p-3 sm:p-5 animate-fade-in'}>
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
<div className="flex-1">
{!embedded && (
<h3
className="text-base sm:text-lg font-bold mb-2"
style={{ color: '#1A1813' }}
>
{t('accountEquityCurve', language)}
</h3>
)}
<div className="flex flex-col sm:flex-row sm:items-baseline gap-2 sm:gap-4">
<span
className="text-2xl sm:text-3xl font-bold mono"
style={{ color: '#1A1813' }}
>
{account?.total_equity.toFixed(2) || '0.00'}
<span
className="text-base sm:text-lg ml-1"
style={{ color: '#8A8478' }}
>
USDT
</span>
</span>
<div className="flex items-center gap-2 flex-wrap">
<span
className="text-sm sm:text-lg font-bold mono px-2 sm:px-3 py-1 rounded flex items-center gap-1"
style={{
color: isProfit ? '#2E8B57' : '#D6433A',
background: isProfit
? 'rgba(46, 139, 87, 0.1)'
: 'rgba(214, 67, 58, 0.1)',
border: `1px solid ${
isProfit
? 'rgba(46, 139, 87, 0.2)'
: 'rgba(214, 67, 58, 0.2)'
}`,
}}
>
{isProfit ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
{isProfit ? '+' : ''}
{currentValue.raw_pnl_pct}%
</span>
<span
className="text-xs sm:text-sm mono"
style={{ color: '#8A8478' }}
>
({isProfit ? '+' : ''}
{currentValue.raw_pnl.toFixed(2)} USDT)
</span>
</div>
</div>
</div>
{/* Display Mode Toggle */}
<div
className="flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1 self-start sm:self-auto"
style={{ background: '#E8E2D5', border: '1px solid rgba(26, 24, 19, 0.14)' }}
>
<button
onClick={() => setDisplayMode('dollar')}
className="px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1"
style={
displayMode === 'dollar'
? {
background: '#E0483B',
color: '#F1ECE2',
}
: { background: 'transparent', color: '#8A8478' }
}
>
<DollarSign className="w-4 h-4" /> USDT
</button>
<button
onClick={() => setDisplayMode('percent')}
className="px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1"
style={
displayMode === 'percent'
? {
background: '#E0483B',
color: '#F1ECE2',
}
: { background: 'transparent', color: '#8A8478' }
}
>
<Percent className="w-4 h-4" />
</button>
</div>
</div>
{/* Chart */}
<div
className="my-2"
style={{
borderRadius: '8px',
overflow: 'hidden',
position: 'relative',
}}
>
{/* NOFX Watermark */}
<div
style={{
position: 'absolute',
top: '15px',
right: '15px',
fontSize: '20px',
fontWeight: 'bold',
color: 'rgba(224, 72, 59, 0.15)',
zIndex: 10,
pointerEvents: 'none',
fontFamily: 'monospace',
}}
>
NOFX
</div>
<ResponsiveContainer width="100%" height={280}>
<LineChart
data={chartData}
margin={{ top: 10, right: 20, left: 5, bottom: 30 }}
>
<defs>
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#E0483B" stopOpacity={0.8} />
<stop offset="95%" stopColor="#E0483B" stopOpacity={0.2} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(26, 24, 19, 0.10)" />
<XAxis
dataKey="time"
stroke="#6B6557"
tick={{ fill: '#6B6557', fontSize: 11 }}
tickLine={{ stroke: 'rgba(26, 24, 19, 0.14)' }}
interval={Math.floor(chartData.length / 10)}
angle={-15}
textAnchor="end"
height={60}
/>
<YAxis
stroke="#6B6557"
tick={{ fill: '#6B6557', fontSize: 12 }}
tickLine={{ stroke: 'rgba(26, 24, 19, 0.14)' }}
domain={calculateYDomain()}
tickFormatter={(value) =>
displayMode === 'dollar' ? `$${value.toFixed(0)}` : `${value}%`
}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine
y={displayMode === 'dollar' ? initialBalance : 0}
stroke="rgba(26, 24, 19, 0.2)"
strokeDasharray="3 3"
label={{
value:
displayMode === 'dollar'
? t('initialBalance', language).split(' ')[0]
: '0%',
fill: '#8A8478',
fontSize: 12,
}}
/>
<Line
type="natural"
dataKey="value"
stroke="url(#colorGradient)"
strokeWidth={3}
dot={chartData.length > 50 ? false : { fill: '#E0483B', r: 3 }}
activeDot={{
r: 6,
fill: '#E0483B',
stroke: '#F1ECE2',
strokeWidth: 2,
}}
connectNulls={true}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* Footer Stats */}
<div
className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 pt-3"
style={{ borderTop: '1px solid rgba(26, 24, 19, 0.14)' }}
>
<div
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(224, 72, 59, 0.05)' }}
>
<div
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#8A8478' }}
>
{t('initialBalance', language)}
</div>
<div
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#1A1813' }}
>
{initialBalance.toFixed(2)} USDT
</div>
</div>
<div
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(224, 72, 59, 0.05)' }}
>
<div
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#8A8478' }}
>
{t('currentEquity', language)}
</div>
<div
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#1A1813' }}
>
{currentValue.raw_equity.toFixed(2)} USDT
</div>
</div>
<div
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(224, 72, 59, 0.05)' }}
>
<div
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#8A8478' }}
>
{t('historicalCycles', language)}
</div>
<div
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#1A1813' }}
>
{validHistory.length} {t('cycles', language)}
</div>
</div>
<div
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(224, 72, 59, 0.05)' }}
>
<div
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#8A8478' }}
>
{t('displayRange', language)}
</div>
<div
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#1A1813' }}
>
{validHistory.length > MAX_DISPLAY_POINTS
? `${t('recent', language)} ${MAX_DISPLAY_POINTS}`
: t('allData', language)}
</div>
</div>
</div>
</div>
)
}