import { useEffect, useMemo, useState, useRef } from 'react'
import { motion } from 'framer-motion'
import {
createChart,
ColorType,
CrosshairMode,
CandlestickSeries,
createSeriesMarkers,
type IChartApi,
type ISeriesApi,
type CandlestickData,
type UTCTimestamp,
type SeriesMarker,
} from 'lightweight-charts'
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceDot,
} from 'recharts'
import {
Clock,
AlertTriangle,
RefreshCw,
CandlestickChart as CandlestickIcon,
} from 'lucide-react'
import { api } from '../../lib/api'
import { t, type Language } from '../../i18n/translations'
import type {
BacktestEquityPoint,
BacktestTradeEvent,
BacktestKlinesResponse,
} from '../../types'
// ============ Equity Chart (Recharts) ============
interface EquityChartProps {
equity: BacktestEquityPoint[]
trades: BacktestTradeEvent[]
}
export function EquityChart({ equity, trades }: EquityChartProps) {
const chartData = useMemo(() => {
return equity.map((point) => ({
time: new Date(point.ts).toLocaleString(),
ts: point.ts,
equity: point.equity,
pnl_pct: point.pnl_pct,
}))
}, [equity])
const tradeMarkers = useMemo(() => {
if (!trades.length || !equity.length) return []
return trades
.filter((t) => t.action.includes('open') || t.action.includes('close'))
.map((trade) => {
const closest = equity.reduce((prev, curr) =>
Math.abs(curr.ts - trade.ts) < Math.abs(prev.ts - trade.ts) ? curr : prev
)
return {
ts: closest.ts,
equity: closest.equity,
action: trade.action,
symbol: trade.symbol,
isOpen: trade.action.includes('open'),
}
})
.slice(-30)
}, [trades, equity])
return (
[`$${value.toFixed(2)}`, 'Equity']}
/>
{tradeMarkers.map((marker, idx) => (
d.ts === marker.ts)}
y={marker.equity}
r={4}
fill={marker.isOpen ? '#0ECB81' : '#F6465D'}
stroke={marker.isOpen ? '#0ECB81' : '#F6465D'}
/>
))}
)
}
// ============ Candlestick Chart with Trade Markers ============
interface CandlestickChartProps {
runId: string
trades: BacktestTradeEvent[]
language: Language
}
export function CandlestickChartComponent({ runId, trades, language }: CandlestickChartProps) {
const chartContainerRef = useRef(null)
const chartRef = useRef(null)
const candleSeriesRef = useRef | null>(null)
const symbols = useMemo(() => {
const symbolSet = new Set(trades.map((t) => t.symbol))
return Array.from(symbolSet).sort()
}, [trades])
const [selectedSymbol, setSelectedSymbol] = useState(symbols[0] || '')
const [selectedTimeframe, setSelectedTimeframe] = useState('15m')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const CHART_TIMEFRAMES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d']
useEffect(() => {
if (symbols.length > 0 && !symbols.includes(selectedSymbol)) {
setSelectedSymbol(symbols[0])
}
}, [symbols, selectedSymbol])
const symbolTrades = useMemo(() => {
return trades.filter((t) => t.symbol === selectedSymbol)
}, [trades, selectedSymbol])
useEffect(() => {
if (!chartContainerRef.current || !selectedSymbol || !runId) return
const container = chartContainerRef.current
const chart = createChart(container, {
layout: {
background: { type: ColorType.Solid, color: '#0B0E11' },
textColor: '#848E9C',
},
grid: {
vertLines: { color: 'rgba(43, 49, 57, 0.5)' },
horzLines: { color: 'rgba(43, 49, 57, 0.5)' },
},
crosshair: {
mode: CrosshairMode.Normal,
},
rightPriceScale: {
borderColor: '#2B3139',
},
timeScale: {
borderColor: '#2B3139',
timeVisible: true,
secondsVisible: false,
},
width: container.clientWidth,
height: 400,
})
chartRef.current = chart
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#0ECB81',
downColor: '#F6465D',
borderUpColor: '#0ECB81',
borderDownColor: '#F6465D',
wickUpColor: '#0ECB81',
wickDownColor: '#F6465D',
})
candleSeriesRef.current = candleSeries
setIsLoading(true)
setError(null)
api
.getBacktestKlines(runId, selectedSymbol, selectedTimeframe)
.then((data: BacktestKlinesResponse) => {
const klineData: CandlestickData[] = data.klines.map((k) => ({
time: k.time as UTCTimestamp,
open: k.open,
high: k.high,
low: k.low,
close: k.close,
}))
candleSeries.setData(klineData)
const markers: SeriesMarker[] = symbolTrades
.map((trade) => {
const tradeTime = Math.floor(trade.ts / 1000)
const closestKline = data.klines.reduce((prev, curr) =>
Math.abs(curr.time - tradeTime) < Math.abs(prev.time - tradeTime) ? curr : prev
)
const isOpen = trade.action.includes('open')
const isLong = trade.side === 'long' || trade.action.includes('long')
const pnl = trade.realized_pnl
let text = ''
let color = '#0ECB81'
if (isOpen) {
if (isLong) {
text = `▲ Long @${trade.price.toFixed(2)}`
color = '#0ECB81'
} else {
text = `▼ Short @${trade.price.toFixed(2)}`
color = '#F6465D'
}
} else {
const pnlStr = pnl >= 0 ? `+$${pnl.toFixed(2)}` : `-$${Math.abs(pnl).toFixed(2)}`
text = `✕ ${pnlStr}`
color = pnl >= 0 ? '#0ECB81' : '#F6465D'
}
return {
time: closestKline.time as UTCTimestamp,
position: isOpen
? (isLong ? 'belowBar' as const : 'aboveBar' as const)
: (isLong ? 'aboveBar' as const : 'belowBar' as const),
color,
shape: 'circle' as const,
size: 2,
text,
}
})
.sort((a, b) => (a.time as number) - (b.time as number))
createSeriesMarkers(candleSeries, markers)
chart.timeScale().fitContent()
setIsLoading(false)
})
.catch((err) => {
setError(err.message || 'Failed to load klines')
setIsLoading(false)
})
const handleResize = () => {
if (chartContainerRef.current) {
chart.applyOptions({ width: chartContainerRef.current.clientWidth })
}
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
chart.remove()
chartRef.current = null
candleSeriesRef.current = null
}
}, [runId, selectedSymbol, selectedTimeframe, symbolTrades])
if (symbols.length === 0) {
return (
{t('backtestChart.noTrades', language)}
)
}
return (
{t('backtestChart.symbol', language)}
{t('backtestChart.interval', language)}
{CHART_TIMEFRAMES.map((tf) => (
))}
({symbolTrades.length} {t('backtestChart.trades', language)})
{isLoading && (
{t('backtestChart.loadingKline', language)}
)}
{error && (
)}
{t('backtestChart.openProfit', language)}
{t('backtestChart.lossClose', language)}
|
▲ Long · ▼ Short · ✕ {t('backtestChart.close', language)}
)
}
// ============ Chart Tab Content ============
interface BacktestChartTabProps {
equity: BacktestEquityPoint[] | undefined
trades: BacktestTradeEvent[] | undefined
selectedRunId: string
language: Language
tr: (key: string) => string
}
export function BacktestChartTab({
equity,
trades,
selectedRunId,
language,
tr,
}: BacktestChartTabProps) {
return (
{t('backtestChart.equityCurve', language)}
{equity && equity.length > 0 ? (
) : (
{tr('charts.equityEmpty')}
)}
{selectedRunId && trades && trades.length > 0 && (
{t('backtestChart.candlestickTradeMarkers', language)}
)}
)
}