@tradejs/app 1.0.8 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +7 -2
  2. package/bin/tradejs-app.mjs +146 -2
  3. package/next.config.mjs +0 -14
  4. package/package.json +35 -19
  5. package/src/app/actions/backtest.ts +54 -0
  6. package/src/app/actions/kline.ts +10 -5
  7. package/src/app/actions/scanner.ts +8 -3
  8. package/src/app/actions/strategies.ts +39 -0
  9. package/src/app/api/ai/route.ts +5 -4
  10. package/src/app/api/auth/[...nextauth]/route.ts +1 -1
  11. package/src/app/api/backtest/configs/route.ts +25 -0
  12. package/src/app/api/backtest/files/route.ts +5 -2
  13. package/src/app/api/backtest/order-log/[strategy]/[name]/route.ts +26 -2
  14. package/src/app/api/backtest/result/[strategy]/[name]/route.ts +27 -2
  15. package/src/app/api/backtest/runs/[jobId]/route.ts +124 -0
  16. package/src/app/api/backtest/runs/route.ts +45 -0
  17. package/src/app/api/backtest/test/[strategy]/[name]/route.ts +19 -2
  18. package/src/app/api/derivatives/summary/route.ts +8 -1
  19. package/src/app/api/kline/[provider]/[universe]/[symbol]/[interval]/route.ts +1 -0
  20. package/src/app/api/kline/{[provider]/[symbol]/[interval]/route.ts → handler.ts} +61 -39
  21. package/src/app/api/scanner/[provider]/[universe]/route.ts +1 -0
  22. package/src/app/api/scanner/[provider]/route.ts +30 -12
  23. package/src/app/api/scanner/route.ts +3 -3
  24. package/src/app/api/signal/[symbol]/[signalId]/route.ts +4 -7
  25. package/src/app/api/strategies/ai/[cardId]/route.ts +22 -0
  26. package/src/app/api/strategies/ai/route.ts +139 -0
  27. package/src/app/api/strategies/replay/[cardId]/route.ts +18 -0
  28. package/src/app/api/strategies/replay/route.ts +44 -0
  29. package/src/app/api/strategies/runtime/route.ts +831 -0
  30. package/src/app/api/user/runtime-deployments/[deploymentId]/route.ts +25 -0
  31. package/src/app/api/user/runtime-deployments/route.ts +94 -0
  32. package/src/app/api/user/runtime-strategy-configs/route.ts +241 -0
  33. package/src/app/api/user/settings/route.ts +18 -1
  34. package/src/app/api/user/trading-accounts/[accountId]/route.ts +35 -0
  35. package/src/app/api/user/trading-accounts/route.ts +134 -0
  36. package/src/app/components/Backtest/CompareList/index.tsx +1 -1
  37. package/src/app/components/Backtest/ResultsPageClient.tsx +376 -0
  38. package/src/app/components/Backtest/TestCard/ActionsMenu/index.tsx +123 -0
  39. package/src/app/components/Backtest/TestCard/Chart/index.tsx +1 -1
  40. package/src/app/components/Backtest/TestCard/CompareButton/index.tsx +1 -1
  41. package/src/app/components/Backtest/TestCard/ConfigDrawer/index.tsx +28 -12
  42. package/src/app/components/Backtest/TestCard/DeleteButton/index.tsx +33 -17
  43. package/src/app/components/Backtest/TestCard/FavoriteIndicator/index.tsx +2 -2
  44. package/src/app/components/Backtest/TestCard/OpenDashboardButton/index.tsx +10 -1
  45. package/src/app/components/Backtest/TestCard/OrdersDrawer/index.tsx +128 -0
  46. package/src/app/components/Backtest/TestCard/Root/index.tsx +1 -1
  47. package/src/app/components/Backtest/TestCard/Stat/index.tsx +2 -0
  48. package/src/app/components/Backtest/TestCard/StatDrawer/index.tsx +66 -0
  49. package/src/app/components/Backtest/TestCard/index.ts +6 -2
  50. package/src/app/components/Backtest/TestList/index.tsx +4 -9
  51. package/src/app/components/Dashboard/AiDrawer/index.tsx +1 -1
  52. package/src/app/components/Dashboard/KlineChart/hooks/useBacktest.ts +1 -1
  53. package/src/app/components/Dashboard/KlineChart/hooks/useBbIndicator.ts +10 -5
  54. package/src/app/components/Dashboard/KlineChart/hooks/useBtcCorrelation.ts +1 -1
  55. package/src/app/components/Dashboard/KlineChart/hooks/useBtcIndicator.ts +4 -1
  56. package/src/app/components/Dashboard/KlineChart/hooks/useEmaIndicator.ts +3 -1
  57. package/src/app/components/Dashboard/KlineChart/hooks/usePluginIndicators.ts +1 -1
  58. package/src/app/components/Dashboard/KlineChart/hooks/useSetup.ts +1 -1
  59. package/src/app/components/Dashboard/KlineChart/hooks/useSignal.ts +1 -1
  60. package/src/app/components/Dashboard/KlineChart/hooks/useSpreadIndicator.ts +1 -1
  61. package/src/app/components/Dashboard/KlineChart/hooks/useTrendLine.ts +1 -1
  62. package/src/app/components/Dashboard/KlineChart/hooks/useWmaIndicator.ts +3 -1
  63. package/src/app/components/Dashboard/KlineChart/index.tsx +16 -6
  64. package/src/app/components/Dashboard/MainChart/index.tsx +3 -22
  65. package/src/app/components/Shared/AppShell.tsx +1 -1
  66. package/src/app/components/Shared/BulkSelection/index.tsx +140 -0
  67. package/src/app/components/Shared/BulkSelection/useBulkSelection.ts +97 -0
  68. package/src/app/components/Shared/BulkSelection/utils.ts +126 -0
  69. package/src/app/components/Shared/Filters/Backtest/index.tsx +33 -48
  70. package/src/app/components/Shared/Filters/FavoriteIndicator/index.tsx +5 -3
  71. package/src/app/components/Shared/Filters/Indicators/index.tsx +2 -2
  72. package/src/app/components/Shared/Filters/Interval/index.tsx +1 -1
  73. package/src/app/components/Shared/Filters/Provider/index.tsx +8 -2
  74. package/src/app/components/Shared/Filters/Symbol/index.tsx +4 -3
  75. package/src/app/components/Shared/Filters/Universe/index.tsx +36 -0
  76. package/src/app/components/Shared/Filters/index.ts +2 -0
  77. package/src/app/components/Shared/OrdersDrawer.tsx +629 -0
  78. package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +55 -28
  79. package/src/app/components/Shared/Sidebar/TradingAccountsPanel.tsx +445 -0
  80. package/src/app/components/Shared/Sidebar/index.tsx +17 -3
  81. package/src/app/components/Strategies/AdvancedMetricsPanel.tsx +727 -0
  82. package/src/app/components/Strategies/RuntimeStrategyCard.tsx +2204 -0
  83. package/src/app/components/Strategies/RuntimeStrategyCardSkeleton.tsx +40 -0
  84. package/src/app/components/Strategies/RuntimeStrategyChart.tsx +121 -0
  85. package/src/app/components/Strategies/RuntimeStrategyConfigDrawer.tsx +458 -0
  86. package/src/app/components/Strategies/StrategySnapshotCard.tsx +2890 -0
  87. package/src/app/components/Strategies/StrategySnapshotChart.tsx +94 -0
  88. package/src/app/components/Strategies/StrategySnapshotList.tsx +82 -0
  89. package/src/app/components/UI/Segment/index.tsx +8 -1
  90. package/src/app/components/UI/Select/index.tsx +24 -13
  91. package/src/app/components/UI/SelectWithSearch/index.tsx +46 -5
  92. package/src/app/layout.tsx +1 -1
  93. package/src/app/lib/backtestJobs.ts +792 -0
  94. package/src/app/lib/cardPageLayout.ts +1 -0
  95. package/src/app/lib/connectorCreator.ts +28 -0
  96. package/src/app/lib/currentUser.ts +1 -1
  97. package/src/app/lib/errorMessage.ts +34 -0
  98. package/src/app/lib/marketDefaults.ts +12 -0
  99. package/src/app/lib/marketRoutes.ts +58 -0
  100. package/src/app/lib/runtimeStrategies.ts +1376 -0
  101. package/src/app/lib/runtimeStrategyConfigForm.ts +86 -0
  102. package/src/app/provider.tsx +1 -1
  103. package/src/app/routes/backtest/page.tsx +1051 -304
  104. package/src/app/routes/dashboard/{[provider]/[symbol]/[interval]/page.tsx → Dashboard.tsx} +35 -15
  105. package/src/app/routes/dashboard/[provider]/[universe]/[symbol]/[interval]/page.tsx +1 -0
  106. package/src/app/routes/dashboard/page.tsx +15 -3
  107. package/src/app/routes/derivatives/page.tsx +1222 -165
  108. package/src/app/routes/strategies/StrategiesPageClient.tsx +593 -0
  109. package/src/app/routes/strategies/[mode]/page.tsx +20 -0
  110. package/src/app/routes/strategies/page.tsx +7 -0
  111. package/src/app/store/ai.ts +1 -1
  112. package/src/app/store/data.ts +73 -17
  113. package/src/app/store/filters.ts +1 -0
  114. package/src/app/store/marketKlineStream.ts +87 -0
  115. package/src/app/store/tests.ts +1 -1
  116. package/src/app/store/tickers.ts +59 -22
  117. package/tsconfig.json +17 -12
  118. package/src/app/components/Backtest/TestCard/OpenReportButton/index.tsx +0 -24
  119. package/src/app/routes/backtest/[test]/page.tsx +0 -33
@@ -1,369 +1,901 @@
1
1
  'use client';
2
2
 
3
- import React, { useEffect, useMemo, useState } from 'react';
3
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
4
4
  import {
5
+ Badge,
5
6
  Box,
6
7
  Button,
7
8
  Checkbox,
8
9
  ClientOnly,
9
- CloseButton,
10
- Dialog,
10
+ Field,
11
11
  Flex,
12
- Portal,
12
+ Grid,
13
+ Input,
14
+ Stack,
13
15
  Text,
14
16
  } from '@chakra-ui/react';
15
- import { deleteBacktest } from '@actions/backtest';
16
- import { useBacktestMutations, useTestList } from '@store';
17
- import { Select, toaster } from '@UI';
18
- import { CompareList } from '@components/Backtest/CompareList';
19
- import { TestList } from '@components/Backtest/TestList';
20
- import { parseTestName } from '@tradejs/core/backtest';
21
-
22
- const ALL_STRATEGIES = '__all__';
23
- const ALL_SUITES = '__all__';
24
-
25
- const Backtest = () => {
26
- const { tests, loadding, fulFilled } = useTestList();
27
- const { removeBacktestTest } = useBacktestMutations();
28
- const [selectedTestNames, setSelectedTestNames] = useState<string[]>([]);
29
- const [isDeleteSelectedOpen, setIsDeleteSelectedOpen] = useState(false);
30
- const [isDeletingSelected, setIsDeletingSelected] = useState(false);
31
- const strategyItems = useMemo(() => {
32
- const names = new Set<string>();
33
- for (const test of tests) {
34
- const strategyName = test.data?.strategyName;
35
- if (typeof strategyName === 'string' && strategyName) {
36
- names.add(strategyName);
37
- }
38
- }
17
+ import {
18
+ FiFolder,
19
+ FiPause,
20
+ FiPlay,
21
+ FiRefreshCw,
22
+ FiSquare,
23
+ FiTrash2,
24
+ FiX,
25
+ } from 'react-icons/fi';
26
+ import { useRouter } from 'next/navigation';
27
+ import {
28
+ controlBacktestRun,
29
+ deleteBacktestRun,
30
+ getBacktestRunConfigs,
31
+ getBacktestRuns,
32
+ startBacktestRun,
33
+ } from '#actions/backtest';
34
+ import { useTickers } from '#store';
35
+ import { EmptyState, Segment, Select, SelectWithSearch, toaster } from '#ui';
36
+ import type {
37
+ BacktestConfigSummary,
38
+ BacktestJobRecord,
39
+ BacktestJobStatus,
40
+ } from '#app/lib/backtestJobs';
39
41
 
40
- return [
41
- { label: 'All strategies', value: ALL_STRATEGIES },
42
- ...Array.from(names)
43
- .sort()
44
- .map((strategyName) => ({
45
- label: strategyName,
46
- value: strategyName,
47
- })),
48
- ];
49
- }, [tests]);
50
- const [selectedStrategy, setSelectedStrategy] = useState(ALL_STRATEGIES);
51
- const suiteItems = useMemo(() => {
52
- const names = new Set<string>();
53
- for (const test of tests) {
54
- if (
55
- selectedStrategy !== ALL_STRATEGIES &&
56
- test.data?.strategyName !== selectedStrategy
57
- ) {
58
- continue;
59
- }
42
+ const PERIOD_ITEMS = [
43
+ { label: 'Days', value: 'days' },
44
+ { label: 'Date range', value: 'range' },
45
+ ];
46
+ const INTERVAL_ITEMS = [
47
+ { label: '5m', value: '5' },
48
+ { label: '15m', value: '15' },
49
+ { label: '30m', value: '30' },
50
+ { label: '1h', value: '60' },
51
+ { label: '4h', value: '240' },
52
+ ];
53
+ const CONNECTOR_ITEMS = [
54
+ { label: 'Bybit', value: 'bybit' },
55
+ { label: 'Binance', value: 'binance' },
56
+ { label: 'Coinbase', value: 'coinbase' },
57
+ ];
60
58
 
61
- const { testSuiteId } = parseTestName(test.value);
62
- if (testSuiteId) {
63
- names.add(testSuiteId);
64
- }
59
+ const controlSurface = 'rgba(255, 255, 255, 0.035)';
60
+ const controlSurfaceHover = 'rgba(255, 255, 255, 0.05)';
61
+
62
+ const inputControlProps = {
63
+ bg: controlSurface,
64
+ borderColor: 'gray.700',
65
+ color: 'gray.100',
66
+ _placeholder: { color: 'gray.500' },
67
+ _hover: {
68
+ bg: controlSurfaceHover,
69
+ borderColor: 'gray.600',
70
+ },
71
+ _focusVisible: {
72
+ borderColor: 'gray.500',
73
+ boxShadow: '0 0 0 1px var(--chakra-colors-gray-500)',
74
+ },
75
+ };
76
+
77
+ type PeriodMode = 'days' | 'range';
78
+ type JobAction = 'pause' | 'stop' | 'resume' | 'cancel' | 'heartbeat';
79
+
80
+ const DAY_MS = 24 * 60 * 60 * 1000;
81
+
82
+ const toInputDate = (date: Date) => date.toISOString().slice(0, 10);
83
+
84
+ const dateToStartMs = (value: string) =>
85
+ Number.isFinite(Date.parse(`${value}T00:00:00.000Z`))
86
+ ? Date.parse(`${value}T00:00:00.000Z`)
87
+ : null;
88
+
89
+ const dateToEndMs = (value: string) =>
90
+ Number.isFinite(Date.parse(`${value}T23:59:59.999Z`))
91
+ ? Date.parse(`${value}T23:59:59.999Z`)
92
+ : null;
93
+
94
+ const formatNumber = (value: number | null | undefined, fractionDigits = 1) =>
95
+ typeof value === 'number' && Number.isFinite(value)
96
+ ? value.toFixed(fractionDigits)
97
+ : '-';
98
+
99
+ const formatDateTime = (value: string | undefined) => {
100
+ if (!value) {
101
+ return '-';
102
+ }
103
+
104
+ const timestamp = Date.parse(value);
105
+ if (!Number.isFinite(timestamp)) {
106
+ return '-';
107
+ }
108
+
109
+ return new Intl.DateTimeFormat('en', {
110
+ month: 'short',
111
+ day: '2-digit',
112
+ hour: '2-digit',
113
+ minute: '2-digit',
114
+ }).format(timestamp);
115
+ };
116
+
117
+ const statusTone = (status: BacktestJobStatus) => {
118
+ if (status === 'running') {
119
+ return 'teal';
120
+ }
121
+ if (status === 'pausing' || status === 'paused') {
122
+ return 'yellow';
123
+ }
124
+ if (status === 'completed') {
125
+ return 'green';
126
+ }
127
+ if (status === 'cancelled') {
128
+ return 'gray';
129
+ }
130
+ return 'red';
131
+ };
132
+
133
+ const statusLabel = (status: BacktestJobStatus) =>
134
+ status.charAt(0).toUpperCase() + status.slice(1);
135
+
136
+ const getJobTitle = (job: BacktestJobRecord) =>
137
+ `${job.request.strategyName} / ${job.request.configId}`;
138
+
139
+ const mergeJob = (
140
+ jobs: BacktestJobRecord[],
141
+ updated: BacktestJobRecord,
142
+ ): BacktestJobRecord[] => {
143
+ const existingIndex = jobs.findIndex((job) => job.id === updated.id);
144
+ if (existingIndex === -1) {
145
+ return [updated, ...jobs];
146
+ }
147
+
148
+ const nextJobs = [...jobs];
149
+ nextJobs[existingIndex] = updated;
150
+ return nextJobs;
151
+ };
152
+
153
+ const buildStrategyItems = (configs: BacktestConfigSummary[]) =>
154
+ [...new Set(configs.map((config) => config.strategyName))]
155
+ .sort((left, right) => left.localeCompare(right))
156
+ .map((strategyName) => ({
157
+ label: strategyName,
158
+ value: strategyName,
159
+ }));
160
+
161
+ const buildConfigItems = (
162
+ configs: BacktestConfigSummary[],
163
+ selectedStrategy: string,
164
+ ) =>
165
+ configs
166
+ .filter((config) => config.strategyName === selectedStrategy)
167
+ .map((config) => ({
168
+ label: config.id,
169
+ value: config.id,
170
+ description: `${config.combinationCount} combos / ${config.paramCount} params`,
171
+ }));
172
+
173
+ const SelectControl = ({ children }: { children: React.ReactNode }) => (
174
+ <Box
175
+ w="full"
176
+ minW={0}
177
+ css={{
178
+ '& [data-part="control"]': {
179
+ width: '100%',
180
+ },
181
+ '& [data-part="trigger"], & [data-part="input"]': {
182
+ background: controlSurface,
183
+ borderColor: 'var(--chakra-colors-gray-700)',
184
+ color: 'var(--chakra-colors-gray-100)',
185
+ minWidth: '0',
186
+ width: '100%',
187
+ },
188
+ '& [data-part="trigger"]': {
189
+ flex: '1',
190
+ minHeight: '40px',
191
+ },
192
+ '& [data-part="input"]': {
193
+ flex: '1',
194
+ minHeight: '40px',
195
+ },
196
+ '& [data-part="indicator-group"]': {
197
+ flexShrink: '0',
198
+ },
199
+ '& [data-part="trigger"]:is(:hover, [data-hover]), & [data-part="input"]:is(:hover, [data-hover])':
200
+ {
201
+ background: controlSurfaceHover,
202
+ borderColor: 'var(--chakra-colors-gray-600)',
203
+ },
204
+ '& [data-part="trigger"]:is(:focus-visible, [data-focus-visible]), & [data-part="input"]:is(:focus-visible, [data-focus-visible])':
205
+ {
206
+ borderColor: 'var(--chakra-colors-gray-500)',
207
+ boxShadow: '0 0 0 1px var(--chakra-colors-gray-500)',
208
+ },
209
+ '& [data-part="control"]:is(:hover, [data-hover]) [data-part="input"]': {
210
+ background: controlSurfaceHover,
211
+ borderColor: 'var(--chakra-colors-gray-600)',
212
+ },
213
+ }}
214
+ >
215
+ {children}
216
+ </Box>
217
+ );
218
+
219
+ interface FormSectionProps {
220
+ title: string;
221
+ columns: string;
222
+ children: React.ReactNode;
223
+ }
224
+
225
+ const FormSection = ({ title, columns, children }: FormSectionProps) => (
226
+ <Stack gap={3} minW={0}>
227
+ <Text
228
+ color="gray.400"
229
+ fontSize="xs"
230
+ fontWeight="700"
231
+ letterSpacing="0"
232
+ textTransform="uppercase"
233
+ >
234
+ {title}
235
+ </Text>
236
+ <Grid templateColumns={columns} gap={3} alignItems="end">
237
+ {children}
238
+ </Grid>
239
+ </Stack>
240
+ );
241
+
242
+ const BacktestRunPage = () => {
243
+ const router = useRouter();
244
+ const [configs, setConfigs] = useState<BacktestConfigSummary[]>([]);
245
+ const [jobs, setJobs] = useState<BacktestJobRecord[]>([]);
246
+ const [loadingConfigs, setLoadingConfigs] = useState(false);
247
+ const [loadingJobs, setLoadingJobs] = useState(false);
248
+ const [starting, setStarting] = useState(false);
249
+ const [busyAction, setBusyAction] = useState('');
250
+ const [selectedStrategy, setSelectedStrategy] = useState('');
251
+ const [selectedConfigId, setSelectedConfigId] = useState('');
252
+ const [periodMode, setPeriodMode] = useState<PeriodMode>('days');
253
+ const [days, setDays] = useState('30');
254
+ const [startDate, setStartDate] = useState(() =>
255
+ toInputDate(new Date(Date.now() - 30 * DAY_MS)),
256
+ );
257
+ const [endDate, setEndDate] = useState(() => toInputDate(new Date()));
258
+ const [ai, setAi] = useState(false);
259
+ const [fast, setFast] = useState(false);
260
+ const [interval, setIntervalValue] = useState('15');
261
+ const [connector, setConnector] = useState('bybit');
262
+ const [selectedTickers, setSelectedTickers] = useState<string[]>([]);
263
+ const [tickersLimit, setTickersLimit] = useState('');
264
+ const [testsLimit, setTestsLimit] = useState('');
265
+ const [parallel, setParallel] = useState('');
266
+ const { tickers: tickerItems, ensureLoaded: ensureTickersLoaded } =
267
+ useTickers(connector, {
268
+ enabled: false,
269
+ });
270
+
271
+ const strategyItems = useMemo(() => buildStrategyItems(configs), [configs]);
272
+ const configItems = useMemo(
273
+ () => buildConfigItems(configs, selectedStrategy),
274
+ [configs, selectedStrategy],
275
+ );
276
+
277
+ const loadConfigs = useCallback(async () => {
278
+ setLoadingConfigs(true);
279
+ try {
280
+ const nextConfigs = await getBacktestRunConfigs();
281
+ setConfigs(nextConfigs);
282
+ } catch (error) {
283
+ toaster.error({
284
+ title: 'Failed to load backtest configs',
285
+ description: (error as Error)?.message || 'Request failed.',
286
+ });
287
+ } finally {
288
+ setLoadingConfigs(false);
289
+ }
290
+ }, []);
291
+
292
+ const loadJobs = useCallback(async () => {
293
+ setLoadingJobs(true);
294
+ try {
295
+ const nextJobs = await getBacktestRuns();
296
+ setJobs(nextJobs);
297
+ } catch (error) {
298
+ toaster.error({
299
+ title: 'Failed to load backtest jobs',
300
+ description: (error as Error)?.message || 'Request failed.',
301
+ });
302
+ } finally {
303
+ setLoadingJobs(false);
65
304
  }
305
+ }, []);
66
306
 
67
- return [
68
- { label: 'All suites', value: ALL_SUITES },
69
- ...Array.from(names)
70
- .sort()
71
- .map((testSuiteId) => ({
72
- label: testSuiteId,
73
- value: testSuiteId,
74
- })),
75
- ];
76
- }, [tests, selectedStrategy]);
77
- const [selectedSuite, setSelectedSuite] = useState(ALL_SUITES);
307
+ useEffect(() => {
308
+ void loadConfigs();
309
+ void loadJobs();
310
+ }, [loadConfigs, loadJobs]);
78
311
 
79
312
  useEffect(() => {
313
+ if (!strategyItems.length) {
314
+ setSelectedStrategy('');
315
+ return;
316
+ }
317
+
80
318
  if (!strategyItems.some((item) => item.value === selectedStrategy)) {
81
- setSelectedStrategy(strategyItems[0]?.value || ALL_STRATEGIES);
319
+ setSelectedStrategy(strategyItems[0]?.value || '');
82
320
  }
83
- }, [strategyItems, selectedStrategy]);
321
+ }, [selectedStrategy, strategyItems]);
84
322
 
85
323
  useEffect(() => {
86
- if (!suiteItems.some((item) => item.value === selectedSuite)) {
87
- setSelectedSuite(suiteItems[0]?.value || ALL_SUITES);
324
+ if (!configItems.length) {
325
+ setSelectedConfigId('');
326
+ return;
88
327
  }
89
- }, [suiteItems, selectedSuite]);
90
-
91
- const filteredTests = useMemo(() => {
92
- return tests.filter((test) => {
93
- if (
94
- selectedStrategy !== ALL_STRATEGIES &&
95
- test.data?.strategyName !== selectedStrategy
96
- ) {
97
- return false;
98
- }
99
328
 
100
- if (selectedSuite !== ALL_SUITES) {
101
- const { testSuiteId } = parseTestName(test.value);
102
- return testSuiteId === selectedSuite;
329
+ if (!configItems.some((item) => item.value === selectedConfigId)) {
330
+ setSelectedConfigId(configItems[0]?.value || '');
331
+ }
332
+ }, [configItems, selectedConfigId]);
333
+
334
+ useEffect(() => {
335
+ const timer = window.setInterval(() => {
336
+ void loadJobs();
337
+ }, 3_000);
338
+
339
+ return () => window.clearInterval(timer);
340
+ }, [loadJobs]);
341
+
342
+ useEffect(() => {
343
+ const timer = window.setInterval(() => {
344
+ const runningJobs = jobs.filter((job) => job.status === 'running');
345
+ if (!runningJobs.length) {
346
+ return;
103
347
  }
104
348
 
105
- return true;
106
- });
107
- }, [tests, selectedStrategy, selectedSuite]);
349
+ void Promise.all(
350
+ runningJobs.map((job) => controlBacktestRun(job.id, 'heartbeat')),
351
+ )
352
+ .then((updatedJobs) => {
353
+ setJobs((currentJobs) => updatedJobs.reduce(mergeJob, currentJobs));
354
+ })
355
+ .catch(() => {
356
+ // Polling will surface the next durable state.
357
+ });
358
+ }, 5_000);
359
+
360
+ return () => window.clearInterval(timer);
361
+ }, [jobs]);
108
362
 
109
- const filteredTestNames = useMemo(
110
- () => filteredTests.map((test) => test.value),
111
- [filteredTests],
363
+ const selectedConfig = useMemo(
364
+ () => configs.find((config) => config.id === selectedConfigId),
365
+ [configs, selectedConfigId],
112
366
  );
113
367
 
114
- const selectedFilteredCount = useMemo(() => {
115
- const filteredSet = new Set(filteredTestNames);
116
- return selectedTestNames.filter((testName) => filteredSet.has(testName))
117
- .length;
118
- }, [filteredTestNames, selectedTestNames]);
368
+ const handleStart = async (event: React.FormEvent<HTMLFormElement>) => {
369
+ event.preventDefault();
119
370
 
120
- const allFilteredSelected =
121
- filteredTests.length > 0 && selectedFilteredCount === filteredTests.length;
122
- const hasSelectedInFilter = selectedFilteredCount > 0;
371
+ if (!selectedStrategy || !selectedConfigId) {
372
+ toaster.error({
373
+ title: 'Select strategy and config',
374
+ description: 'Backtest config is required before launch.',
375
+ });
376
+ return;
377
+ }
123
378
 
124
- const noData = fulFilled && !loadding && filteredTests.length === 0;
379
+ const payload: Record<string, unknown> = {
380
+ strategyName: selectedStrategy,
381
+ configId: selectedConfigId,
382
+ periodMode,
383
+ ai,
384
+ fast,
385
+ interval,
386
+ connector,
387
+ };
125
388
 
126
- useEffect(() => {
127
- const actual = new Set(tests.map((test) => test.value));
128
- setSelectedTestNames((prev) => {
129
- const next = prev.filter((testName) => actual.has(testName));
130
-
131
- if (
132
- next.length === prev.length &&
133
- next.every((name, i) => name === prev[i])
134
- ) {
135
- return prev;
389
+ if (periodMode === 'range') {
390
+ const startTime = dateToStartMs(startDate);
391
+ const endTime = dateToEndMs(endDate);
392
+ if (!startTime || !endTime || startTime >= endTime) {
393
+ toaster.error({
394
+ title: 'Invalid date range',
395
+ description: 'Start date must be earlier than end date.',
396
+ });
397
+ return;
136
398
  }
137
-
138
- return next;
139
- });
140
- }, [tests]);
141
-
142
- const handleToggleSelection = (testName: string, checked: boolean) => {
143
- setSelectedTestNames((prev) => {
144
- if (checked) {
145
- if (prev.includes(testName)) {
146
- return prev;
147
- }
148
- return [...prev, testName];
399
+ payload.startTime = startTime;
400
+ payload.endTime = endTime;
401
+ } else {
402
+ const parsedDays = Number(days);
403
+ if (!Number.isFinite(parsedDays) || parsedDays <= 0) {
404
+ toaster.error({
405
+ title: 'Invalid days value',
406
+ description: 'Days must be greater than zero.',
407
+ });
408
+ return;
149
409
  }
410
+ payload.days = parsedDays;
411
+ }
150
412
 
151
- return prev.filter((name) => name !== testName);
152
- });
153
- };
413
+ if (selectedTickers.length) {
414
+ payload.tickers = selectedTickers.join(',');
415
+ }
154
416
 
155
- const handleSelectAllFiltered = (checked: boolean) => {
156
- setSelectedTestNames((prev) => {
157
- if (!checked) {
158
- const filteredSet = new Set(filteredTestNames);
159
- return prev.filter((name) => !filteredSet.has(name));
417
+ for (const [field, value] of [
418
+ ['tickersLimit', tickersLimit],
419
+ ['testsLimit', testsLimit],
420
+ ['parallel', parallel],
421
+ ] as const) {
422
+ const parsed = Number(value);
423
+ if (value.trim() && Number.isFinite(parsed) && parsed > 0) {
424
+ payload[field] = Math.trunc(parsed);
160
425
  }
426
+ }
161
427
 
162
- const next = new Set(prev);
163
- for (const name of filteredTestNames) {
164
- next.add(name);
165
- }
166
- return Array.from(next);
167
- });
428
+ setStarting(true);
429
+ try {
430
+ const job = await startBacktestRun(payload);
431
+ setJobs((currentJobs) => mergeJob(currentJobs, job));
432
+ toaster.success({
433
+ title: 'Backtest started',
434
+ description: getJobTitle(job),
435
+ });
436
+ } catch (error) {
437
+ toaster.error({
438
+ title: 'Backtest start failed',
439
+ description: (error as Error)?.message || 'Request failed.',
440
+ });
441
+ } finally {
442
+ setStarting(false);
443
+ }
168
444
  };
169
445
 
170
- const handleDeleteSelected = async () => {
171
- const selectedSet = new Set(selectedTestNames);
172
- const targets = filteredTests.filter((test) => selectedSet.has(test.value));
173
-
174
- if (targets.length === 0 || isDeletingSelected) {
175
- setIsDeleteSelectedOpen(false);
176
- return;
446
+ const handleAction = async (jobId: string, action: JobAction) => {
447
+ setBusyAction(`${jobId}:${action}`);
448
+ try {
449
+ const job = await controlBacktestRun(jobId, action);
450
+ setJobs((currentJobs) => mergeJob(currentJobs, job));
451
+ } catch (error) {
452
+ toaster.error({
453
+ title: 'Backtest action failed',
454
+ description: (error as Error)?.message || 'Request failed.',
455
+ });
456
+ } finally {
457
+ setBusyAction('');
177
458
  }
459
+ };
178
460
 
179
- setIsDeletingSelected(true);
180
-
461
+ const handleDeleteJob = async (jobId: string) => {
462
+ setBusyAction(`${jobId}:delete`);
181
463
  try {
182
- const results = await Promise.allSettled(
183
- targets.map(async (test) => {
184
- const strategyName = test.data?.strategyName as string | undefined;
185
- if (!strategyName) {
186
- throw new Error(`Missing strategy for ${test.value}`);
187
- }
188
-
189
- const deleted = await deleteBacktest(test.value, strategyName);
190
- if (!deleted) {
191
- throw new Error(`Delete failed for ${test.value}`);
192
- }
193
-
194
- await removeBacktestTest(test.value);
195
- return test.value;
196
- }),
197
- );
198
-
199
- const successCount = results.filter(
200
- (item) => item.status === 'fulfilled',
201
- ).length;
202
- const failedCount = results.length - successCount;
203
-
204
- if (successCount > 0) {
205
- const deletedSet = new Set(
206
- results
207
- .filter((item) => item.status === 'fulfilled')
208
- .map((item) => item.value),
209
- );
210
- setSelectedTestNames((prev) =>
211
- prev.filter((testName) => !deletedSet.has(testName)),
212
- );
213
- }
214
-
215
- if (failedCount === 0) {
216
- toaster.success({
217
- title: 'Tests deleted',
218
- description: `Deleted: ${successCount}`,
219
- });
220
- } else {
221
- toaster.error({
222
- title: 'Bulk delete finished with errors',
223
- description: `Deleted: ${successCount} of ${targets.length}`,
224
- });
464
+ const deleted = await deleteBacktestRun(jobId);
465
+ if (deleted) {
466
+ setJobs((currentJobs) => currentJobs.filter((job) => job.id !== jobId));
225
467
  }
226
- } catch {
468
+ } catch (error) {
227
469
  toaster.error({
228
- title: 'Delete failed',
229
- description: 'Failed to delete selected tests.',
470
+ title: 'Backtest job delete failed',
471
+ description: (error as Error)?.message || 'Request failed.',
230
472
  });
231
473
  } finally {
232
- setIsDeletingSelected(false);
233
- setIsDeleteSelectedOpen(false);
474
+ setBusyAction('');
234
475
  }
235
476
  };
236
477
 
478
+ const hasConfigs = configs.length > 0;
479
+ const noJobs = !loadingJobs && jobs.length === 0;
480
+
237
481
  return (
238
482
  <ClientOnly>
239
- <Box minH="100vh" bg="gray.900">
483
+ <Box minH="100vh" bg="gray.900" color="gray.100">
240
484
  <Box
241
485
  as="main"
242
486
  minH="100vh"
243
487
  minW="1200px"
244
488
  pl={2}
489
+ pr={4}
490
+ py={3}
245
491
  bg="gray.900"
246
- display="flex"
247
- flexDirection="column"
248
- alignItems="flex-start"
249
492
  >
250
- <Flex
251
- mb={2}
252
- mt={2}
253
- pl={2}
254
- gap={8}
255
- flexDirection="row"
256
- alignItems="center"
257
- >
258
- <Flex gap={3} alignItems="center">
259
- <Select
260
- placeholder="Strategy"
261
- value={[selectedStrategy]}
262
- defaultValue={[selectedStrategy]}
263
- onChange={(value) =>
264
- setSelectedStrategy(value[0] || ALL_STRATEGIES)
265
- }
266
- items={strategyItems}
267
- width="220px"
268
- />
269
- <Select
270
- placeholder="TestSuite"
271
- value={[selectedSuite]}
272
- defaultValue={[selectedSuite]}
273
- onChange={(value) => setSelectedSuite(value[0] || ALL_SUITES)}
274
- items={suiteItems}
275
- width="180px"
276
- />
493
+ <Flex alignItems="center" justifyContent="space-between" mb={3}>
494
+ <Box pl={2}>
495
+ <Text fontSize="lg" fontWeight="700" lineHeight="1.2">
496
+ Backtest runs
497
+ </Text>
498
+ </Box>
499
+ <Flex gap={2}>
500
+ <Button
501
+ type="button"
502
+ size="sm"
503
+ variant="outline"
504
+ colorPalette="teal"
505
+ onClick={() => router.push('/routes/strategies/backtest')}
506
+ >
507
+ <FiFolder />
508
+ Results
509
+ </Button>
510
+ <Button
511
+ type="button"
512
+ size="sm"
513
+ variant="outline"
514
+ onClick={() => {
515
+ void loadConfigs();
516
+ void loadJobs();
517
+ }}
518
+ loading={loadingConfigs || loadingJobs}
519
+ >
520
+ <FiRefreshCw />
521
+ Refresh
522
+ </Button>
277
523
  </Flex>
278
- <CompareList />
279
524
  </Flex>
280
- <Flex mb={4} pl={2} gap={4} alignItems="center" w="full" minH="32px">
281
- <Checkbox.Root
282
- size="sm"
283
- colorPalette="teal"
284
- checked={
285
- allFilteredSelected
286
- ? true
287
- : hasSelectedInFilter
288
- ? 'indeterminate'
289
- : false
290
- }
291
- onCheckedChange={(details) =>
292
- handleSelectAllFiltered(details.checked === true)
293
- }
294
- >
295
- <Checkbox.HiddenInput />
296
- <Checkbox.Control />
297
- </Checkbox.Root>
298
- <Text color="gray.200" fontWeight="semibold">
299
- Selected: {selectedFilteredCount}
300
- </Text>
301
525
 
302
- <Dialog.Root
303
- open={isDeleteSelectedOpen}
304
- onOpenChange={(e) => setIsDeleteSelectedOpen(e.open)}
526
+ <Box maxW="1200px" w="full">
527
+ <Box
528
+ borderWidth="1px"
529
+ borderColor="gray.700"
530
+ bg="gray.800"
531
+ p={4}
532
+ borderRadius="md"
305
533
  >
306
- <Dialog.Trigger asChild>
307
- <Button
308
- size="sm"
309
- colorPalette="red"
310
- variant="outline"
311
- disabled={!hasSelectedInFilter || isDeletingSelected}
312
- >
313
- Delete
314
- </Button>
315
- </Dialog.Trigger>
316
- <Portal>
317
- <Dialog.Backdrop />
318
- <Dialog.Positioner>
319
- <Dialog.Content>
320
- <Dialog.Header>
321
- <Dialog.Title>Delete selected tests</Dialog.Title>
322
- <Dialog.CloseTrigger asChild>
323
- <CloseButton position="absolute" right="3" top="3" />
324
- </Dialog.CloseTrigger>
325
- </Dialog.Header>
326
- <Dialog.Body>
327
- <Text fontSize="sm" color="gray.200">
328
- Delete selected tests ({selectedFilteredCount})?
329
- </Text>
330
- <Text fontSize="sm" color="gray.400" mt={2}>
331
- This action cannot be undone.
534
+ <form onSubmit={handleStart}>
535
+ <Stack gap={5}>
536
+ <Flex
537
+ alignItems="center"
538
+ justifyContent="space-between"
539
+ gap={4}
540
+ >
541
+ <Flex alignItems="center" gap={3} minW={0}>
542
+ <Text fontWeight="700" flexShrink={0}>
543
+ New run
332
544
  </Text>
333
- </Dialog.Body>
334
- <Dialog.Footer>
335
- <Dialog.ActionTrigger asChild>
336
- <Button
337
- variant="outline"
338
- size="sm"
339
- disabled={isDeletingSelected}
545
+ {selectedConfig ? (
546
+ <Flex gap={2} wrap="wrap">
547
+ <Badge colorPalette="teal">
548
+ {selectedConfig.combinationCount} combos
549
+ </Badge>
550
+ <Badge colorPalette="gray">
551
+ {selectedConfig.paramCount} params
552
+ </Badge>
553
+ </Flex>
554
+ ) : null}
555
+ </Flex>
556
+ </Flex>
557
+
558
+ <FormSection
559
+ title="Strategy"
560
+ columns="repeat(2, minmax(0, 1fr))"
561
+ >
562
+ <Field.Root>
563
+ <Field.Label>Strategy</Field.Label>
564
+ <SelectControl>
565
+ <Select
566
+ placeholder="Strategy"
567
+ value={selectedStrategy ? [selectedStrategy] : []}
568
+ defaultValue={
569
+ selectedStrategy ? [selectedStrategy] : []
570
+ }
571
+ onChange={(value) =>
572
+ setSelectedStrategy(value[0] || '')
573
+ }
574
+ items={strategyItems}
575
+ emptyState="No backtest configs"
576
+ width="100%"
577
+ disabled={!hasConfigs}
578
+ />
579
+ </SelectControl>
580
+ </Field.Root>
581
+
582
+ <Field.Root>
583
+ <Field.Label>Config</Field.Label>
584
+ <SelectControl>
585
+ <Select
586
+ placeholder="Config"
587
+ value={selectedConfigId ? [selectedConfigId] : []}
588
+ defaultValue={
589
+ selectedConfigId ? [selectedConfigId] : []
590
+ }
591
+ onChange={(value) =>
592
+ setSelectedConfigId(value[0] || '')
593
+ }
594
+ items={configItems}
595
+ emptyState="No configs for strategy"
596
+ width="100%"
597
+ disabled={!selectedStrategy}
598
+ />
599
+ </SelectControl>
600
+ </Field.Root>
601
+ </FormSection>
602
+
603
+ <Grid
604
+ templateColumns="minmax(0, 1fr) 260px"
605
+ gap={5}
606
+ alignItems="start"
607
+ >
608
+ <FormSection
609
+ title="Date window"
610
+ columns={
611
+ periodMode === 'days'
612
+ ? '280px minmax(0, 1fr)'
613
+ : '280px repeat(2, minmax(0, 1fr))'
614
+ }
615
+ >
616
+ <Field.Root>
617
+ <Field.Label>Mode</Field.Label>
618
+ <Segment
619
+ defaultValue="days"
620
+ value={periodMode}
621
+ items={PERIOD_ITEMS}
622
+ onChange={(value) =>
623
+ setPeriodMode(value === 'range' ? 'range' : 'days')
624
+ }
625
+ />
626
+ </Field.Root>
627
+
628
+ {periodMode === 'days' ? (
629
+ <Field.Root>
630
+ <Field.Label>Days</Field.Label>
631
+ <Input
632
+ value={days}
633
+ type="number"
634
+ min={1}
635
+ step={1}
636
+ {...inputControlProps}
637
+ onChange={(event) => setDays(event.target.value)}
638
+ />
639
+ </Field.Root>
640
+ ) : (
641
+ <>
642
+ <Field.Root>
643
+ <Field.Label>Start</Field.Label>
644
+ <Input
645
+ value={startDate}
646
+ type="date"
647
+ {...inputControlProps}
648
+ onChange={(event) =>
649
+ setStartDate(event.target.value)
650
+ }
651
+ />
652
+ </Field.Root>
653
+ <Field.Root>
654
+ <Field.Label>End</Field.Label>
655
+ <Input
656
+ value={endDate}
657
+ type="date"
658
+ {...inputControlProps}
659
+ onChange={(event) =>
660
+ setEndDate(event.target.value)
661
+ }
662
+ />
663
+ </Field.Root>
664
+ </>
665
+ )}
666
+ </FormSection>
667
+
668
+ <FormSection title="Options" columns="1fr">
669
+ <Flex gap={4} alignItems="center" minH="40px">
670
+ <Checkbox.Root
671
+ colorPalette="teal"
672
+ checked={ai}
673
+ onCheckedChange={(details) =>
674
+ setAi(details.checked === true)
675
+ }
340
676
  >
341
- Cancel
342
- </Button>
343
- </Dialog.ActionTrigger>
344
- <Button
345
- colorPalette="red"
346
- size="sm"
347
- onClick={handleDeleteSelected}
348
- loading={isDeletingSelected}
349
- >
350
- Delete
351
- </Button>
352
- </Dialog.Footer>
353
- </Dialog.Content>
354
- </Dialog.Positioner>
355
- </Portal>
356
- </Dialog.Root>
357
- </Flex>
358
- <Box flex="1" h="full" w="full">
359
- <TestList
360
- tests={filteredTests}
361
- loadding={loadding}
362
- fulFilled={fulFilled}
363
- noData={noData}
364
- selectedTestNames={selectedTestNames}
365
- onToggleSelection={handleToggleSelection}
366
- />
677
+ <Checkbox.HiddenInput />
678
+ <Checkbox.Control
679
+ bg={controlSurface}
680
+ borderColor="gray.600"
681
+ />
682
+ <Checkbox.Label>AI</Checkbox.Label>
683
+ </Checkbox.Root>
684
+ <Checkbox.Root
685
+ colorPalette="teal"
686
+ checked={fast}
687
+ onCheckedChange={(details) =>
688
+ setFast(details.checked === true)
689
+ }
690
+ >
691
+ <Checkbox.HiddenInput />
692
+ <Checkbox.Control
693
+ bg={controlSurface}
694
+ borderColor="gray.600"
695
+ />
696
+ <Checkbox.Label>Fast</Checkbox.Label>
697
+ </Checkbox.Root>
698
+ </Flex>
699
+ </FormSection>
700
+ </Grid>
701
+
702
+ <FormSection
703
+ title="Runtime"
704
+ columns="220px 260px minmax(0, 1fr)"
705
+ >
706
+ <Field.Root>
707
+ <Field.Label>Interval</Field.Label>
708
+ <SelectControl>
709
+ <Select
710
+ placeholder="Interval"
711
+ value={[interval]}
712
+ defaultValue={[interval]}
713
+ onChange={(value) =>
714
+ setIntervalValue(value[0] || '15')
715
+ }
716
+ items={INTERVAL_ITEMS}
717
+ width="100%"
718
+ />
719
+ </SelectControl>
720
+ </Field.Root>
721
+
722
+ <Field.Root>
723
+ <Field.Label>Connector</Field.Label>
724
+ <SelectControl>
725
+ <Select
726
+ placeholder="Connector"
727
+ value={[connector]}
728
+ defaultValue={[connector]}
729
+ onChange={(value) => {
730
+ setConnector(value[0] || 'bybit');
731
+ setSelectedTickers([]);
732
+ }}
733
+ items={CONNECTOR_ITEMS}
734
+ width="100%"
735
+ />
736
+ </SelectControl>
737
+ </Field.Root>
738
+
739
+ <Field.Root>
740
+ <Field.Label>Tickers</Field.Label>
741
+ <Stack gap={2} w="full" minW={0}>
742
+ <SelectControl>
743
+ <SelectWithSearch
744
+ key={connector}
745
+ multiple
746
+ placeholder="All tickers"
747
+ emptyState="No tickers"
748
+ defaultValue={[]}
749
+ value={selectedTickers}
750
+ items={tickerItems}
751
+ width="100%"
752
+ onChange={setSelectedTickers}
753
+ onOpenChange={(open) => {
754
+ if (open) {
755
+ void ensureTickersLoaded().catch(
756
+ () => undefined,
757
+ );
758
+ }
759
+ }}
760
+ />
761
+ </SelectControl>
762
+ {selectedTickers.length ? (
763
+ <Flex gap={2} wrap="wrap">
764
+ {selectedTickers.map((ticker) => (
765
+ <Badge
766
+ key={ticker}
767
+ colorPalette="teal"
768
+ display="inline-flex"
769
+ alignItems="center"
770
+ gap={1}
771
+ py="1"
772
+ >
773
+ {ticker}
774
+ <Box
775
+ as="span"
776
+ role="button"
777
+ tabIndex={0}
778
+ aria-label={`Remove ${ticker}`}
779
+ color="gray.300"
780
+ cursor="pointer"
781
+ _hover={{ color: 'white' }}
782
+ onKeyDown={(event) => {
783
+ if (
784
+ event.key === 'Enter' ||
785
+ event.key === ' '
786
+ ) {
787
+ event.preventDefault();
788
+ setSelectedTickers((currentTickers) =>
789
+ currentTickers.filter(
790
+ (currentTicker) =>
791
+ currentTicker !== ticker,
792
+ ),
793
+ );
794
+ }
795
+ }}
796
+ onClick={() =>
797
+ setSelectedTickers((currentTickers) =>
798
+ currentTickers.filter(
799
+ (currentTicker) =>
800
+ currentTicker !== ticker,
801
+ ),
802
+ )
803
+ }
804
+ >
805
+ <FiX size={12} />
806
+ </Box>
807
+ </Badge>
808
+ ))}
809
+ </Flex>
810
+ ) : null}
811
+ </Stack>
812
+ </Field.Root>
813
+ </FormSection>
814
+
815
+ <FormSection title="Limits" columns="repeat(3, 1fr)">
816
+ <Field.Root>
817
+ <Field.Label>Tickers limit</Field.Label>
818
+ <Input
819
+ value={tickersLimit}
820
+ type="number"
821
+ min={1}
822
+ {...inputControlProps}
823
+ onChange={(event) =>
824
+ setTickersLimit(event.target.value)
825
+ }
826
+ />
827
+ </Field.Root>
828
+
829
+ <Field.Root>
830
+ <Field.Label>Tests limit</Field.Label>
831
+ <Input
832
+ value={testsLimit}
833
+ type="number"
834
+ min={1}
835
+ {...inputControlProps}
836
+ onChange={(event) => setTestsLimit(event.target.value)}
837
+ />
838
+ </Field.Root>
839
+
840
+ <Field.Root>
841
+ <Field.Label>Parallel</Field.Label>
842
+ <Input
843
+ value={parallel}
844
+ type="number"
845
+ min={1}
846
+ {...inputControlProps}
847
+ onChange={(event) => setParallel(event.target.value)}
848
+ />
849
+ </Field.Root>
850
+ </FormSection>
851
+
852
+ <Flex justifyContent="flex-end" pt={1}>
853
+ <Button
854
+ type="submit"
855
+ colorPalette="teal"
856
+ disabled={!selectedConfigId || starting}
857
+ loading={starting}
858
+ minW="160px"
859
+ >
860
+ <FiPlay />
861
+ Start
862
+ </Button>
863
+ </Flex>
864
+ </Stack>
865
+ </form>
866
+ </Box>
867
+ </Box>
868
+
869
+ <Box maxW="1200px" w="full" mt={5}>
870
+ <Flex alignItems="center" justifyContent="space-between" mb={3}>
871
+ <Flex alignItems="center" gap={3}>
872
+ <Text fontWeight="700">Active runs</Text>
873
+ <Badge colorPalette="gray">{jobs.length} jobs</Badge>
874
+ </Flex>
875
+ </Flex>
876
+
877
+ {noJobs ? (
878
+ <EmptyState
879
+ icon={FiFolder}
880
+ title="No backtest jobs"
881
+ description="No queued, running, paused, or finished jobs yet."
882
+ />
883
+ ) : null}
884
+
885
+ <Stack gap={3}>
886
+ {jobs.map((job) => (
887
+ <BacktestJobItem
888
+ key={job.id}
889
+ job={job}
890
+ busyAction={busyAction}
891
+ onAction={handleAction}
892
+ onDelete={handleDeleteJob}
893
+ onOpenResults={() =>
894
+ router.push('/routes/strategies/backtest')
895
+ }
896
+ />
897
+ ))}
898
+ </Stack>
367
899
  </Box>
368
900
  </Box>
369
901
  </Box>
@@ -371,4 +903,219 @@ const Backtest = () => {
371
903
  );
372
904
  };
373
905
 
374
- export default Backtest;
906
+ interface BacktestJobItemProps {
907
+ job: BacktestJobRecord;
908
+ busyAction: string;
909
+ onAction: (jobId: string, action: JobAction) => void;
910
+ onDelete: (jobId: string) => void;
911
+ onOpenResults: () => void;
912
+ }
913
+
914
+ const BacktestJobItem = ({
915
+ job,
916
+ busyAction,
917
+ onAction,
918
+ onDelete,
919
+ onOpenResults,
920
+ }: BacktestJobItemProps) => {
921
+ const progress = job.progress;
922
+ const totalLabel = progress.total == null ? '?' : progress.total;
923
+ const logs = job.logs.slice(-10);
924
+ const canPause = job.status === 'running';
925
+ const canResume = job.status === 'paused';
926
+ const canCancel = !['completed', 'cancelled'].includes(job.status);
927
+ const canDelete = ['completed', 'cancelled', 'failed', 'paused'].includes(
928
+ job.status,
929
+ );
930
+
931
+ return (
932
+ <Box
933
+ borderWidth="1px"
934
+ borderColor="gray.700"
935
+ bg="gray.800"
936
+ p={4}
937
+ borderRadius="md"
938
+ >
939
+ <Flex alignItems="flex-start" justifyContent="space-between" gap={4}>
940
+ <Box minW={0}>
941
+ <Flex gap={2} alignItems="center" wrap="wrap">
942
+ <Text fontWeight="700" wordBreak="break-word">
943
+ {getJobTitle(job)}
944
+ </Text>
945
+ <Badge colorPalette={statusTone(job.status)}>
946
+ {statusLabel(job.status)}
947
+ </Badge>
948
+ {job.request.ai ? <Badge colorPalette="purple">AI</Badge> : null}
949
+ {job.request.fast ? <Badge colorPalette="blue">Fast</Badge> : null}
950
+ </Flex>
951
+ <Text fontSize="xs" color="gray.400" mt={1}>
952
+ Started {formatDateTime(job.startedAt)} · Updated{' '}
953
+ {formatDateTime(job.updatedAt)} · Run #{job.runCount}
954
+ </Text>
955
+ {job.pauseReason ? (
956
+ <Text fontSize="xs" color="yellow.300" mt={1}>
957
+ Pause reason: {job.pauseReason}
958
+ </Text>
959
+ ) : null}
960
+ {job.error ? (
961
+ <Text fontSize="xs" color="red.300" mt={1}>
962
+ {job.error}
963
+ </Text>
964
+ ) : null}
965
+ </Box>
966
+
967
+ <Flex gap={2} flexShrink={0} wrap="wrap" justifyContent="flex-end">
968
+ {canPause ? (
969
+ <>
970
+ <Button
971
+ type="button"
972
+ size="xs"
973
+ variant="outline"
974
+ loading={busyAction === `${job.id}:pause`}
975
+ onClick={() => onAction(job.id, 'pause')}
976
+ >
977
+ <FiPause />
978
+ Pause
979
+ </Button>
980
+ <Button
981
+ type="button"
982
+ size="xs"
983
+ variant="outline"
984
+ loading={busyAction === `${job.id}:stop`}
985
+ onClick={() => onAction(job.id, 'stop')}
986
+ >
987
+ <FiSquare />
988
+ Stop
989
+ </Button>
990
+ </>
991
+ ) : null}
992
+
993
+ {canResume ? (
994
+ <Button
995
+ type="button"
996
+ size="xs"
997
+ colorPalette="teal"
998
+ loading={busyAction === `${job.id}:resume`}
999
+ onClick={() => onAction(job.id, 'resume')}
1000
+ >
1001
+ <FiPlay />
1002
+ Resume
1003
+ </Button>
1004
+ ) : null}
1005
+
1006
+ {job.status === 'completed' ? (
1007
+ <Button
1008
+ type="button"
1009
+ size="xs"
1010
+ variant="outline"
1011
+ colorPalette="teal"
1012
+ onClick={onOpenResults}
1013
+ >
1014
+ <FiFolder />
1015
+ Results
1016
+ </Button>
1017
+ ) : null}
1018
+
1019
+ {canCancel ? (
1020
+ <Button
1021
+ type="button"
1022
+ size="xs"
1023
+ variant="outline"
1024
+ colorPalette="red"
1025
+ loading={busyAction === `${job.id}:cancel`}
1026
+ onClick={() => onAction(job.id, 'cancel')}
1027
+ >
1028
+ <FiX />
1029
+ Cancel
1030
+ </Button>
1031
+ ) : null}
1032
+
1033
+ {canDelete ? (
1034
+ <Button
1035
+ type="button"
1036
+ size="xs"
1037
+ variant="ghost"
1038
+ colorPalette="red"
1039
+ loading={busyAction === `${job.id}:delete`}
1040
+ onClick={() => onDelete(job.id)}
1041
+ >
1042
+ <FiTrash2 />
1043
+ </Button>
1044
+ ) : null}
1045
+ </Flex>
1046
+ </Flex>
1047
+
1048
+ <Box mt={4}>
1049
+ <Flex alignItems="center" justifyContent="space-between" mb={2}>
1050
+ <Text fontSize="sm" color="gray.300">
1051
+ {progress.completed}/{totalLabel} tests
1052
+ </Text>
1053
+ <Text fontSize="sm" color="gray.300">
1054
+ {formatNumber(progress.percent, 1)}%
1055
+ </Text>
1056
+ </Flex>
1057
+ <Box h="8px" bg="gray.800" borderRadius="full" overflow="hidden">
1058
+ <Box
1059
+ h="full"
1060
+ bg={job.status === 'failed' ? 'red.500' : 'teal.400'}
1061
+ width={`${Math.max(0, Math.min(100, progress.percent))}%`}
1062
+ transition="width 0.2s ease"
1063
+ />
1064
+ </Box>
1065
+ </Box>
1066
+
1067
+ <Grid templateColumns="repeat(5, minmax(0, 1fr))" gap={3} mt={4}>
1068
+ <Metric
1069
+ label="Avg P&L"
1070
+ value={`${formatNumber(progress.averageProfit, 2)}$`}
1071
+ />
1072
+ <Metric
1073
+ label="Winrate"
1074
+ value={`${formatNumber(progress.winRate, 1)}%`}
1075
+ />
1076
+ <Metric label="Success" value={String(progress.successTests ?? '-')} />
1077
+ <Metric label="Errors" value={String(progress.errorTests ?? '-')} />
1078
+ <Metric label="PID" value={String(job.pid ?? '-')} />
1079
+ </Grid>
1080
+
1081
+ {logs.length ? (
1082
+ <Box mt={4} bg="gray.950" borderRadius="md" p={3} overflow="hidden">
1083
+ <Stack gap={1}>
1084
+ {logs.map((line, index) => (
1085
+ <Text
1086
+ key={`${job.id}:log:${index}`}
1087
+ fontFamily="mono"
1088
+ fontSize="xs"
1089
+ color="gray.300"
1090
+ whiteSpace="pre-wrap"
1091
+ wordBreak="break-word"
1092
+ >
1093
+ {line}
1094
+ </Text>
1095
+ ))}
1096
+ </Stack>
1097
+ </Box>
1098
+ ) : null}
1099
+ </Box>
1100
+ );
1101
+ };
1102
+
1103
+ const Metric = ({ label, value }: { label: string; value: string }) => (
1104
+ <Box minW={0}>
1105
+ <Text fontSize="xs" color="gray.500">
1106
+ {label}
1107
+ </Text>
1108
+ <Text
1109
+ fontSize="sm"
1110
+ color="gray.100"
1111
+ fontWeight="700"
1112
+ overflow="hidden"
1113
+ textOverflow="ellipsis"
1114
+ whiteSpace="nowrap"
1115
+ >
1116
+ {value}
1117
+ </Text>
1118
+ </Box>
1119
+ );
1120
+
1121
+ export default BacktestRunPage;