@tradejs/app 1.0.9 → 1.0.11

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