mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 20:41:14 +08:00
refactor: remove all backtest module code and references
Delete backtest/ engine (19 files), api/backtest.go, store/backtest.go, web backtest components (7 files), API client, types, docs, screenshot. Clean all backtest references from main.go, api/server.go, store/store.go, App.tsx, HeaderBar.tsx, LandingPage.tsx, translations, README and docs.
This commit is contained in:
@@ -1,434 +0,0 @@
|
||||
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 (
|
||||
<div className="w-full h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="equityGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#F0B90B" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#F0B90B" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke="rgba(43, 49, 57, 0.5)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fill: '#848E9C', fontSize: 10 }}
|
||||
axisLine={{ stroke: '#2B3139' }}
|
||||
tickLine={{ stroke: '#2B3139' }}
|
||||
hide
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: '#848E9C', fontSize: 10 }}
|
||||
axisLine={{ stroke: '#2B3139' }}
|
||||
tickLine={{ stroke: '#2B3139' }}
|
||||
width={60}
|
||||
domain={['auto', 'auto']}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
borderRadius: 8,
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
labelStyle={{ color: '#848E9C' }}
|
||||
formatter={(value: number) => [`$${value.toFixed(2)}`, 'Equity']}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="equity"
|
||||
stroke="#F0B90B"
|
||||
strokeWidth={2}
|
||||
fill="url(#equityGradient)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: '#F0B90B' }}
|
||||
/>
|
||||
{tradeMarkers.map((marker, idx) => (
|
||||
<ReferenceDot
|
||||
key={`${marker.ts}-${idx}`}
|
||||
x={chartData.findIndex((d) => d.ts === marker.ts)}
|
||||
y={marker.equity}
|
||||
r={4}
|
||||
fill={marker.isOpen ? '#0ECB81' : '#F6465D'}
|
||||
stroke={marker.isOpen ? '#0ECB81' : '#F6465D'}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Candlestick Chart with Trade Markers ============
|
||||
|
||||
interface CandlestickChartProps {
|
||||
runId: string
|
||||
trades: BacktestTradeEvent[]
|
||||
language: Language
|
||||
}
|
||||
|
||||
export function CandlestickChartComponent({ runId, trades, language }: CandlestickChartProps) {
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<IChartApi | null>(null)
|
||||
const candleSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
|
||||
|
||||
const symbols = useMemo(() => {
|
||||
const symbolSet = new Set(trades.map((t) => t.symbol))
|
||||
return Array.from(symbolSet).sort()
|
||||
}, [trades])
|
||||
|
||||
const [selectedSymbol, setSelectedSymbol] = useState<string>(symbols[0] || '')
|
||||
const [selectedTimeframe, setSelectedTimeframe] = useState<string>('15m')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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<UTCTimestamp>[] = 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<UTCTimestamp>[] = 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 (
|
||||
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
|
||||
{t('backtestChart.noTrades', language)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<CandlestickIcon size={16} style={{ color: '#F0B90B' }} />
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('backtestChart.symbol', language)}
|
||||
</span>
|
||||
<select
|
||||
value={selectedSymbol}
|
||||
onChange={(e) => setSelectedSymbol(e.target.value)}
|
||||
className="px-3 py-1.5 rounded text-sm"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{symbols.map((sym) => (
|
||||
<option key={sym} value={sym}>
|
||||
{sym}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={14} style={{ color: '#848E9C' }} />
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('backtestChart.interval', language)}
|
||||
</span>
|
||||
<div className="flex rounded overflow-hidden" style={{ border: '1px solid #2B3139' }}>
|
||||
{CHART_TIMEFRAMES.map((tf) => (
|
||||
<button
|
||||
key={tf}
|
||||
onClick={() => setSelectedTimeframe(tf)}
|
||||
className="px-2.5 py-1 text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: selectedTimeframe === tf ? '#F0B90B' : '#1E2329',
|
||||
color: selectedTimeframe === tf ? '#0B0E11' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-xs" style={{ color: '#5E6673' }}>
|
||||
({symbolTrades.length} {t('backtestChart.trades', language)})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={chartContainerRef}
|
||||
className="w-full rounded-lg overflow-hidden"
|
||||
style={{ background: '#0B0E11', minHeight: 400 }}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center h-[400px]" style={{ color: '#848E9C' }}>
|
||||
<RefreshCw className="animate-spin mr-2" size={16} />
|
||||
{t('backtestChart.loadingKline', language)}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center justify-center h-[400px]" style={{ color: '#F6465D' }}>
|
||||
<AlertTriangle className="mr-2" size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs" style={{ color: '#848E9C' }}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ background: '#0ECB81' }} />
|
||||
<span>{t('backtestChart.openProfit', language)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ background: '#F6465D' }} />
|
||||
<span>{t('backtestChart.lossClose', language)}</span>
|
||||
</div>
|
||||
<span style={{ color: '#5E6673' }}>|</span>
|
||||
<span>▲ Long · ▼ Short · ✕ {t('backtestChart.close', language)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 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 (
|
||||
<motion.div
|
||||
key="chart"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3" style={{ color: '#EAECEF' }}>
|
||||
{t('backtestChart.equityCurve', language)}
|
||||
</h4>
|
||||
{equity && equity.length > 0 ? (
|
||||
<EquityChart equity={equity} trades={trades ?? []} />
|
||||
) : (
|
||||
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
|
||||
{tr('charts.equityEmpty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedRunId && trades && trades.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3" style={{ color: '#EAECEF' }}>
|
||||
{t('backtestChart.candlestickTradeMarkers', language)}
|
||||
</h4>
|
||||
<CandlestickChartComponent
|
||||
runId={selectedRunId}
|
||||
trades={trades}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,597 +0,0 @@
|
||||
import { useMemo, type FormEvent } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
RefreshCw,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import type { AIModel, Strategy } from '../../types'
|
||||
import { t as globalT } from '../../i18n/translations'
|
||||
import type { Language } from '../../i18n/translations'
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
type WizardStep = 1 | 2 | 3
|
||||
|
||||
export interface BacktestFormState {
|
||||
runId: string
|
||||
symbols: string
|
||||
timeframes: string[]
|
||||
decisionTf: string
|
||||
cadence: number
|
||||
start: string
|
||||
end: string
|
||||
balance: number
|
||||
fee: number
|
||||
slippage: number
|
||||
btcEthLeverage: number
|
||||
altcoinLeverage: number
|
||||
fill: string
|
||||
prompt: string
|
||||
promptTemplate: string
|
||||
customPrompt: string
|
||||
overridePrompt: boolean
|
||||
cacheAI: boolean
|
||||
replayOnly: boolean
|
||||
aiModelId: string
|
||||
strategyId: string
|
||||
}
|
||||
|
||||
const TIMEFRAME_OPTIONS = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d']
|
||||
const POPULAR_SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT', 'XRPUSDT', 'DOGEUSDT']
|
||||
|
||||
// ============ Config Form ============
|
||||
|
||||
interface BacktestConfigFormProps {
|
||||
formState: BacktestFormState
|
||||
wizardStep: WizardStep
|
||||
isStarting: boolean
|
||||
aiModels: AIModel[] | undefined
|
||||
strategies: Strategy[] | undefined
|
||||
language: string
|
||||
tr: (key: string, params?: Record<string, string | number>) => string
|
||||
onFormChange: (key: string, value: string | number | boolean | string[]) => void
|
||||
onWizardStepChange: (step: WizardStep) => void
|
||||
onStart: (event: FormEvent) => void
|
||||
}
|
||||
|
||||
export function BacktestConfigForm({
|
||||
formState,
|
||||
wizardStep,
|
||||
isStarting,
|
||||
aiModels,
|
||||
strategies,
|
||||
language,
|
||||
tr,
|
||||
onFormChange,
|
||||
onWizardStepChange,
|
||||
onStart,
|
||||
}: BacktestConfigFormProps) {
|
||||
const selectedModel = aiModels?.find((m) => m.id === formState.aiModelId)
|
||||
const selectedStrategy = strategies?.find((s) => s.id === formState.strategyId)
|
||||
|
||||
const strategyHasDynamicCoins = useMemo(() => {
|
||||
const cs = selectedStrategy?.config?.coin_source
|
||||
if (!cs) return false
|
||||
const st = cs.source_type as string
|
||||
if (st === 'ai500' || st === 'oi_top') return true
|
||||
if (st === 'mixed' && (cs.use_ai500 || cs.use_oi_top)) return true
|
||||
if (!st && (cs.use_ai500 || cs.use_oi_top)) return true
|
||||
return false
|
||||
}, [selectedStrategy])
|
||||
|
||||
const coinSourceDescription = useMemo(() => {
|
||||
const cs = selectedStrategy?.config?.coin_source
|
||||
if (!cs) return null
|
||||
let st = cs.source_type as string
|
||||
if (!st) {
|
||||
if (cs.use_ai500 && cs.use_oi_top) st = 'mixed'
|
||||
else if (cs.use_ai500) st = 'ai500'
|
||||
else if (cs.use_oi_top) st = 'oi_top'
|
||||
else if (cs.static_coins?.length) st = 'static'
|
||||
}
|
||||
switch (st) {
|
||||
case 'ai500': return { type: 'AI500', limit: cs.ai500_limit || 30 }
|
||||
case 'oi_top': return { type: 'OI Top', limit: cs.oi_top_limit || 30 }
|
||||
case 'mixed': {
|
||||
const parts: string[] = []
|
||||
if (cs.use_ai500) parts.push(`AI500(${cs.ai500_limit || 30})`)
|
||||
if (cs.use_oi_top) parts.push(`OI Top(${cs.oi_top_limit || 30})`)
|
||||
if (cs.static_coins?.length) parts.push(`Static(${cs.static_coins.length})`)
|
||||
return { type: 'Mixed', desc: parts.join(' + ') }
|
||||
}
|
||||
case 'static': return { type: 'Static', coins: cs.static_coins || [] }
|
||||
default: return null
|
||||
}
|
||||
}, [selectedStrategy])
|
||||
|
||||
const lang = language as Language
|
||||
const quickRanges = [
|
||||
{ label: globalT('backtestConfigForm.quickRange24h', lang), hours: 24 },
|
||||
{ label: globalT('backtestConfigForm.quickRange3d', lang), hours: 72 },
|
||||
{ label: globalT('backtestConfigForm.quickRange7d', lang), hours: 168 },
|
||||
{ label: globalT('backtestConfigForm.quickRange30d', lang), hours: 720 },
|
||||
]
|
||||
|
||||
const applyQuickRange = (hours: number) => {
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - hours * 3600 * 1000)
|
||||
const fmt = (d: Date) => new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16)
|
||||
onFormChange('start', fmt(start))
|
||||
onFormChange('end', fmt(end))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="binance-card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{[1, 2, 3].map((step) => (
|
||||
<div key={step} className="flex items-center">
|
||||
<button
|
||||
onClick={() => onWizardStepChange(step as WizardStep)}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all"
|
||||
style={{
|
||||
background: wizardStep >= step ? '#F0B90B' : '#2B3139',
|
||||
color: wizardStep >= step ? '#0B0E11' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
{step}
|
||||
</button>
|
||||
{step < 3 && (
|
||||
<div
|
||||
className="w-8 h-0.5 mx-1"
|
||||
style={{ background: wizardStep > step ? '#F0B90B' : '#2B3139' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<span className="ml-2 text-xs" style={{ color: '#848E9C' }}>
|
||||
{wizardStep === 1 ? globalT('backtestConfigForm.selectModel', lang)
|
||||
: wizardStep === 2 ? globalT('backtestConfigForm.configure', lang)
|
||||
: globalT('backtestConfigForm.confirmStart', lang)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onStart}>
|
||||
<AnimatePresence mode="wait">
|
||||
{/* Step 1: Model & Symbols */}
|
||||
{wizardStep === 1 && (
|
||||
<motion.div
|
||||
key="step1"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{tr('form.aiModelLabel')}
|
||||
</label>
|
||||
<select
|
||||
className="w-full p-3 rounded-lg text-sm"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
value={formState.aiModelId}
|
||||
onChange={(e) => onFormChange('aiModelId', e.target.value)}
|
||||
>
|
||||
<option value="">{tr('form.selectAiModel')}</option>
|
||||
{aiModels?.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.provider}) {!m.enabled && '⚠️'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedModel && (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded"
|
||||
style={{
|
||||
background: selectedModel.enabled ? 'rgba(14,203,129,0.1)' : 'rgba(246,70,93,0.1)',
|
||||
color: selectedModel.enabled ? '#0ECB81' : '#F6465D',
|
||||
}}
|
||||
>
|
||||
{selectedModel.enabled ? tr('form.enabled') : tr('form.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Strategy Selection (Optional) */}
|
||||
<div>
|
||||
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{globalT('backtestConfigForm.strategyOptional', lang)}
|
||||
</label>
|
||||
<select
|
||||
className="w-full p-3 rounded-lg text-sm"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
value={formState.strategyId}
|
||||
onChange={(e) => onFormChange('strategyId', e.target.value)}
|
||||
>
|
||||
<option value="">{globalT('backtestConfigForm.noSavedStrategy', lang)}</option>
|
||||
{strategies?.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} {s.is_active && '✓'} {s.is_default && '⭐'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{formState.strategyId && coinSourceDescription && (
|
||||
<div className="mt-2 p-2 rounded" style={{ background: 'rgba(240,185,11,0.1)', border: '1px solid rgba(240,185,11,0.2)' }}>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span style={{ color: '#F0B90B' }}>
|
||||
{globalT('backtestConfigForm.coinSource', lang)}
|
||||
</span>
|
||||
<span className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{coinSourceDescription.type}
|
||||
{coinSourceDescription.limit && ` (${coinSourceDescription.limit})`}
|
||||
{coinSourceDescription.desc && ` - ${coinSourceDescription.desc}`}
|
||||
</span>
|
||||
</div>
|
||||
{strategyHasDynamicCoins && (
|
||||
<div className="text-xs mt-1" style={{ color: '#F0B90B' }}>
|
||||
{globalT('backtestConfigForm.clearDynamicCoins', lang)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{tr('form.symbolsLabel')}
|
||||
{strategyHasDynamicCoins && (
|
||||
<span className="ml-2" style={{ color: '#5E6673' }}>
|
||||
({globalT('backtestConfigForm.optionalCoinSource', lang)})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{!strategyHasDynamicCoins && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{POPULAR_SYMBOLS.map((sym) => {
|
||||
const isSelected = formState.symbols.includes(sym)
|
||||
return (
|
||||
<button
|
||||
key={sym}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = formState.symbols.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
const updated = isSelected
|
||||
? current.filter((s) => s !== sym)
|
||||
: [...current, sym]
|
||||
onFormChange('symbols', updated.join(','))
|
||||
}}
|
||||
className="px-2 py-1 rounded text-xs transition-all"
|
||||
style={{
|
||||
background: isSelected ? 'rgba(240,185,11,0.15)' : '#1E2329',
|
||||
border: `1px solid ${isSelected ? '#F0B90B' : '#2B3139'}`,
|
||||
color: isSelected ? '#F0B90B' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
{sym.replace('USDT', '')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
className="w-full p-2 rounded-lg text-xs font-mono"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
value={formState.symbols}
|
||||
onChange={(e) => onFormChange('symbols', e.target.value)}
|
||||
rows={2}
|
||||
placeholder={strategyHasDynamicCoins
|
||||
? globalT('backtestConfigForm.leavEmptyForStrategy', lang)
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
{strategyHasDynamicCoins && formState.symbols && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFormChange('symbols', '')}
|
||||
className="absolute top-2 right-2 px-2 py-1 rounded text-xs"
|
||||
style={{ background: '#F0B90B', color: '#0B0E11' }}
|
||||
>
|
||||
{globalT('backtestConfigForm.clearToUseStrategy', lang)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWizardStepChange(2)}
|
||||
disabled={!selectedModel?.enabled}
|
||||
className="w-full py-2.5 rounded-lg font-medium flex items-center justify-center gap-2 transition-all disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#0B0E11' }}
|
||||
>
|
||||
{globalT('backtestConfigForm.next', lang)}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Parameters */}
|
||||
{wizardStep === 2 && (
|
||||
<motion.div
|
||||
key="step2"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{tr('form.timeRangeLabel')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{quickRanges.map((r) => (
|
||||
<button
|
||||
key={r.hours}
|
||||
type="button"
|
||||
onClick={() => applyQuickRange(r.hours)}
|
||||
className="px-3 py-1 rounded text-xs"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="p-2 rounded-lg text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
value={formState.start}
|
||||
onChange={(e) => onFormChange('start', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="p-2 rounded-lg text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
value={formState.end}
|
||||
onChange={(e) => onFormChange('end', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{globalT('backtestConfigForm.timeframes', lang)}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{TIMEFRAME_OPTIONS.map((tf) => {
|
||||
const isSelected = formState.timeframes.includes(tf)
|
||||
return (
|
||||
<button
|
||||
key={tf}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updated = isSelected
|
||||
? formState.timeframes.filter((t) => t !== tf)
|
||||
: [...formState.timeframes, tf]
|
||||
if (updated.length > 0) onFormChange('timeframes', updated)
|
||||
}}
|
||||
className="px-2 py-1 rounded text-xs transition-all"
|
||||
style={{
|
||||
background: isSelected ? 'rgba(240,185,11,0.15)' : '#1E2329',
|
||||
border: `1px solid ${isSelected ? '#F0B90B' : '#2B3139'}`,
|
||||
color: isSelected ? '#F0B90B' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{tr('form.initialBalanceLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full p-2 rounded-lg text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
value={formState.balance}
|
||||
onChange={(e) => onFormChange('balance', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{tr('form.decisionTfLabel')}
|
||||
</label>
|
||||
<select
|
||||
className="w-full p-2 rounded-lg text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
value={formState.decisionTf}
|
||||
onChange={(e) => onFormChange('decisionTf', e.target.value)}
|
||||
>
|
||||
{formState.timeframes.map((tf) => (
|
||||
<option key={tf} value={tf}>
|
||||
{tf}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWizardStepChange(1)}
|
||||
className="flex-1 py-2 rounded-lg font-medium flex items-center justify-center gap-2"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
{globalT('backtestConfigForm.back', lang)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWizardStepChange(3)}
|
||||
className="flex-1 py-2 rounded-lg font-medium flex items-center justify-center gap-2"
|
||||
style={{ background: '#F0B90B', color: '#0B0E11' }}
|
||||
>
|
||||
{globalT('backtestConfigForm.next', lang)}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Advanced & Confirm */}
|
||||
{wizardStep === 3 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{tr('form.btcEthLeverageLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full p-2 rounded-lg text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
value={formState.btcEthLeverage}
|
||||
onChange={(e) => onFormChange('btcEthLeverage', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{tr('form.altcoinLeverageLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full p-2 rounded-lg text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
value={formState.altcoinLeverage}
|
||||
onChange={(e) => onFormChange('altcoinLeverage', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{tr('form.feeLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full p-2 rounded-lg text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
value={formState.fee}
|
||||
onChange={(e) => onFormChange('fee', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{tr('form.slippageLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full p-2 rounded-lg text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
value={formState.slippage}
|
||||
onChange={(e) => onFormChange('slippage', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{tr('form.cadenceLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full p-2 rounded-lg text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
value={formState.cadence}
|
||||
onChange={(e) => onFormChange('cadence', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{globalT('backtestConfigForm.strategyStyle', lang)}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{['baseline', 'aggressive', 'conservative', 'scalping'].map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => onFormChange('prompt', p)}
|
||||
className="px-3 py-1.5 rounded text-xs transition-all"
|
||||
style={{
|
||||
background: formState.prompt === p ? 'rgba(240,185,11,0.15)' : '#1E2329',
|
||||
border: `1px solid ${formState.prompt === p ? '#F0B90B' : '#2B3139'}`,
|
||||
color: formState.prompt === p ? '#F0B90B' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
{tr(`form.promptPresets.${p}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-xs" style={{ color: '#848E9C' }}>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formState.cacheAI}
|
||||
onChange={(e) => onFormChange('cacheAI', e.target.checked)}
|
||||
className="accent-[#F0B90B]"
|
||||
/>
|
||||
{tr('form.cacheAiLabel')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formState.replayOnly}
|
||||
onChange={(e) => onFormChange('replayOnly', e.target.checked)}
|
||||
className="accent-[#F0B90B]"
|
||||
/>
|
||||
{tr('form.replayOnlyLabel')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWizardStepChange(2)}
|
||||
className="flex-1 py-2 rounded-lg font-medium flex items-center justify-center gap-2"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
{globalT('backtestConfigForm.back', lang)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isStarting}
|
||||
className="flex-1 py-2 rounded-lg font-bold flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#0B0E11' }}
|
||||
>
|
||||
{isStarting ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Zap className="w-4 h-4" />
|
||||
)}
|
||||
{isStarting ? tr('starting') : tr('start')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { WizardStep }
|
||||
@@ -1,36 +0,0 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { DecisionCard } from '../trader/DecisionCard'
|
||||
import type { Language } from '../../i18n/translations'
|
||||
import type { DecisionRecord } from '../../types'
|
||||
|
||||
interface BacktestDecisionsTabProps {
|
||||
decisions: DecisionRecord[] | undefined
|
||||
language: Language
|
||||
tr: (key: string) => string
|
||||
}
|
||||
|
||||
export function BacktestDecisionsTab({ decisions, language, tr }: BacktestDecisionsTabProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key="decisions"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-3 max-h-[500px] overflow-y-auto"
|
||||
>
|
||||
{decisions && decisions.length > 0 ? (
|
||||
decisions.map((d) => (
|
||||
<DecisionCard
|
||||
key={`${d.cycle_number}-${d.timestamp}`}
|
||||
decision={d}
|
||||
language={language}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
|
||||
{tr('decisionTrail.emptyHint')}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
} from 'lucide-react'
|
||||
import { MetricTooltip } from '../common/MetricTooltip'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
import { EquityChart } from './BacktestChartTab'
|
||||
import type {
|
||||
BacktestEquityPoint,
|
||||
BacktestTradeEvent,
|
||||
BacktestMetrics,
|
||||
BacktestPositionStatus,
|
||||
} from '../../types'
|
||||
|
||||
// ============ Stat Card ============
|
||||
|
||||
interface StatCardProps {
|
||||
icon: typeof TrendingUp
|
||||
label: string
|
||||
value: string | number
|
||||
suffix?: string
|
||||
trend?: 'up' | 'down' | 'neutral'
|
||||
color?: string
|
||||
metricKey?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
suffix,
|
||||
trend,
|
||||
color = '#EAECEF',
|
||||
metricKey,
|
||||
language = 'en',
|
||||
}: StatCardProps) {
|
||||
const trendColors = {
|
||||
up: '#0ECB81',
|
||||
down: '#F6465D',
|
||||
neutral: '#848E9C',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-4 rounded-xl"
|
||||
style={{ background: 'rgba(30, 35, 41, 0.6)', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{label}
|
||||
</span>
|
||||
{metricKey && (
|
||||
<MetricTooltip metricKey={metricKey} language={language} size={12} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold" style={{ color }}>
|
||||
{value}
|
||||
</span>
|
||||
{suffix && (
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
{trend && trend !== 'neutral' && (
|
||||
<span style={{ color: trendColors[trend] }}>
|
||||
{trend === 'up' ? <ArrowUpRight className="w-4 h-4" /> : <ArrowDownRight className="w-4 h-4" />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Progress Ring ============
|
||||
|
||||
interface ProgressRingProps {
|
||||
progress: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function ProgressRing({ progress, size = 120 }: ProgressRingProps) {
|
||||
const strokeWidth = 8
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (progress / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg className="transform -rotate-90" width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="#2B3139"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
<motion.circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="#F0B90B"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
initial={{ strokeDashoffset: circumference }}
|
||||
animate={{ strokeDashoffset: offset }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center flex-col">
|
||||
<span className="text-2xl font-bold" style={{ color: '#F0B90B' }}>
|
||||
{progress.toFixed(0)}%
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
Complete
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Positions Display ============
|
||||
|
||||
interface PositionsDisplayProps {
|
||||
positions: BacktestPositionStatus[]
|
||||
language: Language
|
||||
}
|
||||
|
||||
export function PositionsDisplay({ positions, language }: PositionsDisplayProps) {
|
||||
if (!positions || positions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const totalUnrealizedPnL = positions.reduce((sum, p) => sum + p.unrealized_pnl, 0)
|
||||
const totalMargin = positions.reduce((sum, p) => sum + p.margin_used, 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-3 p-3 rounded-lg"
|
||||
style={{ background: 'rgba(30, 35, 41, 0.8)', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('backtestOverview.activePositions', language)}
|
||||
</span>
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-xs"
|
||||
style={{ background: '#F0B90B20', color: '#F0B90B' }}
|
||||
>
|
||||
{positions.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span style={{ color: '#848E9C' }}>
|
||||
{t('backtestOverview.margin', language)}: ${totalMargin.toFixed(2)}
|
||||
</span>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: totalUnrealizedPnL >= 0 ? '#0ECB81' : '#F6465D' }}
|
||||
>
|
||||
{t('backtestOverview.unrealized', language)}: {totalUnrealizedPnL >= 0 ? '+' : ''}
|
||||
${totalUnrealizedPnL.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{positions.map((pos) => {
|
||||
const isLong = pos.side === 'long'
|
||||
const pnlColor = pos.unrealized_pnl >= 0 ? '#0ECB81' : '#F6465D'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`${pos.symbol}-${pos.side}`}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex items-center justify-between p-2 rounded"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded flex items-center justify-center"
|
||||
style={{ background: isLong ? '#0ECB8120' : '#F6465D20' }}
|
||||
>
|
||||
{isLong ? (
|
||||
<TrendingUp className="w-3.5 h-3.5" style={{ color: '#0ECB81' }} />
|
||||
) : (
|
||||
<TrendingDown className="w-3.5 h-3.5" style={{ color: '#F6465D' }} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono font-bold text-sm" style={{ color: '#EAECEF' }}>
|
||||
{pos.symbol.replace('USDT', '')}
|
||||
</span>
|
||||
<span
|
||||
className="px-1 py-0.5 rounded text-[10px] font-medium"
|
||||
style={{
|
||||
background: isLong ? '#0ECB8120' : '#F6465D20',
|
||||
color: isLong ? '#0ECB81' : '#F6465D',
|
||||
}}
|
||||
>
|
||||
{isLong ? 'LONG' : 'SHORT'} {pos.leverage}x
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px]" style={{ color: '#5E6673' }}>
|
||||
{t('backtestOverview.qty', language)}: {pos.quantity.toFixed(4)} ·{' '}
|
||||
{t('backtestOverview.margin', language)}: ${pos.margin_used.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span style={{ color: '#848E9C' }}>
|
||||
{t('backtestOverview.entry', language)}: ${pos.entry_price.toFixed(2)}
|
||||
</span>
|
||||
<span style={{ color: '#EAECEF' }}>
|
||||
{t('backtestOverview.mark', language)}: ${pos.mark_price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1.5 mt-0.5">
|
||||
<span className="font-mono font-bold" style={{ color: pnlColor }}>
|
||||
{pos.unrealized_pnl >= 0 ? '+' : ''}${pos.unrealized_pnl.toFixed(2)}
|
||||
</span>
|
||||
<span
|
||||
className="px-1 py-0.5 rounded text-[10px] font-medium"
|
||||
style={{ background: `${pnlColor}20`, color: pnlColor }}
|
||||
>
|
||||
{pos.unrealized_pnl_pct >= 0 ? '+' : ''}{pos.unrealized_pnl_pct.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Overview Tab Content ============
|
||||
|
||||
interface BacktestOverviewTabProps {
|
||||
equity: BacktestEquityPoint[] | undefined
|
||||
trades: BacktestTradeEvent[] | undefined
|
||||
metrics: BacktestMetrics | undefined
|
||||
language: Language
|
||||
tr: (key: string) => string
|
||||
}
|
||||
|
||||
export function BacktestOverviewTab({
|
||||
equity,
|
||||
trades,
|
||||
metrics,
|
||||
language,
|
||||
tr,
|
||||
}: BacktestOverviewTabProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key="overview"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
{equity && equity.length > 0 ? (
|
||||
<EquityChart equity={equity} trades={trades ?? []} />
|
||||
) : (
|
||||
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
|
||||
{tr('charts.equityEmpty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
|
||||
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
|
||||
<div className="flex items-center gap-1 text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('backtestOverview.winRate', language)}
|
||||
<MetricTooltip metricKey="win_rate" language={language} size={11} />
|
||||
</div>
|
||||
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
||||
{(metrics.win_rate ?? 0).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
|
||||
<div className="flex items-center gap-1 text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('backtestOverview.profitFactor', language)}
|
||||
<MetricTooltip metricKey="profit_factor" language={language} size={11} />
|
||||
</div>
|
||||
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
||||
{(metrics.profit_factor ?? 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('backtestOverview.totalTrades', language)}
|
||||
</div>
|
||||
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
||||
{metrics.trades ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('backtestOverview.bestSymbol', language)}
|
||||
</div>
|
||||
<div className="text-lg font-bold" style={{ color: '#0ECB81' }}>
|
||||
{metrics.best_symbol?.replace('USDT', '') || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,579 +0,0 @@
|
||||
import { useEffect, useMemo, useState, useCallback, type FormEvent } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
Download,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
Brain,
|
||||
Target,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||
import { api } from '../../lib/api'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { confirmToast } from '../../lib/notify'
|
||||
import type {
|
||||
BacktestStatusPayload,
|
||||
BacktestEquityPoint,
|
||||
BacktestTradeEvent,
|
||||
BacktestMetrics,
|
||||
DecisionRecord,
|
||||
AIModel,
|
||||
Strategy,
|
||||
} from '../../types'
|
||||
import {
|
||||
BacktestConfigForm,
|
||||
type WizardStep,
|
||||
type BacktestFormState,
|
||||
} from './BacktestConfigForm'
|
||||
import { BacktestRunList, getStateColor, getStateIcon } from './BacktestRunList'
|
||||
import { StatCard, ProgressRing, PositionsDisplay } from './BacktestOverviewTab'
|
||||
import { BacktestOverviewTab } from './BacktestOverviewTab'
|
||||
import { BacktestChartTab } from './BacktestChartTab'
|
||||
import { BacktestTradesTab } from './BacktestTradesTab'
|
||||
import { BacktestDecisionsTab } from './BacktestDecisionsTab'
|
||||
|
||||
// ============ Types ============
|
||||
type ViewTab = 'overview' | 'chart' | 'trades' | 'decisions' | 'compare'
|
||||
|
||||
const toLocalInput = (date: Date) => {
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
|
||||
return local.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
// ============ Main Component ============
|
||||
export function BacktestPage() {
|
||||
const { language } = useLanguage()
|
||||
const tr = useCallback(
|
||||
(key: string, params?: Record<string, string | number>) => t(`backtestPage.${key}`, language, params),
|
||||
[language]
|
||||
)
|
||||
|
||||
// State
|
||||
const now = new Date()
|
||||
const [wizardStep, setWizardStep] = useState<WizardStep>(1)
|
||||
const [viewTab, setViewTab] = useState<ViewTab>('overview')
|
||||
const [selectedRunId, setSelectedRunId] = useState<string>()
|
||||
const [compareRunIds, setCompareRunIds] = useState<string[]>([])
|
||||
const [isStarting, setIsStarting] = useState(false)
|
||||
const [toast, setToast] = useState<{ text: string; tone: 'info' | 'error' | 'success' } | null>(null)
|
||||
|
||||
// Form state
|
||||
const [formState, setFormState] = useState<BacktestFormState>({
|
||||
runId: '',
|
||||
symbols: 'BTCUSDT,ETHUSDT,SOLUSDT',
|
||||
timeframes: ['3m', '15m', '4h'],
|
||||
decisionTf: '3m',
|
||||
cadence: 20,
|
||||
start: toLocalInput(new Date(now.getTime() - 3 * 24 * 3600 * 1000)),
|
||||
end: toLocalInput(now),
|
||||
balance: 1000,
|
||||
fee: 5,
|
||||
slippage: 2,
|
||||
btcEthLeverage: 5,
|
||||
altcoinLeverage: 5,
|
||||
fill: 'next_open',
|
||||
prompt: 'baseline',
|
||||
promptTemplate: 'default',
|
||||
customPrompt: '',
|
||||
overridePrompt: false,
|
||||
cacheAI: true,
|
||||
replayOnly: false,
|
||||
aiModelId: '',
|
||||
strategyId: '',
|
||||
})
|
||||
|
||||
// Data fetching
|
||||
const { data: runsResp, mutate: refreshRuns } = useSWR(['backtest-runs'], () =>
|
||||
api.getBacktestRuns({ limit: 100, offset: 0 })
|
||||
, { refreshInterval: 5000 })
|
||||
const runs = runsResp?.items ?? []
|
||||
|
||||
const { data: aiModels } = useSWR<AIModel[]>('ai-models', api.getModelConfigs, { refreshInterval: 30000 })
|
||||
const { data: strategies } = useSWR<Strategy[]>('strategies', api.getStrategies, { refreshInterval: 30000 })
|
||||
|
||||
const { data: status } = useSWR<BacktestStatusPayload>(
|
||||
selectedRunId ? ['bt-status', selectedRunId] : null,
|
||||
() => api.getBacktestStatus(selectedRunId!),
|
||||
{ refreshInterval: 2000 }
|
||||
)
|
||||
|
||||
const { data: equity } = useSWR<BacktestEquityPoint[]>(
|
||||
selectedRunId ? ['bt-equity', selectedRunId] : null,
|
||||
() => api.getBacktestEquity(selectedRunId!, '1m', 2000),
|
||||
{ refreshInterval: 5000 }
|
||||
)
|
||||
|
||||
const { data: trades } = useSWR<BacktestTradeEvent[]>(
|
||||
selectedRunId ? ['bt-trades', selectedRunId] : null,
|
||||
() => api.getBacktestTrades(selectedRunId!, 500),
|
||||
{ refreshInterval: 5000 }
|
||||
)
|
||||
|
||||
const { data: metrics } = useSWR<BacktestMetrics>(
|
||||
selectedRunId ? ['bt-metrics', selectedRunId] : null,
|
||||
() => api.getBacktestMetrics(selectedRunId!),
|
||||
{ refreshInterval: 10000 }
|
||||
)
|
||||
|
||||
const { data: decisions } = useSWR<DecisionRecord[]>(
|
||||
selectedRunId ? ['bt-decisions', selectedRunId] : null,
|
||||
() => api.getBacktestDecisions(selectedRunId!, 30),
|
||||
{ refreshInterval: 5000 }
|
||||
)
|
||||
|
||||
const selectedRun = runs.find((r) => r.run_id === selectedRunId)
|
||||
const selectedModel = aiModels?.find((m) => m.id === formState.aiModelId)
|
||||
const selectedStrategy = strategies?.find((s) => s.id === formState.strategyId)
|
||||
|
||||
// Check if selected strategy has dynamic coin source (needed for handleStart)
|
||||
const strategyHasDynamicCoins = useMemo(() => {
|
||||
if (!selectedStrategy) return false
|
||||
const coinSource = selectedStrategy.config?.coin_source
|
||||
if (!coinSource) return false
|
||||
|
||||
if (coinSource.source_type === 'ai500' || coinSource.source_type === 'oi_top') {
|
||||
return true
|
||||
}
|
||||
if (coinSource.source_type === 'mixed' && (coinSource.use_ai500 || coinSource.use_oi_top)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const srcType = coinSource.source_type as string
|
||||
if (!srcType && (coinSource.use_ai500 || coinSource.use_oi_top)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}, [selectedStrategy])
|
||||
|
||||
// Auto-select first model
|
||||
useEffect(() => {
|
||||
if (!formState.aiModelId && aiModels?.length) {
|
||||
const enabled = aiModels.find((m) => m.enabled)
|
||||
if (enabled) setFormState((s) => ({ ...s, aiModelId: enabled.id }))
|
||||
}
|
||||
}, [aiModels, formState.aiModelId])
|
||||
|
||||
// Auto-select first run
|
||||
useEffect(() => {
|
||||
if (!selectedRunId && runs.length > 0) {
|
||||
setSelectedRunId(runs[0].run_id)
|
||||
}
|
||||
}, [runs, selectedRunId])
|
||||
|
||||
// Handlers
|
||||
const handleFormChange = (key: string, value: string | number | boolean | string[]) => {
|
||||
setFormState((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handleStart = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!selectedModel?.enabled) {
|
||||
setToast({ text: tr('toasts.selectModel'), tone: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsStarting(true)
|
||||
const start = new Date(formState.start).getTime()
|
||||
const end = new Date(formState.end).getTime()
|
||||
if (end <= start) throw new Error(tr('toasts.invalidRange'))
|
||||
|
||||
const userSymbols = formState.symbols.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
const symbolsToSend = (userSymbols.length === 0 && strategyHasDynamicCoins) ? [] : userSymbols
|
||||
|
||||
const payload = await api.startBacktest({
|
||||
run_id: formState.runId.trim() || undefined,
|
||||
strategy_id: formState.strategyId || undefined,
|
||||
symbols: symbolsToSend,
|
||||
timeframes: formState.timeframes,
|
||||
decision_timeframe: formState.decisionTf,
|
||||
decision_cadence_nbars: formState.cadence,
|
||||
start_ts: Math.floor(start / 1000),
|
||||
end_ts: Math.floor(end / 1000),
|
||||
initial_balance: formState.balance,
|
||||
fee_bps: formState.fee,
|
||||
slippage_bps: formState.slippage,
|
||||
fill_policy: formState.fill,
|
||||
prompt_variant: formState.prompt,
|
||||
prompt_template: formState.promptTemplate,
|
||||
custom_prompt: formState.customPrompt.trim() || undefined,
|
||||
override_prompt: formState.overridePrompt,
|
||||
cache_ai: formState.cacheAI,
|
||||
replay_only: formState.replayOnly,
|
||||
ai_model_id: formState.aiModelId,
|
||||
leverage: {
|
||||
btc_eth_leverage: formState.btcEthLeverage,
|
||||
altcoin_leverage: formState.altcoinLeverage,
|
||||
},
|
||||
})
|
||||
|
||||
setToast({ text: tr('toasts.startSuccess', { id: payload.run_id }), tone: 'success' })
|
||||
setSelectedRunId(payload.run_id)
|
||||
setWizardStep(1)
|
||||
await refreshRuns()
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : tr('toasts.startFailed')
|
||||
setToast({ text: errMsg, tone: 'error' })
|
||||
} finally {
|
||||
setIsStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleControl = async (action: 'pause' | 'resume' | 'stop') => {
|
||||
if (!selectedRunId) return
|
||||
try {
|
||||
if (action === 'pause') await api.pauseBacktest(selectedRunId)
|
||||
if (action === 'resume') await api.resumeBacktest(selectedRunId)
|
||||
if (action === 'stop') await api.stopBacktest(selectedRunId)
|
||||
setToast({ text: tr('toasts.actionSuccess', { action, id: selectedRunId }), tone: 'success' })
|
||||
await refreshRuns()
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : tr('toasts.actionFailed')
|
||||
setToast({ text: errMsg, tone: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedRunId) return
|
||||
const confirmed = await confirmToast(tr('toasts.confirmDelete', { id: selectedRunId }), {
|
||||
title: t('backtestPageExtra.confirmDelete', language),
|
||||
okText: t('backtestPageExtra.delete', language),
|
||||
cancelText: t('backtestPageExtra.cancel', language),
|
||||
})
|
||||
if (!confirmed) return
|
||||
try {
|
||||
await api.deleteBacktestRun(selectedRunId)
|
||||
setToast({ text: tr('toasts.deleteSuccess'), tone: 'success' })
|
||||
setSelectedRunId(undefined)
|
||||
await refreshRuns()
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : tr('toasts.deleteFailed')
|
||||
setToast({ text: errMsg, tone: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!selectedRunId) return
|
||||
try {
|
||||
const blob = await api.exportBacktest(selectedRunId)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${selectedRunId}_export.zip`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
setToast({ text: tr('toasts.exportSuccess', { id: selectedRunId }), tone: 'success' })
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : tr('toasts.exportFailed')
|
||||
setToast({ text: errMsg, tone: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCompare = (runId: string) => {
|
||||
setCompareRunIds((prev) =>
|
||||
prev.includes(runId) ? prev.filter((id) => id !== runId) : [...prev, runId].slice(-3)
|
||||
)
|
||||
}
|
||||
|
||||
// Render
|
||||
return (
|
||||
<DeepVoidBackground className="py-8" disableAnimation>
|
||||
<div className="w-full px-4 md:px-8 space-y-6">
|
||||
{/* Toast */}
|
||||
<AnimatePresence>
|
||||
{toast && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="p-3 rounded-lg text-sm"
|
||||
style={{
|
||||
background:
|
||||
toast.tone === 'error'
|
||||
? 'rgba(246,70,93,0.15)'
|
||||
: toast.tone === 'success'
|
||||
? 'rgba(14,203,129,0.15)'
|
||||
: 'rgba(240,185,11,0.15)',
|
||||
color: toast.tone === 'error' ? '#F6465D' : toast.tone === 'success' ? '#0ECB81' : '#F0B90B',
|
||||
border: `1px solid ${toast.tone === 'error' ? 'rgba(246,70,93,0.3)' : toast.tone === 'success' ? 'rgba(14,203,129,0.3)' : 'rgba(240,185,11,0.3)'}`,
|
||||
}}
|
||||
>
|
||||
{toast.text}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-3" style={{ color: '#EAECEF' }}>
|
||||
<Brain className="w-7 h-7" style={{ color: '#F0B90B' }} />
|
||||
{tr('title')}
|
||||
</h1>
|
||||
<p className="text-sm mt-1" style={{ color: '#848E9C' }}>
|
||||
{tr('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setWizardStep(1)}
|
||||
className="px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-all hover:opacity-90"
|
||||
style={{ background: '#F0B90B', color: '#0B0E11' }}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
{t('backtestPageExtra.newBacktest', language)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Left Panel - Config / History */}
|
||||
<div className="space-y-4">
|
||||
<BacktestConfigForm
|
||||
formState={formState}
|
||||
wizardStep={wizardStep}
|
||||
isStarting={isStarting}
|
||||
aiModels={aiModels}
|
||||
strategies={strategies}
|
||||
language={language}
|
||||
tr={tr}
|
||||
onFormChange={handleFormChange}
|
||||
onWizardStepChange={setWizardStep}
|
||||
onStart={handleStart}
|
||||
/>
|
||||
|
||||
<BacktestRunList
|
||||
runs={runs}
|
||||
selectedRunId={selectedRunId}
|
||||
compareRunIds={compareRunIds}
|
||||
language={language}
|
||||
tr={tr}
|
||||
onSelectRun={setSelectedRunId}
|
||||
onToggleCompare={toggleCompare}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Results */}
|
||||
<div className="xl:col-span-2 space-y-4">
|
||||
{!selectedRunId ? (
|
||||
<div
|
||||
className="binance-card p-12 text-center"
|
||||
style={{ color: '#5E6673' }}
|
||||
>
|
||||
<Brain className="w-12 h-12 mx-auto mb-4 opacity-30" />
|
||||
<p>{tr('emptyStates.selectRun')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Status Bar */}
|
||||
<div className="binance-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<ProgressRing progress={status?.progress_pct ?? selectedRun?.summary.progress_pct ?? 0} size={80} />
|
||||
<div>
|
||||
<h2 className="font-mono font-bold" style={{ color: '#EAECEF' }}>
|
||||
{selectedRunId}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{
|
||||
background: `${getStateColor(status?.state ?? selectedRun?.state ?? '')}20`,
|
||||
color: getStateColor(status?.state ?? selectedRun?.state ?? ''),
|
||||
}}
|
||||
>
|
||||
{getStateIcon(status?.state ?? selectedRun?.state ?? '')}
|
||||
{tr(`states.${status?.state ?? selectedRun?.state}`)}
|
||||
</span>
|
||||
{selectedRun?.summary.decision_tf && (
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{selectedRun.summary.decision_tf} · {selectedRun.summary.symbol_count} symbols
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{(status?.state === 'running' || selectedRun?.state === 'running') && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleControl('pause')}
|
||||
className="p-2 rounded-lg transition-all hover:bg-[#2B3139]"
|
||||
style={{ border: '1px solid #2B3139' }}
|
||||
title={tr('actions.pause')}
|
||||
>
|
||||
<Pause className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleControl('stop')}
|
||||
className="p-2 rounded-lg transition-all hover:bg-[#2B3139]"
|
||||
style={{ border: '1px solid #2B3139' }}
|
||||
title={tr('actions.stop')}
|
||||
>
|
||||
<Square className="w-4 h-4" style={{ color: '#F6465D' }} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{status?.state === 'paused' && (
|
||||
<button
|
||||
onClick={() => handleControl('resume')}
|
||||
className="p-2 rounded-lg transition-all hover:bg-[#2B3139]"
|
||||
style={{ border: '1px solid #2B3139' }}
|
||||
title={tr('actions.resume')}
|
||||
>
|
||||
<Play className="w-4 h-4" style={{ color: '#0ECB81' }} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="p-2 rounded-lg transition-all hover:bg-[#2B3139]"
|
||||
style={{ border: '1px solid #2B3139' }}
|
||||
title={tr('detail.exportLabel')}
|
||||
>
|
||||
<Download className="w-4 h-4" style={{ color: '#EAECEF' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-2 rounded-lg transition-all hover:bg-[#2B3139]"
|
||||
style={{ border: '1px solid #2B3139' }}
|
||||
title={tr('detail.deleteLabel')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" style={{ color: '#F6465D' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(status?.note || status?.last_error) && (
|
||||
<div
|
||||
className="mt-3 p-2 rounded-lg text-xs flex items-center gap-2"
|
||||
style={{
|
||||
background: 'rgba(246,70,93,0.1)',
|
||||
border: '1px solid rgba(246,70,93,0.3)',
|
||||
color: '#F6465D',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
{status?.note || status?.last_error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Real-time Positions Display */}
|
||||
{status?.positions && status.positions.length > 0 && (
|
||||
<PositionsDisplay positions={status.positions} language={language} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard
|
||||
icon={Target}
|
||||
label={t('backtestPageExtra.equity', language)}
|
||||
value={(status?.equity ?? 0).toFixed(2)}
|
||||
suffix="USDT"
|
||||
language={language}
|
||||
/>
|
||||
<StatCard
|
||||
icon={TrendingUp}
|
||||
label={t('backtestPageExtra.totalReturn', language)}
|
||||
value={`${(metrics?.total_return_pct ?? 0).toFixed(2)}%`}
|
||||
trend={(metrics?.total_return_pct ?? 0) >= 0 ? 'up' : 'down'}
|
||||
color={(metrics?.total_return_pct ?? 0) >= 0 ? '#0ECB81' : '#F6465D'}
|
||||
metricKey="total_return"
|
||||
language={language}
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label={t('backtestPageExtra.maxDD', language)}
|
||||
value={`${(metrics?.max_drawdown_pct ?? 0).toFixed(2)}%`}
|
||||
color="#F6465D"
|
||||
metricKey="max_drawdown"
|
||||
language={language}
|
||||
/>
|
||||
<StatCard
|
||||
icon={BarChart3}
|
||||
label={t('backtestPageExtra.sharpe', language)}
|
||||
value={(metrics?.sharpe_ratio ?? 0).toFixed(2)}
|
||||
metricKey="sharpe_ratio"
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="binance-card">
|
||||
<div className="flex border-b" style={{ borderColor: '#2B3139' }}>
|
||||
{(['overview', 'chart', 'trades', 'decisions'] as ViewTab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setViewTab(tab)}
|
||||
className="px-4 py-3 text-sm font-medium transition-all relative"
|
||||
style={{ color: viewTab === tab ? '#F0B90B' : '#848E9C' }}
|
||||
>
|
||||
{tab === 'overview'
|
||||
? t('backtestPageExtra.tabOverview', language)
|
||||
: tab === 'chart'
|
||||
? t('backtestPageExtra.tabChart', language)
|
||||
: tab === 'trades'
|
||||
? t('backtestPageExtra.tabTrades', language)
|
||||
: t('backtestPageExtra.tabDecisions', language)}
|
||||
{viewTab === tab && (
|
||||
<motion.div
|
||||
layoutId="tab-indicator"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5"
|
||||
style={{ background: '#F0B90B' }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<AnimatePresence mode="wait">
|
||||
{viewTab === 'overview' && (
|
||||
<BacktestOverviewTab
|
||||
equity={equity}
|
||||
trades={trades}
|
||||
metrics={metrics}
|
||||
language={language}
|
||||
tr={tr}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewTab === 'chart' && (
|
||||
<BacktestChartTab
|
||||
equity={equity}
|
||||
trades={trades}
|
||||
selectedRunId={selectedRunId}
|
||||
language={language}
|
||||
tr={tr}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewTab === 'trades' && (
|
||||
<BacktestTradesTab trades={trades} />
|
||||
)}
|
||||
|
||||
{viewTab === 'decisions' && (
|
||||
<BacktestDecisionsTab
|
||||
decisions={decisions}
|
||||
language={language}
|
||||
tr={tr}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Pause,
|
||||
Clock,
|
||||
Layers,
|
||||
Eye,
|
||||
} from 'lucide-react'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
export interface BacktestRunItem {
|
||||
run_id: string
|
||||
state: string
|
||||
summary: {
|
||||
progress_pct: number
|
||||
equity_last: number
|
||||
decision_tf?: string
|
||||
symbol_count?: number
|
||||
}
|
||||
}
|
||||
|
||||
// ============ State Helpers ============
|
||||
|
||||
export function getStateColor(state: string) {
|
||||
switch (state) {
|
||||
case 'running':
|
||||
return '#F0B90B'
|
||||
case 'completed':
|
||||
return '#0ECB81'
|
||||
case 'failed':
|
||||
case 'liquidated':
|
||||
return '#F6465D'
|
||||
case 'paused':
|
||||
return '#848E9C'
|
||||
default:
|
||||
return '#848E9C'
|
||||
}
|
||||
}
|
||||
|
||||
export function getStateIcon(state: string) {
|
||||
switch (state) {
|
||||
case 'running':
|
||||
return <Activity className="w-4 h-4" />
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-4 h-4" />
|
||||
case 'failed':
|
||||
case 'liquidated':
|
||||
return <XCircle className="w-4 h-4" />
|
||||
case 'paused':
|
||||
return <Pause className="w-4 h-4" />
|
||||
default:
|
||||
return <Clock className="w-4 h-4" />
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Run History List ============
|
||||
|
||||
interface BacktestRunListProps {
|
||||
runs: BacktestRunItem[]
|
||||
selectedRunId: string | undefined
|
||||
compareRunIds: string[]
|
||||
language: Language
|
||||
tr: (key: string, params?: Record<string, string | number>) => string
|
||||
onSelectRun: (runId: string) => void
|
||||
onToggleCompare: (runId: string) => void
|
||||
}
|
||||
|
||||
export function BacktestRunList({
|
||||
runs,
|
||||
selectedRunId,
|
||||
compareRunIds,
|
||||
language,
|
||||
tr,
|
||||
onSelectRun,
|
||||
onToggleCompare,
|
||||
}: BacktestRunListProps) {
|
||||
return (
|
||||
<div className="binance-card p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
|
||||
<Layers className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
{tr('runList.title')}
|
||||
</h3>
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{runs.length} {t('backtestPageExtra.runs', language)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{runs.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm" style={{ color: '#5E6673' }}>
|
||||
{tr('emptyStates.noRuns')}
|
||||
</div>
|
||||
) : (
|
||||
runs.map((run) => (
|
||||
<button
|
||||
key={run.run_id}
|
||||
onClick={() => onSelectRun(run.run_id)}
|
||||
className="w-full p-3 rounded-lg text-left transition-all"
|
||||
style={{
|
||||
background: run.run_id === selectedRunId ? 'rgba(240,185,11,0.1)' : '#1E2329',
|
||||
border: `1px solid ${run.run_id === selectedRunId ? '#F0B90B' : '#2B3139'}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-xs" style={{ color: '#EAECEF' }}>
|
||||
{run.run_id.slice(0, 20)}...
|
||||
</span>
|
||||
<span
|
||||
className="flex items-center gap-1 text-xs"
|
||||
style={{ color: getStateColor(run.state) }}
|
||||
>
|
||||
{getStateIcon(run.state)}
|
||||
{tr(`states.${run.state}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{run.summary.progress_pct.toFixed(0)}% · ${run.summary.equity_last.toFixed(0)}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleCompare(run.run_id)
|
||||
}}
|
||||
className="p-1 rounded"
|
||||
style={{
|
||||
background: compareRunIds.includes(run.run_id)
|
||||
? 'rgba(240,185,11,0.2)'
|
||||
: 'transparent',
|
||||
}}
|
||||
title={t('backtestPageExtra.addToCompare', language)}
|
||||
>
|
||||
<Eye
|
||||
className="w-3 h-3"
|
||||
style={{
|
||||
color: compareRunIds.includes(run.run_id) ? '#F0B90B' : '#5E6673',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { TrendingUp, TrendingDown } from 'lucide-react'
|
||||
import type { BacktestTradeEvent } from '../../types'
|
||||
|
||||
// ============ Trade Timeline ============
|
||||
|
||||
function TradeTimeline({ trades }: { trades: BacktestTradeEvent[] }) {
|
||||
const recentTrades = useMemo(() => [...trades].slice(-20).reverse(), [trades])
|
||||
|
||||
if (recentTrades.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
|
||||
No trades yet
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-2">
|
||||
{recentTrades.map((trade, idx) => {
|
||||
const isOpen = trade.action.includes('open')
|
||||
const isLong = trade.action.includes('long')
|
||||
const bgColor = isOpen ? 'rgba(14, 203, 129, 0.1)' : 'rgba(246, 70, 93, 0.1)'
|
||||
const borderColor = isOpen ? 'rgba(14, 203, 129, 0.3)' : 'rgba(246, 70, 93, 0.3)'
|
||||
const iconColor = isOpen ? '#0ECB81' : '#F6465D'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`${trade.ts}-${trade.symbol}-${idx}`}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="p-3 rounded-lg flex items-center gap-3"
|
||||
style={{ background: bgColor, border: `1px solid ${borderColor}` }}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center"
|
||||
style={{ background: `${iconColor}20` }}
|
||||
>
|
||||
{isLong ? (
|
||||
<TrendingUp className="w-4 h-4" style={{ color: iconColor }} />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4" style={{ color: iconColor }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono font-bold text-sm" style={{ color: '#EAECEF' }}>
|
||||
{trade.symbol.replace('USDT', '')}
|
||||
</span>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{ background: `${iconColor}20`, color: iconColor }}
|
||||
>
|
||||
{trade.action.replace('_', ' ').toUpperCase()}
|
||||
</span>
|
||||
{trade.leverage && (
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{trade.leverage}x
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{new Date(trade.ts).toLocaleString()} · Qty: {trade.qty.toFixed(4)} · ${trade.price.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div
|
||||
className="font-mono font-bold"
|
||||
style={{ color: trade.realized_pnl >= 0 ? '#0ECB81' : '#F6465D' }}
|
||||
>
|
||||
{trade.realized_pnl >= 0 ? '+' : ''}
|
||||
{trade.realized_pnl.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
USDT
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Trades Tab Content ============
|
||||
|
||||
interface BacktestTradesTabProps {
|
||||
trades: BacktestTradeEvent[] | undefined
|
||||
}
|
||||
|
||||
export function BacktestTradesTab({ trades }: BacktestTradesTabProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key="trades"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<TradeTimeline trades={trades ?? []} />
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ type Page =
|
||||
| 'competition'
|
||||
| 'traders'
|
||||
| 'trader'
|
||||
| 'backtest'
|
||||
| 'strategy'
|
||||
| 'strategy-market'
|
||||
| 'data'
|
||||
@@ -100,7 +99,6 @@ export default function HeaderBar({
|
||||
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
|
||||
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
|
||||
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
|
||||
{ page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
|
||||
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
|
||||
]
|
||||
|
||||
@@ -342,7 +340,6 @@ export default function HeaderBar({
|
||||
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
|
||||
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
|
||||
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
|
||||
{ page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
|
||||
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user