mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +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:
@@ -24,7 +24,6 @@ import { t } from './i18n/translations'
|
||||
import { useSystemConfig } from './hooks/useSystemConfig'
|
||||
|
||||
import { OFFICIAL_LINKS } from './constants/branding'
|
||||
import { BacktestPage } from './components/backtest/BacktestPage'
|
||||
import type {
|
||||
SystemStatus,
|
||||
AccountInfo,
|
||||
@@ -39,7 +38,6 @@ type Page =
|
||||
| 'competition'
|
||||
| 'traders'
|
||||
| 'trader'
|
||||
| 'backtest'
|
||||
| 'strategy'
|
||||
| 'strategy-market'
|
||||
| 'data'
|
||||
@@ -66,7 +64,6 @@ function App() {
|
||||
const hash = window.location.hash.slice(1) // 去掉 #
|
||||
|
||||
if (path === '/traders' || hash === 'traders') return 'traders'
|
||||
if (path === '/backtest' || hash === 'backtest') return 'backtest'
|
||||
if (path === '/strategy' || hash === 'strategy') return 'strategy'
|
||||
if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market'
|
||||
if (path === '/data' || hash === 'data') return 'data'
|
||||
@@ -92,7 +89,6 @@ function App() {
|
||||
'data': '/data',
|
||||
'traders': '/traders',
|
||||
'trader': '/dashboard',
|
||||
'backtest': '/backtest',
|
||||
'strategy': '/strategy',
|
||||
'faq': '/faq',
|
||||
'login': '/login',
|
||||
@@ -147,8 +143,6 @@ function App() {
|
||||
|
||||
if (path === '/traders' || hash === 'traders') {
|
||||
setCurrentPage('traders')
|
||||
} else if (path === '/backtest' || hash === 'backtest') {
|
||||
setCurrentPage('backtest')
|
||||
} else if (path === '/strategy' || hash === 'strategy') {
|
||||
setCurrentPage('strategy')
|
||||
} else if (path === '/strategy-market' || hash === 'strategy-market') {
|
||||
@@ -410,7 +404,6 @@ function App() {
|
||||
'strategy-market': '/strategy-market',
|
||||
'traders': '/traders',
|
||||
'trader': '/dashboard',
|
||||
'backtest': '/backtest',
|
||||
'strategy': '/strategy',
|
||||
'faq': '/faq',
|
||||
}
|
||||
@@ -496,8 +489,6 @@ function App() {
|
||||
setCurrentPage('trader')
|
||||
}}
|
||||
/>
|
||||
) : currentPage === 'backtest' ? (
|
||||
<BacktestPage />
|
||||
) : currentPage === 'strategy' ? (
|
||||
<StrategyStudioPage />
|
||||
) : (
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ export const translations = {
|
||||
details: 'Details',
|
||||
tradingPanel: 'Trading Panel',
|
||||
competition: 'Competition',
|
||||
backtest: 'Backtest',
|
||||
running: 'RUNNING',
|
||||
stopped: 'STOPPED',
|
||||
adminMode: 'Admin Mode',
|
||||
@@ -95,170 +94,6 @@ export const translations = {
|
||||
fullscreen: 'Fullscreen',
|
||||
exitFullscreen: 'Exit Fullscreen',
|
||||
|
||||
// Backtest Page
|
||||
backtestPage: {
|
||||
title: 'Backtest Lab',
|
||||
subtitle:
|
||||
'Pick a model + time range to replay the full AI decision loop.',
|
||||
start: 'Start Backtest',
|
||||
starting: 'Starting...',
|
||||
quickRanges: {
|
||||
h24: '24h',
|
||||
d3: '3d',
|
||||
d7: '7d',
|
||||
},
|
||||
actions: {
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
stop: 'Stop',
|
||||
},
|
||||
states: {
|
||||
running: 'Running',
|
||||
paused: 'Paused',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
liquidated: 'Liquidated',
|
||||
},
|
||||
form: {
|
||||
aiModelLabel: 'AI Model',
|
||||
selectAiModel: 'Select AI model',
|
||||
providerLabel: 'Provider',
|
||||
statusLabel: 'Status',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
noModelWarning:
|
||||
'Please add and enable an AI model on the Model Config page first.',
|
||||
runIdLabel: 'Run ID',
|
||||
runIdPlaceholder: 'Leave blank to auto-generate',
|
||||
decisionTfLabel: 'Decision TF',
|
||||
cadenceLabel: 'Decision cadence (bars)',
|
||||
timeRangeLabel: 'Time range',
|
||||
symbolsLabel: 'Symbols (comma-separated)',
|
||||
customTfPlaceholder: 'Custom TFs (comma separated, e.g. 2h,6h)',
|
||||
initialBalanceLabel: 'Initial balance (USDT)',
|
||||
feeLabel: 'Fee (bps)',
|
||||
slippageLabel: 'Slippage (bps)',
|
||||
btcEthLeverageLabel: 'BTC/ETH leverage (x)',
|
||||
altcoinLeverageLabel: 'Altcoin leverage (x)',
|
||||
fillPolicies: {
|
||||
nextOpen: 'Next open',
|
||||
barVwap: 'Bar VWAP',
|
||||
midPrice: 'Mid price',
|
||||
},
|
||||
promptPresets: {
|
||||
baseline: 'Baseline',
|
||||
aggressive: 'Aggressive',
|
||||
conservative: 'Conservative',
|
||||
scalping: 'Scalping',
|
||||
},
|
||||
cacheAiLabel: 'Reuse AI cache',
|
||||
replayOnlyLabel: 'Replay only',
|
||||
overridePromptLabel: 'Use only custom prompt',
|
||||
customPromptLabel: 'Custom prompt (optional)',
|
||||
customPromptPlaceholder:
|
||||
'Append or fully customize the strategy prompt',
|
||||
},
|
||||
runList: {
|
||||
title: 'Runs',
|
||||
count: 'Total {count} records',
|
||||
},
|
||||
filters: {
|
||||
allStates: 'All states',
|
||||
searchPlaceholder: 'Run ID / label',
|
||||
},
|
||||
tableHeaders: {
|
||||
runId: 'Run ID',
|
||||
label: 'Label',
|
||||
state: 'State',
|
||||
progress: 'Progress',
|
||||
equity: 'Equity',
|
||||
lastError: 'Last Error',
|
||||
updated: 'Updated',
|
||||
},
|
||||
emptyStates: {
|
||||
noRuns: 'No runs yet',
|
||||
selectRun: 'Select a run to view details',
|
||||
},
|
||||
detail: {
|
||||
tfAndSymbols: 'TF: {tf} · Symbols {count}',
|
||||
labelPlaceholder: 'Label note',
|
||||
saveLabel: 'Save',
|
||||
deleteLabel: 'Delete',
|
||||
exportLabel: 'Export',
|
||||
errorLabel: 'Error',
|
||||
},
|
||||
toasts: {
|
||||
selectModel: 'Please select an AI model first.',
|
||||
modelDisabled: 'AI model {name} is disabled.',
|
||||
invalidRange: 'End time must be later than start time.',
|
||||
startSuccess: 'Backtest {id} started.',
|
||||
startFailed: 'Failed to start. Please try again later.',
|
||||
actionSuccess: '{action} {id} succeeded.',
|
||||
actionFailed: 'Operation failed. Please try again later.',
|
||||
labelSaved: 'Label updated.',
|
||||
labelFailed: 'Failed to update label.',
|
||||
confirmDelete: 'Delete backtest {id}? This action cannot be undone.',
|
||||
deleteSuccess: 'Backtest record deleted.',
|
||||
deleteFailed: 'Failed to delete. Please try again later.',
|
||||
traceFailed: 'Failed to fetch AI trace.',
|
||||
exportSuccess: 'Exported data for {id}.',
|
||||
exportFailed: 'Failed to export.',
|
||||
},
|
||||
aiTrace: {
|
||||
title: 'AI Trace',
|
||||
clear: 'Clear',
|
||||
cyclePlaceholder: 'Cycle',
|
||||
fetch: 'Fetch',
|
||||
prompt: 'Prompt',
|
||||
cot: 'Chain of thought',
|
||||
output: 'Output',
|
||||
cycleTag: 'Cycle #{cycle}',
|
||||
},
|
||||
decisionTrail: {
|
||||
title: 'AI Decision Trail',
|
||||
subtitle: 'Showing last {count} cycles',
|
||||
empty: 'No records yet',
|
||||
emptyHint:
|
||||
'The AI thought & execution log will appear once the run starts.',
|
||||
},
|
||||
charts: {
|
||||
equityTitle: 'Equity Curve',
|
||||
equityEmpty: 'No data yet',
|
||||
},
|
||||
metrics: {
|
||||
title: 'Metrics',
|
||||
totalReturn: 'Total Return %',
|
||||
maxDrawdown: 'Max Drawdown %',
|
||||
sharpe: 'Sharpe',
|
||||
profitFactor: 'Profit Factor',
|
||||
pending: 'Calculating...',
|
||||
realized: 'Realized PnL',
|
||||
unrealized: 'Unrealized PnL',
|
||||
},
|
||||
trades: {
|
||||
title: 'Trade Events',
|
||||
headers: {
|
||||
time: 'Time',
|
||||
symbol: 'Symbol',
|
||||
action: 'Action',
|
||||
qty: 'Qty',
|
||||
leverage: 'Leverage',
|
||||
pnl: 'PnL',
|
||||
},
|
||||
empty: 'No trades yet',
|
||||
},
|
||||
metadata: {
|
||||
title: 'Metadata',
|
||||
created: 'Created',
|
||||
updated: 'Updated',
|
||||
processedBars: 'Processed Bars',
|
||||
maxDrawdown: 'Max DD',
|
||||
liquidated: 'Liquidated',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
},
|
||||
},
|
||||
|
||||
// Competition Page
|
||||
aiCompetition: 'AI Competition',
|
||||
traders: 'traders',
|
||||
@@ -838,7 +673,7 @@ export const translations = {
|
||||
// ===== GETTING STARTED =====
|
||||
faqWhatIsNOFX: 'What is NOFX?',
|
||||
faqWhatIsNOFXAnswer:
|
||||
'NOFX is an open-source AI-powered trading operating system for cryptocurrency and US stock markets. It uses large language models (LLMs) like DeepSeek, GPT, Claude, Gemini to analyze market data and make autonomous trading decisions. Key features include: multi-AI model support, multi-exchange trading, visual strategy builder, and backtesting.',
|
||||
'NOFX is an open-source AI-powered trading operating system for cryptocurrency and US stock markets. It uses large language models (LLMs) like DeepSeek, GPT, Claude, Gemini to analyze market data and make autonomous trading decisions. Key features include: multi-AI model support, multi-exchange trading, and visual strategy builder.',
|
||||
|
||||
faqHowDoesItWork: 'How does NOFX work?',
|
||||
faqHowDoesItWorkAnswer:
|
||||
@@ -846,7 +681,7 @@ export const translations = {
|
||||
|
||||
faqIsProfitable: 'Is NOFX profitable?',
|
||||
faqIsProfitableAnswer:
|
||||
'AI trading is experimental and NOT guaranteed to be profitable. Cryptocurrency futures are highly volatile and risky. NOFX is designed for educational and research purposes. We strongly recommend: starting with small amounts (10-50 USDT), never investing more than you can afford to lose, thoroughly testing with backtests before live trading, and understanding that past performance does not guarantee future results.',
|
||||
'AI trading is experimental and NOT guaranteed to be profitable. Cryptocurrency futures are highly volatile and risky. NOFX is designed for educational and research purposes. We strongly recommend: starting with small amounts (10-50 USDT), never investing more than you can afford to lose, thoroughly testing before live trading, and understanding that past performance does not guarantee future results.',
|
||||
|
||||
faqSupportedExchanges: 'Which exchanges are supported?',
|
||||
faqSupportedExchangesAnswer:
|
||||
@@ -998,10 +833,6 @@ export const translations = {
|
||||
faqStrategyStudioAnswer:
|
||||
'Strategy Studio is a visual strategy builder where you configure: 1) Coin Sources - which cryptocurrencies to trade (static list, AI500 top coins, OI ranking); 2) Technical Indicators - EMA, MACD, RSI, ATR, Volume, Open Interest, Funding Rate; 3) Risk Controls - leverage limits, position sizing, margin caps; 4) Custom Prompts - specific instructions for AI. No coding required.',
|
||||
|
||||
faqBacktestLab: 'What is Backtest Lab?',
|
||||
faqBacktestLabAnswer:
|
||||
'Backtest Lab tests your strategy against historical data without risking real funds. Features: 1) Configure AI model, date range, initial balance; 2) Watch real-time progress with equity curve; 3) View metrics: Return %, Max Drawdown, Sharpe Ratio, Win Rate; 4) Analyze individual trades and AI reasoning. Essential for validating strategies before live trading.',
|
||||
|
||||
faqCompetitionMode: 'What is Competition Mode?',
|
||||
faqCompetitionModeAnswer:
|
||||
'Competition page shows a real-time leaderboard of all your traders. Compare: ROI, P&L, Sharpe ratio, win rate, number of trades. Use this to A/B test different AI models, strategies, or configurations. Traders can be marked as "Show in Competition" to appear on the leaderboard.',
|
||||
@@ -1025,7 +856,7 @@ export const translations = {
|
||||
|
||||
faqCompareAIModels: 'How do I compare different AI models?',
|
||||
faqCompareAIModelsAnswer:
|
||||
'Create multiple traders with different AI models but same strategy/exchange. Run them simultaneously and compare on Competition page. Metrics to watch: ROI, win rate, Sharpe ratio, max drawdown. Alternatively, use Backtest Lab to test models against same historical data.',
|
||||
'Create multiple traders with different AI models but same strategy/exchange. Run them simultaneously and compare on Competition page. Metrics to watch: ROI, win rate, Sharpe ratio, max drawdown.',
|
||||
|
||||
// ===== CONTRIBUTING =====
|
||||
faqHowToContribute: 'How can I contribute to NOFX?',
|
||||
@@ -1256,53 +1087,6 @@ export const translations = {
|
||||
runAiTestHint: 'Click to run AI test',
|
||||
},
|
||||
|
||||
// Backtest Page (additional keys)
|
||||
backtestPageExtra: {
|
||||
newBacktest: 'New Backtest',
|
||||
confirmDelete: 'Confirm Delete',
|
||||
delete: 'Delete',
|
||||
cancel: 'Cancel',
|
||||
equity: 'Equity',
|
||||
totalReturn: 'Return',
|
||||
maxDD: 'Max DD',
|
||||
sharpe: 'Sharpe',
|
||||
tabOverview: 'Overview',
|
||||
tabChart: 'Chart',
|
||||
tabTrades: 'Trades',
|
||||
tabDecisions: 'Decisions',
|
||||
addToCompare: 'Add to compare',
|
||||
runs: 'runs',
|
||||
runCount: 'runs',
|
||||
},
|
||||
|
||||
// Backtest Overview Tab
|
||||
backtestOverview: {
|
||||
activePositions: 'Active Positions',
|
||||
margin: 'Margin',
|
||||
unrealized: 'Unrealized',
|
||||
qty: 'Qty',
|
||||
entry: 'Entry',
|
||||
mark: 'Mark',
|
||||
winRate: 'Win Rate',
|
||||
profitFactor: 'Profit Factor',
|
||||
totalTrades: 'Total Trades',
|
||||
bestSymbol: 'Best Symbol',
|
||||
},
|
||||
|
||||
// Backtest Chart Tab
|
||||
backtestChart: {
|
||||
noTrades: 'No trades to display',
|
||||
symbol: 'Symbol',
|
||||
interval: 'Interval',
|
||||
trades: 'trades',
|
||||
loadingKline: 'Loading kline data...',
|
||||
openProfit: 'Open/Profit',
|
||||
lossClose: 'Loss Close',
|
||||
close: 'Close',
|
||||
equityCurve: 'Equity Curve',
|
||||
candlestickTradeMarkers: 'Candlestick & Trade Markers',
|
||||
},
|
||||
|
||||
// Metric Tooltip
|
||||
metricTooltip: {
|
||||
formula: 'Formula',
|
||||
@@ -1314,10 +1098,9 @@ export const translations = {
|
||||
accessDenied: 'ACCESS DENIED',
|
||||
subtitleWithFeature: 'Module "{featureName}" requires elevated privileges',
|
||||
subtitleDefault: 'Authorization required for this module',
|
||||
description: 'Initialize authentication protocol to unlock full system capabilities: AI Trader configuration, Strategy Market data streams, and Backtest Simulation core.',
|
||||
description: 'Initialize authentication protocol to unlock full system capabilities: AI Trader configuration and Strategy Market data streams.',
|
||||
benefit1: 'AI Trader Control',
|
||||
benefit2: 'HFT Strategy Market',
|
||||
benefit3: 'Historical Backtest Engine',
|
||||
benefit4: 'Full System Visualization',
|
||||
loginButton: 'EXECUTE LOGIN',
|
||||
registerButton: 'REGISTER NEW ID',
|
||||
@@ -1552,28 +1335,6 @@ export const translations = {
|
||||
no: 'No',
|
||||
},
|
||||
|
||||
// BacktestConfigForm
|
||||
backtestConfigForm: {
|
||||
selectModel: 'Select Model',
|
||||
configure: 'Configure',
|
||||
confirmStart: 'Confirm',
|
||||
strategyOptional: 'Strategy (Optional)',
|
||||
noSavedStrategy: 'No saved strategy',
|
||||
coinSource: 'Coin Source:',
|
||||
clearDynamicCoins: 'Clear the symbols field below to use strategy\'s dynamic coins',
|
||||
optionalCoinSource: 'Optional - strategy has coin source',
|
||||
leavEmptyForStrategy: 'Leave empty to use strategy coin source',
|
||||
clearToUseStrategy: 'Clear to use strategy',
|
||||
next: 'Next',
|
||||
timeframes: 'Timeframes',
|
||||
back: 'Back',
|
||||
strategyStyle: 'Strategy Style',
|
||||
quickRange24h: '24h',
|
||||
quickRange3d: '3d',
|
||||
quickRange7d: '7d',
|
||||
quickRange30d: '30d',
|
||||
},
|
||||
|
||||
},
|
||||
zh: {
|
||||
// Header
|
||||
@@ -1583,7 +1344,6 @@ export const translations = {
|
||||
details: '详情',
|
||||
tradingPanel: '交易面板',
|
||||
competition: '竞赛',
|
||||
backtest: '回测',
|
||||
running: '运行中',
|
||||
stopped: '已停止',
|
||||
adminMode: '管理员模式',
|
||||
@@ -1669,166 +1429,6 @@ export const translations = {
|
||||
fullscreen: '全屏',
|
||||
exitFullscreen: '退出全屏',
|
||||
|
||||
// Backtest Page
|
||||
backtestPage: {
|
||||
title: '回测实验室',
|
||||
subtitle: '选择模型与时间范围,快速复盘 AI 决策链路。',
|
||||
start: '启动回测',
|
||||
starting: '启动中...',
|
||||
quickRanges: {
|
||||
h24: '24小时',
|
||||
d3: '3天',
|
||||
d7: '7天',
|
||||
},
|
||||
actions: {
|
||||
pause: '暂停',
|
||||
resume: '恢复',
|
||||
stop: '停止',
|
||||
},
|
||||
states: {
|
||||
running: '运行中',
|
||||
paused: '已暂停',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
liquidated: '已爆仓',
|
||||
},
|
||||
form: {
|
||||
aiModelLabel: 'AI 模型',
|
||||
selectAiModel: '选择AI模型',
|
||||
providerLabel: 'Provider',
|
||||
statusLabel: '状态',
|
||||
enabled: '已启用',
|
||||
disabled: '未启用',
|
||||
noModelWarning: '请先在「模型配置」页面添加并启用AI模型。',
|
||||
runIdLabel: 'Run ID',
|
||||
runIdPlaceholder: '留空则自动生成',
|
||||
decisionTfLabel: '决策周期',
|
||||
cadenceLabel: '决策节奏(根数)',
|
||||
timeRangeLabel: '时间范围',
|
||||
symbolsLabel: '交易标的(逗号分隔)',
|
||||
customTfPlaceholder: '自定义周期(逗号分隔,例如 2h,6h)',
|
||||
initialBalanceLabel: '初始资金 (USDT)',
|
||||
feeLabel: '手续费 (bps)',
|
||||
slippageLabel: '滑点 (bps)',
|
||||
btcEthLeverageLabel: 'BTC/ETH 杠杆 (倍)',
|
||||
altcoinLeverageLabel: '山寨币杠杆 (倍)',
|
||||
fillPolicies: {
|
||||
nextOpen: '下一根开盘价',
|
||||
barVwap: 'K线 VWAP',
|
||||
midPrice: '中间价',
|
||||
},
|
||||
promptPresets: {
|
||||
baseline: '基础版',
|
||||
aggressive: '激进版',
|
||||
conservative: '稳健版',
|
||||
scalping: '剥头皮',
|
||||
},
|
||||
cacheAiLabel: '复用AI缓存',
|
||||
replayOnlyLabel: '仅回放记录',
|
||||
overridePromptLabel: '仅使用自定义提示词',
|
||||
customPromptLabel: '自定义提示词(可选)',
|
||||
customPromptPlaceholder: '追加或完全自定义策略提示词',
|
||||
},
|
||||
runList: {
|
||||
title: '运行列表',
|
||||
count: '共 {count} 条记录',
|
||||
},
|
||||
filters: {
|
||||
allStates: '全部状态',
|
||||
searchPlaceholder: 'Run ID / 标签',
|
||||
},
|
||||
tableHeaders: {
|
||||
runId: 'Run ID',
|
||||
label: '标签',
|
||||
state: '状态',
|
||||
progress: '进度',
|
||||
equity: '净值',
|
||||
lastError: '最后错误',
|
||||
updated: '更新时间',
|
||||
},
|
||||
emptyStates: {
|
||||
noRuns: '暂无记录',
|
||||
selectRun: '请选择一个运行查看详情',
|
||||
},
|
||||
detail: {
|
||||
tfAndSymbols: '周期: {tf} · 币种 {count}',
|
||||
labelPlaceholder: '备注标签',
|
||||
saveLabel: '保存',
|
||||
deleteLabel: '删除',
|
||||
exportLabel: '导出',
|
||||
errorLabel: '错误',
|
||||
},
|
||||
toasts: {
|
||||
selectModel: '请先选择一个AI模型。',
|
||||
modelDisabled: 'AI模型 {name} 尚未启用。',
|
||||
invalidRange: '结束时间必须晚于开始时间。',
|
||||
startSuccess: '回测 {id} 已启动。',
|
||||
startFailed: '启动失败,请稍后再试。',
|
||||
actionSuccess: '{action} {id} 成功。',
|
||||
actionFailed: '操作失败,请稍后再试。',
|
||||
labelSaved: '标签已更新。',
|
||||
labelFailed: '更新标签失败。',
|
||||
confirmDelete: '确认删除回测 {id} 吗?该操作不可恢复。',
|
||||
deleteSuccess: '回测记录已删除。',
|
||||
deleteFailed: '删除失败,请稍后再试。',
|
||||
traceFailed: '获取AI思维链失败。',
|
||||
exportSuccess: '已导出 {id} 的数据。',
|
||||
exportFailed: '导出失败。',
|
||||
},
|
||||
aiTrace: {
|
||||
title: 'AI 思维链',
|
||||
clear: '清除',
|
||||
cyclePlaceholder: '循环编号',
|
||||
fetch: '获取',
|
||||
prompt: '提示词',
|
||||
cot: '思考链',
|
||||
output: '输出',
|
||||
cycleTag: '周期 #{cycle}',
|
||||
},
|
||||
decisionTrail: {
|
||||
title: 'AI 决策轨迹',
|
||||
subtitle: '展示最近 {count} 次循环',
|
||||
empty: '暂无记录',
|
||||
emptyHint: '回测运行后将自动记录每次 AI 思考与执行',
|
||||
},
|
||||
charts: {
|
||||
equityTitle: '净值曲线',
|
||||
equityEmpty: '暂无数据',
|
||||
},
|
||||
metrics: {
|
||||
title: '指标',
|
||||
totalReturn: '总收益率 %',
|
||||
maxDrawdown: '最大回撤 %',
|
||||
sharpe: '夏普比率',
|
||||
profitFactor: '盈亏因子',
|
||||
pending: '计算中...',
|
||||
realized: '已实现盈亏',
|
||||
unrealized: '未实现盈亏',
|
||||
},
|
||||
trades: {
|
||||
title: '交易事件',
|
||||
headers: {
|
||||
time: '时间',
|
||||
symbol: '币种',
|
||||
action: '操作',
|
||||
qty: '数量',
|
||||
leverage: '杠杆',
|
||||
pnl: '盈亏',
|
||||
},
|
||||
empty: '暂无交易',
|
||||
},
|
||||
metadata: {
|
||||
title: '元信息',
|
||||
created: '创建时间',
|
||||
updated: '更新时间',
|
||||
processedBars: '已处理K线',
|
||||
maxDrawdown: '最大回撤',
|
||||
liquidated: '是否爆仓',
|
||||
yes: '是',
|
||||
no: '否',
|
||||
},
|
||||
},
|
||||
|
||||
// Competition Page
|
||||
aiCompetition: 'AI竞赛',
|
||||
traders: '交易员',
|
||||
@@ -2521,10 +2121,6 @@ export const translations = {
|
||||
faqStrategyStudioAnswer:
|
||||
'策略工作室是可视化策略构建器,您可以配置:1)币种来源 - 交易哪些加密货币(静态列表、AI500 热门币、OI 排行);2)技术指标 - EMA、MACD、RSI、ATR、成交量、持仓量、资金费率;3)风控 - 杠杆限制、仓位大小、保证金上限;4)自定义提示词 - AI 的特定指令。无需编程。',
|
||||
|
||||
faqBacktestLab: '什么是回测实验室?',
|
||||
faqBacktestLabAnswer:
|
||||
'回测实验室用历史数据测试您的策略,无需冒真金风险。功能:1)配置 AI 模型、日期范围、初始余额;2)实时观看进度和权益曲线;3)查看指标:收益率、最大回撤、夏普比率、胜率;4)分析单笔交易和 AI 推理。实盘交易前验证策略的必备工具。',
|
||||
|
||||
faqCompetitionMode: '什么是竞赛模式?',
|
||||
faqCompetitionModeAnswer:
|
||||
'竞赛页面显示所有交易员的实时排行榜。比较:ROI、盈亏、夏普比率、胜率、交易次数。用于 A/B 测试不同 AI 模型、策略或配置。交易员可标记为"在竞赛中显示"以出现在排行榜上。',
|
||||
@@ -2548,7 +2144,7 @@ export const translations = {
|
||||
|
||||
faqCompareAIModels: '如何比较不同 AI 模型?',
|
||||
faqCompareAIModelsAnswer:
|
||||
'创建多个交易员,使用不同 AI 模型但相同策略/交易所。同时运行并在竞赛页面比较。关注指标:ROI、胜率、夏普比率、最大回撤。或者使用回测实验室用相同历史数据测试模型。',
|
||||
'创建多个交易员,使用不同 AI 模型但相同策略/交易所。同时运行并在竞赛页面比较。关注指标:ROI、胜率、夏普比率、最大回撤。',
|
||||
|
||||
// ===== 参与贡献 =====
|
||||
faqHowToContribute: '如何为 NOFX 做贡献?',
|
||||
@@ -2772,53 +2368,6 @@ export const translations = {
|
||||
runAiTestHint: '点击运行 AI 测试',
|
||||
},
|
||||
|
||||
// Backtest Page (additional keys)
|
||||
backtestPageExtra: {
|
||||
newBacktest: '新建回测',
|
||||
confirmDelete: '确认删除',
|
||||
delete: '删除',
|
||||
cancel: '取消',
|
||||
equity: '当前净值',
|
||||
totalReturn: '总收益率',
|
||||
maxDD: '最大回撤',
|
||||
sharpe: '夏普比率',
|
||||
tabOverview: '概览',
|
||||
tabChart: '图表',
|
||||
tabTrades: '交易',
|
||||
tabDecisions: 'AI决策',
|
||||
addToCompare: '添加到对比',
|
||||
runs: '条',
|
||||
runCount: '条',
|
||||
},
|
||||
|
||||
// Backtest Overview Tab
|
||||
backtestOverview: {
|
||||
activePositions: '当前持仓',
|
||||
margin: '保证金',
|
||||
unrealized: '浮盈',
|
||||
qty: '数量',
|
||||
entry: '开仓',
|
||||
mark: '现价',
|
||||
winRate: '胜率',
|
||||
profitFactor: '盈亏因子',
|
||||
totalTrades: '总交易数',
|
||||
bestSymbol: '最佳币种',
|
||||
},
|
||||
|
||||
// Backtest Chart Tab
|
||||
backtestChart: {
|
||||
noTrades: '没有交易记录',
|
||||
symbol: '币种',
|
||||
interval: '周期',
|
||||
trades: '笔交易',
|
||||
loadingKline: '加载K线数据...',
|
||||
openProfit: '开仓/盈利',
|
||||
lossClose: '亏损平仓',
|
||||
close: '平仓',
|
||||
equityCurve: '资金曲线',
|
||||
candlestickTradeMarkers: 'K线图 & 交易标记',
|
||||
},
|
||||
|
||||
// Metric Tooltip
|
||||
metricTooltip: {
|
||||
formula: '计算公式',
|
||||
@@ -2830,10 +2379,9 @@ export const translations = {
|
||||
accessDenied: '访问被拒绝',
|
||||
subtitleWithFeature: '访问「{featureName}」需要更高权限',
|
||||
subtitleDefault: '此模块需要授权访问',
|
||||
description: '初始化身份验证协议以解锁完整系统功能:AI 交易员配置、策略市场数据流、回测模拟核心。',
|
||||
description: '初始化身份验证协议以解锁完整系统功能:AI 交易员配置、策略市场数据流。',
|
||||
benefit1: 'AI 交易员控制权',
|
||||
benefit2: '高频策略核心市场',
|
||||
benefit3: '历史数据回测引擎',
|
||||
benefit4: '全系统数据可视化',
|
||||
loginButton: '执行登录指令',
|
||||
registerButton: '注册新用户 ID',
|
||||
@@ -3062,27 +2610,6 @@ export const translations = {
|
||||
no: '否',
|
||||
},
|
||||
|
||||
backtestConfigForm: {
|
||||
selectModel: '选择模型',
|
||||
configure: '配置参数',
|
||||
confirmStart: '确认启动',
|
||||
strategyOptional: '策略配置(可选)',
|
||||
noSavedStrategy: '不使用保存的策略',
|
||||
coinSource: '币种来源:',
|
||||
clearDynamicCoins: '⚡ 清空下方币种输入框即可使用策略的动态币种',
|
||||
optionalCoinSource: '可选 - 策略已配置币种来源',
|
||||
leavEmptyForStrategy: '留空将使用策略配置的币种来源',
|
||||
clearToUseStrategy: '清空使用策略币种',
|
||||
next: '下一步',
|
||||
timeframes: '时间周期',
|
||||
back: '上一步',
|
||||
strategyStyle: '策略风格',
|
||||
quickRange24h: '24小时',
|
||||
quickRange3d: '3天',
|
||||
quickRange7d: '7天',
|
||||
quickRange30d: '30天',
|
||||
},
|
||||
|
||||
},
|
||||
id: {
|
||||
// Header
|
||||
@@ -3092,7 +2619,6 @@ export const translations = {
|
||||
details: 'Detail',
|
||||
tradingPanel: 'Panel Trading',
|
||||
competition: 'Kompetisi',
|
||||
backtest: 'Backtest',
|
||||
running: 'BERJALAN',
|
||||
stopped: 'BERHENTI',
|
||||
adminMode: 'Mode Admin',
|
||||
@@ -3178,166 +2704,6 @@ export const translations = {
|
||||
fullscreen: 'Layar Penuh',
|
||||
exitFullscreen: 'Keluar Layar Penuh',
|
||||
|
||||
// Backtest Page
|
||||
backtestPage: {
|
||||
title: 'Lab Backtest',
|
||||
subtitle: 'Pilih model + rentang waktu untuk memutar ulang alur keputusan AI.',
|
||||
start: 'Mulai Backtest',
|
||||
starting: 'Memulai...',
|
||||
quickRanges: {
|
||||
h24: '24j',
|
||||
d3: '3h',
|
||||
d7: '7h',
|
||||
},
|
||||
actions: {
|
||||
pause: 'Jeda',
|
||||
resume: 'Lanjutkan',
|
||||
stop: 'Berhenti',
|
||||
},
|
||||
states: {
|
||||
running: 'Berjalan',
|
||||
paused: 'Dijeda',
|
||||
completed: 'Selesai',
|
||||
failed: 'Gagal',
|
||||
liquidated: 'Terlikuidasi',
|
||||
},
|
||||
form: {
|
||||
aiModelLabel: 'Model AI',
|
||||
selectAiModel: 'Pilih model AI',
|
||||
providerLabel: 'Penyedia',
|
||||
statusLabel: 'Status',
|
||||
enabled: 'Aktif',
|
||||
disabled: 'Nonaktif',
|
||||
noModelWarning: 'Silakan tambahkan dan aktifkan model AI di halaman Konfigurasi Model terlebih dahulu.',
|
||||
runIdLabel: 'Run ID',
|
||||
runIdPlaceholder: 'Kosongkan untuk otomatis',
|
||||
decisionTfLabel: 'TF Keputusan',
|
||||
cadenceLabel: 'Irama keputusan (bar)',
|
||||
timeRangeLabel: 'Rentang waktu',
|
||||
symbolsLabel: 'Simbol (pisahkan dengan koma)',
|
||||
customTfPlaceholder: 'TF kustom (pisahkan dengan koma, misal 2h,6h)',
|
||||
initialBalanceLabel: 'Saldo awal (USDT)',
|
||||
feeLabel: 'Biaya (bps)',
|
||||
slippageLabel: 'Selisih harga (bps)',
|
||||
btcEthLeverageLabel: 'Leverage BTC/ETH (x)',
|
||||
altcoinLeverageLabel: 'Leverage Altcoin (x)',
|
||||
fillPolicies: {
|
||||
nextOpen: 'Harga buka berikutnya',
|
||||
barVwap: 'VWAP Bar',
|
||||
midPrice: 'Harga tengah',
|
||||
},
|
||||
promptPresets: {
|
||||
baseline: 'Dasar',
|
||||
aggressive: 'Agresif',
|
||||
conservative: 'Konservatif',
|
||||
scalping: 'Scalping',
|
||||
},
|
||||
cacheAiLabel: 'Gunakan cache AI',
|
||||
replayOnlyLabel: 'Hanya putar ulang',
|
||||
overridePromptLabel: 'Gunakan hanya prompt kustom',
|
||||
customPromptLabel: 'Prompt kustom (opsional)',
|
||||
customPromptPlaceholder: 'Tambahkan atau kustomisasi prompt strategi sepenuhnya',
|
||||
},
|
||||
runList: {
|
||||
title: 'Daftar Run',
|
||||
count: 'Total {count} catatan',
|
||||
},
|
||||
filters: {
|
||||
allStates: 'Semua status',
|
||||
searchPlaceholder: 'Run ID / label',
|
||||
},
|
||||
tableHeaders: {
|
||||
runId: 'Run ID',
|
||||
label: 'Label',
|
||||
state: 'Status',
|
||||
progress: 'Progres',
|
||||
equity: 'Ekuitas',
|
||||
lastError: 'Error Terakhir',
|
||||
updated: 'Diperbarui',
|
||||
},
|
||||
emptyStates: {
|
||||
noRuns: 'Belum ada run',
|
||||
selectRun: 'Pilih run untuk melihat detail',
|
||||
},
|
||||
detail: {
|
||||
tfAndSymbols: 'TF: {tf} · Simbol {count}',
|
||||
labelPlaceholder: 'Catatan label',
|
||||
saveLabel: 'Simpan',
|
||||
deleteLabel: 'Hapus',
|
||||
exportLabel: 'Ekspor',
|
||||
errorLabel: 'Error',
|
||||
},
|
||||
toasts: {
|
||||
selectModel: 'Silakan pilih model AI terlebih dahulu.',
|
||||
modelDisabled: 'Model AI {name} tidak aktif.',
|
||||
invalidRange: 'Waktu akhir harus lebih lambat dari waktu mulai.',
|
||||
startSuccess: 'Backtest {id} dimulai.',
|
||||
startFailed: 'Gagal memulai. Silakan coba lagi nanti.',
|
||||
actionSuccess: '{action} {id} berhasil.',
|
||||
actionFailed: 'Operasi gagal. Silakan coba lagi nanti.',
|
||||
labelSaved: 'Label diperbarui.',
|
||||
labelFailed: 'Gagal memperbarui label.',
|
||||
confirmDelete: 'Hapus backtest {id}? Tindakan ini tidak dapat dibatalkan.',
|
||||
deleteSuccess: 'Catatan backtest dihapus.',
|
||||
deleteFailed: 'Gagal menghapus. Silakan coba lagi nanti.',
|
||||
traceFailed: 'Gagal mengambil jejak AI.',
|
||||
exportSuccess: 'Data untuk {id} diekspor.',
|
||||
exportFailed: 'Gagal mengekspor.',
|
||||
},
|
||||
aiTrace: {
|
||||
title: 'Jejak AI',
|
||||
clear: 'Hapus',
|
||||
cyclePlaceholder: 'Siklus',
|
||||
fetch: 'Ambil',
|
||||
prompt: 'Prompt',
|
||||
cot: 'Rantai pemikiran',
|
||||
output: 'Output',
|
||||
cycleTag: 'Siklus #{cycle}',
|
||||
},
|
||||
decisionTrail: {
|
||||
title: 'Jejak Keputusan AI',
|
||||
subtitle: 'Menampilkan {count} siklus terakhir',
|
||||
empty: 'Belum ada catatan',
|
||||
emptyHint: 'Log pemikiran & eksekusi AI akan muncul setelah run dimulai.',
|
||||
},
|
||||
charts: {
|
||||
equityTitle: 'Kurva Ekuitas',
|
||||
equityEmpty: 'Belum ada data',
|
||||
},
|
||||
metrics: {
|
||||
title: 'Metrik',
|
||||
totalReturn: 'Total Return %',
|
||||
maxDrawdown: 'Drawdown Maks %',
|
||||
sharpe: 'Sharpe',
|
||||
profitFactor: 'Profit Factor',
|
||||
pending: 'Menghitung...',
|
||||
realized: 'L/R Terealisasi',
|
||||
unrealized: 'L/R Belum Terealisasi',
|
||||
},
|
||||
trades: {
|
||||
title: 'Riwayat Trading',
|
||||
headers: {
|
||||
time: 'Waktu',
|
||||
symbol: 'Simbol',
|
||||
action: 'Aksi',
|
||||
qty: 'Jml',
|
||||
leverage: 'Leverage',
|
||||
pnl: 'L/R',
|
||||
},
|
||||
empty: 'Belum ada trading',
|
||||
},
|
||||
metadata: {
|
||||
title: 'Metadata',
|
||||
created: 'Dibuat',
|
||||
updated: 'Diperbarui',
|
||||
processedBars: 'Bar Diproses',
|
||||
maxDrawdown: 'DD Maks',
|
||||
liquidated: 'Terlikuidasi',
|
||||
yes: 'Ya',
|
||||
no: 'Tidak',
|
||||
},
|
||||
},
|
||||
|
||||
// Competition Page
|
||||
aiCompetition: 'Kompetisi AI',
|
||||
traders: 'trader',
|
||||
@@ -3799,11 +3165,11 @@ export const translations = {
|
||||
faqCategoryAIModels: 'Model AI',
|
||||
faqCategoryContributing: 'Kontribusi',
|
||||
faqWhatIsNOFX: 'Apa itu NOFX?',
|
||||
faqWhatIsNOFXAnswer: 'NOFX adalah sistem operasi trading bertenaga AI open-source untuk pasar kripto dan saham AS. Ia menggunakan model bahasa besar (LLM) seperti DeepSeek, GPT, Claude, Gemini untuk menganalisis data pasar dan membuat keputusan trading secara otonom. Fitur utama: dukungan multi-model AI, trading multi-bursa, pembangun strategi visual, dan backtesting.',
|
||||
faqWhatIsNOFXAnswer: 'NOFX adalah sistem operasi trading bertenaga AI open-source untuk pasar kripto dan saham AS. Ia menggunakan model bahasa besar (LLM) seperti DeepSeek, GPT, Claude, Gemini untuk menganalisis data pasar dan membuat keputusan trading secara otonom. Fitur utama: dukungan multi-model AI, trading multi-bursa, dan pembangun strategi visual.',
|
||||
faqHowDoesItWork: 'Bagaimana cara kerja NOFX?',
|
||||
faqHowDoesItWorkAnswer: 'NOFX bekerja dalam 5 langkah: 1) Konfigurasi model AI dan kredensial API bursa; 2) Buat strategi trading (pemilihan koin, indikator, kontrol risiko); 3) Buat "Trader" menggabungkan Model AI + Bursa + Strategi; 4) Mulai trader - dia akan menganalisis data pasar secara berkala dan membuat keputusan beli/jual/tahan; 5) Pantau performa di dasbor.',
|
||||
faqIsProfitable: 'Apakah NOFX menguntungkan?',
|
||||
faqIsProfitableAnswer: 'Trading AI bersifat eksperimental dan TIDAK dijamin menguntungkan. Futures kripto sangat volatil dan berisiko. NOFX dirancang untuk tujuan edukasi dan riset. Kami sangat menyarankan: mulai dengan jumlah kecil (10-50 USDT), jangan investasi melebihi yang sanggup Anda rugi, uji dengan backtest sebelum trading nyata.',
|
||||
faqIsProfitableAnswer: 'Trading AI bersifat eksperimental dan TIDAK dijamin menguntungkan. Futures kripto sangat volatil dan berisiko. NOFX dirancang untuk tujuan edukasi dan riset. Kami sangat menyarankan: mulai dengan jumlah kecil (10-50 USDT), jangan investasi melebihi yang sanggup Anda rugi, uji sebelum trading nyata.',
|
||||
faqSupportedExchanges: 'Bursa mana yang didukung?',
|
||||
faqSupportedExchangesAnswer: 'CEX (Tersentralisasi): Binance Futures, Bybit, OKX, Bitget. DEX (Terdesentralisasi): Hyperliquid, Aster DEX, Lighter. Setiap bursa memiliki fitur berbeda - Binance memiliki likuiditas terbesar, Hyperliquid sepenuhnya on-chain tanpa KYC.',
|
||||
faqSupportedAIModels: 'Model AI mana yang didukung?',
|
||||
@@ -3876,8 +3242,6 @@ export const translations = {
|
||||
faqCanNOFXStealFundsAnswer: 'NOFX open-source (lisensi AGPL-3.0) - Anda bisa audit semua kode. API key disimpan lokal di mesin ANDA, tidak pernah dikirim ke server eksternal.',
|
||||
faqStrategyStudio: 'Apa itu Strategy Studio?',
|
||||
faqStrategyStudioAnswer: 'Strategy Studio adalah pembangun strategi visual untuk konfigurasi: Sumber Koin, Indikator Teknikal, Kontrol Risiko, dan Prompt Kustom. Tanpa coding.',
|
||||
faqBacktestLab: 'Apa itu Lab Backtest?',
|
||||
faqBacktestLabAnswer: 'Lab Backtest menguji strategi Anda terhadap data historis tanpa risiko dana nyata.',
|
||||
faqCompetitionMode: 'Apa itu Mode Kompetisi?',
|
||||
faqCompetitionModeAnswer: 'Halaman kompetisi menampilkan papan peringkat realtime semua trader Anda. Bandingkan ROI, L/R, rasio Sharpe, win rate.',
|
||||
faqChainOfThought: 'Apa itu Chain of Thought (CoT)?',
|
||||
@@ -4087,53 +3451,6 @@ export const translations = {
|
||||
runAiTestHint: 'Klik untuk menjalankan uji AI',
|
||||
},
|
||||
|
||||
// Backtest Page (additional keys)
|
||||
backtestPageExtra: {
|
||||
newBacktest: 'Backtest Baru',
|
||||
confirmDelete: 'Konfirmasi Hapus',
|
||||
delete: 'Hapus',
|
||||
cancel: 'Batal',
|
||||
equity: 'Ekuitas',
|
||||
totalReturn: 'Return',
|
||||
maxDD: 'Max DD',
|
||||
sharpe: 'Sharpe',
|
||||
tabOverview: 'Ringkasan',
|
||||
tabChart: 'Grafik',
|
||||
tabTrades: 'Trade',
|
||||
tabDecisions: 'Keputusan AI',
|
||||
addToCompare: 'Tambah ke perbandingan',
|
||||
runs: 'berjalan',
|
||||
runCount: 'berjalan',
|
||||
},
|
||||
|
||||
// Backtest Overview Tab
|
||||
backtestOverview: {
|
||||
activePositions: 'Posisi Aktif',
|
||||
margin: 'Margin',
|
||||
unrealized: 'Belum Terealisasi',
|
||||
qty: 'Kuantitas',
|
||||
entry: 'Masuk',
|
||||
mark: 'Harga Saat Ini',
|
||||
winRate: 'Win Rate',
|
||||
profitFactor: 'Profit Factor',
|
||||
totalTrades: 'Total Trade',
|
||||
bestSymbol: 'Simbol Terbaik',
|
||||
},
|
||||
|
||||
// Backtest Chart Tab
|
||||
backtestChart: {
|
||||
noTrades: 'Tidak ada trade untuk ditampilkan',
|
||||
symbol: 'Simbol',
|
||||
interval: 'Interval',
|
||||
trades: 'trade',
|
||||
loadingKline: 'Memuat data kline...',
|
||||
openProfit: 'Buka/Profit',
|
||||
lossClose: 'Tutup Rugi',
|
||||
close: 'Tutup',
|
||||
equityCurve: 'Kurva Ekuitas',
|
||||
candlestickTradeMarkers: 'Candlestick & Penanda Trade',
|
||||
},
|
||||
|
||||
// Metric Tooltip
|
||||
metricTooltip: {
|
||||
formula: 'Formula',
|
||||
@@ -4145,10 +3462,9 @@ export const translations = {
|
||||
accessDenied: 'AKSES DITOLAK',
|
||||
subtitleWithFeature: 'Modul "{featureName}" memerlukan hak akses lebih tinggi',
|
||||
subtitleDefault: 'Otorisasi diperlukan untuk modul ini',
|
||||
description: 'Inisialisasi protokol autentikasi untuk membuka kemampuan sistem penuh: konfigurasi Trader AI, aliran data Pasar Strategi, dan inti Simulasi Backtest.',
|
||||
description: 'Inisialisasi protokol autentikasi untuk membuka kemampuan sistem penuh: konfigurasi Trader AI dan aliran data Pasar Strategi.',
|
||||
benefit1: 'Kontrol Trader AI',
|
||||
benefit2: 'Pasar Strategi HFT',
|
||||
benefit3: 'Mesin Backtest Historis',
|
||||
benefit4: 'Visualisasi Sistem Penuh',
|
||||
loginButton: 'JALANKAN LOGIN',
|
||||
registerButton: 'DAFTAR ID BARU',
|
||||
@@ -4377,27 +3693,6 @@ export const translations = {
|
||||
no: 'Tidak',
|
||||
},
|
||||
|
||||
backtestConfigForm: {
|
||||
selectModel: 'Pilih Model',
|
||||
configure: 'Konfigurasi',
|
||||
confirmStart: 'Konfirmasi',
|
||||
strategyOptional: 'Strategi (Opsional)',
|
||||
noSavedStrategy: 'Tanpa strategi tersimpan',
|
||||
coinSource: 'Sumber Koin:',
|
||||
clearDynamicCoins: 'Kosongkan kolom simbol di bawah untuk menggunakan koin dinamis strategi',
|
||||
optionalCoinSource: 'Opsional - strategi sudah memiliki sumber koin',
|
||||
leavEmptyForStrategy: 'Kosongkan untuk menggunakan sumber koin strategi',
|
||||
clearToUseStrategy: 'Kosongkan untuk strategi',
|
||||
next: 'Lanjut',
|
||||
timeframes: 'Timeframe',
|
||||
back: 'Kembali',
|
||||
strategyStyle: 'Gaya Strategi',
|
||||
quickRange24h: '24j',
|
||||
quickRange3d: '3h',
|
||||
quickRange7d: '7h',
|
||||
quickRange30d: '30h',
|
||||
},
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import type {
|
||||
DecisionRecord,
|
||||
BacktestRunsResponse,
|
||||
BacktestStartConfig,
|
||||
BacktestStatusPayload,
|
||||
BacktestEquityPoint,
|
||||
BacktestTradeEvent,
|
||||
BacktestMetrics,
|
||||
BacktestRunMetadata,
|
||||
BacktestKlinesResponse,
|
||||
} from '../../types'
|
||||
import { API_BASE, getAuthHeaders, handleJSONResponse } from './helpers'
|
||||
|
||||
export const backtestApi = {
|
||||
async getBacktestRuns(params?: {
|
||||
state?: string
|
||||
search?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<BacktestRunsResponse> {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.state) query.set('state', params.state)
|
||||
if (params?.search) query.set('search', params.search)
|
||||
if (params?.limit) query.set('limit', String(params.limit))
|
||||
if (params?.offset) query.set('offset', String(params.offset))
|
||||
const res = await fetch(
|
||||
`${API_BASE}/backtest/runs${query.toString() ? `?${query}` : ''}`,
|
||||
{
|
||||
headers: getAuthHeaders(),
|
||||
}
|
||||
)
|
||||
return handleJSONResponse<BacktestRunsResponse>(res)
|
||||
},
|
||||
|
||||
async startBacktest(config: BacktestStartConfig): Promise<BacktestRunMetadata> {
|
||||
const res = await fetch(`${API_BASE}/backtest/start`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ config }),
|
||||
})
|
||||
return handleJSONResponse<BacktestRunMetadata>(res)
|
||||
},
|
||||
|
||||
async pauseBacktest(runId: string): Promise<BacktestRunMetadata> {
|
||||
const res = await fetch(`${API_BASE}/backtest/pause`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ run_id: runId }),
|
||||
})
|
||||
return handleJSONResponse<BacktestRunMetadata>(res)
|
||||
},
|
||||
|
||||
async resumeBacktest(runId: string): Promise<BacktestRunMetadata> {
|
||||
const res = await fetch(`${API_BASE}/backtest/resume`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ run_id: runId }),
|
||||
})
|
||||
return handleJSONResponse<BacktestRunMetadata>(res)
|
||||
},
|
||||
|
||||
async stopBacktest(runId: string): Promise<BacktestRunMetadata> {
|
||||
const res = await fetch(`${API_BASE}/backtest/stop`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ run_id: runId }),
|
||||
})
|
||||
return handleJSONResponse<BacktestRunMetadata>(res)
|
||||
},
|
||||
|
||||
async updateBacktestLabel(
|
||||
runId: string,
|
||||
label: string
|
||||
): Promise<BacktestRunMetadata> {
|
||||
const res = await fetch(`${API_BASE}/backtest/label`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ run_id: runId, label }),
|
||||
})
|
||||
return handleJSONResponse<BacktestRunMetadata>(res)
|
||||
},
|
||||
|
||||
async deleteBacktestRun(runId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/backtest/delete`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ run_id: runId }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text())
|
||||
}
|
||||
},
|
||||
|
||||
async getBacktestStatus(runId: string): Promise<BacktestStatusPayload> {
|
||||
const res = await fetch(`${API_BASE}/backtest/status?run_id=${runId}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return handleJSONResponse<BacktestStatusPayload>(res)
|
||||
},
|
||||
|
||||
async getBacktestEquity(
|
||||
runId: string,
|
||||
timeframe?: string,
|
||||
limit?: number
|
||||
): Promise<BacktestEquityPoint[]> {
|
||||
const query = new URLSearchParams({ run_id: runId })
|
||||
if (timeframe) query.set('tf', timeframe)
|
||||
if (limit) query.set('limit', String(limit))
|
||||
const res = await fetch(`${API_BASE}/backtest/equity?${query}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return handleJSONResponse<BacktestEquityPoint[]>(res)
|
||||
},
|
||||
|
||||
async getBacktestTrades(
|
||||
runId: string,
|
||||
limit = 200
|
||||
): Promise<BacktestTradeEvent[]> {
|
||||
const query = new URLSearchParams({
|
||||
run_id: runId,
|
||||
limit: String(limit),
|
||||
})
|
||||
const res = await fetch(`${API_BASE}/backtest/trades?${query}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return handleJSONResponse<BacktestTradeEvent[]>(res)
|
||||
},
|
||||
|
||||
async getBacktestMetrics(runId: string): Promise<BacktestMetrics> {
|
||||
const res = await fetch(`${API_BASE}/backtest/metrics?run_id=${runId}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return handleJSONResponse<BacktestMetrics>(res)
|
||||
},
|
||||
|
||||
async getBacktestKlines(
|
||||
runId: string,
|
||||
symbol: string,
|
||||
timeframe?: string
|
||||
): Promise<BacktestKlinesResponse> {
|
||||
const query = new URLSearchParams({ run_id: runId, symbol })
|
||||
if (timeframe) query.set('timeframe', timeframe)
|
||||
const res = await fetch(`${API_BASE}/backtest/klines?${query}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return handleJSONResponse<BacktestKlinesResponse>(res)
|
||||
},
|
||||
|
||||
async getBacktestTrace(
|
||||
runId: string,
|
||||
cycle?: number
|
||||
): Promise<DecisionRecord> {
|
||||
const query = new URLSearchParams({ run_id: runId })
|
||||
if (cycle) query.set('cycle', String(cycle))
|
||||
const res = await fetch(`${API_BASE}/backtest/trace?${query}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return handleJSONResponse<DecisionRecord>(res)
|
||||
},
|
||||
|
||||
async getBacktestDecisions(
|
||||
runId: string,
|
||||
limit = 20,
|
||||
offset = 0
|
||||
): Promise<DecisionRecord[]> {
|
||||
const query = new URLSearchParams({
|
||||
run_id: runId,
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
})
|
||||
const res = await fetch(`${API_BASE}/backtest/decisions?${query}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return handleJSONResponse<DecisionRecord[]>(res)
|
||||
},
|
||||
|
||||
async exportBacktest(runId: string): Promise<Blob> {
|
||||
const res = await fetch(`${API_BASE}/backtest/export?run_id=${runId}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
try {
|
||||
const data = text ? JSON.parse(text) : null
|
||||
throw new Error(
|
||||
data?.error || data?.message || text || 'Export failed, please try again later'
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message) {
|
||||
throw err
|
||||
}
|
||||
throw new Error(text || 'Export failed, please try again later')
|
||||
}
|
||||
}
|
||||
return res.blob()
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { traderApi } from './traders'
|
||||
import { backtestApi } from './backtest'
|
||||
import { strategyApi } from './strategies'
|
||||
import { configApi } from './config'
|
||||
import { dataApi } from './data'
|
||||
@@ -7,7 +6,6 @@ import { telegramApi } from './telegram'
|
||||
|
||||
export const api = {
|
||||
...traderApi,
|
||||
...backtestApi,
|
||||
...strategyApi,
|
||||
...configApi,
|
||||
...dataApi,
|
||||
|
||||
@@ -41,7 +41,6 @@ export function LandingPage() {
|
||||
'strategy-market': '/strategy-market',
|
||||
'traders': '/traders',
|
||||
'trader': '/dashboard',
|
||||
'backtest': '/backtest',
|
||||
'strategy': '/strategy',
|
||||
'faq': '/faq',
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
// Backtest types
|
||||
export interface BacktestRunSummary {
|
||||
symbol_count: number;
|
||||
decision_tf: string;
|
||||
processed_bars: number;
|
||||
progress_pct: number;
|
||||
equity_last: number;
|
||||
max_drawdown_pct: number;
|
||||
liquidated: boolean;
|
||||
liquidation_note?: string;
|
||||
}
|
||||
|
||||
export interface BacktestRunMetadata {
|
||||
run_id: string;
|
||||
label?: string;
|
||||
user_id?: string;
|
||||
last_error?: string;
|
||||
version: number;
|
||||
state: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
summary: BacktestRunSummary;
|
||||
}
|
||||
|
||||
export interface BacktestRunsResponse {
|
||||
total: number;
|
||||
items: BacktestRunMetadata[];
|
||||
}
|
||||
|
||||
// Position status for real-time display during backtest
|
||||
export interface BacktestPositionStatus {
|
||||
symbol: string;
|
||||
side: string;
|
||||
quantity: number;
|
||||
entry_price: number;
|
||||
mark_price: number;
|
||||
leverage: number;
|
||||
unrealized_pnl: number;
|
||||
unrealized_pnl_pct: number;
|
||||
margin_used: number;
|
||||
}
|
||||
|
||||
export interface BacktestStatusPayload {
|
||||
run_id: string;
|
||||
state: string;
|
||||
progress_pct: number;
|
||||
processed_bars: number;
|
||||
current_time: number;
|
||||
decision_cycle: number;
|
||||
equity: number;
|
||||
unrealized_pnl: number;
|
||||
realized_pnl: number;
|
||||
positions?: BacktestPositionStatus[];
|
||||
note?: string;
|
||||
last_error?: string;
|
||||
last_updated_iso: string;
|
||||
}
|
||||
|
||||
export interface BacktestEquityPoint {
|
||||
ts: number;
|
||||
equity: number;
|
||||
available: number;
|
||||
pnl: number;
|
||||
pnl_pct: number;
|
||||
dd_pct: number;
|
||||
cycle: number;
|
||||
}
|
||||
|
||||
export interface BacktestTradeEvent {
|
||||
ts: number;
|
||||
symbol: string;
|
||||
action: string;
|
||||
side?: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
fee: number;
|
||||
slippage: number;
|
||||
order_value: number;
|
||||
realized_pnl: number;
|
||||
leverage?: number;
|
||||
cycle: number;
|
||||
position_after: number;
|
||||
liquidation: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface BacktestMetrics {
|
||||
total_return_pct: number;
|
||||
max_drawdown_pct: number;
|
||||
sharpe_ratio: number;
|
||||
profit_factor: number;
|
||||
win_rate: number;
|
||||
trades: number;
|
||||
avg_win: number;
|
||||
avg_loss: number;
|
||||
best_symbol: string;
|
||||
worst_symbol: string;
|
||||
liquidated: boolean;
|
||||
symbol_stats?: Record<
|
||||
string,
|
||||
{
|
||||
total_trades: number;
|
||||
winning_trades: number;
|
||||
losing_trades: number;
|
||||
total_pnl: number;
|
||||
avg_pnl: number;
|
||||
win_rate: number;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export interface BacktestStartConfig {
|
||||
run_id?: string;
|
||||
ai_model_id?: string;
|
||||
strategy_id?: string; // Optional: use saved strategy from Strategy Studio
|
||||
symbols: string[];
|
||||
timeframes: string[];
|
||||
decision_timeframe: string;
|
||||
decision_cadence_nbars: number;
|
||||
start_ts: number;
|
||||
end_ts: number;
|
||||
initial_balance: number;
|
||||
fee_bps: number;
|
||||
slippage_bps: number;
|
||||
fill_policy: string;
|
||||
prompt_variant?: string;
|
||||
prompt_template?: string;
|
||||
custom_prompt?: string;
|
||||
override_prompt?: boolean;
|
||||
cache_ai?: boolean;
|
||||
replay_only?: boolean;
|
||||
checkpoint_interval_bars?: number;
|
||||
checkpoint_interval_seconds?: number;
|
||||
replay_decision_dir?: string;
|
||||
shared_ai_cache_path?: string;
|
||||
ai?: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
key?: string;
|
||||
secret_key?: string;
|
||||
base_url?: string;
|
||||
};
|
||||
leverage?: {
|
||||
btc_eth_leverage?: number;
|
||||
altcoin_leverage?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Kline data for backtest chart
|
||||
export interface BacktestKline {
|
||||
time: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface BacktestKlinesResponse {
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
start_ts: number;
|
||||
end_ts: number;
|
||||
count: number;
|
||||
klines: BacktestKline[];
|
||||
run_id: string;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './trading'
|
||||
export * from './backtest'
|
||||
export * from './strategy'
|
||||
export * from './config'
|
||||
|
||||
Reference in New Issue
Block a user