@tradejs/app 2.0.21 → 3.0.1

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