@tradejs/app 1.0.8 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +7 -2
  2. package/bin/tradejs-app.mjs +146 -2
  3. package/next.config.mjs +0 -14
  4. package/package.json +35 -19
  5. package/src/app/actions/backtest.ts +54 -0
  6. package/src/app/actions/kline.ts +10 -5
  7. package/src/app/actions/scanner.ts +8 -3
  8. package/src/app/actions/strategies.ts +39 -0
  9. package/src/app/api/ai/route.ts +5 -4
  10. package/src/app/api/auth/[...nextauth]/route.ts +1 -1
  11. package/src/app/api/backtest/configs/route.ts +25 -0
  12. package/src/app/api/backtest/files/route.ts +5 -2
  13. package/src/app/api/backtest/order-log/[strategy]/[name]/route.ts +26 -2
  14. package/src/app/api/backtest/result/[strategy]/[name]/route.ts +27 -2
  15. package/src/app/api/backtest/runs/[jobId]/route.ts +124 -0
  16. package/src/app/api/backtest/runs/route.ts +45 -0
  17. package/src/app/api/backtest/test/[strategy]/[name]/route.ts +19 -2
  18. package/src/app/api/derivatives/summary/route.ts +8 -1
  19. package/src/app/api/kline/[provider]/[universe]/[symbol]/[interval]/route.ts +1 -0
  20. package/src/app/api/kline/{[provider]/[symbol]/[interval]/route.ts → handler.ts} +61 -39
  21. package/src/app/api/scanner/[provider]/[universe]/route.ts +1 -0
  22. package/src/app/api/scanner/[provider]/route.ts +30 -12
  23. package/src/app/api/scanner/route.ts +3 -3
  24. package/src/app/api/signal/[symbol]/[signalId]/route.ts +4 -7
  25. package/src/app/api/strategies/ai/[cardId]/route.ts +22 -0
  26. package/src/app/api/strategies/ai/route.ts +139 -0
  27. package/src/app/api/strategies/replay/[cardId]/route.ts +18 -0
  28. package/src/app/api/strategies/replay/route.ts +44 -0
  29. package/src/app/api/strategies/runtime/route.ts +831 -0
  30. package/src/app/api/user/runtime-deployments/[deploymentId]/route.ts +25 -0
  31. package/src/app/api/user/runtime-deployments/route.ts +94 -0
  32. package/src/app/api/user/runtime-strategy-configs/route.ts +241 -0
  33. package/src/app/api/user/settings/route.ts +18 -1
  34. package/src/app/api/user/trading-accounts/[accountId]/route.ts +35 -0
  35. package/src/app/api/user/trading-accounts/route.ts +134 -0
  36. package/src/app/components/Backtest/CompareList/index.tsx +1 -1
  37. package/src/app/components/Backtest/ResultsPageClient.tsx +376 -0
  38. package/src/app/components/Backtest/TestCard/ActionsMenu/index.tsx +123 -0
  39. package/src/app/components/Backtest/TestCard/Chart/index.tsx +1 -1
  40. package/src/app/components/Backtest/TestCard/CompareButton/index.tsx +1 -1
  41. package/src/app/components/Backtest/TestCard/ConfigDrawer/index.tsx +28 -12
  42. package/src/app/components/Backtest/TestCard/DeleteButton/index.tsx +33 -17
  43. package/src/app/components/Backtest/TestCard/FavoriteIndicator/index.tsx +2 -2
  44. package/src/app/components/Backtest/TestCard/OpenDashboardButton/index.tsx +10 -1
  45. package/src/app/components/Backtest/TestCard/OrdersDrawer/index.tsx +128 -0
  46. package/src/app/components/Backtest/TestCard/Root/index.tsx +1 -1
  47. package/src/app/components/Backtest/TestCard/Stat/index.tsx +2 -0
  48. package/src/app/components/Backtest/TestCard/StatDrawer/index.tsx +66 -0
  49. package/src/app/components/Backtest/TestCard/index.ts +6 -2
  50. package/src/app/components/Backtest/TestList/index.tsx +4 -9
  51. package/src/app/components/Dashboard/AiDrawer/index.tsx +1 -1
  52. package/src/app/components/Dashboard/KlineChart/hooks/useBacktest.ts +1 -1
  53. package/src/app/components/Dashboard/KlineChart/hooks/useBbIndicator.ts +10 -5
  54. package/src/app/components/Dashboard/KlineChart/hooks/useBtcCorrelation.ts +1 -1
  55. package/src/app/components/Dashboard/KlineChart/hooks/useBtcIndicator.ts +4 -1
  56. package/src/app/components/Dashboard/KlineChart/hooks/useEmaIndicator.ts +3 -1
  57. package/src/app/components/Dashboard/KlineChart/hooks/usePluginIndicators.ts +1 -1
  58. package/src/app/components/Dashboard/KlineChart/hooks/useSetup.ts +1 -1
  59. package/src/app/components/Dashboard/KlineChart/hooks/useSignal.ts +1 -1
  60. package/src/app/components/Dashboard/KlineChart/hooks/useSpreadIndicator.ts +1 -1
  61. package/src/app/components/Dashboard/KlineChart/hooks/useTrendLine.ts +1 -1
  62. package/src/app/components/Dashboard/KlineChart/hooks/useWmaIndicator.ts +3 -1
  63. package/src/app/components/Dashboard/KlineChart/index.tsx +16 -6
  64. package/src/app/components/Dashboard/MainChart/index.tsx +3 -22
  65. package/src/app/components/Shared/AppShell.tsx +1 -1
  66. package/src/app/components/Shared/BulkSelection/index.tsx +140 -0
  67. package/src/app/components/Shared/BulkSelection/useBulkSelection.ts +97 -0
  68. package/src/app/components/Shared/BulkSelection/utils.ts +126 -0
  69. package/src/app/components/Shared/Filters/Backtest/index.tsx +33 -48
  70. package/src/app/components/Shared/Filters/FavoriteIndicator/index.tsx +5 -3
  71. package/src/app/components/Shared/Filters/Indicators/index.tsx +2 -2
  72. package/src/app/components/Shared/Filters/Interval/index.tsx +1 -1
  73. package/src/app/components/Shared/Filters/Provider/index.tsx +8 -2
  74. package/src/app/components/Shared/Filters/Symbol/index.tsx +4 -3
  75. package/src/app/components/Shared/Filters/Universe/index.tsx +36 -0
  76. package/src/app/components/Shared/Filters/index.ts +2 -0
  77. package/src/app/components/Shared/OrdersDrawer.tsx +629 -0
  78. package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +55 -28
  79. package/src/app/components/Shared/Sidebar/TradingAccountsPanel.tsx +445 -0
  80. package/src/app/components/Shared/Sidebar/index.tsx +17 -3
  81. package/src/app/components/Strategies/AdvancedMetricsPanel.tsx +727 -0
  82. package/src/app/components/Strategies/RuntimeStrategyCard.tsx +2204 -0
  83. package/src/app/components/Strategies/RuntimeStrategyCardSkeleton.tsx +40 -0
  84. package/src/app/components/Strategies/RuntimeStrategyChart.tsx +121 -0
  85. package/src/app/components/Strategies/RuntimeStrategyConfigDrawer.tsx +458 -0
  86. package/src/app/components/Strategies/StrategySnapshotCard.tsx +2890 -0
  87. package/src/app/components/Strategies/StrategySnapshotChart.tsx +94 -0
  88. package/src/app/components/Strategies/StrategySnapshotList.tsx +82 -0
  89. package/src/app/components/UI/Segment/index.tsx +8 -1
  90. package/src/app/components/UI/Select/index.tsx +24 -13
  91. package/src/app/components/UI/SelectWithSearch/index.tsx +46 -5
  92. package/src/app/layout.tsx +1 -1
  93. package/src/app/lib/backtestJobs.ts +792 -0
  94. package/src/app/lib/cardPageLayout.ts +1 -0
  95. package/src/app/lib/connectorCreator.ts +28 -0
  96. package/src/app/lib/currentUser.ts +1 -1
  97. package/src/app/lib/errorMessage.ts +34 -0
  98. package/src/app/lib/marketDefaults.ts +12 -0
  99. package/src/app/lib/marketRoutes.ts +58 -0
  100. package/src/app/lib/runtimeStrategies.ts +1376 -0
  101. package/src/app/lib/runtimeStrategyConfigForm.ts +86 -0
  102. package/src/app/provider.tsx +1 -1
  103. package/src/app/routes/backtest/page.tsx +1051 -304
  104. package/src/app/routes/dashboard/{[provider]/[symbol]/[interval]/page.tsx → Dashboard.tsx} +35 -15
  105. package/src/app/routes/dashboard/[provider]/[universe]/[symbol]/[interval]/page.tsx +1 -0
  106. package/src/app/routes/dashboard/page.tsx +15 -3
  107. package/src/app/routes/derivatives/page.tsx +1222 -165
  108. package/src/app/routes/strategies/StrategiesPageClient.tsx +593 -0
  109. package/src/app/routes/strategies/[mode]/page.tsx +20 -0
  110. package/src/app/routes/strategies/page.tsx +7 -0
  111. package/src/app/store/ai.ts +1 -1
  112. package/src/app/store/data.ts +73 -17
  113. package/src/app/store/filters.ts +1 -0
  114. package/src/app/store/marketKlineStream.ts +87 -0
  115. package/src/app/store/tests.ts +1 -1
  116. package/src/app/store/tickers.ts +59 -22
  117. package/tsconfig.json +17 -12
  118. package/src/app/components/Backtest/TestCard/OpenReportButton/index.tsx +0 -24
  119. package/src/app/routes/backtest/[test]/page.tsx +0 -33
@@ -0,0 +1,2890 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Box,
5
+ Button,
6
+ Checkbox,
7
+ CloseButton,
8
+ Dialog,
9
+ Drawer,
10
+ Flex,
11
+ Menu,
12
+ Portal,
13
+ SimpleGrid,
14
+ Stat,
15
+ Text,
16
+ } from '@chakra-ui/react';
17
+ import { type ReactNode, useMemo, useState } from 'react';
18
+ import {
19
+ calculateAdvancedTradeMetrics,
20
+ getFormatted,
21
+ type AdvancedTradeInput,
22
+ } from '@tradejs/core/backtest';
23
+ import type {
24
+ StrategyChartDetail,
25
+ StrategyChartMetric,
26
+ StrategyChartOrder,
27
+ StrategyChartSnapshot,
28
+ TestStat,
29
+ TestThresholdsKey,
30
+ } from '@tradejs/types';
31
+ import {
32
+ formatCompactNumber,
33
+ formatFee,
34
+ formatInteger,
35
+ formatPercent,
36
+ formatPriceUsdt,
37
+ formatSignedNumber,
38
+ formatUsdt,
39
+ getPnlColor,
40
+ OrdersDrawerPanel,
41
+ type OrdersDrawerOrder,
42
+ } from '#components/Shared/OrdersDrawer';
43
+ import { deleteStrategyCard } from '#actions/strategies';
44
+ import { toaster } from '#ui';
45
+ import { AdvancedMetricsPanel } from './AdvancedMetricsPanel';
46
+ import { StrategySnapshotChart } from './StrategySnapshotChart';
47
+
48
+ const MS_IN_HOUR = 60 * 60 * 1000;
49
+ const SNAPSHOT_ORDER_ROW_HEIGHT = 318;
50
+ const DIRECTION_DETAIL_PREFIX = 'direction:';
51
+ const SYMBOL_DETAIL_PREFIX = 'symbol:';
52
+ const AI_STAT_DIRECTIONS = ['LONG', 'SHORT'] as const;
53
+ const SNAPSHOT_SUMMARY_METRICS: {
54
+ id: TestThresholdsKey;
55
+ label: string;
56
+ }[] = [
57
+ { id: 'netProfit', label: 'P&L' },
58
+ { id: 'minAmount', label: 'Min Amount' },
59
+ { id: 'maxDrawdown', label: 'Drawdown' },
60
+ { id: 'orders', label: 'Orders' },
61
+ { id: 'winRate', label: 'Win Rate' },
62
+ { id: 'riskRewardRatio', label: 'Risk Ratio' },
63
+ { id: 'maxConsecutiveWins', label: 'Max Gross Streak' },
64
+ { id: 'maxConsecutiveLosses', label: 'Max Loss Streak' },
65
+ ];
66
+
67
+ type AiStatDirection = (typeof AI_STAT_DIRECTIONS)[number];
68
+
69
+ interface DirectionMetric {
70
+ id: string;
71
+ label: string;
72
+ value: string;
73
+ tone?: StrategyChartMetric['tone'];
74
+ }
75
+
76
+ interface DirectionStatGroup {
77
+ direction: AiStatDirection;
78
+ metrics: DirectionMetric[];
79
+ hasData: boolean;
80
+ }
81
+
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
+ interface SymbolPnlRank {
105
+ symbol: string;
106
+ pnl: number;
107
+ orders: number | null;
108
+ winRate: number | null;
109
+ avgPnl: number | null;
110
+ }
111
+
112
+ interface AiDiagnosticMetric {
113
+ id: string;
114
+ label: string;
115
+ value: string;
116
+ detail?: string;
117
+ tone?: StrategyChartMetric['tone'];
118
+ }
119
+
120
+ interface AiDiagnosticGroup {
121
+ id: string;
122
+ title: string;
123
+ description: string;
124
+ columns: number;
125
+ metrics: AiDiagnosticMetric[];
126
+ }
127
+
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
+ const getMetricColor = (tone: StrategyChartMetric['tone']) => {
170
+ switch (tone) {
171
+ case 'success':
172
+ return 'teal.500';
173
+ case 'warning':
174
+ return 'fg.warning';
175
+ case 'neutral':
176
+ return 'gray.300';
177
+ case 'error':
178
+ return 'fg.error';
179
+ default:
180
+ return 'gray.200';
181
+ }
182
+ };
183
+
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
+ const asFiniteNumber = (value: unknown) =>
231
+ typeof value === 'number' && Number.isFinite(value) ? value : null;
232
+
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
+ const getSnapshotTradePnls = (snapshot: StrategyChartSnapshot) => {
265
+ const orderPnls = snapshot.orders
266
+ .map((order) => asFiniteNumber(order.pnl))
267
+ .filter((pnl): pnl is number => pnl != null);
268
+
269
+ if (orderPnls.length) {
270
+ return orderPnls;
271
+ }
272
+
273
+ return snapshot.orderLog
274
+ .map((_, index) =>
275
+ index === 0
276
+ ? null
277
+ : asFiniteNumber(getSnapshotStepPnl(snapshot.orderLog, index)),
278
+ )
279
+ .filter((pnl): pnl is number => pnl != null);
280
+ };
281
+
282
+ const calculateSnapshotRiskRewardRatio = (pnls: number[]) => {
283
+ const wins = pnls.filter((pnl) => pnl > 0);
284
+ const losses = pnls.filter((pnl) => pnl < 0);
285
+
286
+ if (!wins.length || !losses.length) {
287
+ return null;
288
+ }
289
+
290
+ const avgWin = wins.reduce((sum, pnl) => sum + pnl, 0) / wins.length;
291
+ const avgLossAbs = Math.abs(
292
+ losses.reduce((sum, pnl) => sum + pnl, 0) / losses.length,
293
+ );
294
+
295
+ return avgLossAbs > 0 ? avgWin / avgLossAbs : null;
296
+ };
297
+
298
+ const buildSnapshotSummaryStat = (
299
+ snapshot: StrategyChartSnapshot,
300
+ ): Partial<TestStat> => {
301
+ const amounts = snapshot.orderLog
302
+ .map(([, amount]) => asFiniteNumber(amount))
303
+ .filter((amount): amount is number => amount != null);
304
+ const firstAmount = amounts[0] ?? null;
305
+ const lastAmount = amounts.at(-1) ?? null;
306
+ const pnls = getSnapshotTradePnls(snapshot);
307
+ const wins = pnls.filter((pnl) => pnl > 0).length;
308
+ const orders =
309
+ asFiniteNumber(snapshot.stat?.orders) ??
310
+ (snapshot.orders.length || pnls.length);
311
+ const netProfit =
312
+ asFiniteNumber(snapshot.stat?.netProfit) ??
313
+ (firstAmount != null && lastAmount != null
314
+ ? lastAmount - firstAmount
315
+ : pnls.reduce((sum, pnl) => sum + pnl, 0));
316
+ const minAmount =
317
+ asFiniteNumber(snapshot.stat?.minAmount) ??
318
+ (amounts.length ? Math.min(...amounts) : null);
319
+ const maxDrawdown =
320
+ asFiniteNumber(snapshot.stat?.maxDrawdown) ??
321
+ calculateMaxDrawdownValue(snapshot.orderLog);
322
+ const winRate =
323
+ asFiniteNumber(snapshot.stat?.winRate) ??
324
+ (orders > 0 ? (wins / orders) * 100 : 0);
325
+ const riskRewardRatio =
326
+ asFiniteNumber(snapshot.stat?.riskRewardRatio) ??
327
+ calculateSnapshotRiskRewardRatio(pnls);
328
+ const maxConsecutiveWins =
329
+ asFiniteNumber(snapshot.stat?.maxConsecutiveWins) ??
330
+ calculateMaxGrossStreak(snapshot.orderLog);
331
+ const maxConsecutiveLosses =
332
+ asFiniteNumber(snapshot.stat?.maxConsecutiveLosses) ??
333
+ calculateMaxLossStreak(snapshot.orderLog);
334
+
335
+ return {
336
+ netProfit,
337
+ minAmount: minAmount ?? undefined,
338
+ maxDrawdown: maxDrawdown ?? undefined,
339
+ orders,
340
+ winRate,
341
+ riskRewardRatio,
342
+ maxConsecutiveWins,
343
+ maxConsecutiveLosses,
344
+ };
345
+ };
346
+
347
+ const buildSnapshotSummaryMetrics = (snapshot: StrategyChartSnapshot) => {
348
+ const stat = buildSnapshotSummaryStat(snapshot);
349
+
350
+ return SNAPSHOT_SUMMARY_METRICS.map(({ id, label }) => {
351
+ const { formatted, level } = getFormatted(stat, id);
352
+
353
+ return {
354
+ id,
355
+ label,
356
+ value: formatted,
357
+ tone: level,
358
+ };
359
+ });
360
+ };
361
+
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
+ const formatPrice = (value: number | null | undefined) =>
542
+ formatPriceUsdt(value);
543
+
544
+ const formatBps = (value: number | null | undefined) => {
545
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
546
+ return 'n/a';
547
+ }
548
+
549
+ return `${formatCompactNumber(value, {
550
+ maximumFractionDigits: 2,
551
+ minimumFractionDigits: 2,
552
+ })} bps`;
553
+ };
554
+
555
+ const formatAiExitReason = (reason: string | null | undefined) =>
556
+ reason ? reason.replace(/_/g, ' ').toUpperCase() : 'CLOSED';
557
+
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
+ const getAiExitReasonColor = (reason: string | null | undefined) => {
956
+ switch (reason) {
957
+ case 'take_profit':
958
+ return 'teal';
959
+ case 'stop_loss':
960
+ return 'red';
961
+ default:
962
+ return 'gray';
963
+ }
964
+ };
965
+
966
+ const buildSlippageDetail = ({
967
+ requestedPrice,
968
+ slippageBps,
969
+ }: {
970
+ requestedPrice?: number | null;
971
+ slippageBps?: number | null;
972
+ }) => (
973
+ <>
974
+ plan {formatPrice(requestedPrice)}
975
+ <br />
976
+ slip {formatBps(slippageBps)}
977
+ </>
978
+ );
979
+
980
+ const buildAiFeesDetail = (order: StrategyChartOrder) => (
981
+ <>
982
+ open {formatFee(order.openFee)}
983
+ <br />
984
+ close {formatFee(order.closeFee)}
985
+ <br />
986
+ funding {formatFee(order.fundingFee)}
987
+ </>
988
+ );
989
+
990
+ const normalizeSnapshotDirection = (
991
+ direction: StrategyChartOrder['direction'],
992
+ ): OrdersDrawerOrder['direction'] =>
993
+ direction === 'LONG' || direction === 'SHORT' ? direction : null;
994
+
995
+ const getSnapshotOrderTimestamp = (order: StrategyChartOrder) => {
996
+ const timestamp =
997
+ order.timestamp ?? order.entryTimestamp ?? order.exitTimestamp;
998
+ return typeof timestamp === 'number' && Number.isFinite(timestamp)
999
+ ? timestamp
1000
+ : null;
1001
+ };
1002
+
1003
+ const getReplayOrderStatus = (type: string | null | undefined) => {
1004
+ if (type?.startsWith('OPEN')) {
1005
+ return { label: 'ACTIVE', color: 'orange', status: 'active' as const };
1006
+ }
1007
+
1008
+ if (type?.startsWith('TAKE_PROFIT')) {
1009
+ return { label: 'TAKE PROFIT', color: 'teal', status: 'closed' as const };
1010
+ }
1011
+
1012
+ if (type?.startsWith('STOP_LOSS')) {
1013
+ return { label: 'STOP LOSS', color: 'red', status: 'closed' as const };
1014
+ }
1015
+
1016
+ if (type?.startsWith('CLOSE')) {
1017
+ return { label: 'CLOSE', color: 'gray', status: 'closed' as const };
1018
+ }
1019
+
1020
+ return { label: 'REPLAY', color: 'gray', status: 'closed' as const };
1021
+ };
1022
+
1023
+ const isReplayOpenOrder = (order: StrategyChartOrder) =>
1024
+ order.exitReason?.startsWith('OPEN') === true;
1025
+
1026
+ const getReplayPairKey = (order: StrategyChartOrder) => {
1027
+ const direction = normalizeSnapshotDirection(order.direction);
1028
+ if (!order.symbol || !direction) {
1029
+ return null;
1030
+ }
1031
+
1032
+ return `${order.symbol}:${direction}`;
1033
+ };
1034
+
1035
+ const getFiniteNumber = (value: number | null | undefined) =>
1036
+ typeof value === 'number' && Number.isFinite(value) ? value : null;
1037
+
1038
+ const sumFiniteNumbers = (
1039
+ ...values: Array<number | null | undefined>
1040
+ ): number | null => {
1041
+ let total = 0;
1042
+ let hasValue = false;
1043
+
1044
+ for (const value of values) {
1045
+ const finiteValue = getFiniteNumber(value);
1046
+ if (finiteValue == null) {
1047
+ continue;
1048
+ }
1049
+
1050
+ total += finiteValue;
1051
+ hasValue = true;
1052
+ }
1053
+
1054
+ return hasValue ? total : null;
1055
+ };
1056
+
1057
+ const multiplyFiniteNumber = (
1058
+ value: number | null | undefined,
1059
+ multiplier: number,
1060
+ ) => {
1061
+ const finiteValue = getFiniteNumber(value);
1062
+ return finiteValue == null ? null : finiteValue * multiplier;
1063
+ };
1064
+
1065
+ const buildReplayFeesDetail = ({
1066
+ openFee,
1067
+ closeFee,
1068
+ fundingFee,
1069
+ }: {
1070
+ openFee?: number | null;
1071
+ closeFee?: number | null;
1072
+ fundingFee?: number | null;
1073
+ }) => (
1074
+ <>
1075
+ open {formatFee(openFee)}
1076
+ <br />
1077
+ close {formatFee(closeFee)}
1078
+ <br />
1079
+ funding {formatFee(fundingFee)}
1080
+ </>
1081
+ );
1082
+
1083
+ type ReplayOpenPosition = {
1084
+ order: StrategyChartOrder;
1085
+ remainingQty: number | null;
1086
+ };
1087
+
1088
+ const getReplayExitShare = (
1089
+ entry: ReplayOpenPosition,
1090
+ exitOrder: StrategyChartOrder,
1091
+ ) => {
1092
+ const entryQty = getFiniteNumber(entry.order.qty);
1093
+ const exitQty = getFiniteNumber(exitOrder.qty);
1094
+
1095
+ if (entryQty == null || entryQty <= 0 || exitQty == null || exitQty <= 0) {
1096
+ return 1;
1097
+ }
1098
+
1099
+ const remainingQty = entry.remainingQty ?? entryQty;
1100
+ return Math.min(exitQty, remainingQty) / entryQty;
1101
+ };
1102
+
1103
+ const consumeReplayEntryQty = (
1104
+ entry: ReplayOpenPosition,
1105
+ exitOrder: StrategyChartOrder,
1106
+ ) => {
1107
+ const exitQty = getFiniteNumber(exitOrder.qty);
1108
+ if (entry.remainingQty == null || exitQty == null) {
1109
+ return;
1110
+ }
1111
+
1112
+ entry.remainingQty = Math.max(0, entry.remainingQty - exitQty);
1113
+ };
1114
+
1115
+ const isReplayEntryConsumed = (entry: ReplayOpenPosition) =>
1116
+ entry.remainingQty != null && entry.remainingQty <= 0.00000001;
1117
+
1118
+ const buildReplayTradeCard = ({
1119
+ snapshot,
1120
+ entryOrder,
1121
+ exitOrder,
1122
+ tradeIndex,
1123
+ entryShare = 1,
1124
+ }: {
1125
+ snapshot: StrategyChartSnapshot;
1126
+ entryOrder: StrategyChartOrder;
1127
+ exitOrder?: StrategyChartOrder;
1128
+ tradeIndex: number;
1129
+ entryShare?: number;
1130
+ }): OrdersDrawerOrder => {
1131
+ const orderStatus = getReplayOrderStatus(
1132
+ exitOrder?.exitReason ?? entryOrder.exitReason,
1133
+ );
1134
+ const entryTimestamp = getSnapshotOrderTimestamp(entryOrder);
1135
+ const exitTimestamp = exitOrder ? getSnapshotOrderTimestamp(exitOrder) : null;
1136
+ const durationHours =
1137
+ entryTimestamp != null && exitTimestamp != null
1138
+ ? (exitTimestamp - entryTimestamp) / MS_IN_HOUR
1139
+ : null;
1140
+ const openFee = multiplyFiniteNumber(entryOrder.openFee, entryShare);
1141
+ const openPnl = multiplyFiniteNumber(entryOrder.pnl, entryShare);
1142
+ const closeFee = exitOrder?.closeFee ?? exitOrder?.totalFee ?? null;
1143
+ const fundingFee = sumFiniteNumbers(
1144
+ multiplyFiniteNumber(entryOrder.fundingFee, entryShare),
1145
+ exitOrder?.fundingFee,
1146
+ );
1147
+ const totalFee = sumFiniteNumbers(openFee, closeFee, fundingFee);
1148
+ const pnl = sumFiniteNumbers(openPnl, exitOrder?.pnl);
1149
+ const qty = exitOrder?.qty ?? entryOrder.qty;
1150
+ const notional =
1151
+ entryOrder.notional != null
1152
+ ? multiplyFiniteNumber(entryOrder.notional, entryShare)
1153
+ : exitOrder?.notional;
1154
+
1155
+ return {
1156
+ id: `${snapshot.cardId}:replay-trade:${tradeIndex}:${entryOrder.id}:${exitOrder?.id ?? 'active'}`,
1157
+ title: entryOrder.symbol
1158
+ ? `${entryOrder.symbol} · Replay #${tradeIndex}`
1159
+ : `Replay #${tradeIndex}`,
1160
+ period: {
1161
+ start: entryTimestamp,
1162
+ end: exitTimestamp,
1163
+ durationHours,
1164
+ },
1165
+ direction: normalizeSnapshotDirection(entryOrder.direction),
1166
+ status: orderStatus.status,
1167
+ statusLabel: orderStatus.label,
1168
+ statusColor: orderStatus.color,
1169
+ pnl,
1170
+ metrics: [
1171
+ {
1172
+ title: 'Entry',
1173
+ value: formatPrice(entryOrder.entryPrice),
1174
+ detail: buildSlippageDetail({
1175
+ requestedPrice: entryOrder.requestedEntryPrice,
1176
+ slippageBps: entryOrder.entrySlippageBps,
1177
+ }),
1178
+ },
1179
+ {
1180
+ title: 'Exit',
1181
+ value: formatPrice(exitOrder?.exitPrice),
1182
+ detail: buildSlippageDetail({
1183
+ requestedPrice: exitOrder?.requestedExitPrice,
1184
+ slippageBps: exitOrder?.exitSlippageBps,
1185
+ }),
1186
+ },
1187
+ {
1188
+ title: 'Notional',
1189
+ value: formatUsdt(notional),
1190
+ },
1191
+ {
1192
+ title: 'Fees',
1193
+ value: formatFee(totalFee),
1194
+ detail: buildReplayFeesDetail({ openFee, closeFee, fundingFee }),
1195
+ },
1196
+ {
1197
+ title: 'Qty',
1198
+ value: formatCompactNumber(qty, {
1199
+ maximumFractionDigits: 8,
1200
+ minimumFractionDigits: 0,
1201
+ }),
1202
+ },
1203
+ {
1204
+ title: 'Equity',
1205
+ value: formatUsdt(exitOrder?.equityAfter ?? entryOrder.equityAfter),
1206
+ detail: `prev ${formatUsdt(entryOrder.equityBefore)}`,
1207
+ },
1208
+ ],
1209
+ };
1210
+ };
1211
+
1212
+ const buildReplaySnapshotOrders = (
1213
+ snapshot: StrategyChartSnapshot,
1214
+ ): OrdersDrawerOrder[] => {
1215
+ const persistedOrders = snapshot.orders
1216
+ .map((order, index) => ({ order, index }))
1217
+ .sort((left, right) => {
1218
+ const leftTimestamp =
1219
+ getSnapshotOrderTimestamp(left.order) ?? Number.NEGATIVE_INFINITY;
1220
+ const rightTimestamp =
1221
+ getSnapshotOrderTimestamp(right.order) ?? Number.NEGATIVE_INFINITY;
1222
+
1223
+ return leftTimestamp - rightTimestamp || left.index - right.index;
1224
+ });
1225
+
1226
+ if (persistedOrders.length) {
1227
+ const openPositions = new Map<string, ReplayOpenPosition[]>();
1228
+ const tradeCards: OrdersDrawerOrder[] = [];
1229
+
1230
+ for (const { order } of persistedOrders) {
1231
+ const pairKey = getReplayPairKey(order);
1232
+ if (!pairKey) {
1233
+ continue;
1234
+ }
1235
+
1236
+ if (isReplayOpenOrder(order)) {
1237
+ const bucket = openPositions.get(pairKey) ?? [];
1238
+ bucket.push({
1239
+ order,
1240
+ remainingQty: getFiniteNumber(order.qty),
1241
+ });
1242
+ openPositions.set(pairKey, bucket);
1243
+ continue;
1244
+ }
1245
+
1246
+ const bucket = openPositions.get(pairKey);
1247
+ const entry = bucket?.[0];
1248
+ if (!entry) {
1249
+ continue;
1250
+ }
1251
+
1252
+ const entryShare = getReplayExitShare(entry, order);
1253
+ tradeCards.push(
1254
+ buildReplayTradeCard({
1255
+ snapshot,
1256
+ entryOrder: entry.order,
1257
+ exitOrder: order,
1258
+ tradeIndex: tradeCards.length + 1,
1259
+ entryShare,
1260
+ }),
1261
+ );
1262
+ consumeReplayEntryQty(entry, order);
1263
+
1264
+ if (entry.remainingQty == null || isReplayEntryConsumed(entry)) {
1265
+ bucket.shift();
1266
+ }
1267
+ }
1268
+
1269
+ for (const bucket of openPositions.values()) {
1270
+ for (const entry of bucket) {
1271
+ if (isReplayEntryConsumed(entry)) {
1272
+ continue;
1273
+ }
1274
+
1275
+ tradeCards.push(
1276
+ buildReplayTradeCard({
1277
+ snapshot,
1278
+ entryOrder: entry.order,
1279
+ tradeIndex: tradeCards.length + 1,
1280
+ }),
1281
+ );
1282
+ }
1283
+ }
1284
+
1285
+ return tradeCards.reverse();
1286
+ }
1287
+
1288
+ return snapshot.orderLog
1289
+ .slice(1)
1290
+ .map<OrdersDrawerOrder | null>((current, index) => {
1291
+ const previous = snapshot.orderLog[index];
1292
+ if (!previous) {
1293
+ return null;
1294
+ }
1295
+
1296
+ const [timestamp, equityAfter] = current;
1297
+ const [, equityBefore] = previous;
1298
+ const pnl = equityAfter - equityBefore;
1299
+ if (
1300
+ !Number.isFinite(timestamp) ||
1301
+ !Number.isFinite(equityBefore) ||
1302
+ !Number.isFinite(equityAfter) ||
1303
+ !Number.isFinite(pnl)
1304
+ ) {
1305
+ return null;
1306
+ }
1307
+
1308
+ const orderIndex = index + 1;
1309
+
1310
+ return {
1311
+ id: `${snapshot.cardId}:replay:${orderIndex}:${timestamp}`,
1312
+ title: `Replay #${orderIndex}`,
1313
+ period: {
1314
+ start: timestamp,
1315
+ },
1316
+ status: 'closed',
1317
+ statusLabel: 'REPLAY',
1318
+ statusColor: 'gray',
1319
+ pnl,
1320
+ metrics: [
1321
+ {
1322
+ title: 'Entry',
1323
+ value: 'n/a',
1324
+ detail: buildSlippageDetail({}),
1325
+ },
1326
+ {
1327
+ title: 'Exit',
1328
+ value: 'n/a',
1329
+ detail: buildSlippageDetail({}),
1330
+ },
1331
+ {
1332
+ title: 'Notional',
1333
+ value: formatUsdt(null),
1334
+ },
1335
+ {
1336
+ title: 'Fees',
1337
+ value: formatFee(null),
1338
+ detail: buildReplayFeesDetail({}),
1339
+ },
1340
+ {
1341
+ title: 'Qty',
1342
+ value: formatCompactNumber(null),
1343
+ },
1344
+ {
1345
+ title: 'Equity',
1346
+ value: formatUsdt(equityAfter),
1347
+ detail: `prev ${formatUsdt(equityBefore)}`,
1348
+ },
1349
+ ],
1350
+ };
1351
+ })
1352
+ .filter((order): order is OrdersDrawerOrder => order != null)
1353
+ .reverse();
1354
+ };
1355
+
1356
+ const buildAiSnapshotOrders = (
1357
+ snapshot: StrategyChartSnapshot,
1358
+ ): OrdersDrawerOrder[] =>
1359
+ snapshot.orders
1360
+ .map((order, index) => ({ order, index }))
1361
+ .sort((left, right) => {
1362
+ const leftEntry =
1363
+ typeof left.order.entryTimestamp === 'number' &&
1364
+ Number.isFinite(left.order.entryTimestamp)
1365
+ ? left.order.entryTimestamp
1366
+ : Number.NEGATIVE_INFINITY;
1367
+ const rightEntry =
1368
+ typeof right.order.entryTimestamp === 'number' &&
1369
+ Number.isFinite(right.order.entryTimestamp)
1370
+ ? right.order.entryTimestamp
1371
+ : Number.NEGATIVE_INFINITY;
1372
+
1373
+ return rightEntry - leftEntry || left.index - right.index;
1374
+ })
1375
+ .map(({ order, index }) => {
1376
+ const orderIndex = order.sequence ?? index + 1;
1377
+ const durationHours =
1378
+ typeof order.entryTimestamp === 'number' &&
1379
+ Number.isFinite(order.entryTimestamp) &&
1380
+ typeof order.exitTimestamp === 'number' &&
1381
+ Number.isFinite(order.exitTimestamp)
1382
+ ? (order.exitTimestamp - order.entryTimestamp) / MS_IN_HOUR
1383
+ : null;
1384
+ const title = order.symbol
1385
+ ? `${order.symbol} · AI step #${orderIndex}`
1386
+ : `AI step #${orderIndex}`;
1387
+
1388
+ return {
1389
+ id: `${snapshot.cardId}:${order.id}`,
1390
+ title,
1391
+ period: {
1392
+ start: order.entryTimestamp,
1393
+ end: order.exitTimestamp,
1394
+ durationHours,
1395
+ },
1396
+ direction: normalizeSnapshotDirection(order.direction),
1397
+ status:
1398
+ typeof order.exitTimestamp === 'number' &&
1399
+ Number.isFinite(order.exitTimestamp)
1400
+ ? 'closed'
1401
+ : 'active',
1402
+ statusLabel: formatAiExitReason(order.exitReason),
1403
+ statusColor: getAiExitReasonColor(order.exitReason),
1404
+ pnl: order.pnl,
1405
+ metrics: [
1406
+ {
1407
+ title: 'Entry',
1408
+ value: formatPrice(order.entryPrice),
1409
+ detail: buildSlippageDetail({
1410
+ requestedPrice: order.requestedEntryPrice,
1411
+ slippageBps: order.entrySlippageBps,
1412
+ }),
1413
+ },
1414
+ {
1415
+ title: 'Exit',
1416
+ value: formatPrice(order.exitPrice),
1417
+ detail: buildSlippageDetail({
1418
+ requestedPrice: order.requestedExitPrice,
1419
+ slippageBps: order.exitSlippageBps,
1420
+ }),
1421
+ },
1422
+ {
1423
+ title: 'Notional',
1424
+ value: formatUsdt(order.notional),
1425
+ },
1426
+ {
1427
+ title: 'Fees',
1428
+ value: formatFee(order.totalFee),
1429
+ detail: buildAiFeesDetail(order),
1430
+ },
1431
+ {
1432
+ title: 'Qty',
1433
+ value: formatCompactNumber(order.qty, {
1434
+ maximumFractionDigits: 8,
1435
+ minimumFractionDigits: 0,
1436
+ }),
1437
+ },
1438
+ {
1439
+ title: 'Equity',
1440
+ value: formatUsdt(order.equityAfter),
1441
+ detail: `prev ${formatUsdt(order.equityBefore)}`,
1442
+ },
1443
+ ],
1444
+ };
1445
+ });
1446
+
1447
+ const buildSnapshotOrders = (
1448
+ snapshot: StrategyChartSnapshot,
1449
+ mode: 'replay' | 'ai',
1450
+ ): OrdersDrawerOrder[] =>
1451
+ mode === 'replay'
1452
+ ? buildReplaySnapshotOrders(snapshot)
1453
+ : buildAiSnapshotOrders(snapshot);
1454
+
1455
+ const buildSnapshotAdvancedTrades = (
1456
+ snapshot: StrategyChartSnapshot,
1457
+ ): AdvancedTradeInput[] =>
1458
+ snapshot.orders.flatMap((order): AdvancedTradeInput[] => {
1459
+ const timestamp =
1460
+ order.exitTimestamp ?? order.timestamp ?? order.entryTimestamp;
1461
+
1462
+ if (
1463
+ typeof timestamp !== 'number' ||
1464
+ !Number.isFinite(timestamp) ||
1465
+ typeof order.pnl !== 'number' ||
1466
+ !Number.isFinite(order.pnl)
1467
+ ) {
1468
+ return [];
1469
+ }
1470
+
1471
+ const slippageCost =
1472
+ typeof order.totalSlippageCost === 'number' &&
1473
+ Number.isFinite(order.totalSlippageCost)
1474
+ ? Math.abs(order.totalSlippageCost)
1475
+ : null;
1476
+
1477
+ return [
1478
+ {
1479
+ id: order.id,
1480
+ timestamp,
1481
+ pnl: order.pnl,
1482
+ symbol: order.symbol ?? null,
1483
+ direction: order.direction ?? null,
1484
+ exitReason: order.exitReason ?? null,
1485
+ slippageCost,
1486
+ grossPnl: slippageCost == null ? order.pnl : order.pnl + slippageCost,
1487
+ approved: true,
1488
+ blocked: false,
1489
+ },
1490
+ ];
1491
+ });
1492
+
1493
+ const directionMetricLabels: Record<string, string> = {
1494
+ approved: 'Approved',
1495
+ precision: 'Precision',
1496
+ monthlyPnl: 'Monthly P&L',
1497
+ pnl: 'P&L',
1498
+ avgProfit: 'Avg Profit',
1499
+ };
1500
+
1501
+ const directionMetricOrder = [
1502
+ 'approved',
1503
+ 'precision',
1504
+ 'monthlyPnl',
1505
+ 'pnl',
1506
+ 'avgProfit',
1507
+ ] as const;
1508
+
1509
+ const aiDrawerMetricOrder = [
1510
+ 'monthlyPnl',
1511
+ 'avgProfit',
1512
+ 'maxDrawdown',
1513
+ 'maxLossStreak',
1514
+ 'approved',
1515
+ 'approvedPerDay',
1516
+ 'accuracy',
1517
+ 'precision',
1518
+ ] as const;
1519
+
1520
+ const aiDrawerMetricOrderIndex = new Map<string, number>(
1521
+ aiDrawerMetricOrder.map((metricId, index) => [metricId, index]),
1522
+ );
1523
+
1524
+ const sortAiDrawerMetrics = (metrics: StrategyChartMetric[]) =>
1525
+ metrics
1526
+ .filter((metric) => metric.id !== 'recall')
1527
+ .sort((left, right) => {
1528
+ const leftIndex = aiDrawerMetricOrderIndex.get(left.id) ?? 100;
1529
+ const rightIndex = aiDrawerMetricOrderIndex.get(right.id) ?? 100;
1530
+
1531
+ return leftIndex - rightIndex || left.label.localeCompare(right.label);
1532
+ });
1533
+
1534
+ const isDirectionDetail = (detail: StrategyChartDetail) =>
1535
+ detail.id.startsWith(DIRECTION_DETAIL_PREFIX);
1536
+
1537
+ const isSymbolDetail = (detail: StrategyChartDetail) =>
1538
+ detail.id.startsWith(SYMBOL_DETAIL_PREFIX);
1539
+
1540
+ const isStructuredDetail = (detail: StrategyChartDetail) =>
1541
+ isDirectionDetail(detail) || isSymbolDetail(detail);
1542
+
1543
+ const getDetailById = (
1544
+ details: StrategyChartDetail[] | undefined,
1545
+ id: string,
1546
+ ) => details?.find((detail) => detail.id === id) ?? null;
1547
+
1548
+ const parseFormattedNumber = (value: string) => {
1549
+ const normalized = value
1550
+ .replace(/\s/g, '')
1551
+ .replace(',', '.')
1552
+ .replace(/[^\d.+-]/g, '');
1553
+ const parsed = Number(normalized);
1554
+ return Number.isFinite(parsed) ? parsed : null;
1555
+ };
1556
+
1557
+ const parseConfusionDetail = (detail: StrategyChartDetail | null) => {
1558
+ if (!detail) {
1559
+ return null;
1560
+ }
1561
+
1562
+ const values = detail.value
1563
+ .split('/')
1564
+ .map((part) => parseFormattedNumber(part))
1565
+ .filter((value): value is number => value !== null);
1566
+
1567
+ return values.length === 4 ? values : null;
1568
+ };
1569
+
1570
+ const getPnlBarColor = (value: number) => {
1571
+ if (value > 0) {
1572
+ return 'teal.500';
1573
+ }
1574
+ if (value < 0) {
1575
+ return 'red.500';
1576
+ }
1577
+ return 'gray.500';
1578
+ };
1579
+
1580
+ const buildDirectionStatGroups = (
1581
+ details: StrategyChartDetail[] | undefined,
1582
+ ): DirectionStatGroup[] => {
1583
+ const grouped = new Map<AiStatDirection, Map<string, DirectionMetric>>();
1584
+
1585
+ for (const direction of AI_STAT_DIRECTIONS) {
1586
+ grouped.set(direction, new Map());
1587
+ }
1588
+
1589
+ for (const detail of details ?? []) {
1590
+ const [, direction, metricId] = detail.id.split(':');
1591
+ if (metricId == null) {
1592
+ continue;
1593
+ }
1594
+
1595
+ if (direction !== 'LONG' && direction !== 'SHORT') {
1596
+ continue;
1597
+ }
1598
+
1599
+ const metric: DirectionMetric = {
1600
+ id: metricId,
1601
+ label: directionMetricLabels[metricId] ?? detail.label,
1602
+ value: detail.value,
1603
+ };
1604
+ if (detail.tone) {
1605
+ metric.tone = detail.tone;
1606
+ }
1607
+
1608
+ grouped.get(direction)?.set(metricId, metric);
1609
+ }
1610
+
1611
+ return AI_STAT_DIRECTIONS.map((direction) => {
1612
+ const values = grouped.get(direction) ?? new Map<string, DirectionMetric>();
1613
+ const metrics = directionMetricOrder.map(
1614
+ (metricId): DirectionMetric =>
1615
+ values.get(metricId) ?? {
1616
+ id: metricId,
1617
+ label: directionMetricLabels[metricId],
1618
+ value: 'n/a',
1619
+ tone: 'default',
1620
+ },
1621
+ );
1622
+
1623
+ return {
1624
+ direction,
1625
+ metrics,
1626
+ hasData: values.size > 0,
1627
+ };
1628
+ });
1629
+ };
1630
+
1631
+ const buildAiDiagnosticGroups = (
1632
+ details: StrategyChartDetail[] | undefined,
1633
+ ): AiDiagnosticGroup[] => {
1634
+ const plainDetails = details?.filter((detail) => !isStructuredDetail(detail));
1635
+ const groups: AiDiagnosticGroup[] = [];
1636
+ const windowDetail = getDetailById(plainDetails, 'window');
1637
+ const confusion = parseConfusionDetail(
1638
+ getDetailById(plainDetails, 'confusion'),
1639
+ );
1640
+ const avgProfitAll = getDetailById(plainDetails, 'avgProfitAll');
1641
+ const expectancyDelta = getDetailById(plainDetails, 'expectancyDelta');
1642
+
1643
+ if (windowDetail) {
1644
+ groups.push({
1645
+ id: 'window',
1646
+ title: 'Evaluation window',
1647
+ description: 'source rows used for this AI snapshot',
1648
+ columns: 1,
1649
+ metrics: [
1650
+ {
1651
+ id: windowDetail.id,
1652
+ label: 'Window',
1653
+ value: windowDetail.value,
1654
+ detail: 'UTC range',
1655
+ tone: windowDetail.tone,
1656
+ },
1657
+ ],
1658
+ });
1659
+ }
1660
+
1661
+ if (confusion) {
1662
+ const [truePositive, falsePositive, trueNegative, falseNegative] =
1663
+ confusion;
1664
+
1665
+ groups.push({
1666
+ id: 'confusion',
1667
+ title: 'Decision matrix',
1668
+ description: 'approved vs blocked outcomes',
1669
+ columns: 4,
1670
+ metrics: [
1671
+ {
1672
+ id: 'truePositive',
1673
+ label: 'TP',
1674
+ value: formatInteger(truePositive),
1675
+ detail: 'winner approved',
1676
+ tone: 'success',
1677
+ },
1678
+ {
1679
+ id: 'falsePositive',
1680
+ label: 'FP',
1681
+ value: formatInteger(falsePositive),
1682
+ detail: 'loser approved',
1683
+ tone: falsePositive > 0 ? 'warning' : 'neutral',
1684
+ },
1685
+ {
1686
+ id: 'trueNegative',
1687
+ label: 'TN',
1688
+ value: formatInteger(trueNegative),
1689
+ detail: 'loser blocked',
1690
+ tone: 'success',
1691
+ },
1692
+ {
1693
+ id: 'falseNegative',
1694
+ label: 'FN',
1695
+ value: formatInteger(falseNegative),
1696
+ detail: 'winner blocked',
1697
+ tone: falseNegative > 0 ? 'warning' : 'neutral',
1698
+ },
1699
+ ],
1700
+ });
1701
+ }
1702
+
1703
+ const liftMetrics: AiDiagnosticMetric[] = [];
1704
+
1705
+ if (avgProfitAll) {
1706
+ liftMetrics.push({
1707
+ id: avgProfitAll.id,
1708
+ label: 'Avg all candidates',
1709
+ value: avgProfitAll.value,
1710
+ detail: 'before AI approval',
1711
+ tone: avgProfitAll.tone,
1712
+ });
1713
+ }
1714
+
1715
+ if (expectancyDelta) {
1716
+ liftMetrics.push({
1717
+ id: expectancyDelta.id,
1718
+ label: 'Expectancy lift',
1719
+ value: expectancyDelta.value,
1720
+ detail: 'approved avg minus all avg',
1721
+ tone: expectancyDelta.tone,
1722
+ });
1723
+ }
1724
+
1725
+ if (liftMetrics.length) {
1726
+ groups.push({
1727
+ id: 'lift',
1728
+ title: 'Gate lift',
1729
+ description: 'what approval changes',
1730
+ columns: 2,
1731
+ metrics: liftMetrics,
1732
+ });
1733
+ }
1734
+
1735
+ return groups;
1736
+ };
1737
+
1738
+ const AiDiagnosticCard = ({ metric }: { metric: AiDiagnosticMetric }) => (
1739
+ <Box
1740
+ p={3}
1741
+ borderWidth="1px"
1742
+ borderColor="gray.800"
1743
+ borderRadius="md"
1744
+ bg="blackAlpha.200"
1745
+ minW="0"
1746
+ >
1747
+ <Text
1748
+ fontSize="2xs"
1749
+ color="gray.500"
1750
+ fontWeight="bold"
1751
+ textTransform="uppercase"
1752
+ lineHeight="1.2"
1753
+ >
1754
+ {metric.label}
1755
+ </Text>
1756
+ <Text
1757
+ mt={2}
1758
+ fontSize="lg"
1759
+ color={getMetricColor(metric.tone)}
1760
+ fontWeight="bold"
1761
+ fontFamily="mono"
1762
+ lineHeight="1.15"
1763
+ whiteSpace="nowrap"
1764
+ overflow="hidden"
1765
+ textOverflow="ellipsis"
1766
+ >
1767
+ {metric.value}
1768
+ </Text>
1769
+ {metric.detail ? (
1770
+ <Text
1771
+ mt={2}
1772
+ fontSize="xs"
1773
+ color="gray.500"
1774
+ lineHeight="1.25"
1775
+ whiteSpace="nowrap"
1776
+ overflow="hidden"
1777
+ textOverflow="ellipsis"
1778
+ >
1779
+ {metric.detail}
1780
+ </Text>
1781
+ ) : null}
1782
+ </Box>
1783
+ );
1784
+
1785
+ const AiDiagnosticGroupBlock = ({ group }: { group: AiDiagnosticGroup }) => (
1786
+ <Box>
1787
+ <Flex justify="space-between" align="baseline" gap={3} mb={3}>
1788
+ <Text color="gray.300" fontSize="sm" fontWeight="semibold">
1789
+ {group.title}
1790
+ </Text>
1791
+ <Text color="gray.600" fontSize="xs" textAlign="right">
1792
+ {group.description}
1793
+ </Text>
1794
+ </Flex>
1795
+ <SimpleGrid
1796
+ columns={{ base: 1, md: Math.min(group.columns, 3), xl: group.columns }}
1797
+ gap={3}
1798
+ >
1799
+ {group.metrics.map((metric) => (
1800
+ <AiDiagnosticCard key={metric.id} metric={metric} />
1801
+ ))}
1802
+ </SimpleGrid>
1803
+ </Box>
1804
+ );
1805
+
1806
+ const AiDiagnosticsPanel = ({ groups }: { groups: AiDiagnosticGroup[] }) => {
1807
+ if (!groups.length) {
1808
+ return null;
1809
+ }
1810
+
1811
+ return (
1812
+ <Box
1813
+ p={4}
1814
+ borderWidth="1px"
1815
+ borderColor="gray.800"
1816
+ borderRadius="md"
1817
+ bg="gray.900"
1818
+ >
1819
+ <Flex justify="space-between" align="baseline" gap={4}>
1820
+ <Text fontSize="md" fontWeight="semibold" color="gray.100">
1821
+ AI diagnostics
1822
+ </Text>
1823
+ <Text color="gray.600" fontSize="xs" textAlign="right">
1824
+ classifier-only details
1825
+ </Text>
1826
+ </Flex>
1827
+ <Flex direction="column" gap={5} mt={4}>
1828
+ {groups.map((group) => (
1829
+ <AiDiagnosticGroupBlock key={group.id} group={group} />
1830
+ ))}
1831
+ </Flex>
1832
+ </Box>
1833
+ );
1834
+ };
1835
+
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
+ const buildSymbolPnlRanking = (
1936
+ details: StrategyChartDetail[] | undefined,
1937
+ ): SymbolPnlRank[] => {
1938
+ const grouped = new Map<string, Partial<SymbolPnlRank>>();
1939
+
1940
+ for (const detail of details ?? []) {
1941
+ if (!isSymbolDetail(detail)) {
1942
+ continue;
1943
+ }
1944
+
1945
+ const [, symbol, metricId] = detail.id.split(':');
1946
+ if (!symbol || !metricId) {
1947
+ continue;
1948
+ }
1949
+
1950
+ const current = grouped.get(symbol) ?? { symbol };
1951
+ if (metricId === 'pnl') {
1952
+ const pnl = parseFormattedNumber(detail.value);
1953
+ if (pnl != null) {
1954
+ current.pnl = pnl;
1955
+ }
1956
+ }
1957
+ if (metricId === 'orders') {
1958
+ current.orders = parseFormattedNumber(detail.value);
1959
+ }
1960
+ if (metricId === 'winRate') {
1961
+ current.winRate = parseFormattedNumber(detail.value);
1962
+ }
1963
+
1964
+ grouped.set(symbol, current);
1965
+ }
1966
+
1967
+ return [...grouped.values()]
1968
+ .filter(
1969
+ (rank): rank is SymbolPnlRank =>
1970
+ typeof rank.symbol === 'string' &&
1971
+ typeof rank.pnl === 'number' &&
1972
+ Number.isFinite(rank.pnl),
1973
+ )
1974
+ .map((rank) => ({
1975
+ symbol: rank.symbol,
1976
+ pnl: rank.pnl,
1977
+ orders: rank.orders ?? null,
1978
+ winRate: rank.winRate ?? null,
1979
+ avgPnl:
1980
+ typeof rank.orders === 'number' && rank.orders > 0
1981
+ ? rank.pnl / rank.orders
1982
+ : null,
1983
+ }))
1984
+ .sort(
1985
+ (left, right) =>
1986
+ Math.abs(right.pnl) - Math.abs(left.pnl) ||
1987
+ right.pnl - left.pnl ||
1988
+ left.symbol.localeCompare(right.symbol),
1989
+ );
1990
+ };
1991
+
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
+ );
2096
+ const drawerBaseMetrics =
2097
+ mode === 'ai'
2098
+ ? snapshot.metrics
2099
+ .filter((metric) => metric.id !== 'pnl')
2100
+ .map((metric) =>
2101
+ metric.id === 'quality' || metric.label === 'Quality'
2102
+ ? {
2103
+ id: 'maxDrawdown',
2104
+ label: 'Max drawdown',
2105
+ value:
2106
+ calculateMaxDrawdownPercent(snapshot.orderLog) ?? 'n/a',
2107
+ tone: 'warning' as const,
2108
+ }
2109
+ : metric,
2110
+ )
2111
+ : snapshot.metrics;
2112
+ const maxLossStreak = useMemo(
2113
+ () => calculateMaxLossStreak(snapshot.orderLog),
2114
+ [snapshot.orderLog],
2115
+ );
2116
+ const drawerMetrics = useMemo(
2117
+ () =>
2118
+ mode === 'ai'
2119
+ ? sortAiDrawerMetrics([
2120
+ ...drawerBaseMetrics,
2121
+ {
2122
+ id: 'maxLossStreak',
2123
+ label: 'Max loss streak',
2124
+ value: formatInteger(maxLossStreak),
2125
+ tone:
2126
+ maxLossStreak > 0 ? ('warning' as const) : ('success' as const),
2127
+ },
2128
+ ])
2129
+ : 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({
2137
+ trades: buildSnapshotAdvancedTrades(snapshot),
2138
+ orderLog: snapshot.orderLog,
2139
+ startTimestamp: firstPoint?.[0] ?? null,
2140
+ endTimestamp: lastPoint?.[0] ?? null,
2141
+ });
2142
+ }, [snapshot]);
2143
+ const hasOrdersDrawer = snapshotOrders.length > 0;
2144
+ const hasStatDrawer = mode === 'ai' || Boolean(snapshot.details?.length);
2145
+
2146
+ const handleDelete = async () => {
2147
+ if (isDeleting) {
2148
+ return;
2149
+ }
2150
+
2151
+ setIsDeleting(true);
2152
+ try {
2153
+ const deleted = await deleteStrategyCard(mode, snapshot.cardId);
2154
+ if (!deleted) {
2155
+ toaster.error({
2156
+ title: 'Delete failed',
2157
+ description: 'Strategy card was not deleted.',
2158
+ });
2159
+ return;
2160
+ }
2161
+
2162
+ onDeleted?.(snapshot.cardId);
2163
+ setDeleteOpen(false);
2164
+ toaster.success({
2165
+ title: 'Strategy card deleted',
2166
+ description: snapshot.title,
2167
+ });
2168
+ } catch {
2169
+ toaster.error({
2170
+ title: 'Delete failed',
2171
+ description: 'Unexpected error while deleting strategy card.',
2172
+ });
2173
+ } finally {
2174
+ setIsDeleting(false);
2175
+ }
2176
+ };
2177
+
2178
+ const renderSymbolPnlRanking = ({
2179
+ title,
2180
+ subtitle,
2181
+ ranking,
2182
+ }: {
2183
+ title: string;
2184
+ subtitle: string;
2185
+ ranking: SymbolPnlRank[];
2186
+ }) => (
2187
+ <Box
2188
+ p={4}
2189
+ borderWidth="1px"
2190
+ borderColor="gray.800"
2191
+ borderRadius="md"
2192
+ bg="gray.900"
2193
+ >
2194
+ <Flex justify="space-between" align="center" mb={4}>
2195
+ <Text fontSize="sm" color="gray.300" fontWeight="semibold">
2196
+ {title}
2197
+ </Text>
2198
+ <Text fontSize="xs" color="gray.500">
2199
+ {subtitle}
2200
+ </Text>
2201
+ </Flex>
2202
+
2203
+ {ranking.length ? (
2204
+ <>
2205
+ <Flex align="center" gap={4} mb={2}>
2206
+ <Text
2207
+ flex="0 0 220px"
2208
+ fontSize="xs"
2209
+ color="gray.500"
2210
+ fontWeight="semibold"
2211
+ >
2212
+ Contracts
2213
+ </Text>
2214
+ <Box flex="1" />
2215
+ <Text
2216
+ flex="0 0 96px"
2217
+ fontSize="xs"
2218
+ color="gray.500"
2219
+ fontWeight="semibold"
2220
+ textAlign="right"
2221
+ >
2222
+ P&L (USDT)
2223
+ </Text>
2224
+ </Flex>
2225
+
2226
+ <Flex direction="column" gap={3}>
2227
+ {ranking.map((rank) => {
2228
+ const barWidth = Math.max(
2229
+ 6,
2230
+ (Math.abs(rank.pnl) / symbolRankingMaxAbsPnl) * 100,
2231
+ );
2232
+
2233
+ return (
2234
+ <Flex key={rank.symbol} align="center" gap={4} minH="34px">
2235
+ <Box flex="0 0 220px" minW={0}>
2236
+ <Text
2237
+ fontSize="sm"
2238
+ color="gray.100"
2239
+ fontWeight="semibold"
2240
+ lineHeight="1.2"
2241
+ overflow="hidden"
2242
+ textOverflow="ellipsis"
2243
+ whiteSpace="nowrap"
2244
+ >
2245
+ {rank.symbol}
2246
+ </Text>
2247
+ <Text
2248
+ mt={1}
2249
+ fontSize="xs"
2250
+ color="gray.500"
2251
+ fontFamily="mono"
2252
+ >
2253
+ {formatInteger(rank.orders)} orders · win{' '}
2254
+ {formatPercent(rank.winRate)} · avg{' '}
2255
+ {rank.avgPnl == null
2256
+ ? 'n/a'
2257
+ : formatSignedNumber(rank.avgPnl)}
2258
+ </Text>
2259
+ </Box>
2260
+
2261
+ <Box flex="1" h="12px" bg="gray.800">
2262
+ <Box
2263
+ h="full"
2264
+ w={`${barWidth}%`}
2265
+ bg={getPnlBarColor(rank.pnl)}
2266
+ />
2267
+ </Box>
2268
+
2269
+ <Text
2270
+ flex="0 0 96px"
2271
+ color={getPnlColor(rank.pnl)}
2272
+ fontSize="lg"
2273
+ fontFamily="mono"
2274
+ fontWeight="bold"
2275
+ textAlign="right"
2276
+ >
2277
+ {formatSignedNumber(rank.pnl)}
2278
+ </Text>
2279
+ </Flex>
2280
+ );
2281
+ })}
2282
+ </Flex>
2283
+ </>
2284
+ ) : (
2285
+ <Box
2286
+ p={3}
2287
+ borderWidth="1px"
2288
+ borderColor="gray.800"
2289
+ borderRadius="md"
2290
+ bg="blackAlpha.300"
2291
+ >
2292
+ <Text fontSize="sm" color="gray.500">
2293
+ No symbol P&L data
2294
+ </Text>
2295
+ </Box>
2296
+ )}
2297
+ </Box>
2298
+ );
2299
+
2300
+ return (
2301
+ <Box
2302
+ p={2}
2303
+ mb={4}
2304
+ maxW="1400px"
2305
+ borderRadius="md"
2306
+ shadow="sm"
2307
+ borderWidth="1px"
2308
+ borderColor="gray.800"
2309
+ overflowX="auto"
2310
+ >
2311
+ <Flex gap="4" p={4} mb={3} alignItems="center" wrap="wrap">
2312
+ {onToggleSelection ? (
2313
+ <Checkbox.Root
2314
+ size="sm"
2315
+ colorPalette="teal"
2316
+ checked={selected}
2317
+ onCheckedChange={(details) =>
2318
+ onToggleSelection(snapshot.cardId, details.checked === true)
2319
+ }
2320
+ >
2321
+ <Checkbox.HiddenInput />
2322
+ <Checkbox.Control bg="gray.800" borderColor="gray.500" />
2323
+ </Checkbox.Root>
2324
+ ) : null}
2325
+
2326
+ <Text fontSize="lg" fontWeight="bold" color="gray.200">
2327
+ {snapshot.title}
2328
+ </Text>
2329
+
2330
+ <Flex gap="1">
2331
+ <Text fontSize="sm" fontWeight="bold" color="gray.400" mt={1}>
2332
+ {sourceLabel}
2333
+ </Text>
2334
+ <Text fontSize="lg" fontWeight="bold" color="gray.200">
2335
+ {sourceValue}
2336
+ </Text>
2337
+ </Flex>
2338
+
2339
+ {tagsLabel ? (
2340
+ <Box
2341
+ px={2}
2342
+ py={1}
2343
+ borderWidth="1px"
2344
+ borderColor="teal.900"
2345
+ borderRadius="sm"
2346
+ bg="teal.950"
2347
+ color="teal.300"
2348
+ fontFamily="mono"
2349
+ fontSize="sm"
2350
+ fontWeight="semibold"
2351
+ lineHeight="1"
2352
+ >
2353
+ {tagsLabel}
2354
+ </Box>
2355
+ ) : null}
2356
+
2357
+ <Flex ml="auto" align="center" gap={3}>
2358
+ {displaySubtitle ? (
2359
+ <Box
2360
+ order={0}
2361
+ px={2}
2362
+ py={1}
2363
+ borderWidth="1px"
2364
+ borderColor="gray.700"
2365
+ borderRadius="sm"
2366
+ bg="gray.800"
2367
+ color="gray.200"
2368
+ fontFamily="mono"
2369
+ fontSize="sm"
2370
+ fontWeight="semibold"
2371
+ lineHeight="1"
2372
+ >
2373
+ {displaySubtitle}
2374
+ </Box>
2375
+ ) : null}
2376
+
2377
+ <Box order={1}>
2378
+ <Menu.Root positioning={{ placement: 'bottom-end' }}>
2379
+ <Menu.Trigger asChild>
2380
+ <Button size="sm" variant="outline">
2381
+ Actions
2382
+ </Button>
2383
+ </Menu.Trigger>
2384
+ <Portal>
2385
+ <Menu.Positioner>
2386
+ <Menu.Content minW="160px">
2387
+ {hasOrdersDrawer ? (
2388
+ <Menu.Item
2389
+ value="orders"
2390
+ onClick={() => setOrdersOpen(true)}
2391
+ >
2392
+ Orders
2393
+ </Menu.Item>
2394
+ ) : null}
2395
+ {hasStatDrawer ? (
2396
+ <Menu.Item
2397
+ value="stat"
2398
+ onClick={() => setDetailsOpen(true)}
2399
+ >
2400
+ Stat
2401
+ </Menu.Item>
2402
+ ) : null}
2403
+ {hasStatDrawer ? <Menu.Separator /> : null}
2404
+ <Menu.Item
2405
+ value="delete"
2406
+ color="fg.error"
2407
+ onClick={() => setDeleteOpen(true)}
2408
+ >
2409
+ Delete
2410
+ </Menu.Item>
2411
+ </Menu.Content>
2412
+ </Menu.Positioner>
2413
+ </Portal>
2414
+ </Menu.Root>
2415
+ </Box>
2416
+ </Flex>
2417
+
2418
+ <OrdersDrawerPanel
2419
+ title={`${snapshot.title} orders`}
2420
+ open={ordersOpen}
2421
+ orders={snapshotOrders}
2422
+ rowHeight={SNAPSHOT_ORDER_ROW_HEIGHT}
2423
+ showStatusFilter={false}
2424
+ emptyText={
2425
+ mode === 'replay'
2426
+ ? 'No replay order points for this card.'
2427
+ : 'No AI order points for this card.'
2428
+ }
2429
+ onOpenChange={setOrdersOpen}
2430
+ />
2431
+
2432
+ <Drawer.Root
2433
+ size="xl"
2434
+ open={detailsOpen}
2435
+ onOpenChange={(e) => setDetailsOpen(e.open)}
2436
+ >
2437
+ <Portal>
2438
+ <Drawer.Backdrop />
2439
+ <Drawer.Positioner>
2440
+ <Drawer.Content
2441
+ display="flex"
2442
+ flexDirection="column"
2443
+ w="50vw"
2444
+ minW="640px"
2445
+ maxW="50vw"
2446
+ bg="gray.950"
2447
+ >
2448
+ <Drawer.Header>
2449
+ <Drawer.Title>{snapshot.title}</Drawer.Title>
2450
+ <Drawer.CloseTrigger asChild>
2451
+ <CloseButton size="sm" />
2452
+ </Drawer.CloseTrigger>
2453
+ </Drawer.Header>
2454
+
2455
+ <Drawer.Body
2456
+ display="flex"
2457
+ flexDirection="column"
2458
+ gap={4}
2459
+ overflowY="auto"
2460
+ flex="1"
2461
+ minH="0"
2462
+ w="full"
2463
+ >
2464
+ <Box
2465
+ p={4}
2466
+ borderWidth="1px"
2467
+ borderColor="gray.800"
2468
+ borderRadius="md"
2469
+ bg="gray.900"
2470
+ >
2471
+ <Text fontSize="sm" color="gray.500" mb={3}>
2472
+ {snapshot.subtitle || 'AI train details'}
2473
+ </Text>
2474
+
2475
+ <SimpleGrid columns={{ base: 2, md: 4 }} gap={3}>
2476
+ {drawerMetrics.map((metric) => (
2477
+ <Box
2478
+ key={metric.id}
2479
+ p={3}
2480
+ borderWidth="1px"
2481
+ borderColor="gray.800"
2482
+ borderRadius="md"
2483
+ bg="blackAlpha.300"
2484
+ >
2485
+ <Text
2486
+ fontSize="xs"
2487
+ color="gray.400"
2488
+ fontWeight="semibold"
2489
+ textTransform="uppercase"
2490
+ >
2491
+ {metric.label}
2492
+ </Text>
2493
+ <Text
2494
+ mt={1}
2495
+ fontSize="xl"
2496
+ color={getMetricColor(metric.tone)}
2497
+ fontWeight="bold"
2498
+ fontFamily="mono"
2499
+ lineHeight="1.2"
2500
+ >
2501
+ {metric.value}
2502
+ </Text>
2503
+ </Box>
2504
+ ))}
2505
+ </SimpleGrid>
2506
+ </Box>
2507
+
2508
+ <AdvancedMetricsPanel metrics={advancedMetrics} />
2509
+
2510
+ {monthlyStats.length ? (
2511
+ <Box
2512
+ p={4}
2513
+ borderWidth="1px"
2514
+ borderColor="gray.800"
2515
+ borderRadius="md"
2516
+ bg="gray.900"
2517
+ >
2518
+ <Text
2519
+ fontSize="sm"
2520
+ color="gray.300"
2521
+ fontWeight="semibold"
2522
+ mb={3}
2523
+ >
2524
+ Monthly Performance
2525
+ </Text>
2526
+
2527
+ <Flex direction="column" gap={4}>
2528
+ {monthlyStats.map((yearGroup) => (
2529
+ <Box key={yearGroup.year}>
2530
+ <Flex align="center" gap={3} mb={3}>
2531
+ <Text
2532
+ fontSize="lg"
2533
+ color="gray.100"
2534
+ fontWeight="bold"
2535
+ fontFamily="mono"
2536
+ >
2537
+ {yearGroup.year}
2538
+ </Text>
2539
+ <Box flex="1" h="1px" bg="gray.800" />
2540
+ </Flex>
2541
+
2542
+ <Flex direction="column" gap={3}>
2543
+ {buildQuarterlyMonthlyStats(yearGroup.months).map(
2544
+ (quarter) => (
2545
+ <Flex
2546
+ key={`${yearGroup.year}-${quarter.label}`}
2547
+ align="stretch"
2548
+ gap={3}
2549
+ >
2550
+ <Flex
2551
+ w="34px"
2552
+ flexShrink={0}
2553
+ align="center"
2554
+ justify="center"
2555
+ >
2556
+ <Text
2557
+ fontSize="xs"
2558
+ color="gray.500"
2559
+ fontFamily="mono"
2560
+ fontWeight="bold"
2561
+ >
2562
+ {quarter.label}
2563
+ </Text>
2564
+ </Flex>
2565
+
2566
+ <SimpleGrid columns={3} gap={3} flex="1">
2567
+ {quarter.months.map(
2568
+ (month, monthOffset) => {
2569
+ const monthIndex =
2570
+ quarter.monthIndexes[monthOffset] ??
2571
+ 0;
2572
+
2573
+ if (!month) {
2574
+ return (
2575
+ <Box
2576
+ key={`${quarter.label}-${monthIndex}-empty`}
2577
+ minH="116px"
2578
+ visibility="hidden"
2579
+ />
2580
+ );
2581
+ }
2582
+
2583
+ const winRate =
2584
+ month.orders > 0
2585
+ ? (month.wins / month.orders) *
2586
+ 100
2587
+ : null;
2588
+
2589
+ return (
2590
+ <Box
2591
+ key={month.id}
2592
+ p={3}
2593
+ minH="116px"
2594
+ borderWidth="1px"
2595
+ borderColor="gray.800"
2596
+ borderLeftWidth="3px"
2597
+ borderLeftColor={getPnlColor(
2598
+ month.pnl,
2599
+ )}
2600
+ borderRadius="md"
2601
+ bg="blackAlpha.300"
2602
+ >
2603
+ <Flex
2604
+ justify="space-between"
2605
+ align="baseline"
2606
+ gap={2}
2607
+ >
2608
+ <Text
2609
+ fontSize="sm"
2610
+ color="gray.200"
2611
+ fontWeight="bold"
2612
+ >
2613
+ {month.monthLabel}
2614
+ </Text>
2615
+ <Text
2616
+ fontSize="xs"
2617
+ color="gray.500"
2618
+ fontFamily="mono"
2619
+ >
2620
+ {String(
2621
+ month.monthIndex,
2622
+ ).padStart(2, '0')}
2623
+ </Text>
2624
+ </Flex>
2625
+ <Text
2626
+ mt={3}
2627
+ fontSize="xl"
2628
+ color={getPnlColor(month.pnl)}
2629
+ fontWeight="bold"
2630
+ fontFamily="mono"
2631
+ lineHeight="1.2"
2632
+ >
2633
+ {formatSignedNumber(month.pnl)}
2634
+ </Text>
2635
+ <Flex
2636
+ mt={3}
2637
+ justify="space-between"
2638
+ gap={3}
2639
+ >
2640
+ <Box>
2641
+ <Text
2642
+ fontSize="xs"
2643
+ color="gray.500"
2644
+ >
2645
+ Orders
2646
+ </Text>
2647
+ <Text
2648
+ fontSize="sm"
2649
+ color="gray.300"
2650
+ fontFamily="mono"
2651
+ fontWeight="semibold"
2652
+ >
2653
+ {formatInteger(
2654
+ month.orders,
2655
+ )}
2656
+ </Text>
2657
+ </Box>
2658
+ <Box textAlign="right">
2659
+ <Text
2660
+ fontSize="xs"
2661
+ color="gray.500"
2662
+ >
2663
+ Win rate
2664
+ </Text>
2665
+ <Text
2666
+ fontSize="sm"
2667
+ color="gray.300"
2668
+ fontFamily="mono"
2669
+ fontWeight="semibold"
2670
+ >
2671
+ {formatPercent(winRate)}
2672
+ </Text>
2673
+ </Box>
2674
+ </Flex>
2675
+ </Box>
2676
+ );
2677
+ },
2678
+ )}
2679
+ </SimpleGrid>
2680
+ </Flex>
2681
+ ),
2682
+ )}
2683
+ </Flex>
2684
+ </Box>
2685
+ ))}
2686
+ </Flex>
2687
+ </Box>
2688
+ ) : null}
2689
+
2690
+ {mode === 'ai' ? (
2691
+ <Flex direction="column" gap={4}>
2692
+ <ChartPanel
2693
+ title="Drawdown Timeline"
2694
+ subtitle="equity peak to current equity"
2695
+ >
2696
+ <DrawdownTimelineChart points={drawdownPoints} />
2697
+ </ChartPanel>
2698
+
2699
+ <ChartPanel
2700
+ title="Rolling Performance"
2701
+ subtitle="last 50 trades"
2702
+ >
2703
+ <RollingPerformanceChart
2704
+ points={rollingPerformancePoints}
2705
+ />
2706
+ </ChartPanel>
2707
+
2708
+ <ChartPanel
2709
+ title="Win / Loss Streak Timeline"
2710
+ subtitle="trade sequence"
2711
+ >
2712
+ <WinLossStreakTimelineChart
2713
+ trades={snapshotTradePoints}
2714
+ />
2715
+ </ChartPanel>
2716
+
2717
+ <ChartPanel
2718
+ title="P&L Distribution"
2719
+ subtitle="trade result buckets"
2720
+ >
2721
+ <PnlDistributionChart bins={pnlDistributionBins} />
2722
+ </ChartPanel>
2723
+
2724
+ <ChartPanel
2725
+ title="P&L by Time of Day / Session"
2726
+ subtitle="UTC"
2727
+ >
2728
+ <TimeOfDaySessionChart
2729
+ sessions={sessionPnlStats}
2730
+ hours={hourlyPnlStats}
2731
+ />
2732
+ </ChartPanel>
2733
+ </Flex>
2734
+ ) : null}
2735
+
2736
+ {mode === 'ai' ? (
2737
+ <Flex direction="column" gap={4}>
2738
+ {renderSymbolPnlRanking({
2739
+ title: 'P&L Ranking',
2740
+ subtitle: 'Top 10 contracts',
2741
+ ranking: topSymbolPnlRanking,
2742
+ })}
2743
+ {renderSymbolPnlRanking({
2744
+ title: 'Worst Contracts',
2745
+ subtitle: 'Worst 10 contracts',
2746
+ ranking: worstSymbolPnlRanking,
2747
+ })}
2748
+ </Flex>
2749
+ ) : null}
2750
+
2751
+ <Box
2752
+ p={4}
2753
+ borderWidth="1px"
2754
+ borderColor="gray.800"
2755
+ borderRadius="md"
2756
+ bg="gray.900"
2757
+ >
2758
+ <Text
2759
+ fontSize="sm"
2760
+ color="gray.300"
2761
+ fontWeight="semibold"
2762
+ mb={3}
2763
+ >
2764
+ LONG / SHORT
2765
+ </Text>
2766
+ <SimpleGrid columns={{ base: 1, md: 2 }} gap={3}>
2767
+ {directionStatGroups.map((group) => (
2768
+ <Box
2769
+ key={group.direction}
2770
+ p={3}
2771
+ borderWidth="1px"
2772
+ borderColor="gray.800"
2773
+ borderRadius="md"
2774
+ bg="blackAlpha.300"
2775
+ >
2776
+ <Flex justify="space-between" align="center" mb={3}>
2777
+ <Text
2778
+ fontSize="sm"
2779
+ color={
2780
+ group.direction === 'LONG'
2781
+ ? 'teal.400'
2782
+ : 'pink.300'
2783
+ }
2784
+ fontWeight="bold"
2785
+ >
2786
+ {group.direction}
2787
+ </Text>
2788
+ {!group.hasData ? (
2789
+ <Text fontSize="xs" color="gray.500">
2790
+ no data
2791
+ </Text>
2792
+ ) : null}
2793
+ </Flex>
2794
+
2795
+ <SimpleGrid columns={1} gap={2}>
2796
+ {group.metrics.map((metric) => (
2797
+ <Flex
2798
+ key={metric.id}
2799
+ justify="space-between"
2800
+ align="baseline"
2801
+ gap={3}
2802
+ >
2803
+ <Text fontSize="xs" color="gray.500">
2804
+ {metric.label}
2805
+ </Text>
2806
+ <Text
2807
+ fontSize="sm"
2808
+ color={getMetricColor(metric.tone)}
2809
+ fontFamily="mono"
2810
+ fontWeight="semibold"
2811
+ textAlign="right"
2812
+ >
2813
+ {metric.value}
2814
+ </Text>
2815
+ </Flex>
2816
+ ))}
2817
+ </SimpleGrid>
2818
+ </Box>
2819
+ ))}
2820
+ </SimpleGrid>
2821
+ </Box>
2822
+
2823
+ <AiDiagnosticsPanel groups={aiDiagnosticGroups} />
2824
+ </Drawer.Body>
2825
+ </Drawer.Content>
2826
+ </Drawer.Positioner>
2827
+ </Portal>
2828
+ </Drawer.Root>
2829
+
2830
+ <Dialog.Root
2831
+ open={deleteOpen}
2832
+ onOpenChange={(e) => setDeleteOpen(e.open)}
2833
+ >
2834
+ <Portal>
2835
+ <Dialog.Backdrop />
2836
+ <Dialog.Positioner>
2837
+ <Dialog.Content>
2838
+ <Dialog.Header>
2839
+ <Dialog.Title>Delete card</Dialog.Title>
2840
+ <Dialog.CloseTrigger asChild>
2841
+ <CloseButton position="absolute" right="3" top="3" />
2842
+ </Dialog.CloseTrigger>
2843
+ </Dialog.Header>
2844
+ <Dialog.Body>
2845
+ <Text fontSize="sm" color="gray.200">
2846
+ Delete strategy card <b>{snapshot.title}</b>?
2847
+ </Text>
2848
+ <Text fontSize="sm" color="gray.400" mt={2}>
2849
+ This action cannot be undone.
2850
+ </Text>
2851
+ </Dialog.Body>
2852
+ <Dialog.Footer>
2853
+ <Dialog.ActionTrigger asChild>
2854
+ <Button variant="outline" size="sm" disabled={isDeleting}>
2855
+ Cancel
2856
+ </Button>
2857
+ </Dialog.ActionTrigger>
2858
+ <Button
2859
+ colorPalette="red"
2860
+ size="sm"
2861
+ onClick={handleDelete}
2862
+ loading={isDeleting}
2863
+ >
2864
+ Delete
2865
+ </Button>
2866
+ </Dialog.Footer>
2867
+ </Dialog.Content>
2868
+ </Dialog.Positioner>
2869
+ </Portal>
2870
+ </Dialog.Root>
2871
+ </Flex>
2872
+
2873
+ <StrategySnapshotChart
2874
+ orderLog={snapshot.orderLog}
2875
+ emptyText={emptyText}
2876
+ />
2877
+
2878
+ <SimpleGrid columns={{ base: 4, md: 8 }} p={4}>
2879
+ {metrics.map((metric) => (
2880
+ <Stat.Root key={metric.id} size="md">
2881
+ <Stat.Label>{metric.label}</Stat.Label>
2882
+ <Stat.ValueText color={getMetricColor(metric.tone)}>
2883
+ {metric.value}
2884
+ </Stat.ValueText>
2885
+ </Stat.Root>
2886
+ ))}
2887
+ </SimpleGrid>
2888
+ </Box>
2889
+ );
2890
+ };