From 1e135ea9c3e87d89417d947132b495444eb64e9c Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sun, 14 Dec 2025 00:23:51 +0800 Subject: [PATCH] feat: redesign backtest module UI and fix 404 issue - Fix backtest API 404 by registering routes in setupRoutes() - Redesign BacktestPage with 3-step wizard configuration - Add progress ring visualization with animation - Add equity chart with trade markers using Recharts - Add trade timeline with card-style display - Add stats cards for equity, return, drawdown, sharpe - Add tab navigation for overview/chart/trades/decisions - Improve run history list with status icons - Add lightweight-charts dependency (for future use) --- api/server.go | 4 + web/package-lock.json | 16 + web/package.json | 1 + web/src/components/BacktestPage.tsx | 2356 ++++++++++++++------------- 4 files changed, 1269 insertions(+), 1108 deletions(-) diff --git a/api/server.go b/api/server.go index 35decaea..2b2e110b 100644 --- a/api/server.go +++ b/api/server.go @@ -187,6 +187,10 @@ func (s *Server) setupRoutes() { protected.GET("/decisions", s.handleDecisions) protected.GET("/decisions/latest", s.handleLatestDecisions) protected.GET("/statistics", s.handleStatistics) + + // Backtest routes + backtest := protected.Group("/backtest") + s.registerBacktestRoutes(backtest) } } } diff --git a/web/package-lock.json b/web/package-lock.json index 54318122..72d1a5ab 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,6 +15,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.23.24", + "lightweight-charts": "^5.0.9", "lucide-react": "^0.552.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -4483,6 +4484,12 @@ "node": ">=12.0.0" } }, + "node_modules/fancy-canvas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz", + "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5797,6 +5804,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lightweight-charts": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.0.9.tgz", + "integrity": "sha512-8oQIis8jfZVfSwz8j9Z5x3O79dIRTkEYI9UY7DKtE4O3ZxlHjMK3L0+4nOVOOFq4FHI/oSIzz1RHeNImCk6/Jg==", + "license": "Apache-2.0", + "dependencies": { + "fancy-canvas": "2.1.0" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", diff --git a/web/package.json b/web/package.json index b1bc84a7..0b329f86 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.23.24", + "lightweight-charts": "^5.0.9", "lucide-react": "^0.552.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/web/src/components/BacktestPage.tsx b/web/src/components/BacktestPage.tsx index 1e75bb57..de792250 100644 --- a/web/src/components/BacktestPage.tsx +++ b/web/src/components/BacktestPage.tsx @@ -1,13 +1,40 @@ -import { useEffect, useMemo, useState, type FormEvent } from 'react' +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, + ChevronRight, + ChevronLeft, + Clock, + TrendingUp, + TrendingDown, + Activity, + BarChart3, + Brain, + Zap, + Target, + AlertTriangle, + CheckCircle2, + XCircle, + RefreshCw, + Layers, + Eye, + ArrowUpRight, + ArrowDownRight, +} from 'lucide-react' import { ResponsiveContainer, - LineChart, - Line, + AreaChart, + Area, XAxis, YAxis, CartesianGrid, Tooltip, + ReferenceDot, } from 'recharts' import { api } from '../lib/api' import { useLanguage } from '../contexts/LanguageContext' @@ -23,25 +50,319 @@ import type { AIModel, } from '../types' -const timeframeOptions = ['1m', '3m', '5m', '15m', '1h', '4h', '1d'] -type ControlAction = 'pause' | 'resume' | 'stop' +// ============ Types ============ +type WizardStep = 1 | 2 | 3 +type ViewTab = 'overview' | 'chart' | 'trades' | 'decisions' | 'compare' +const TIMEFRAME_OPTIONS = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] +const POPULAR_SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT', 'XRPUSDT', 'DOGEUSDT'] + +// ============ Helper Functions ============ const toLocalInput = (date: Date) => { const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000) return local.toISOString().slice(0, 16) } + +// ============ Sub Components ============ + +// Stats Card Component +function StatCard({ + icon: Icon, + label, + value, + suffix, + trend, + color = '#EAECEF', +}: { + icon: typeof TrendingUp + label: string + value: string | number + suffix?: string + trend?: 'up' | 'down' | 'neutral' + color?: string +}) { + const trendColors = { + up: '#0ECB81', + down: '#F6465D', + neutral: '#848E9C', + } + + return ( +
+
+ + + {label} + +
+
+ + {value} + + {suffix && ( + + {suffix} + + )} + {trend && trend !== 'neutral' && ( + + {trend === 'up' ? : } + + )} +
+
+ ) +} + +// Progress Ring Component +function ProgressRing({ progress, size = 120 }: { progress: number; size?: number }) { + const strokeWidth = 8 + const radius = (size - strokeWidth) / 2 + const circumference = radius * 2 * Math.PI + const offset = circumference - (progress / 100) * circumference + + return ( +
+ + + + +
+ + {progress.toFixed(0)}% + + + Complete + +
+
+ ) +} + +// Equity Chart Component using Recharts +function BacktestChart({ + equity, + trades, +}: { + equity: BacktestEquityPoint[] + trades: BacktestTradeEvent[] +}) { + 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]) + + // Find trade points to mark on chart + const tradeMarkers = useMemo(() => { + if (!trades.length || !equity.length) return [] + return trades + .filter((t) => t.action.includes('open') || t.action.includes('close')) + .map((trade) => { + // Find closest equity point + 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) // Limit markers + }, [trades, equity]) + + return ( +
+ + + + + + + + + + + + [`$${value.toFixed(2)}`, 'Equity']} + /> + + {/* Trade markers */} + {tradeMarkers.map((marker, idx) => ( + d.ts === marker.ts)} + y={marker.equity} + r={4} + fill={marker.isOpen ? '#0ECB81' : '#F6465D'} + stroke={marker.isOpen ? '#0ECB81' : '#F6465D'} + /> + ))} + + +
+ ) +} + +// Trade Timeline Component +function TradeTimeline({ trades }: { trades: BacktestTradeEvent[] }) { + const recentTrades = useMemo(() => [...trades].slice(-20).reverse(), [trades]) + + if (recentTrades.length === 0) { + return ( +
+ No trades yet +
+ ) + } + + return ( +
+ {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 ( + +
+ {isLong ? ( + + ) : ( + + )} +
+
+
+ + {trade.symbol.replace('USDT', '')} + + + {trade.action.replace('_', ' ').toUpperCase()} + + {trade.leverage && ( + + {trade.leverage}x + + )} +
+
+ {new Date(trade.ts).toLocaleString()} · Qty: {trade.qty.toFixed(4)} · ${trade.price.toFixed(2)} +
+
+
+
= 0 ? '#0ECB81' : '#F6465D' }} + > + {trade.realized_pnl >= 0 ? '+' : ''} + {trade.realized_pnl.toFixed(2)} +
+
+ USDT +
+
+
+ ) + })} +
+ ) +} + +// ============ Main Component ============ export function BacktestPage() { const { language } = useLanguage() - const tr = (key: string, params?: Record) => - t(`backtestPage.${key}`, language, params) - const titleText = tr('title') - const subtitleText = tr('subtitle') + const tr = useCallback( + (key: string, params?: Record) => t(`backtestPage.${key}`, language, params), + [language] + ) + + // State const now = new Date() + const [wizardStep, setWizardStep] = useState(1) + const [viewTab, setViewTab] = useState('overview') + const [selectedRunId, setSelectedRunId] = useState() + const [compareRunIds, setCompareRunIds] = useState([]) + const [isStarting, setIsStarting] = useState(false) + const [toast, setToast] = useState<{ text: string; tone: 'info' | 'error' | 'success' } | null>(null) + + // Form state const [formState, setFormState] = useState({ runId: '', symbols: 'BTCUSDT,ETHUSDT,SOLUSDT', - timeframes: '3m,15m,4h', + timeframes: ['3m', '15m', '4h'], decisionTf: '3m', cadence: 20, start: toLocalInput(new Date(now.getTime() - 3 * 24 * 3600 * 1000)), @@ -60,292 +381,148 @@ export function BacktestPage() { replayOnly: false, aiModelId: '', }) - const [stateFilter, setStateFilter] = useState('') - const [search, setSearch] = useState('') - const [selectedRunId, setSelectedRunId] = useState() - const [equityTf, setEquityTf] = useState('1h') - const [toast, setToast] = useState<{ - text: string - tone: 'info' | 'error' | 'success' - } | null>(null) - const [trace, setTrace] = useState() - const [traceCycle, setTraceCycle] = useState('') - const [actionLoading, setActionLoading] = useState(null) - const [isStarting, setIsStarting] = useState(false) - const [labelDraft, setLabelDraft] = useState('') - const quickRanges = useMemo( - () => [ - { label: tr('quickRanges.h24'), hours: 24 }, - { label: tr('quickRanges.d3'), hours: 72 }, - { label: tr('quickRanges.d7'), hours: 24 * 7 }, - ], - [language] - ) - const actionLabels: Record = { - pause: tr('actions.pause'), - resume: tr('actions.resume'), - stop: tr('actions.stop'), - } - const stateOptions = useMemo( - () => - ['running', 'paused', 'completed', 'failed', 'liquidated'].map( - (value) => ({ - value, - label: tr(`states.${value}`), - }) - ), - [language] - ) - const stateLabels = useMemo( - () => - stateOptions.reduce>((acc, option) => { - acc[option.value] = option.label - return acc - }, {}), - [stateOptions] - ) - const { data: runsResp, mutate: refreshRuns } = useSWR( - ['backtest-runs', stateFilter, search], - () => - api.getBacktestRuns({ - state: stateFilter || undefined, - search: search || undefined, - limit: 200, - offset: 0, - }), - { refreshInterval: 8000 } - ) + // 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('ai-models', api.getModelConfigs, { refreshInterval: 30000 }) + + const { data: status } = useSWR( + selectedRunId ? ['bt-status', selectedRunId] : null, + () => api.getBacktestStatus(selectedRunId!), + { refreshInterval: 2000 } + ) + + const { data: equity } = useSWR( + selectedRunId ? ['bt-equity', selectedRunId] : null, + () => api.getBacktestEquity(selectedRunId!, '1m', 2000), + { refreshInterval: 5000 } + ) + + const { data: trades } = useSWR( + selectedRunId ? ['bt-trades', selectedRunId] : null, + () => api.getBacktestTrades(selectedRunId!, 500), + { refreshInterval: 5000 } + ) + + const { data: metrics } = useSWR( + selectedRunId ? ['bt-metrics', selectedRunId] : null, + () => api.getBacktestMetrics(selectedRunId!), + { refreshInterval: 10000 } + ) + + const { data: decisions } = useSWR( + 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) + + // 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]) - useEffect(() => { - const current = runs.find((run) => run.run_id === selectedRunId) - setLabelDraft(current?.label ?? '') - }, [runs, selectedRunId]) - - const selectedRun = runs.find((run) => run.run_id === selectedRunId) - - const { data: status } = useSWR( - selectedRunId ? ['bt-status', selectedRunId] : null, - () => api.getBacktestStatus(selectedRunId!), - { refreshInterval: 4000 } - ) - - const { data: equity } = useSWR( - selectedRunId ? ['bt-equity', selectedRunId, equityTf] : null, - () => api.getBacktestEquity(selectedRunId!, equityTf, 1000), - { refreshInterval: 6000 } - ) - - const { data: trades } = useSWR( - selectedRunId ? ['bt-trades', selectedRunId] : null, - () => api.getBacktestTrades(selectedRunId!, 200), - { refreshInterval: 8000 } - ) - - const { data: metrics } = useSWR( - selectedRunId ? ['bt-metrics', selectedRunId] : null, - () => api.getBacktestMetrics(selectedRunId!), - { refreshInterval: 12000 } - ) - const { data: decisions } = useSWR( - selectedRunId ? ['bt-decisions', selectedRunId] : null, - () => api.getBacktestDecisions(selectedRunId!, 50), - { refreshInterval: 8000 } - ) - - const { data: promptTemplates } = useSWR( - 'prompt-templates', - api.getPromptTemplates - ) - const { data: aiModels } = useSWR( - 'ai-models', - api.getModelConfigs, - { refreshInterval: 30000 } - ) - - const selectedModel = useMemo( - () => aiModels?.find((model) => model.id === formState.aiModelId), - [aiModels, formState.aiModelId] - ) - - const selectedTimeframes = useMemo(() => { - return formState.timeframes - .split(',') - .map((tf) => tf.trim()) - .filter(Boolean) - }, [formState.timeframes]) - - useEffect(() => { - if ( - selectedTimeframes.length > 0 && - !selectedTimeframes.includes(formState.decisionTf) - ) { - handleFormChange('decisionTf', selectedTimeframes[0]) - } - }, [selectedTimeframes, formState.decisionTf]) - - useEffect(() => { - if (formState.aiModelId || !aiModels || aiModels.length === 0) { - return - } - const enabled = aiModels.find((model) => model.enabled) - handleFormChange('aiModelId', (enabled ?? aiModels[0]).id) - }, [aiModels, formState.aiModelId]) - - const handleFormChange = (key: string, value: string | number | boolean) => + // Handlers + const handleFormChange = (key: string, value: string | number | boolean | string[]) => { setFormState((prev) => ({ ...prev, [key]: value })) + } const handleStart = async (event: FormEvent) => { event.preventDefault() - if (!selectedModel) { - setToast({ - text: tr('toasts.selectModel'), - tone: 'error', - }) - return - } - if (!selectedModel.enabled) { - setToast({ - text: tr('toasts.modelDisabled', { name: selectedModel.name }), - tone: 'error', - }) + if (!selectedModel?.enabled) { + setToast({ text: tr('toasts.selectModel'), tone: 'error' }) return } + try { setIsStarting(true) - setToast(null) const start = new Date(formState.start).getTime() const end = new Date(formState.end).getTime() - if (!start || !end || end <= start) - throw new Error(tr('toasts.invalidRange')) + if (end <= start) throw new Error(tr('toasts.invalidRange')) + const payload = await api.startBacktest({ run_id: formState.runId.trim() || undefined, - symbols: formState.symbols - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - timeframes: formState.timeframes - .split(',') - .map((s) => s.trim()) - .filter(Boolean), + symbols: formState.symbols.split(',').map((s) => s.trim()).filter(Boolean), + timeframes: formState.timeframes, decision_timeframe: formState.decisionTf, - decision_cadence_nbars: Number(formState.cadence), + decision_cadence_nbars: formState.cadence, start_ts: Math.floor(start / 1000), end_ts: Math.floor(end / 1000), - initial_balance: Number(formState.balance), - fee_bps: Number(formState.fee), - slippage_bps: Number(formState.slippage), + initial_balance: formState.balance, + fee_bps: formState.fee, + slippage_bps: formState.slippage, fill_policy: formState.fill, prompt_variant: formState.prompt, - prompt_template: formState.promptTemplate || undefined, + 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 || undefined, + ai_model_id: formState.aiModelId, leverage: { - btc_eth_leverage: Number(formState.btcEthLeverage), - altcoin_leverage: Number(formState.altcoinLeverage), + 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: any) { - setToast({ - text: error?.message ?? tr('toasts.startFailed'), - tone: 'error', - }) + } 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: ControlAction) => { + const handleControl = async (action: 'pause' | 'resume' | 'stop') => { if (!selectedRunId) return - setActionLoading(action) 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: actionLabels[action] ?? action, - id: selectedRunId, - }), - tone: 'success', - }) + setToast({ text: tr('toasts.actionSuccess', { action, id: selectedRunId }), tone: 'success' }) await refreshRuns() - } catch (error: any) { - setToast({ - text: error?.message ?? tr('toasts.actionFailed'), - tone: 'error', - }) - } finally { - setActionLoading(null) + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : tr('toasts.actionFailed') + setToast({ text: errMsg, tone: 'error' }) } } - const handleSaveLabel = async () => { + const handleDelete = async () => { if (!selectedRunId) return - try { - await api.updateBacktestLabel(selectedRunId, labelDraft) - setToast({ text: tr('toasts.labelSaved'), tone: 'success' }) - await refreshRuns() - } catch (error: any) { - setToast({ - text: error?.message ?? tr('toasts.labelFailed'), - tone: 'error', - }) - } - } - - const handleDeleteRun = async () => { - if (!selectedRunId) return - - const confirmed = await confirmToast( - tr('toasts.confirmDelete', { id: selectedRunId }), - { - title: language === 'zh' ? '确认删除' : 'Confirm Delete', - okText: language === 'zh' ? '删除' : 'Delete', - cancelText: language === 'zh' ? '取消' : 'Cancel', - } - ) + const confirmed = await confirmToast(tr('toasts.confirmDelete', { id: selectedRunId }), { + title: language === 'zh' ? '确认删除' : 'Confirm Delete', + okText: language === 'zh' ? '删除' : 'Delete', + cancelText: language === 'zh' ? '取消' : 'Cancel', + }) if (!confirmed) return - try { await api.deleteBacktestRun(selectedRunId) setToast({ text: tr('toasts.deleteSuccess'), tone: 'success' }) setSelectedRunId(undefined) await refreshRuns() - } catch (error: any) { - setToast({ - text: error?.message ?? tr('toasts.deleteFailed'), - tone: 'error', - }) - } - } - - const handleTrace = async () => { - if (!selectedRunId) return - try { - const record = await api.getBacktestTrace( - selectedRunId, - traceCycle ? Number(traceCycle) : undefined - ) - setTrace(record) - } catch (error: any) { - setToast({ - text: error?.message ?? tr('toasts.traceFailed'), - tone: 'error', - }) + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : tr('toasts.deleteFailed') + setToast({ text: errMsg, tone: 'error' }) } } @@ -359,31 +536,26 @@ export function BacktestPage() { link.download = `${selectedRunId}_export.zip` link.click() URL.revokeObjectURL(url) - setToast({ - text: tr('toasts.exportSuccess', { id: selectedRunId }), - tone: 'success', - }) - } catch (error: any) { - setToast({ - text: error?.message ?? tr('toasts.exportFailed'), - tone: 'error', - }) + 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 toggleTimeframe = (tf: string) => { - const set = new Set(selectedTimeframes) - if (set.has(tf)) { - if (set.size === 1) { - return - } - set.delete(tf) - } else { - set.add(tf) - } - handleFormChange('timeframes', Array.from(set).join(',')) + const toggleCompare = (runId: string) => { + setCompareRunIds((prev) => + prev.includes(runId) ? prev.filter((id) => id !== runId) : [...prev, runId].slice(-3) + ) } + const quickRanges = [ + { label: language === 'zh' ? '24小时' : '24h', hours: 24 }, + { label: language === 'zh' ? '3天' : '3d', hours: 72 }, + { label: language === 'zh' ? '7天' : '7d', hours: 168 }, + { label: language === 'zh' ? '30天' : '30d', hours: 720 }, + ] + const applyQuickRange = (hours: number) => { const endDate = new Date() const startDate = new Date(endDate.getTime() - hours * 3600 * 1000) @@ -391,889 +563,857 @@ export function BacktestPage() { handleFormChange('end', toLocalInput(endDate)) } - const equitySeries = useMemo( - () => - equity?.map((point) => ({ - time: new Date(point.ts).toLocaleString(), - equity: point.equity, - pnl_pct: point.pnl_pct, - })) ?? [], - [equity] - ) + const 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' + } + } - const latestTrades = useMemo( - () => (trades ? [...trades].slice(-15).reverse() : []), - [trades] - ) + const getStateIcon = (state: string) => { + switch (state) { + case 'running': + return + case 'completed': + return + case 'failed': + case 'liquidated': + return + case 'paused': + return + default: + return + } + } + // Render return (
- {toast && ( -
- {toast.text} + {/* Toast */} + + {toast && ( + + {toast.text} + + )} + + + {/* Header */} +
+
+

+ + {tr('title')} +

+

+ {tr('subtitle')} +

- )} -
-
-
-
-

- {titleText} -

-

- {subtitleText} -

-
- -
+ +
-
- - {selectedModel && ( -
- - {tr('form.providerLabel')}: {selectedModel.provider} - - - {tr('form.statusLabel')}:{' '} - - {selectedModel.enabled - ? tr('form.enabled') - : tr('form.disabled')} - - -
- )} - {!selectedModel && aiModels && aiModels.length === 0 && ( -
- {tr('form.noModelWarning')} -
- )} -
- -
- - - -
- -
-
- {tr('form.timeRangeLabel')} -
- {quickRanges.map((range) => ( +
+ {/* Left Panel - Config / History */} +
+ {/* Wizard */} +
+
+ {[1, 2, 3].map((step) => ( +
- ))} -
-
-
- handleFormChange('start', e.target.value)} - /> - handleFormChange('end', e.target.value)} - /> -
-
- -
-