@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
@@ -0,0 +1,682 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Box,
5
+ CloseButton,
6
+ Drawer,
7
+ Flex,
8
+ Portal,
9
+ SimpleGrid,
10
+ Text,
11
+ } from '@chakra-ui/react';
12
+ import type { StrategyChartSnapshot } from '@tradejs/types';
13
+ import {
14
+ formatInteger,
15
+ formatPercent,
16
+ formatSignedNumber,
17
+ getPnlColor,
18
+ } from '#components/Shared/OrdersDrawer';
19
+ import { buildQuarterlyMonthlyStats } from '#app/lib/strategyPerformance';
20
+ import { AdvancedMetricsPanel } from './AdvancedMetricsPanel';
21
+ import {
22
+ ChartPanel,
23
+ DrawdownTimelineChart,
24
+ PnlDistributionChart,
25
+ RollingPerformanceChart,
26
+ TimeOfDaySessionChart,
27
+ WinLossStreakTimelineChart,
28
+ } from './StrategyPerformanceCharts';
29
+ import {
30
+ buildStrategySnapshotCardViewModel,
31
+ getMetricColor,
32
+ getPnlBarColor,
33
+ type AiDiagnosticGroup,
34
+ type AiDiagnosticMetric,
35
+ type SymbolPnlRank,
36
+ } from './StrategySnapshotCard.presenter';
37
+
38
+ type StrategySnapshotCardViewModel = ReturnType<
39
+ typeof buildStrategySnapshotCardViewModel
40
+ >;
41
+
42
+ const AiDiagnosticCard = ({ metric }: { metric: AiDiagnosticMetric }) => (
43
+ <Box
44
+ p={3}
45
+ borderWidth="1px"
46
+ borderColor="gray.800"
47
+ borderRadius="md"
48
+ bg="blackAlpha.200"
49
+ minW="0"
50
+ >
51
+ <Text
52
+ fontSize="2xs"
53
+ color="gray.500"
54
+ fontWeight="bold"
55
+ textTransform="uppercase"
56
+ lineHeight="1.2"
57
+ >
58
+ {metric.label}
59
+ </Text>
60
+ <Text
61
+ mt={2}
62
+ fontSize="lg"
63
+ color={getMetricColor(metric.tone)}
64
+ fontWeight="bold"
65
+ fontFamily="mono"
66
+ lineHeight="1.15"
67
+ whiteSpace="nowrap"
68
+ overflow="hidden"
69
+ textOverflow="ellipsis"
70
+ >
71
+ {metric.value}
72
+ </Text>
73
+ {metric.detail ? (
74
+ <Text
75
+ mt={2}
76
+ fontSize="xs"
77
+ color="gray.500"
78
+ lineHeight="1.25"
79
+ whiteSpace="nowrap"
80
+ overflow="hidden"
81
+ textOverflow="ellipsis"
82
+ >
83
+ {metric.detail}
84
+ </Text>
85
+ ) : null}
86
+ </Box>
87
+ );
88
+
89
+ const AiDiagnosticGroupBlock = ({ group }: { group: AiDiagnosticGroup }) => (
90
+ <Box>
91
+ <Flex justify="space-between" align="baseline" gap={3} mb={3}>
92
+ <Text color="gray.300" fontSize="sm" fontWeight="semibold">
93
+ {group.title}
94
+ </Text>
95
+ <Text color="gray.600" fontSize="xs" textAlign="right">
96
+ {group.description}
97
+ </Text>
98
+ </Flex>
99
+ <SimpleGrid
100
+ columns={{ base: 1, md: Math.min(group.columns, 3), xl: group.columns }}
101
+ gap={3}
102
+ >
103
+ {group.metrics.map((metric) => (
104
+ <AiDiagnosticCard key={metric.id} metric={metric} />
105
+ ))}
106
+ </SimpleGrid>
107
+ </Box>
108
+ );
109
+
110
+ const AiDiagnosticsPanel = ({ groups }: { groups: AiDiagnosticGroup[] }) => {
111
+ if (!groups.length) {
112
+ return null;
113
+ }
114
+
115
+ return (
116
+ <Box
117
+ p={4}
118
+ borderWidth="1px"
119
+ borderColor="gray.800"
120
+ borderRadius="md"
121
+ bg="gray.900"
122
+ >
123
+ <Flex justify="space-between" align="baseline" gap={4}>
124
+ <Text fontSize="md" fontWeight="semibold" color="gray.100">
125
+ AI diagnostics
126
+ </Text>
127
+ <Text color="gray.600" fontSize="xs" textAlign="right">
128
+ classifier-only details
129
+ </Text>
130
+ </Flex>
131
+ <Flex direction="column" gap={5} mt={4}>
132
+ {groups.map((group) => (
133
+ <AiDiagnosticGroupBlock key={group.id} group={group} />
134
+ ))}
135
+ </Flex>
136
+ </Box>
137
+ );
138
+ };
139
+
140
+ export const StrategySnapshotCardDetailsDrawer = ({
141
+ snapshot,
142
+ mode,
143
+ open,
144
+ onOpenChange,
145
+ viewModel,
146
+ }: {
147
+ snapshot: StrategyChartSnapshot;
148
+ mode: 'replay' | 'ai';
149
+ open: boolean;
150
+ onOpenChange: (open: boolean) => void;
151
+ viewModel: StrategySnapshotCardViewModel;
152
+ }) => {
153
+ const {
154
+ aiDiagnosticGroups,
155
+ directionStatGroups,
156
+ topSymbolPnlRanking,
157
+ worstSymbolPnlRanking,
158
+ symbolRankingMaxAbsPnl,
159
+ drawerMetrics,
160
+ advancedMetrics,
161
+ } = viewModel;
162
+ const {
163
+ monthlyStats,
164
+ tradePoints: snapshotTradePoints,
165
+ drawdownPoints,
166
+ rollingPerformancePoints,
167
+ pnlDistributionBins,
168
+ sessionPnlStats,
169
+ hourlyPnlStats,
170
+ } = viewModel.performance;
171
+
172
+ const renderSymbolPnlRanking = ({
173
+ title,
174
+ subtitle,
175
+ ranking,
176
+ }: {
177
+ title: string;
178
+ subtitle: string;
179
+ ranking: SymbolPnlRank[];
180
+ }) => (
181
+ <Box
182
+ p={4}
183
+ borderWidth="1px"
184
+ borderColor="gray.800"
185
+ borderRadius="md"
186
+ bg="gray.900"
187
+ >
188
+ <Flex justify="space-between" align="center" mb={4}>
189
+ <Text fontSize="sm" color="gray.300" fontWeight="semibold">
190
+ {title}
191
+ </Text>
192
+ <Text fontSize="xs" color="gray.500">
193
+ {subtitle}
194
+ </Text>
195
+ </Flex>
196
+
197
+ {ranking.length ? (
198
+ <>
199
+ <Flex align="center" gap={4} mb={2}>
200
+ <Text
201
+ flex="0 0 220px"
202
+ fontSize="xs"
203
+ color="gray.500"
204
+ fontWeight="semibold"
205
+ >
206
+ Contracts
207
+ </Text>
208
+ <Box flex="1" />
209
+ <Text
210
+ flex="0 0 96px"
211
+ fontSize="xs"
212
+ color="gray.500"
213
+ fontWeight="semibold"
214
+ textAlign="right"
215
+ >
216
+ P&L (USDT)
217
+ </Text>
218
+ </Flex>
219
+
220
+ <Flex direction="column" gap={3}>
221
+ {ranking.map((rank) => {
222
+ const barWidth = Math.max(
223
+ 6,
224
+ (Math.abs(rank.pnl) / symbolRankingMaxAbsPnl) * 100,
225
+ );
226
+
227
+ return (
228
+ <Flex key={rank.symbol} align="center" gap={4} minH="34px">
229
+ <Box flex="0 0 220px" minW={0}>
230
+ <Text
231
+ fontSize="sm"
232
+ color="gray.100"
233
+ fontWeight="semibold"
234
+ lineHeight="1.2"
235
+ overflow="hidden"
236
+ textOverflow="ellipsis"
237
+ whiteSpace="nowrap"
238
+ >
239
+ {rank.symbol}
240
+ </Text>
241
+ <Text
242
+ mt={1}
243
+ fontSize="xs"
244
+ color="gray.500"
245
+ fontFamily="mono"
246
+ >
247
+ {formatInteger(rank.orders)} orders · win{' '}
248
+ {formatPercent(rank.winRate)} · avg{' '}
249
+ {rank.avgPnl == null
250
+ ? 'n/a'
251
+ : formatSignedNumber(rank.avgPnl)}
252
+ </Text>
253
+ </Box>
254
+
255
+ <Box flex="1" h="12px" bg="gray.800">
256
+ <Box
257
+ h="full"
258
+ w={`${barWidth}%`}
259
+ bg={getPnlBarColor(rank.pnl)}
260
+ />
261
+ </Box>
262
+
263
+ <Text
264
+ flex="0 0 96px"
265
+ color={getPnlColor(rank.pnl)}
266
+ fontSize="lg"
267
+ fontFamily="mono"
268
+ fontWeight="bold"
269
+ textAlign="right"
270
+ >
271
+ {formatSignedNumber(rank.pnl)}
272
+ </Text>
273
+ </Flex>
274
+ );
275
+ })}
276
+ </Flex>
277
+ </>
278
+ ) : (
279
+ <Box
280
+ p={3}
281
+ borderWidth="1px"
282
+ borderColor="gray.800"
283
+ borderRadius="md"
284
+ bg="blackAlpha.300"
285
+ >
286
+ <Text fontSize="sm" color="gray.500">
287
+ No symbol P&L data
288
+ </Text>
289
+ </Box>
290
+ )}
291
+ </Box>
292
+ );
293
+
294
+ return (
295
+ <Drawer.Root
296
+ size="xl"
297
+ open={open}
298
+ onOpenChange={(e) => onOpenChange(e.open)}
299
+ >
300
+ <Portal>
301
+ <Drawer.Backdrop />
302
+ <Drawer.Positioner>
303
+ <Drawer.Content
304
+ display="flex"
305
+ flexDirection="column"
306
+ w="50vw"
307
+ minW="640px"
308
+ maxW="50vw"
309
+ bg="gray.950"
310
+ >
311
+ <Drawer.Header>
312
+ <Drawer.Title>{snapshot.title}</Drawer.Title>
313
+ <Drawer.CloseTrigger asChild>
314
+ <CloseButton size="sm" />
315
+ </Drawer.CloseTrigger>
316
+ </Drawer.Header>
317
+
318
+ <Drawer.Body
319
+ display="flex"
320
+ flexDirection="column"
321
+ gap={4}
322
+ overflowY="auto"
323
+ flex="1"
324
+ minH="0"
325
+ w="full"
326
+ >
327
+ <Box
328
+ p={4}
329
+ borderWidth="1px"
330
+ borderColor="gray.800"
331
+ borderRadius="md"
332
+ bg="gray.900"
333
+ >
334
+ <Text fontSize="sm" color="gray.500" mb={3}>
335
+ {snapshot.subtitle || 'AI train details'}
336
+ </Text>
337
+
338
+ <SimpleGrid columns={{ base: 2, md: 4 }} gap={3}>
339
+ {drawerMetrics.map((metric) => (
340
+ <Box
341
+ key={metric.id}
342
+ p={3}
343
+ borderWidth="1px"
344
+ borderColor="gray.800"
345
+ borderRadius="md"
346
+ bg="blackAlpha.300"
347
+ >
348
+ <Text
349
+ fontSize="xs"
350
+ color="gray.400"
351
+ fontWeight="semibold"
352
+ textTransform="uppercase"
353
+ >
354
+ {metric.label}
355
+ </Text>
356
+ <Text
357
+ mt={1}
358
+ fontSize="xl"
359
+ color={getMetricColor(metric.tone)}
360
+ fontWeight="bold"
361
+ fontFamily="mono"
362
+ lineHeight="1.2"
363
+ >
364
+ {metric.value}
365
+ </Text>
366
+ </Box>
367
+ ))}
368
+ </SimpleGrid>
369
+ </Box>
370
+
371
+ <AdvancedMetricsPanel metrics={advancedMetrics} />
372
+
373
+ {monthlyStats.length ? (
374
+ <Box
375
+ p={4}
376
+ borderWidth="1px"
377
+ borderColor="gray.800"
378
+ borderRadius="md"
379
+ bg="gray.900"
380
+ >
381
+ <Text
382
+ fontSize="sm"
383
+ color="gray.300"
384
+ fontWeight="semibold"
385
+ mb={3}
386
+ >
387
+ Monthly Performance
388
+ </Text>
389
+
390
+ <Flex direction="column" gap={4}>
391
+ {monthlyStats.map((yearGroup) => (
392
+ <Box key={yearGroup.year}>
393
+ <Flex align="center" gap={3} mb={3}>
394
+ <Text
395
+ fontSize="lg"
396
+ color="gray.100"
397
+ fontWeight="bold"
398
+ fontFamily="mono"
399
+ >
400
+ {yearGroup.year}
401
+ </Text>
402
+ <Box flex="1" h="1px" bg="gray.800" />
403
+ </Flex>
404
+
405
+ <Flex direction="column" gap={3}>
406
+ {buildQuarterlyMonthlyStats(yearGroup.months).map(
407
+ (quarter) => (
408
+ <Flex
409
+ key={`${yearGroup.year}-${quarter.label}`}
410
+ align="stretch"
411
+ gap={3}
412
+ >
413
+ <Flex
414
+ w="34px"
415
+ flexShrink={0}
416
+ align="center"
417
+ justify="center"
418
+ >
419
+ <Text
420
+ fontSize="xs"
421
+ color="gray.500"
422
+ fontFamily="mono"
423
+ fontWeight="bold"
424
+ >
425
+ {quarter.label}
426
+ </Text>
427
+ </Flex>
428
+
429
+ <SimpleGrid columns={3} gap={3} flex="1">
430
+ {quarter.months.map((month, monthOffset) => {
431
+ const monthIndex =
432
+ quarter.monthIndexes[monthOffset] ?? 0;
433
+
434
+ if (!month) {
435
+ return (
436
+ <Box
437
+ key={`${quarter.label}-${monthIndex}-empty`}
438
+ minH="116px"
439
+ visibility="hidden"
440
+ />
441
+ );
442
+ }
443
+
444
+ const winRate =
445
+ month.orders > 0
446
+ ? (month.wins / month.orders) * 100
447
+ : null;
448
+
449
+ return (
450
+ <Box
451
+ key={month.id}
452
+ p={3}
453
+ minH="116px"
454
+ borderWidth="1px"
455
+ borderColor="gray.800"
456
+ borderLeftWidth="3px"
457
+ borderLeftColor={getPnlColor(month.pnl)}
458
+ borderRadius="md"
459
+ bg="blackAlpha.300"
460
+ >
461
+ <Flex
462
+ justify="space-between"
463
+ align="baseline"
464
+ gap={2}
465
+ >
466
+ <Text
467
+ fontSize="sm"
468
+ color="gray.200"
469
+ fontWeight="bold"
470
+ >
471
+ {month.monthLabel}
472
+ </Text>
473
+ <Text
474
+ fontSize="xs"
475
+ color="gray.500"
476
+ fontFamily="mono"
477
+ >
478
+ {String(month.monthIndex).padStart(
479
+ 2,
480
+ '0',
481
+ )}
482
+ </Text>
483
+ </Flex>
484
+ <Text
485
+ mt={3}
486
+ fontSize="xl"
487
+ color={getPnlColor(month.pnl)}
488
+ fontWeight="bold"
489
+ fontFamily="mono"
490
+ lineHeight="1.2"
491
+ >
492
+ {formatSignedNumber(month.pnl)}
493
+ </Text>
494
+ <Flex
495
+ mt={3}
496
+ justify="space-between"
497
+ gap={3}
498
+ >
499
+ <Box>
500
+ <Text
501
+ fontSize="xs"
502
+ color="gray.500"
503
+ >
504
+ Orders
505
+ </Text>
506
+ <Text
507
+ fontSize="sm"
508
+ color="gray.300"
509
+ fontFamily="mono"
510
+ fontWeight="semibold"
511
+ >
512
+ {formatInteger(month.orders)}
513
+ </Text>
514
+ </Box>
515
+ <Box textAlign="right">
516
+ <Text
517
+ fontSize="xs"
518
+ color="gray.500"
519
+ >
520
+ Win rate
521
+ </Text>
522
+ <Text
523
+ fontSize="sm"
524
+ color="gray.300"
525
+ fontFamily="mono"
526
+ fontWeight="semibold"
527
+ >
528
+ {formatPercent(winRate)}
529
+ </Text>
530
+ </Box>
531
+ </Flex>
532
+ </Box>
533
+ );
534
+ })}
535
+ </SimpleGrid>
536
+ </Flex>
537
+ ),
538
+ )}
539
+ </Flex>
540
+ </Box>
541
+ ))}
542
+ </Flex>
543
+ </Box>
544
+ ) : null}
545
+
546
+ {mode === 'ai' ? (
547
+ <Flex direction="column" gap={4}>
548
+ <ChartPanel
549
+ title="Drawdown Timeline"
550
+ subtitle="equity peak to current equity"
551
+ >
552
+ <DrawdownTimelineChart points={drawdownPoints} />
553
+ </ChartPanel>
554
+
555
+ <ChartPanel
556
+ title="Rolling Performance"
557
+ subtitle="last 50 trades"
558
+ >
559
+ <RollingPerformanceChart
560
+ points={rollingPerformancePoints}
561
+ />
562
+ </ChartPanel>
563
+
564
+ <ChartPanel
565
+ title="Win / Loss Streak Timeline"
566
+ subtitle="trade sequence"
567
+ >
568
+ <WinLossStreakTimelineChart trades={snapshotTradePoints} />
569
+ </ChartPanel>
570
+
571
+ <ChartPanel
572
+ title="P&L Distribution"
573
+ subtitle="trade result buckets"
574
+ >
575
+ <PnlDistributionChart bins={pnlDistributionBins} />
576
+ </ChartPanel>
577
+
578
+ <ChartPanel
579
+ title="P&L by Time of Day / Session"
580
+ subtitle="UTC"
581
+ >
582
+ <TimeOfDaySessionChart
583
+ sessions={sessionPnlStats}
584
+ hours={hourlyPnlStats}
585
+ />
586
+ </ChartPanel>
587
+ </Flex>
588
+ ) : null}
589
+
590
+ {mode === 'ai' ? (
591
+ <Flex direction="column" gap={4}>
592
+ {renderSymbolPnlRanking({
593
+ title: 'P&L Ranking',
594
+ subtitle: 'Top 10 contracts',
595
+ ranking: topSymbolPnlRanking,
596
+ })}
597
+ {renderSymbolPnlRanking({
598
+ title: 'Worst Contracts',
599
+ subtitle: 'Worst 10 contracts',
600
+ ranking: worstSymbolPnlRanking,
601
+ })}
602
+ </Flex>
603
+ ) : null}
604
+
605
+ <Box
606
+ p={4}
607
+ borderWidth="1px"
608
+ borderColor="gray.800"
609
+ borderRadius="md"
610
+ bg="gray.900"
611
+ >
612
+ <Text
613
+ fontSize="sm"
614
+ color="gray.300"
615
+ fontWeight="semibold"
616
+ mb={3}
617
+ >
618
+ LONG / SHORT
619
+ </Text>
620
+ <SimpleGrid columns={{ base: 1, md: 2 }} gap={3}>
621
+ {directionStatGroups.map((group) => (
622
+ <Box
623
+ key={group.direction}
624
+ p={3}
625
+ borderWidth="1px"
626
+ borderColor="gray.800"
627
+ borderRadius="md"
628
+ bg="blackAlpha.300"
629
+ >
630
+ <Flex justify="space-between" align="center" mb={3}>
631
+ <Text
632
+ fontSize="sm"
633
+ color={
634
+ group.direction === 'LONG' ? 'teal.400' : 'pink.300'
635
+ }
636
+ fontWeight="bold"
637
+ >
638
+ {group.direction}
639
+ </Text>
640
+ {!group.hasData ? (
641
+ <Text fontSize="xs" color="gray.500">
642
+ no data
643
+ </Text>
644
+ ) : null}
645
+ </Flex>
646
+
647
+ <SimpleGrid columns={1} gap={2}>
648
+ {group.metrics.map((metric) => (
649
+ <Flex
650
+ key={metric.id}
651
+ justify="space-between"
652
+ align="baseline"
653
+ gap={3}
654
+ >
655
+ <Text fontSize="xs" color="gray.500">
656
+ {metric.label}
657
+ </Text>
658
+ <Text
659
+ fontSize="sm"
660
+ color={getMetricColor(metric.tone)}
661
+ fontFamily="mono"
662
+ fontWeight="semibold"
663
+ textAlign="right"
664
+ >
665
+ {metric.value}
666
+ </Text>
667
+ </Flex>
668
+ ))}
669
+ </SimpleGrid>
670
+ </Box>
671
+ ))}
672
+ </SimpleGrid>
673
+ </Box>
674
+
675
+ <AiDiagnosticsPanel groups={aiDiagnosticGroups} />
676
+ </Drawer.Body>
677
+ </Drawer.Content>
678
+ </Drawer.Positioner>
679
+ </Portal>
680
+ </Drawer.Root>
681
+ );
682
+ };