@tradejs/app 2.0.18 → 2.0.20

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 (52) hide show
  1. package/package.json +14 -8
  2. package/src/app/actions/backtest.ts +2 -1
  3. package/src/app/actions/scanner.ts +2 -1
  4. package/src/app/api/backtest/files/route.ts +2 -1
  5. package/src/app/api/backtest/test/[strategy]/[name]/route.ts +1 -1
  6. package/src/app/api/derivatives/[symbol]/[interval]/route.ts +1 -1
  7. package/src/app/api/derivatives/summary/route.ts +1 -1
  8. package/src/app/api/signal/[symbol]/[signalId]/route.ts +2 -1
  9. package/src/app/api/spread/[symbol]/[interval]/route.ts +1 -1
  10. package/src/app/api/spread/summary/route.ts +1 -1
  11. package/src/app/api/strategies/runtime/route.ts +4 -674
  12. package/src/app/api/user/runtime-deployments/[deploymentId]/route.ts +1 -1
  13. package/src/app/api/user/runtime-deployments/route.ts +2 -2
  14. package/src/app/api/user/runtime-strategy-configs/route.ts +22 -212
  15. package/src/app/api/user/trading-accounts/[accountId]/route.ts +1 -1
  16. package/src/app/components/Backtest/TestList/index.tsx +1 -1
  17. package/src/app/components/Dashboard/KlineChart/figures/circle.ts +1 -1
  18. package/src/app/components/Dashboard/KlineChart/figures/diamond.ts +1 -1
  19. package/src/app/components/Dashboard/KlineChart/figures/label.ts +1 -1
  20. package/src/app/components/Dashboard/KlineChart/figures/rectangle.ts +1 -1
  21. package/src/app/components/Dashboard/KlineChart/figures/star.ts +1 -1
  22. package/src/app/components/Dashboard/KlineChart/index.tsx +2 -1
  23. package/src/app/components/Shared/Filters/Root/index.tsx +1 -1
  24. package/src/app/components/Shared/Filters/context.ts +1 -1
  25. package/src/app/components/Strategies/RuntimeStrategyCard.tsx +82 -861
  26. package/src/app/components/Strategies/RuntimeStrategyChart.tsx +115 -184
  27. package/src/app/components/Strategies/StrategyEvidencePopover.tsx +259 -0
  28. package/src/app/components/Strategies/StrategyPerformanceCharts.tsx +419 -0
  29. package/src/app/components/Strategies/StrategySnapshotCard.tsx +122 -937
  30. package/src/app/components/UI/Segment/index.tsx +1 -1
  31. package/src/app/components/UI/Select/index.tsx +1 -1
  32. package/src/app/components/UI/SelectWithSearch/index.tsx +1 -1
  33. package/src/app/lib/backtestJobContracts.ts +67 -0
  34. package/src/app/lib/backtestJobProgress.ts +28 -0
  35. package/src/app/lib/backtestJobRequest.ts +107 -0
  36. package/src/app/lib/backtestJobs.ts +25 -257
  37. package/src/app/lib/runtimeDashboard.ts +700 -0
  38. package/src/app/lib/runtimeStrategies.ts +22 -457
  39. package/src/app/lib/runtimeStrategyConfigService.ts +279 -0
  40. package/src/app/lib/runtimeStrategyLineage.ts +264 -0
  41. package/src/app/lib/runtimeTradeReconciliation.ts +113 -0
  42. package/src/app/lib/runtimeTradeSync.ts +1 -1
  43. package/src/app/lib/strategyEvidenceTimeline.ts +298 -0
  44. package/src/app/lib/strategyPerformance.ts +387 -0
  45. package/src/app/routes/dashboard/Dashboard.tsx +2 -6
  46. package/src/app/routes/derivatives/derivativesViewModel.ts +253 -0
  47. package/src/app/routes/derivatives/page.tsx +70 -262
  48. package/src/app/store/filters.ts +2 -1
  49. package/src/app/store/indicators.ts +2 -1
  50. package/src/app/store/tests.ts +1 -1
  51. package/src/app/store/tickers.ts +2 -1
  52. package/src/app/types/ui.ts +20 -0
@@ -14,7 +14,7 @@ import {
14
14
  Stat,
15
15
  Text,
16
16
  } from '@chakra-ui/react';
17
- import { type ReactNode, useMemo, useState } from 'react';
17
+ import { useMemo, useState } from 'react';
18
18
  import {
19
19
  calculateAdvancedTradeMetrics,
20
20
  getFormatted,
@@ -41,9 +41,26 @@ import {
41
41
  type OrdersDrawerOrder,
42
42
  } from '#components/Shared/OrdersDrawer';
43
43
  import { deleteStrategyCard } from '#actions/strategies';
44
+ import {
45
+ buildQuarterlyMonthlyStats,
46
+ buildStrategyPerformanceViewModel,
47
+ calculateMaxDrawdownValue,
48
+ calculateMaxGrossStreak,
49
+ calculateMaxLossStreak,
50
+ formatMaxDrawdownPercent as calculateMaxDrawdownPercent,
51
+ getEquityStepPnl as getSnapshotStepPnl,
52
+ } from '#app/lib/strategyPerformance';
44
53
  import { toaster } from '#ui';
45
54
  import { AdvancedMetricsPanel } from './AdvancedMetricsPanel';
46
55
  import { StrategySnapshotChart } from './StrategySnapshotChart';
56
+ import {
57
+ ChartPanel,
58
+ DrawdownTimelineChart,
59
+ PnlDistributionChart,
60
+ RollingPerformanceChart,
61
+ TimeOfDaySessionChart,
62
+ WinLossStreakTimelineChart,
63
+ } from './StrategyPerformanceCharts';
47
64
 
48
65
  const MS_IN_HOUR = 60 * 60 * 1000;
49
66
  const SNAPSHOT_ORDER_ROW_HEIGHT = 318;
@@ -79,28 +96,6 @@ interface DirectionStatGroup {
79
96
  hasData: boolean;
80
97
  }
81
98
 
82
- interface MonthlyStat {
83
- id: string;
84
- year: number;
85
- monthIndex: number;
86
- monthLabel: string;
87
- orders: number;
88
- wins: number;
89
- pnl: number;
90
- }
91
-
92
- interface YearlyMonthlyStats {
93
- year: number;
94
- months: MonthlyStat[];
95
- }
96
-
97
- interface QuarterlyMonthlyStats {
98
- label: string;
99
- monthIndexes: readonly number[];
100
- months: (MonthlyStat | null)[];
101
- hasData: boolean;
102
- }
103
-
104
99
  interface SymbolPnlRank {
105
100
  symbol: string;
106
101
  pnl: number;
@@ -125,47 +120,6 @@ interface AiDiagnosticGroup {
125
120
  metrics: AiDiagnosticMetric[];
126
121
  }
127
122
 
128
- interface SnapshotTradePoint {
129
- index: number;
130
- timestamp: number;
131
- pnl: number;
132
- equity: number;
133
- hour: number;
134
- session: TradingSession;
135
- }
136
-
137
- interface DrawdownPoint {
138
- timestamp: number;
139
- drawdownPercent: number;
140
- }
141
-
142
- interface RollingPerformancePoint {
143
- index: number;
144
- winRate: number;
145
- pnl: number;
146
- }
147
-
148
- interface DistributionBin {
149
- id: string;
150
- min: number;
151
- max: number;
152
- count: number;
153
- }
154
-
155
- type TradingSession = 'Asia' | 'Europe' | 'US';
156
-
157
- interface SessionPnlStat {
158
- session: TradingSession;
159
- pnl: number;
160
- orders: number;
161
- }
162
-
163
- interface HourlyPnlStat {
164
- hour: number;
165
- pnl: number;
166
- orders: number;
167
- }
168
-
169
123
  const getMetricColor = (tone: StrategyChartMetric['tone']) => {
170
124
  switch (tone) {
171
125
  case 'success':
@@ -181,86 +135,9 @@ const getMetricColor = (tone: StrategyChartMetric['tone']) => {
181
135
  }
182
136
  };
183
137
 
184
- const calculateMaxDrawdownValue = (orderLog: Array<[number, number]>) => {
185
- if (!orderLog.length) {
186
- return null;
187
- }
188
-
189
- let peak = orderLog[0]?.[1] ?? 0;
190
- let maxDrawdownPercent = 0;
191
-
192
- for (const [, amount] of orderLog) {
193
- if (!Number.isFinite(amount)) {
194
- continue;
195
- }
196
-
197
- peak = Math.max(peak, amount);
198
- if (peak <= 0) {
199
- continue;
200
- }
201
-
202
- const drawdownPercent = ((peak - amount) / peak) * 100;
203
- maxDrawdownPercent = Math.max(maxDrawdownPercent, drawdownPercent);
204
- }
205
-
206
- return maxDrawdownPercent;
207
- };
208
-
209
- const calculateMaxDrawdownPercent = (orderLog: Array<[number, number]>) => {
210
- const maxDrawdownPercent = calculateMaxDrawdownValue(orderLog);
211
- return maxDrawdownPercent == null
212
- ? null
213
- : `${maxDrawdownPercent.toFixed(1)}%`;
214
- };
215
-
216
- const getSnapshotStepPnl = (
217
- orderLog: StrategyChartSnapshot['orderLog'],
218
- index: number,
219
- ) => {
220
- const current = orderLog[index];
221
- const previous = orderLog[index - 1];
222
-
223
- if (!current || !previous) {
224
- return null;
225
- }
226
-
227
- return current[1] - previous[1];
228
- };
229
-
230
138
  const asFiniteNumber = (value: unknown) =>
231
139
  typeof value === 'number' && Number.isFinite(value) ? value : null;
232
140
 
233
- const calculateMaxPnlStreak = (
234
- orderLog: StrategyChartSnapshot['orderLog'],
235
- isStreakPnl: (pnl: number) => boolean,
236
- ) => {
237
- let currentStreak = 0;
238
- let maxStreak = 0;
239
-
240
- for (let index = 1; index < orderLog.length; index += 1) {
241
- const pnl = getSnapshotStepPnl(orderLog, index);
242
- if (typeof pnl !== 'number' || !Number.isFinite(pnl)) {
243
- continue;
244
- }
245
-
246
- if (isStreakPnl(pnl)) {
247
- currentStreak += 1;
248
- maxStreak = Math.max(maxStreak, currentStreak);
249
- continue;
250
- }
251
-
252
- currentStreak = 0;
253
- }
254
-
255
- return maxStreak;
256
- };
257
-
258
- const calculateMaxGrossStreak = (orderLog: StrategyChartSnapshot['orderLog']) =>
259
- calculateMaxPnlStreak(orderLog, (pnl) => pnl > 0);
260
-
261
- const calculateMaxLossStreak = (orderLog: StrategyChartSnapshot['orderLog']) =>
262
- calculateMaxPnlStreak(orderLog, (pnl) => pnl < 0);
263
-
264
141
  const getSnapshotTradePnls = (snapshot: StrategyChartSnapshot) => {
265
142
  const orderPnls = snapshot.orders
266
143
  .map((order) => asFiniteNumber(order.pnl))
@@ -359,185 +236,6 @@ const buildSnapshotSummaryMetrics = (snapshot: StrategyChartSnapshot) => {
359
236
  });
360
237
  };
361
238
 
362
- const resolveTradingSession = (hour: number): TradingSession => {
363
- if (hour < 8) {
364
- return 'Asia';
365
- }
366
-
367
- if (hour < 16) {
368
- return 'Europe';
369
- }
370
-
371
- return 'US';
372
- };
373
-
374
- const buildSnapshotTradePoints = (
375
- orderLog: StrategyChartSnapshot['orderLog'],
376
- ): SnapshotTradePoint[] => {
377
- const points: SnapshotTradePoint[] = [];
378
-
379
- for (let index = 1; index < orderLog.length; index += 1) {
380
- const current = orderLog[index];
381
- const previous = orderLog[index - 1];
382
- if (!current || !previous) {
383
- continue;
384
- }
385
-
386
- const [timestamp, equity] = current;
387
- const pnl = equity - previous[1];
388
- if (
389
- !Number.isFinite(timestamp) ||
390
- !Number.isFinite(equity) ||
391
- !Number.isFinite(pnl)
392
- ) {
393
- continue;
394
- }
395
-
396
- const hour = new Date(timestamp).getUTCHours();
397
- points.push({
398
- index,
399
- timestamp,
400
- pnl,
401
- equity,
402
- hour,
403
- session: resolveTradingSession(hour),
404
- });
405
- }
406
-
407
- return points;
408
- };
409
-
410
- const buildDrawdownPoints = (
411
- orderLog: StrategyChartSnapshot['orderLog'],
412
- ): DrawdownPoint[] => {
413
- let peak = orderLog[0]?.[1] ?? 0;
414
-
415
- return orderLog
416
- .map(([timestamp, equity]) => {
417
- if (!Number.isFinite(equity)) {
418
- return null;
419
- }
420
-
421
- peak = Math.max(peak, equity);
422
- const drawdownPercent = peak > 0 ? ((peak - equity) / peak) * 100 : 0;
423
-
424
- return {
425
- timestamp,
426
- drawdownPercent,
427
- };
428
- })
429
- .filter((point): point is DrawdownPoint => point != null);
430
- };
431
-
432
- const buildRollingPerformance = (
433
- trades: SnapshotTradePoint[],
434
- windowSize = 50,
435
- ): RollingPerformancePoint[] =>
436
- trades.map((trade, index) => {
437
- const windowTrades = trades.slice(
438
- Math.max(0, index - windowSize + 1),
439
- index + 1,
440
- );
441
- const wins = windowTrades.filter((item) => item.pnl > 0).length;
442
- const pnl = windowTrades.reduce((sum, item) => sum + item.pnl, 0);
443
-
444
- return {
445
- index: trade.index,
446
- winRate: windowTrades.length > 0 ? (wins / windowTrades.length) * 100 : 0,
447
- pnl,
448
- };
449
- });
450
-
451
- const buildPnlDistribution = (
452
- trades: SnapshotTradePoint[],
453
- binCount = 12,
454
- ): DistributionBin[] => {
455
- if (!trades.length) {
456
- return [];
457
- }
458
-
459
- const pnlValues = trades.map((trade) => trade.pnl);
460
- const min = Math.min(...pnlValues);
461
- const max = Math.max(...pnlValues);
462
-
463
- if (!Number.isFinite(min) || !Number.isFinite(max)) {
464
- return [];
465
- }
466
-
467
- if (min === max) {
468
- return [
469
- {
470
- id: `${min}:${max}`,
471
- min,
472
- max,
473
- count: trades.length,
474
- },
475
- ];
476
- }
477
-
478
- const step = (max - min) / binCount;
479
- const bins = Array.from({ length: binCount }, (_, index) => ({
480
- id: String(index),
481
- min: min + step * index,
482
- max: index === binCount - 1 ? max : min + step * (index + 1),
483
- count: 0,
484
- }));
485
-
486
- for (const pnl of pnlValues) {
487
- const rawIndex = Math.floor((pnl - min) / step);
488
- const index = Math.max(0, Math.min(binCount - 1, rawIndex));
489
- const bin = bins[index];
490
- if (bin) {
491
- bin.count += 1;
492
- }
493
- }
494
-
495
- return bins;
496
- };
497
-
498
- const buildSessionPnlStats = (
499
- trades: SnapshotTradePoint[],
500
- ): SessionPnlStat[] => {
501
- const stats = new Map<TradingSession, SessionPnlStat>(
502
- (['Asia', 'Europe', 'US'] as const).map((session) => [
503
- session,
504
- { session, pnl: 0, orders: 0 },
505
- ]),
506
- );
507
-
508
- for (const trade of trades) {
509
- const stat = stats.get(trade.session);
510
- if (!stat) {
511
- continue;
512
- }
513
-
514
- stat.pnl += trade.pnl;
515
- stat.orders += 1;
516
- }
517
-
518
- return [...stats.values()];
519
- };
520
-
521
- const buildHourlyPnlStats = (trades: SnapshotTradePoint[]): HourlyPnlStat[] => {
522
- const stats = Array.from({ length: 24 }, (_, hour) => ({
523
- hour,
524
- pnl: 0,
525
- orders: 0,
526
- }));
527
-
528
- for (const trade of trades) {
529
- const stat = stats[trade.hour];
530
- if (!stat) {
531
- continue;
532
- }
533
-
534
- stat.pnl += trade.pnl;
535
- stat.orders += 1;
536
- }
537
-
538
- return stats;
539
- };
540
-
541
239
  const formatPrice = (value: number | null | undefined) =>
542
240
  formatPriceUsdt(value);
543
241
 
@@ -555,403 +253,6 @@ const formatBps = (value: number | null | undefined) => {
555
253
  const formatAiExitReason = (reason: string | null | undefined) =>
556
254
  reason ? reason.replace(/_/g, ' ').toUpperCase() : 'CLOSED';
557
255
 
558
- const CHART_WIDTH = 640;
559
- const CHART_HEIGHT = 120;
560
- const CHART_PADDING = 10;
561
- const POSITIVE_CHART_COLOR = '#5eead4';
562
- const NEGATIVE_CHART_COLOR = '#f87171';
563
- const NEUTRAL_CHART_COLOR = '#6b7280';
564
-
565
- const getChartPnlColor = (value: number) =>
566
- value > 0
567
- ? POSITIVE_CHART_COLOR
568
- : value < 0
569
- ? NEGATIVE_CHART_COLOR
570
- : NEUTRAL_CHART_COLOR;
571
-
572
- const buildPolylinePoints = (values: number[]) => {
573
- if (!values.length) {
574
- return '';
575
- }
576
-
577
- const min = Math.min(...values);
578
- const max = Math.max(...values);
579
- const range = max - min || 1;
580
- const drawableWidth = CHART_WIDTH - CHART_PADDING * 2;
581
- const drawableHeight = CHART_HEIGHT - CHART_PADDING * 2;
582
-
583
- return values
584
- .map((value, index) => {
585
- const x =
586
- CHART_PADDING +
587
- (values.length === 1
588
- ? 0
589
- : (index / (values.length - 1)) * drawableWidth);
590
- const y = CHART_PADDING + ((max - value) / range) * drawableHeight;
591
-
592
- return `${x.toFixed(2)},${y.toFixed(2)}`;
593
- })
594
- .join(' ');
595
- };
596
-
597
- const ChartPanel = ({
598
- title,
599
- subtitle,
600
- children,
601
- }: {
602
- title: string;
603
- subtitle?: string;
604
- children: ReactNode;
605
- }) => (
606
- <Box
607
- p={4}
608
- borderWidth="1px"
609
- borderColor="gray.800"
610
- borderRadius="md"
611
- bg="gray.900"
612
- minH="210px"
613
- >
614
- <Flex justify="space-between" align="baseline" gap={3} mb={3}>
615
- <Text fontSize="sm" color="gray.300" fontWeight="semibold">
616
- {title}
617
- </Text>
618
- {subtitle ? (
619
- <Text fontSize="xs" color="gray.500" textAlign="right">
620
- {subtitle}
621
- </Text>
622
- ) : null}
623
- </Flex>
624
- {children}
625
- </Box>
626
- );
627
-
628
- const EmptyChart = () => (
629
- <Flex h="140px" align="center" justify="center">
630
- <Text fontSize="sm" color="gray.500">
631
- No trade data
632
- </Text>
633
- </Flex>
634
- );
635
-
636
- const DrawdownTimelineChart = ({ points }: { points: DrawdownPoint[] }) => {
637
- if (points.length < 2) {
638
- return <EmptyChart />;
639
- }
640
-
641
- const values = points.map((point) => -point.drawdownPercent);
642
- const maxDrawdown = Math.max(...points.map((point) => point.drawdownPercent));
643
- const linePoints = buildPolylinePoints(values);
644
-
645
- return (
646
- <Box>
647
- <svg
648
- width="100%"
649
- viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
650
- role="img"
651
- aria-label="Drawdown timeline"
652
- >
653
- <line
654
- x1={CHART_PADDING}
655
- x2={CHART_WIDTH - CHART_PADDING}
656
- y1={CHART_PADDING}
657
- y2={CHART_PADDING}
658
- stroke="#374151"
659
- strokeDasharray="4 4"
660
- />
661
- <polyline
662
- points={linePoints}
663
- fill="none"
664
- stroke={NEGATIVE_CHART_COLOR}
665
- strokeWidth="2"
666
- />
667
- </svg>
668
- <Flex justify="space-between" align="center" mt={2}>
669
- <Text fontSize="xs" color="gray.500">
670
- max drawdown
671
- </Text>
672
- <Text
673
- fontSize="sm"
674
- color="orange.300"
675
- fontFamily="mono"
676
- fontWeight="bold"
677
- >
678
- {formatPercent(maxDrawdown)}
679
- </Text>
680
- </Flex>
681
- </Box>
682
- );
683
- };
684
-
685
- const WinLossStreakTimelineChart = ({
686
- trades,
687
- }: {
688
- trades: SnapshotTradePoint[];
689
- }) => {
690
- if (!trades.length) {
691
- return <EmptyChart />;
692
- }
693
-
694
- const maxAbsPnl = Math.max(...trades.map((trade) => Math.abs(trade.pnl)), 1);
695
- const centerY = CHART_HEIGHT / 2;
696
- const barWidth = CHART_WIDTH / trades.length;
697
-
698
- return (
699
- <Box>
700
- <svg
701
- width="100%"
702
- viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
703
- role="img"
704
- aria-label="Win loss streak timeline"
705
- >
706
- <line
707
- x1="0"
708
- x2={CHART_WIDTH}
709
- y1={centerY}
710
- y2={centerY}
711
- stroke="#374151"
712
- />
713
- {trades.map((trade, index) => {
714
- const height = Math.max(2, (Math.abs(trade.pnl) / maxAbsPnl) * 48);
715
- const isWin = trade.pnl > 0;
716
- const y = isWin ? centerY - height : centerY;
717
-
718
- return (
719
- <rect
720
- key={`${trade.timestamp}-${index}`}
721
- x={index * barWidth}
722
- y={y}
723
- width={Math.max(1, barWidth - 0.4)}
724
- height={height}
725
- fill={getChartPnlColor(trade.pnl)}
726
- opacity="0.9"
727
- />
728
- );
729
- })}
730
- </svg>
731
- <Flex justify="space-between" align="center" mt={2}>
732
- <Text fontSize="xs" color="gray.500">
733
- wins above line, losses below
734
- </Text>
735
- <Text
736
- fontSize="sm"
737
- color="gray.300"
738
- fontFamily="mono"
739
- fontWeight="bold"
740
- >
741
- {formatInteger(trades.length)} trades
742
- </Text>
743
- </Flex>
744
- </Box>
745
- );
746
- };
747
-
748
- const PnlDistributionChart = ({ bins }: { bins: DistributionBin[] }) => {
749
- if (!bins.length) {
750
- return <EmptyChart />;
751
- }
752
-
753
- const maxCount = Math.max(...bins.map((bin) => bin.count), 1);
754
- const barWidth = CHART_WIDTH / bins.length;
755
-
756
- return (
757
- <Box>
758
- <svg
759
- width="100%"
760
- viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
761
- role="img"
762
- aria-label="P and L distribution"
763
- >
764
- {bins.map((bin, index) => {
765
- const height = Math.max(2, (bin.count / maxCount) * 92);
766
- const x = index * barWidth + 2;
767
- const y = CHART_HEIGHT - height - CHART_PADDING;
768
- const midpoint = (bin.min + bin.max) / 2;
769
-
770
- return (
771
- <rect
772
- key={bin.id}
773
- x={x}
774
- y={y}
775
- width={Math.max(2, barWidth - 4)}
776
- height={height}
777
- fill={getChartPnlColor(midpoint)}
778
- />
779
- );
780
- })}
781
- </svg>
782
- <Flex justify="space-between" align="center" mt={2}>
783
- <Text fontSize="xs" color="gray.500">
784
- trade P&L buckets
785
- </Text>
786
- <Text
787
- fontSize="sm"
788
- color="gray.300"
789
- fontFamily="mono"
790
- fontWeight="bold"
791
- >
792
- {formatInteger(bins.reduce((sum, bin) => sum + bin.count, 0))}
793
- </Text>
794
- </Flex>
795
- </Box>
796
- );
797
- };
798
-
799
- const RollingPerformanceChart = ({
800
- points,
801
- }: {
802
- points: RollingPerformancePoint[];
803
- }) => {
804
- if (points.length < 2) {
805
- return <EmptyChart />;
806
- }
807
-
808
- const winRateLine = buildPolylinePoints(points.map((point) => point.winRate));
809
- const pnlLine = buildPolylinePoints(points.map((point) => point.pnl));
810
- const latest = points[points.length - 1];
811
-
812
- return (
813
- <Box>
814
- <svg
815
- width="100%"
816
- viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
817
- role="img"
818
- aria-label="Rolling win rate and rolling P and L"
819
- >
820
- <line
821
- x1={CHART_PADDING}
822
- x2={CHART_WIDTH - CHART_PADDING}
823
- y1={CHART_HEIGHT / 2}
824
- y2={CHART_HEIGHT / 2}
825
- stroke="#374151"
826
- strokeDasharray="4 4"
827
- />
828
- <polyline
829
- points={pnlLine}
830
- fill="none"
831
- stroke={POSITIVE_CHART_COLOR}
832
- strokeWidth="2"
833
- />
834
- <polyline
835
- points={winRateLine}
836
- fill="none"
837
- stroke="#fbbf24"
838
- strokeWidth="2"
839
- opacity="0.9"
840
- />
841
- </svg>
842
- <Flex justify="space-between" align="center" mt={2}>
843
- <Text fontSize="xs" color="gray.500">
844
- teal P&L, yellow win rate
845
- </Text>
846
- <Text
847
- fontSize="sm"
848
- color="gray.300"
849
- fontFamily="mono"
850
- fontWeight="bold"
851
- >
852
- {formatPercent(latest?.winRate)} /{' '}
853
- {formatSignedNumber(latest?.pnl ?? null)}
854
- </Text>
855
- </Flex>
856
- </Box>
857
- );
858
- };
859
-
860
- const TimeOfDaySessionChart = ({
861
- sessions,
862
- hours,
863
- }: {
864
- sessions: SessionPnlStat[];
865
- hours: HourlyPnlStat[];
866
- }) => {
867
- if (!sessions.some((session) => session.orders > 0)) {
868
- return <EmptyChart />;
869
- }
870
-
871
- const maxSessionAbsPnl = Math.max(
872
- ...sessions.map((session) => Math.abs(session.pnl)),
873
- 1,
874
- );
875
- const maxHourAbsPnl = Math.max(...hours.map((hour) => Math.abs(hour.pnl)), 1);
876
-
877
- return (
878
- <Flex direction="column" gap={4}>
879
- <SimpleGrid columns={3} gap={3}>
880
- {sessions.map((session) => {
881
- const width = Math.max(
882
- 4,
883
- (Math.abs(session.pnl) / maxSessionAbsPnl) * 100,
884
- );
885
-
886
- return (
887
- <Box key={session.session}>
888
- <Flex justify="space-between" align="center" mb={1}>
889
- <Text fontSize="xs" color="gray.400" fontWeight="semibold">
890
- {session.session}
891
- </Text>
892
- <Text
893
- fontSize="xs"
894
- color={getPnlColor(session.pnl)}
895
- fontFamily="mono"
896
- fontWeight="bold"
897
- >
898
- {formatSignedNumber(session.pnl)}
899
- </Text>
900
- </Flex>
901
- <Box h="8px" bg="gray.800">
902
- <Box
903
- h="full"
904
- w={`${width}%`}
905
- bg={getChartPnlColor(session.pnl)}
906
- />
907
- </Box>
908
- <Text mt={1} fontSize="xs" color="gray.500" fontFamily="mono">
909
- {formatInteger(session.orders)} orders
910
- </Text>
911
- </Box>
912
- );
913
- })}
914
- </SimpleGrid>
915
-
916
- <svg
917
- width="100%"
918
- viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
919
- role="img"
920
- aria-label="P and L by UTC hour"
921
- >
922
- <line
923
- x1="0"
924
- x2={CHART_WIDTH}
925
- y1={CHART_HEIGHT / 2}
926
- y2={CHART_HEIGHT / 2}
927
- stroke="#374151"
928
- />
929
- {hours.map((hour) => {
930
- const barWidth = CHART_WIDTH / 24;
931
- const height = Math.max(1, (Math.abs(hour.pnl) / maxHourAbsPnl) * 46);
932
- const isPositive = hour.pnl >= 0;
933
- const y = isPositive ? CHART_HEIGHT / 2 - height : CHART_HEIGHT / 2;
934
-
935
- return (
936
- <rect
937
- key={hour.hour}
938
- x={hour.hour * barWidth + 2}
939
- y={y}
940
- width={Math.max(2, barWidth - 4)}
941
- height={height}
942
- fill={getChartPnlColor(hour.pnl)}
943
- opacity={hour.orders > 0 ? 0.95 : 0.2}
944
- />
945
- );
946
- })}
947
- </svg>
948
- <Text fontSize="xs" color="gray.500">
949
- UTC hours, sessions: Asia 00-07, Europe 08-15, US 16-23
950
- </Text>
951
- </Flex>
952
- );
953
- };
954
-
955
256
  const getAiExitReasonColor = (reason: string | null | undefined) => {
956
257
  switch (reason) {
957
258
  case 'take_profit':
@@ -1833,105 +1134,6 @@ const AiDiagnosticsPanel = ({ groups }: { groups: AiDiagnosticGroup[] }) => {
1833
1134
  );
1834
1135
  };
1835
1136
 
1836
- const getMonthLabel = (monthIndex: number) =>
1837
- new Date(Date.UTC(2026, monthIndex - 1, 1)).toLocaleString('en-US', {
1838
- month: 'short',
1839
- });
1840
-
1841
- const monthQuarters = [
1842
- { label: 'Q1', months: [1, 2, 3] },
1843
- { label: 'Q2', months: [4, 5, 6] },
1844
- { label: 'Q3', months: [7, 8, 9] },
1845
- { label: 'Q4', months: [10, 11, 12] },
1846
- ] as const;
1847
-
1848
- const buildQuarterlyMonthlyStats = (
1849
- months: MonthlyStat[],
1850
- ): QuarterlyMonthlyStats[] => {
1851
- const byMonth = new Map(months.map((month) => [month.monthIndex, month]));
1852
-
1853
- return monthQuarters
1854
- .map((quarter) => {
1855
- const quarterMonths = quarter.months.map(
1856
- (monthIndex) => byMonth.get(monthIndex) ?? null,
1857
- );
1858
-
1859
- return {
1860
- label: quarter.label,
1861
- monthIndexes: quarter.months,
1862
- months: quarterMonths,
1863
- hasData: quarterMonths.some((month) => month != null),
1864
- };
1865
- })
1866
- .filter((quarter) => quarter.hasData);
1867
- };
1868
-
1869
- const buildMonthlyStats = (
1870
- orderLog: StrategyChartSnapshot['orderLog'],
1871
- ): YearlyMonthlyStats[] => {
1872
- const grouped = new Map<string, MonthlyStat>();
1873
-
1874
- for (let index = 1; index < orderLog.length; index += 1) {
1875
- const current = orderLog[index];
1876
- const previous = orderLog[index - 1];
1877
- if (!current || !previous) {
1878
- continue;
1879
- }
1880
-
1881
- const [timestamp, amount] = current;
1882
- const previousAmount = previous[1];
1883
- if (
1884
- typeof timestamp !== 'number' ||
1885
- !Number.isFinite(timestamp) ||
1886
- typeof amount !== 'number' ||
1887
- !Number.isFinite(amount) ||
1888
- typeof previousAmount !== 'number' ||
1889
- !Number.isFinite(previousAmount)
1890
- ) {
1891
- continue;
1892
- }
1893
-
1894
- const date = new Date(timestamp);
1895
- const year = date.getUTCFullYear();
1896
- const monthIndex = date.getUTCMonth() + 1;
1897
- const id = `${year}-${String(monthIndex).padStart(2, '0')}`;
1898
- const pnl = amount - previousAmount;
1899
- const existing = grouped.get(id) ?? {
1900
- id,
1901
- year,
1902
- monthIndex,
1903
- monthLabel: getMonthLabel(monthIndex),
1904
- orders: 0,
1905
- wins: 0,
1906
- pnl: 0,
1907
- };
1908
-
1909
- existing.orders += 1;
1910
- existing.wins += pnl > 0 ? 1 : 0;
1911
- existing.pnl += pnl;
1912
- grouped.set(id, existing);
1913
- }
1914
-
1915
- const monthlyStats = [...grouped.values()].sort(
1916
- (left, right) =>
1917
- left.year - right.year || left.monthIndex - right.monthIndex,
1918
- );
1919
- const yearlyStats = new Map<number, MonthlyStat[]>();
1920
-
1921
- for (const month of monthlyStats) {
1922
- const months = yearlyStats.get(month.year) ?? [];
1923
- months.push(month);
1924
- yearlyStats.set(month.year, months);
1925
- }
1926
-
1927
- return [...yearlyStats.entries()]
1928
- .sort(([leftYear], [rightYear]) => leftYear - rightYear)
1929
- .map(([year, months]) => ({
1930
- year,
1931
- months,
1932
- }));
1933
- };
1934
-
1935
1137
  const buildSymbolPnlRanking = (
1936
1138
  details: StrategyChartDetail[] | undefined,
1937
1139
  ): SymbolPnlRank[] => {
@@ -1989,110 +1191,14 @@ const buildSymbolPnlRanking = (
1989
1191
  );
1990
1192
  };
1991
1193
 
1992
- export const StrategySnapshotCard = ({
1993
- snapshot,
1994
- emptyText,
1995
- mode,
1996
- onDeleted,
1997
- selected = false,
1998
- onToggleSelection,
1999
- }: {
2000
- snapshot: StrategyChartSnapshot;
2001
- emptyText: string;
2002
- mode: 'replay' | 'ai';
2003
- onDeleted?: (cardId: string) => void;
2004
- selected?: boolean;
2005
- onToggleSelection?: (cardId: string, checked: boolean) => void;
2006
- }) => {
2007
- const [detailsOpen, setDetailsOpen] = useState(false);
2008
- const [ordersOpen, setOrdersOpen] = useState(false);
2009
- const [deleteOpen, setDeleteOpen] = useState(false);
2010
- const [isDeleting, setIsDeleting] = useState(false);
2011
- const snapshotOrders = useMemo(
2012
- () => buildSnapshotOrders(snapshot, mode),
2013
- [mode, snapshot],
2014
- );
2015
- const aiDiagnosticGroups = useMemo(
2016
- () => buildAiDiagnosticGroups(snapshot.details),
2017
- [snapshot.details],
2018
- );
2019
- const directionStatGroups = useMemo(
2020
- () => buildDirectionStatGroups(snapshot.details),
2021
- [snapshot.details],
2022
- );
2023
- const symbolPnlRanking = useMemo(
2024
- () => buildSymbolPnlRanking(snapshot.details),
2025
- [snapshot.details],
2026
- );
2027
- const topSymbolPnlRanking = useMemo(
2028
- () =>
2029
- [...symbolPnlRanking]
2030
- .sort(
2031
- (left, right) =>
2032
- right.pnl - left.pnl || left.symbol.localeCompare(right.symbol),
2033
- )
2034
- .slice(0, 10),
2035
- [symbolPnlRanking],
2036
- );
2037
- const worstSymbolPnlRanking = useMemo(
2038
- () =>
2039
- [...symbolPnlRanking]
2040
- .sort(
2041
- (left, right) =>
2042
- left.pnl - right.pnl || left.symbol.localeCompare(right.symbol),
2043
- )
2044
- .slice(0, 10),
2045
- [symbolPnlRanking],
2046
- );
2047
- const symbolRankingMaxAbsPnl = useMemo(
2048
- () => Math.max(...symbolPnlRanking.map((rank) => Math.abs(rank.pnl)), 1),
2049
- [symbolPnlRanking],
2050
- );
2051
- const monthlyStats = useMemo(
2052
- () => buildMonthlyStats(snapshot.orderLog),
2053
- [snapshot.orderLog],
2054
- );
2055
- const snapshotTradePoints = useMemo(
2056
- () => buildSnapshotTradePoints(snapshot.orderLog),
2057
- [snapshot.orderLog],
2058
- );
2059
- const drawdownPoints = useMemo(
2060
- () => buildDrawdownPoints(snapshot.orderLog),
2061
- [snapshot.orderLog],
2062
- );
2063
- const rollingPerformancePoints = useMemo(
2064
- () => buildRollingPerformance(snapshotTradePoints, 50),
2065
- [snapshotTradePoints],
2066
- );
2067
- const pnlDistributionBins = useMemo(
2068
- () => buildPnlDistribution(snapshotTradePoints),
2069
- [snapshotTradePoints],
2070
- );
2071
- const sessionPnlStats = useMemo(
2072
- () => buildSessionPnlStats(snapshotTradePoints),
2073
- [snapshotTradePoints],
2074
- );
2075
- const hourlyPnlStats = useMemo(
2076
- () => buildHourlyPnlStats(snapshotTradePoints),
2077
- [snapshotTradePoints],
2078
- );
2079
- const symbolsLabel =
2080
- snapshot.symbols.length > 3
2081
- ? `${snapshot.symbols.slice(0, 3).join(', ')} +${snapshot.symbols.length - 3}`
2082
- : snapshot.symbols.join(', ') || 'n/a';
2083
- const sourceLabel =
2084
- mode === 'ai' && snapshot.datasetId ? 'dataset:' : 'symbols:';
2085
- const sourceValue =
2086
- mode === 'ai' && snapshot.datasetId ? snapshot.datasetId : symbolsLabel;
2087
- const tagsLabel = snapshot.tags?.join(' · ') ?? '';
2088
- const displaySubtitle =
2089
- mode === 'ai'
2090
- ? snapshot.subtitle?.replace(/^q\d+\+\s*(?:·\s*)?/i, '').trim()
2091
- : snapshot.subtitle;
2092
- const metrics = useMemo(
2093
- () => buildSnapshotSummaryMetrics(snapshot),
2094
- [snapshot],
2095
- );
1194
+ export const buildStrategySnapshotCardViewModel = (
1195
+ snapshot: StrategyChartSnapshot,
1196
+ mode: 'replay' | 'ai',
1197
+ ) => {
1198
+ const snapshotOrders = buildSnapshotOrders(snapshot, mode);
1199
+ const symbolPnlRanking = buildSymbolPnlRanking(snapshot.details);
1200
+ const performance = buildStrategyPerformanceViewModel(snapshot.orderLog);
1201
+ const maxLossStreak = calculateMaxLossStreak(snapshot.orderLog);
2096
1202
  const drawerBaseMetrics =
2097
1203
  mode === 'ai'
2098
1204
  ? snapshot.metrics
@@ -2109,12 +1215,46 @@ export const StrategySnapshotCard = ({
2109
1215
  : metric,
2110
1216
  )
2111
1217
  : snapshot.metrics;
2112
- const maxLossStreak = useMemo(
2113
- () => calculateMaxLossStreak(snapshot.orderLog),
2114
- [snapshot.orderLog],
2115
- );
2116
- const drawerMetrics = useMemo(
2117
- () =>
1218
+ const firstPoint = snapshot.orderLog[0];
1219
+ const lastPoint = snapshot.orderLog[snapshot.orderLog.length - 1];
1220
+ const symbolsLabel =
1221
+ snapshot.symbols.length > 3
1222
+ ? `${snapshot.symbols.slice(0, 3).join(', ')} +${snapshot.symbols.length - 3}`
1223
+ : snapshot.symbols.join(', ') || 'n/a';
1224
+
1225
+ return {
1226
+ snapshotOrders,
1227
+ aiDiagnosticGroups: buildAiDiagnosticGroups(snapshot.details),
1228
+ directionStatGroups: buildDirectionStatGroups(snapshot.details),
1229
+ symbolPnlRanking,
1230
+ topSymbolPnlRanking: [...symbolPnlRanking]
1231
+ .sort(
1232
+ (left, right) =>
1233
+ right.pnl - left.pnl || left.symbol.localeCompare(right.symbol),
1234
+ )
1235
+ .slice(0, 10),
1236
+ worstSymbolPnlRanking: [...symbolPnlRanking]
1237
+ .sort(
1238
+ (left, right) =>
1239
+ left.pnl - right.pnl || left.symbol.localeCompare(right.symbol),
1240
+ )
1241
+ .slice(0, 10),
1242
+ symbolRankingMaxAbsPnl: Math.max(
1243
+ ...symbolPnlRanking.map((rank) => Math.abs(rank.pnl)),
1244
+ 1,
1245
+ ),
1246
+ performance,
1247
+ symbolsLabel,
1248
+ sourceLabel: mode === 'ai' && snapshot.datasetId ? 'dataset:' : 'symbols:',
1249
+ sourceValue:
1250
+ mode === 'ai' && snapshot.datasetId ? snapshot.datasetId : symbolsLabel,
1251
+ tagsLabel: snapshot.tags?.join(' · ') ?? '',
1252
+ displaySubtitle:
1253
+ mode === 'ai'
1254
+ ? snapshot.subtitle?.replace(/^q\d+\+\s*(?:·\s*)?/i, '').trim()
1255
+ : snapshot.subtitle,
1256
+ metrics: buildSnapshotSummaryMetrics(snapshot),
1257
+ drawerMetrics:
2118
1258
  mode === 'ai'
2119
1259
  ? sortAiDrawerMetrics([
2120
1260
  ...drawerBaseMetrics,
@@ -2127,21 +1267,66 @@ export const StrategySnapshotCard = ({
2127
1267
  },
2128
1268
  ])
2129
1269
  : drawerBaseMetrics,
2130
- [drawerBaseMetrics, maxLossStreak, mode],
2131
- );
2132
- const advancedMetrics = useMemo(() => {
2133
- const firstPoint = snapshot.orderLog[0];
2134
- const lastPoint = snapshot.orderLog[snapshot.orderLog.length - 1];
2135
-
2136
- return calculateAdvancedTradeMetrics({
1270
+ advancedMetrics: calculateAdvancedTradeMetrics({
2137
1271
  trades: buildSnapshotAdvancedTrades(snapshot),
2138
1272
  orderLog: snapshot.orderLog,
2139
1273
  startTimestamp: firstPoint?.[0] ?? null,
2140
1274
  endTimestamp: lastPoint?.[0] ?? null,
2141
- });
2142
- }, [snapshot]);
2143
- const hasOrdersDrawer = snapshotOrders.length > 0;
2144
- const hasStatDrawer = mode === 'ai' || Boolean(snapshot.details?.length);
1275
+ }),
1276
+ hasOrdersDrawer: snapshotOrders.length > 0,
1277
+ hasStatDrawer: mode === 'ai' || Boolean(snapshot.details?.length),
1278
+ };
1279
+ };
1280
+
1281
+ export const StrategySnapshotCard = ({
1282
+ snapshot,
1283
+ emptyText,
1284
+ mode,
1285
+ onDeleted,
1286
+ selected = false,
1287
+ onToggleSelection,
1288
+ }: {
1289
+ snapshot: StrategyChartSnapshot;
1290
+ emptyText: string;
1291
+ mode: 'replay' | 'ai';
1292
+ onDeleted?: (cardId: string) => void;
1293
+ selected?: boolean;
1294
+ onToggleSelection?: (cardId: string, checked: boolean) => void;
1295
+ }) => {
1296
+ const [detailsOpen, setDetailsOpen] = useState(false);
1297
+ const [ordersOpen, setOrdersOpen] = useState(false);
1298
+ const [deleteOpen, setDeleteOpen] = useState(false);
1299
+ const [isDeleting, setIsDeleting] = useState(false);
1300
+ const viewModel = useMemo(
1301
+ () => buildStrategySnapshotCardViewModel(snapshot, mode),
1302
+ [mode, snapshot],
1303
+ );
1304
+ const {
1305
+ snapshotOrders,
1306
+ aiDiagnosticGroups,
1307
+ directionStatGroups,
1308
+ topSymbolPnlRanking,
1309
+ worstSymbolPnlRanking,
1310
+ symbolRankingMaxAbsPnl,
1311
+ sourceLabel,
1312
+ sourceValue,
1313
+ tagsLabel,
1314
+ displaySubtitle,
1315
+ metrics,
1316
+ drawerMetrics,
1317
+ advancedMetrics,
1318
+ hasOrdersDrawer,
1319
+ hasStatDrawer,
1320
+ } = viewModel;
1321
+ const {
1322
+ monthlyStats,
1323
+ tradePoints: snapshotTradePoints,
1324
+ drawdownPoints,
1325
+ rollingPerformancePoints,
1326
+ pnlDistributionBins,
1327
+ sessionPnlStats,
1328
+ hourlyPnlStats,
1329
+ } = viewModel.performance;
2145
1330
 
2146
1331
  const handleDelete = async () => {
2147
1332
  if (isDeleting) {