@tradejs/app 2.0.21 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/package.json +8 -8
  2. package/src/app/actions/backtest.ts +1 -1
  3. package/src/app/actions/strategies.ts +1 -1
  4. package/src/app/api/ai/route.ts +13 -6
  5. package/src/app/api/user/settings/route.ts +48 -28
  6. package/src/app/components/Shared/OrdersDrawer.tsx +4 -2
  7. package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +3 -3
  8. package/src/app/components/Strategies/RuntimeStrategyCard.presenter.ts +536 -0
  9. package/src/app/components/Strategies/RuntimeStrategyCard.tsx +16 -1207
  10. package/src/app/components/Strategies/RuntimeStrategyConfigDrawer.tsx +1 -1
  11. package/src/app/components/Strategies/RuntimeStrategyStatsDrawer.tsx +677 -0
  12. package/src/app/components/Strategies/StrategySnapshotCard.details.presenter.ts +38 -0
  13. package/src/app/components/Strategies/StrategySnapshotCard.diagnostics.presenter.ts +263 -0
  14. package/src/app/components/Strategies/StrategySnapshotCard.orders.presenter.ts +680 -0
  15. package/src/app/components/Strategies/StrategySnapshotCard.presenter.ts +119 -0
  16. package/src/app/components/Strategies/StrategySnapshotCard.ranking.presenter.ts +76 -0
  17. package/src/app/components/Strategies/StrategySnapshotCard.tsx +13 -1793
  18. package/src/app/components/Strategies/StrategySnapshotCardDetailsDrawer.tsx +682 -0
  19. package/src/app/lib/runtimeStrategies.ts +7 -80
  20. package/src/app/lib/runtimeStrategyContracts.ts +85 -0
  21. package/src/app/routes/backtest/BacktestJobItem.tsx +275 -0
  22. package/src/app/routes/backtest/BacktestRunForm.tsx +502 -0
  23. package/src/app/routes/backtest/page.tsx +16 -1099
  24. package/src/app/routes/backtest/useBacktestRunsController.ts +441 -0
  25. package/src/app/routes/strategies/StrategiesPageClient.tsx +1 -1
@@ -6,7 +6,6 @@ import {
6
6
  Checkbox,
7
7
  CloseButton,
8
8
  Dialog,
9
- Drawer,
10
9
  Flex,
11
10
  Menu,
12
11
  Portal,
@@ -15,1268 +14,17 @@ import {
15
14
  Text,
16
15
  } from '@chakra-ui/react';
17
16
  import { 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';
17
+ import type { StrategyChartSnapshot } from '@tradejs/types';
18
+ import { OrdersDrawerPanel } from '#components/Shared/OrdersDrawer';
43
19
  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';
53
20
  import { toaster } from '#ui';
54
- import { AdvancedMetricsPanel } from './AdvancedMetricsPanel';
55
21
  import { StrategySnapshotChart } from './StrategySnapshotChart';
22
+ import { StrategySnapshotCardDetailsDrawer } from './StrategySnapshotCardDetailsDrawer';
56
23
  import {
57
- ChartPanel,
58
- DrawdownTimelineChart,
59
- PnlDistributionChart,
60
- RollingPerformanceChart,
61
- TimeOfDaySessionChart,
62
- WinLossStreakTimelineChart,
63
- } from './StrategyPerformanceCharts';
64
-
65
- const MS_IN_HOUR = 60 * 60 * 1000;
66
- const SNAPSHOT_ORDER_ROW_HEIGHT = 318;
67
- const DIRECTION_DETAIL_PREFIX = 'direction:';
68
- const SYMBOL_DETAIL_PREFIX = 'symbol:';
69
- const AI_STAT_DIRECTIONS = ['LONG', 'SHORT'] as const;
70
- const SNAPSHOT_SUMMARY_METRICS: {
71
- id: TestThresholdsKey;
72
- label: string;
73
- }[] = [
74
- { id: 'netProfit', label: 'P&L' },
75
- { id: 'minAmount', label: 'Min Amount' },
76
- { id: 'maxDrawdown', label: 'Drawdown' },
77
- { id: 'orders', label: 'Orders' },
78
- { id: 'winRate', label: 'Win Rate' },
79
- { id: 'riskRewardRatio', label: 'Risk Ratio' },
80
- { id: 'maxConsecutiveWins', label: 'Max Gross Streak' },
81
- { id: 'maxConsecutiveLosses', label: 'Max Loss Streak' },
82
- ];
83
-
84
- type AiStatDirection = (typeof AI_STAT_DIRECTIONS)[number];
85
-
86
- interface DirectionMetric {
87
- id: string;
88
- label: string;
89
- value: string;
90
- tone?: StrategyChartMetric['tone'];
91
- }
92
-
93
- interface DirectionStatGroup {
94
- direction: AiStatDirection;
95
- metrics: DirectionMetric[];
96
- hasData: boolean;
97
- }
98
-
99
- interface SymbolPnlRank {
100
- symbol: string;
101
- pnl: number;
102
- orders: number | null;
103
- winRate: number | null;
104
- avgPnl: number | null;
105
- }
106
-
107
- interface AiDiagnosticMetric {
108
- id: string;
109
- label: string;
110
- value: string;
111
- detail?: string;
112
- tone?: StrategyChartMetric['tone'];
113
- }
114
-
115
- interface AiDiagnosticGroup {
116
- id: string;
117
- title: string;
118
- description: string;
119
- columns: number;
120
- metrics: AiDiagnosticMetric[];
121
- }
122
-
123
- const getMetricColor = (tone: StrategyChartMetric['tone']) => {
124
- switch (tone) {
125
- case 'success':
126
- return 'teal.500';
127
- case 'warning':
128
- return 'fg.warning';
129
- case 'neutral':
130
- return 'gray.300';
131
- case 'error':
132
- return 'fg.error';
133
- default:
134
- return 'gray.200';
135
- }
136
- };
137
-
138
- const asFiniteNumber = (value: unknown) =>
139
- typeof value === 'number' && Number.isFinite(value) ? value : null;
140
-
141
- const getSnapshotTradePnls = (snapshot: StrategyChartSnapshot) => {
142
- const orderPnls = snapshot.orders
143
- .map((order) => asFiniteNumber(order.pnl))
144
- .filter((pnl): pnl is number => pnl != null);
145
-
146
- if (orderPnls.length) {
147
- return orderPnls;
148
- }
149
-
150
- return snapshot.orderLog
151
- .map((_, index) =>
152
- index === 0
153
- ? null
154
- : asFiniteNumber(getSnapshotStepPnl(snapshot.orderLog, index)),
155
- )
156
- .filter((pnl): pnl is number => pnl != null);
157
- };
158
-
159
- const calculateSnapshotRiskRewardRatio = (pnls: number[]) => {
160
- const wins = pnls.filter((pnl) => pnl > 0);
161
- const losses = pnls.filter((pnl) => pnl < 0);
162
-
163
- if (!wins.length || !losses.length) {
164
- return null;
165
- }
166
-
167
- const avgWin = wins.reduce((sum, pnl) => sum + pnl, 0) / wins.length;
168
- const avgLossAbs = Math.abs(
169
- losses.reduce((sum, pnl) => sum + pnl, 0) / losses.length,
170
- );
171
-
172
- return avgLossAbs > 0 ? avgWin / avgLossAbs : null;
173
- };
174
-
175
- const buildSnapshotSummaryStat = (
176
- snapshot: StrategyChartSnapshot,
177
- ): Partial<TestStat> => {
178
- const amounts = snapshot.orderLog
179
- .map(([, amount]) => asFiniteNumber(amount))
180
- .filter((amount): amount is number => amount != null);
181
- const firstAmount = amounts[0] ?? null;
182
- const lastAmount = amounts.at(-1) ?? null;
183
- const pnls = getSnapshotTradePnls(snapshot);
184
- const wins = pnls.filter((pnl) => pnl > 0).length;
185
- const orders =
186
- asFiniteNumber(snapshot.stat?.orders) ??
187
- (snapshot.orders.length || pnls.length);
188
- const netProfit =
189
- asFiniteNumber(snapshot.stat?.netProfit) ??
190
- (firstAmount != null && lastAmount != null
191
- ? lastAmount - firstAmount
192
- : pnls.reduce((sum, pnl) => sum + pnl, 0));
193
- const minAmount =
194
- asFiniteNumber(snapshot.stat?.minAmount) ??
195
- (amounts.length ? Math.min(...amounts) : null);
196
- const maxDrawdown =
197
- asFiniteNumber(snapshot.stat?.maxDrawdown) ??
198
- calculateMaxDrawdownValue(snapshot.orderLog);
199
- const winRate =
200
- asFiniteNumber(snapshot.stat?.winRate) ??
201
- (orders > 0 ? (wins / orders) * 100 : 0);
202
- const riskRewardRatio =
203
- asFiniteNumber(snapshot.stat?.riskRewardRatio) ??
204
- calculateSnapshotRiskRewardRatio(pnls);
205
- const maxConsecutiveWins =
206
- asFiniteNumber(snapshot.stat?.maxConsecutiveWins) ??
207
- calculateMaxGrossStreak(snapshot.orderLog);
208
- const maxConsecutiveLosses =
209
- asFiniteNumber(snapshot.stat?.maxConsecutiveLosses) ??
210
- calculateMaxLossStreak(snapshot.orderLog);
211
-
212
- return {
213
- netProfit,
214
- minAmount: minAmount ?? undefined,
215
- maxDrawdown: maxDrawdown ?? undefined,
216
- orders,
217
- winRate,
218
- riskRewardRatio,
219
- maxConsecutiveWins,
220
- maxConsecutiveLosses,
221
- };
222
- };
223
-
224
- const buildSnapshotSummaryMetrics = (snapshot: StrategyChartSnapshot) => {
225
- const stat = buildSnapshotSummaryStat(snapshot);
226
-
227
- return SNAPSHOT_SUMMARY_METRICS.map(({ id, label }) => {
228
- const { formatted, level } = getFormatted(stat, id);
229
-
230
- return {
231
- id,
232
- label,
233
- value: formatted,
234
- tone: level,
235
- };
236
- });
237
- };
238
-
239
- const formatPrice = (value: number | null | undefined) =>
240
- formatPriceUsdt(value);
241
-
242
- const formatBps = (value: number | null | undefined) => {
243
- if (typeof value !== 'number' || !Number.isFinite(value)) {
244
- return 'n/a';
245
- }
246
-
247
- return `${formatCompactNumber(value, {
248
- maximumFractionDigits: 2,
249
- minimumFractionDigits: 2,
250
- })} bps`;
251
- };
252
-
253
- const formatAiExitReason = (reason: string | null | undefined) =>
254
- reason ? reason.replace(/_/g, ' ').toUpperCase() : 'CLOSED';
255
-
256
- const getAiExitReasonColor = (reason: string | null | undefined) => {
257
- switch (reason) {
258
- case 'take_profit':
259
- return 'teal';
260
- case 'stop_loss':
261
- return 'red';
262
- default:
263
- return 'gray';
264
- }
265
- };
266
-
267
- const buildSlippageDetail = ({
268
- requestedPrice,
269
- slippageBps,
270
- }: {
271
- requestedPrice?: number | null;
272
- slippageBps?: number | null;
273
- }) => (
274
- <>
275
- plan {formatPrice(requestedPrice)}
276
- <br />
277
- slip {formatBps(slippageBps)}
278
- </>
279
- );
280
-
281
- const buildAiFeesDetail = (order: StrategyChartOrder) => (
282
- <>
283
- open {formatFee(order.openFee)}
284
- <br />
285
- close {formatFee(order.closeFee)}
286
- <br />
287
- funding {formatFee(order.fundingFee)}
288
- </>
289
- );
290
-
291
- const normalizeSnapshotDirection = (
292
- direction: StrategyChartOrder['direction'],
293
- ): OrdersDrawerOrder['direction'] =>
294
- direction === 'LONG' || direction === 'SHORT' ? direction : null;
295
-
296
- const getSnapshotOrderTimestamp = (order: StrategyChartOrder) => {
297
- const timestamp =
298
- order.timestamp ?? order.entryTimestamp ?? order.exitTimestamp;
299
- return typeof timestamp === 'number' && Number.isFinite(timestamp)
300
- ? timestamp
301
- : null;
302
- };
303
-
304
- const getReplayOrderStatus = (type: string | null | undefined) => {
305
- if (type?.startsWith('OPEN')) {
306
- return { label: 'ACTIVE', color: 'orange', status: 'active' as const };
307
- }
308
-
309
- if (type?.startsWith('TAKE_PROFIT')) {
310
- return { label: 'TAKE PROFIT', color: 'teal', status: 'closed' as const };
311
- }
312
-
313
- if (type?.startsWith('STOP_LOSS')) {
314
- return { label: 'STOP LOSS', color: 'red', status: 'closed' as const };
315
- }
316
-
317
- if (type?.startsWith('CLOSE')) {
318
- return { label: 'CLOSE', color: 'gray', status: 'closed' as const };
319
- }
320
-
321
- return { label: 'REPLAY', color: 'gray', status: 'closed' as const };
322
- };
323
-
324
- const isReplayOpenOrder = (order: StrategyChartOrder) =>
325
- order.exitReason?.startsWith('OPEN') === true;
326
-
327
- const getReplayPairKey = (order: StrategyChartOrder) => {
328
- const direction = normalizeSnapshotDirection(order.direction);
329
- if (!order.symbol || !direction) {
330
- return null;
331
- }
332
-
333
- return `${order.symbol}:${direction}`;
334
- };
335
-
336
- const getFiniteNumber = (value: number | null | undefined) =>
337
- typeof value === 'number' && Number.isFinite(value) ? value : null;
338
-
339
- const sumFiniteNumbers = (
340
- ...values: Array<number | null | undefined>
341
- ): number | null => {
342
- let total = 0;
343
- let hasValue = false;
344
-
345
- for (const value of values) {
346
- const finiteValue = getFiniteNumber(value);
347
- if (finiteValue == null) {
348
- continue;
349
- }
350
-
351
- total += finiteValue;
352
- hasValue = true;
353
- }
354
-
355
- return hasValue ? total : null;
356
- };
357
-
358
- const multiplyFiniteNumber = (
359
- value: number | null | undefined,
360
- multiplier: number,
361
- ) => {
362
- const finiteValue = getFiniteNumber(value);
363
- return finiteValue == null ? null : finiteValue * multiplier;
364
- };
365
-
366
- const buildReplayFeesDetail = ({
367
- openFee,
368
- closeFee,
369
- fundingFee,
370
- }: {
371
- openFee?: number | null;
372
- closeFee?: number | null;
373
- fundingFee?: number | null;
374
- }) => (
375
- <>
376
- open {formatFee(openFee)}
377
- <br />
378
- close {formatFee(closeFee)}
379
- <br />
380
- funding {formatFee(fundingFee)}
381
- </>
382
- );
383
-
384
- type ReplayOpenPosition = {
385
- order: StrategyChartOrder;
386
- remainingQty: number | null;
387
- };
388
-
389
- const getReplayExitShare = (
390
- entry: ReplayOpenPosition,
391
- exitOrder: StrategyChartOrder,
392
- ) => {
393
- const entryQty = getFiniteNumber(entry.order.qty);
394
- const exitQty = getFiniteNumber(exitOrder.qty);
395
-
396
- if (entryQty == null || entryQty <= 0 || exitQty == null || exitQty <= 0) {
397
- return 1;
398
- }
399
-
400
- const remainingQty = entry.remainingQty ?? entryQty;
401
- return Math.min(exitQty, remainingQty) / entryQty;
402
- };
403
-
404
- const consumeReplayEntryQty = (
405
- entry: ReplayOpenPosition,
406
- exitOrder: StrategyChartOrder,
407
- ) => {
408
- const exitQty = getFiniteNumber(exitOrder.qty);
409
- if (entry.remainingQty == null || exitQty == null) {
410
- return;
411
- }
412
-
413
- entry.remainingQty = Math.max(0, entry.remainingQty - exitQty);
414
- };
415
-
416
- const isReplayEntryConsumed = (entry: ReplayOpenPosition) =>
417
- entry.remainingQty != null && entry.remainingQty <= 0.00000001;
418
-
419
- const buildReplayTradeCard = ({
420
- snapshot,
421
- entryOrder,
422
- exitOrder,
423
- tradeIndex,
424
- entryShare = 1,
425
- }: {
426
- snapshot: StrategyChartSnapshot;
427
- entryOrder: StrategyChartOrder;
428
- exitOrder?: StrategyChartOrder;
429
- tradeIndex: number;
430
- entryShare?: number;
431
- }): OrdersDrawerOrder => {
432
- const orderStatus = getReplayOrderStatus(
433
- exitOrder?.exitReason ?? entryOrder.exitReason,
434
- );
435
- const entryTimestamp = getSnapshotOrderTimestamp(entryOrder);
436
- const exitTimestamp = exitOrder ? getSnapshotOrderTimestamp(exitOrder) : null;
437
- const durationHours =
438
- entryTimestamp != null && exitTimestamp != null
439
- ? (exitTimestamp - entryTimestamp) / MS_IN_HOUR
440
- : null;
441
- const openFee = multiplyFiniteNumber(entryOrder.openFee, entryShare);
442
- const openPnl = multiplyFiniteNumber(entryOrder.pnl, entryShare);
443
- const closeFee = exitOrder?.closeFee ?? exitOrder?.totalFee ?? null;
444
- const fundingFee = sumFiniteNumbers(
445
- multiplyFiniteNumber(entryOrder.fundingFee, entryShare),
446
- exitOrder?.fundingFee,
447
- );
448
- const totalFee = sumFiniteNumbers(openFee, closeFee, fundingFee);
449
- const pnl = sumFiniteNumbers(openPnl, exitOrder?.pnl);
450
- const qty = exitOrder?.qty ?? entryOrder.qty;
451
- const notional =
452
- entryOrder.notional != null
453
- ? multiplyFiniteNumber(entryOrder.notional, entryShare)
454
- : exitOrder?.notional;
455
-
456
- return {
457
- id: `${snapshot.cardId}:replay-trade:${tradeIndex}:${entryOrder.id}:${exitOrder?.id ?? 'active'}`,
458
- title: entryOrder.symbol
459
- ? `${entryOrder.symbol} · Replay #${tradeIndex}`
460
- : `Replay #${tradeIndex}`,
461
- period: {
462
- start: entryTimestamp,
463
- end: exitTimestamp,
464
- durationHours,
465
- },
466
- direction: normalizeSnapshotDirection(entryOrder.direction),
467
- status: orderStatus.status,
468
- statusLabel: orderStatus.label,
469
- statusColor: orderStatus.color,
470
- pnl,
471
- metrics: [
472
- {
473
- title: 'Entry',
474
- value: formatPrice(entryOrder.entryPrice),
475
- detail: buildSlippageDetail({
476
- requestedPrice: entryOrder.requestedEntryPrice,
477
- slippageBps: entryOrder.entrySlippageBps,
478
- }),
479
- },
480
- {
481
- title: 'Exit',
482
- value: formatPrice(exitOrder?.exitPrice),
483
- detail: buildSlippageDetail({
484
- requestedPrice: exitOrder?.requestedExitPrice,
485
- slippageBps: exitOrder?.exitSlippageBps,
486
- }),
487
- },
488
- {
489
- title: 'Notional',
490
- value: formatUsdt(notional),
491
- },
492
- {
493
- title: 'Fees',
494
- value: formatFee(totalFee),
495
- detail: buildReplayFeesDetail({ openFee, closeFee, fundingFee }),
496
- },
497
- {
498
- title: 'Qty',
499
- value: formatCompactNumber(qty, {
500
- maximumFractionDigits: 8,
501
- minimumFractionDigits: 0,
502
- }),
503
- },
504
- {
505
- title: 'Equity',
506
- value: formatUsdt(exitOrder?.equityAfter ?? entryOrder.equityAfter),
507
- detail: `prev ${formatUsdt(entryOrder.equityBefore)}`,
508
- },
509
- ],
510
- };
511
- };
512
-
513
- const buildReplaySnapshotOrders = (
514
- snapshot: StrategyChartSnapshot,
515
- ): OrdersDrawerOrder[] => {
516
- const persistedOrders = snapshot.orders
517
- .map((order, index) => ({ order, index }))
518
- .sort((left, right) => {
519
- const leftTimestamp =
520
- getSnapshotOrderTimestamp(left.order) ?? Number.NEGATIVE_INFINITY;
521
- const rightTimestamp =
522
- getSnapshotOrderTimestamp(right.order) ?? Number.NEGATIVE_INFINITY;
523
-
524
- return leftTimestamp - rightTimestamp || left.index - right.index;
525
- });
526
-
527
- if (persistedOrders.length) {
528
- const openPositions = new Map<string, ReplayOpenPosition[]>();
529
- const tradeCards: OrdersDrawerOrder[] = [];
530
-
531
- for (const { order } of persistedOrders) {
532
- const pairKey = getReplayPairKey(order);
533
- if (!pairKey) {
534
- continue;
535
- }
536
-
537
- if (isReplayOpenOrder(order)) {
538
- const bucket = openPositions.get(pairKey) ?? [];
539
- bucket.push({
540
- order,
541
- remainingQty: getFiniteNumber(order.qty),
542
- });
543
- openPositions.set(pairKey, bucket);
544
- continue;
545
- }
546
-
547
- const bucket = openPositions.get(pairKey);
548
- const entry = bucket?.[0];
549
- if (!entry) {
550
- continue;
551
- }
552
-
553
- const entryShare = getReplayExitShare(entry, order);
554
- tradeCards.push(
555
- buildReplayTradeCard({
556
- snapshot,
557
- entryOrder: entry.order,
558
- exitOrder: order,
559
- tradeIndex: tradeCards.length + 1,
560
- entryShare,
561
- }),
562
- );
563
- consumeReplayEntryQty(entry, order);
564
-
565
- if (entry.remainingQty == null || isReplayEntryConsumed(entry)) {
566
- bucket.shift();
567
- }
568
- }
569
-
570
- for (const bucket of openPositions.values()) {
571
- for (const entry of bucket) {
572
- if (isReplayEntryConsumed(entry)) {
573
- continue;
574
- }
575
-
576
- tradeCards.push(
577
- buildReplayTradeCard({
578
- snapshot,
579
- entryOrder: entry.order,
580
- tradeIndex: tradeCards.length + 1,
581
- }),
582
- );
583
- }
584
- }
585
-
586
- return tradeCards.reverse();
587
- }
588
-
589
- return snapshot.orderLog
590
- .slice(1)
591
- .map<OrdersDrawerOrder | null>((current, index) => {
592
- const previous = snapshot.orderLog[index];
593
- if (!previous) {
594
- return null;
595
- }
596
-
597
- const [timestamp, equityAfter] = current;
598
- const [, equityBefore] = previous;
599
- const pnl = equityAfter - equityBefore;
600
- if (
601
- !Number.isFinite(timestamp) ||
602
- !Number.isFinite(equityBefore) ||
603
- !Number.isFinite(equityAfter) ||
604
- !Number.isFinite(pnl)
605
- ) {
606
- return null;
607
- }
608
-
609
- const orderIndex = index + 1;
610
-
611
- return {
612
- id: `${snapshot.cardId}:replay:${orderIndex}:${timestamp}`,
613
- title: `Replay #${orderIndex}`,
614
- period: {
615
- start: timestamp,
616
- },
617
- status: 'closed',
618
- statusLabel: 'REPLAY',
619
- statusColor: 'gray',
620
- pnl,
621
- metrics: [
622
- {
623
- title: 'Entry',
624
- value: 'n/a',
625
- detail: buildSlippageDetail({}),
626
- },
627
- {
628
- title: 'Exit',
629
- value: 'n/a',
630
- detail: buildSlippageDetail({}),
631
- },
632
- {
633
- title: 'Notional',
634
- value: formatUsdt(null),
635
- },
636
- {
637
- title: 'Fees',
638
- value: formatFee(null),
639
- detail: buildReplayFeesDetail({}),
640
- },
641
- {
642
- title: 'Qty',
643
- value: formatCompactNumber(null),
644
- },
645
- {
646
- title: 'Equity',
647
- value: formatUsdt(equityAfter),
648
- detail: `prev ${formatUsdt(equityBefore)}`,
649
- },
650
- ],
651
- };
652
- })
653
- .filter((order): order is OrdersDrawerOrder => order != null)
654
- .reverse();
655
- };
656
-
657
- const buildAiSnapshotOrders = (
658
- snapshot: StrategyChartSnapshot,
659
- ): OrdersDrawerOrder[] =>
660
- snapshot.orders
661
- .map((order, index) => ({ order, index }))
662
- .sort((left, right) => {
663
- const leftEntry =
664
- typeof left.order.entryTimestamp === 'number' &&
665
- Number.isFinite(left.order.entryTimestamp)
666
- ? left.order.entryTimestamp
667
- : Number.NEGATIVE_INFINITY;
668
- const rightEntry =
669
- typeof right.order.entryTimestamp === 'number' &&
670
- Number.isFinite(right.order.entryTimestamp)
671
- ? right.order.entryTimestamp
672
- : Number.NEGATIVE_INFINITY;
673
-
674
- return rightEntry - leftEntry || left.index - right.index;
675
- })
676
- .map(({ order, index }) => {
677
- const orderIndex = order.sequence ?? index + 1;
678
- const durationHours =
679
- typeof order.entryTimestamp === 'number' &&
680
- Number.isFinite(order.entryTimestamp) &&
681
- typeof order.exitTimestamp === 'number' &&
682
- Number.isFinite(order.exitTimestamp)
683
- ? (order.exitTimestamp - order.entryTimestamp) / MS_IN_HOUR
684
- : null;
685
- const title = order.symbol
686
- ? `${order.symbol} · AI step #${orderIndex}`
687
- : `AI step #${orderIndex}`;
688
-
689
- return {
690
- id: `${snapshot.cardId}:${order.id}`,
691
- title,
692
- period: {
693
- start: order.entryTimestamp,
694
- end: order.exitTimestamp,
695
- durationHours,
696
- },
697
- direction: normalizeSnapshotDirection(order.direction),
698
- status:
699
- typeof order.exitTimestamp === 'number' &&
700
- Number.isFinite(order.exitTimestamp)
701
- ? 'closed'
702
- : 'active',
703
- statusLabel: formatAiExitReason(order.exitReason),
704
- statusColor: getAiExitReasonColor(order.exitReason),
705
- pnl: order.pnl,
706
- metrics: [
707
- {
708
- title: 'Entry',
709
- value: formatPrice(order.entryPrice),
710
- detail: buildSlippageDetail({
711
- requestedPrice: order.requestedEntryPrice,
712
- slippageBps: order.entrySlippageBps,
713
- }),
714
- },
715
- {
716
- title: 'Exit',
717
- value: formatPrice(order.exitPrice),
718
- detail: buildSlippageDetail({
719
- requestedPrice: order.requestedExitPrice,
720
- slippageBps: order.exitSlippageBps,
721
- }),
722
- },
723
- {
724
- title: 'Notional',
725
- value: formatUsdt(order.notional),
726
- },
727
- {
728
- title: 'Fees',
729
- value: formatFee(order.totalFee),
730
- detail: buildAiFeesDetail(order),
731
- },
732
- {
733
- title: 'Qty',
734
- value: formatCompactNumber(order.qty, {
735
- maximumFractionDigits: 8,
736
- minimumFractionDigits: 0,
737
- }),
738
- },
739
- {
740
- title: 'Equity',
741
- value: formatUsdt(order.equityAfter),
742
- detail: `prev ${formatUsdt(order.equityBefore)}`,
743
- },
744
- ],
745
- };
746
- });
747
-
748
- const buildSnapshotOrders = (
749
- snapshot: StrategyChartSnapshot,
750
- mode: 'replay' | 'ai',
751
- ): OrdersDrawerOrder[] =>
752
- mode === 'replay'
753
- ? buildReplaySnapshotOrders(snapshot)
754
- : buildAiSnapshotOrders(snapshot);
755
-
756
- const buildSnapshotAdvancedTrades = (
757
- snapshot: StrategyChartSnapshot,
758
- ): AdvancedTradeInput[] =>
759
- snapshot.orders.flatMap((order): AdvancedTradeInput[] => {
760
- const timestamp =
761
- order.exitTimestamp ?? order.timestamp ?? order.entryTimestamp;
762
-
763
- if (
764
- typeof timestamp !== 'number' ||
765
- !Number.isFinite(timestamp) ||
766
- typeof order.pnl !== 'number' ||
767
- !Number.isFinite(order.pnl)
768
- ) {
769
- return [];
770
- }
771
-
772
- const slippageCost =
773
- typeof order.totalSlippageCost === 'number' &&
774
- Number.isFinite(order.totalSlippageCost)
775
- ? Math.abs(order.totalSlippageCost)
776
- : null;
777
-
778
- return [
779
- {
780
- id: order.id,
781
- timestamp,
782
- pnl: order.pnl,
783
- symbol: order.symbol ?? null,
784
- direction: order.direction ?? null,
785
- exitReason: order.exitReason ?? null,
786
- slippageCost,
787
- grossPnl: slippageCost == null ? order.pnl : order.pnl + slippageCost,
788
- approved: true,
789
- blocked: false,
790
- },
791
- ];
792
- });
793
-
794
- const directionMetricLabels: Record<string, string> = {
795
- approved: 'Approved',
796
- precision: 'Precision',
797
- monthlyPnl: 'Monthly P&L',
798
- pnl: 'P&L',
799
- avgProfit: 'Avg Profit',
800
- };
801
-
802
- const directionMetricOrder = [
803
- 'approved',
804
- 'precision',
805
- 'monthlyPnl',
806
- 'pnl',
807
- 'avgProfit',
808
- ] as const;
809
-
810
- const aiDrawerMetricOrder = [
811
- 'monthlyPnl',
812
- 'avgProfit',
813
- 'maxDrawdown',
814
- 'maxLossStreak',
815
- 'approved',
816
- 'approvedPerDay',
817
- 'accuracy',
818
- 'precision',
819
- ] as const;
820
-
821
- const aiDrawerMetricOrderIndex = new Map<string, number>(
822
- aiDrawerMetricOrder.map((metricId, index) => [metricId, index]),
823
- );
824
-
825
- const sortAiDrawerMetrics = (metrics: StrategyChartMetric[]) =>
826
- metrics
827
- .filter((metric) => metric.id !== 'recall')
828
- .sort((left, right) => {
829
- const leftIndex = aiDrawerMetricOrderIndex.get(left.id) ?? 100;
830
- const rightIndex = aiDrawerMetricOrderIndex.get(right.id) ?? 100;
831
-
832
- return leftIndex - rightIndex || left.label.localeCompare(right.label);
833
- });
834
-
835
- const isDirectionDetail = (detail: StrategyChartDetail) =>
836
- detail.id.startsWith(DIRECTION_DETAIL_PREFIX);
837
-
838
- const isSymbolDetail = (detail: StrategyChartDetail) =>
839
- detail.id.startsWith(SYMBOL_DETAIL_PREFIX);
840
-
841
- const isStructuredDetail = (detail: StrategyChartDetail) =>
842
- isDirectionDetail(detail) || isSymbolDetail(detail);
843
-
844
- const getDetailById = (
845
- details: StrategyChartDetail[] | undefined,
846
- id: string,
847
- ) => details?.find((detail) => detail.id === id) ?? null;
848
-
849
- const parseFormattedNumber = (value: string) => {
850
- const normalized = value
851
- .replace(/\s/g, '')
852
- .replace(',', '.')
853
- .replace(/[^\d.+-]/g, '');
854
- const parsed = Number(normalized);
855
- return Number.isFinite(parsed) ? parsed : null;
856
- };
857
-
858
- const parseConfusionDetail = (detail: StrategyChartDetail | null) => {
859
- if (!detail) {
860
- return null;
861
- }
862
-
863
- const values = detail.value
864
- .split('/')
865
- .map((part) => parseFormattedNumber(part))
866
- .filter((value): value is number => value !== null);
867
-
868
- return values.length === 4 ? values : null;
869
- };
870
-
871
- const getPnlBarColor = (value: number) => {
872
- if (value > 0) {
873
- return 'teal.500';
874
- }
875
- if (value < 0) {
876
- return 'red.500';
877
- }
878
- return 'gray.500';
879
- };
880
-
881
- const buildDirectionStatGroups = (
882
- details: StrategyChartDetail[] | undefined,
883
- ): DirectionStatGroup[] => {
884
- const grouped = new Map<AiStatDirection, Map<string, DirectionMetric>>();
885
-
886
- for (const direction of AI_STAT_DIRECTIONS) {
887
- grouped.set(direction, new Map());
888
- }
889
-
890
- for (const detail of details ?? []) {
891
- const [, direction, metricId] = detail.id.split(':');
892
- if (metricId == null) {
893
- continue;
894
- }
895
-
896
- if (direction !== 'LONG' && direction !== 'SHORT') {
897
- continue;
898
- }
899
-
900
- const metric: DirectionMetric = {
901
- id: metricId,
902
- label: directionMetricLabels[metricId] ?? detail.label,
903
- value: detail.value,
904
- };
905
- if (detail.tone) {
906
- metric.tone = detail.tone;
907
- }
908
-
909
- grouped.get(direction)?.set(metricId, metric);
910
- }
911
-
912
- return AI_STAT_DIRECTIONS.map((direction) => {
913
- const values = grouped.get(direction) ?? new Map<string, DirectionMetric>();
914
- const metrics = directionMetricOrder.map(
915
- (metricId): DirectionMetric =>
916
- values.get(metricId) ?? {
917
- id: metricId,
918
- label: directionMetricLabels[metricId],
919
- value: 'n/a',
920
- tone: 'default',
921
- },
922
- );
923
-
924
- return {
925
- direction,
926
- metrics,
927
- hasData: values.size > 0,
928
- };
929
- });
930
- };
931
-
932
- const buildAiDiagnosticGroups = (
933
- details: StrategyChartDetail[] | undefined,
934
- ): AiDiagnosticGroup[] => {
935
- const plainDetails = details?.filter((detail) => !isStructuredDetail(detail));
936
- const groups: AiDiagnosticGroup[] = [];
937
- const windowDetail = getDetailById(plainDetails, 'window');
938
- const confusion = parseConfusionDetail(
939
- getDetailById(plainDetails, 'confusion'),
940
- );
941
- const avgProfitAll = getDetailById(plainDetails, 'avgProfitAll');
942
- const expectancyDelta = getDetailById(plainDetails, 'expectancyDelta');
943
-
944
- if (windowDetail) {
945
- groups.push({
946
- id: 'window',
947
- title: 'Evaluation window',
948
- description: 'source rows used for this AI snapshot',
949
- columns: 1,
950
- metrics: [
951
- {
952
- id: windowDetail.id,
953
- label: 'Window',
954
- value: windowDetail.value,
955
- detail: 'UTC range',
956
- tone: windowDetail.tone,
957
- },
958
- ],
959
- });
960
- }
961
-
962
- if (confusion) {
963
- const [truePositive, falsePositive, trueNegative, falseNegative] =
964
- confusion;
965
-
966
- groups.push({
967
- id: 'confusion',
968
- title: 'Decision matrix',
969
- description: 'approved vs blocked outcomes',
970
- columns: 4,
971
- metrics: [
972
- {
973
- id: 'truePositive',
974
- label: 'TP',
975
- value: formatInteger(truePositive),
976
- detail: 'winner approved',
977
- tone: 'success',
978
- },
979
- {
980
- id: 'falsePositive',
981
- label: 'FP',
982
- value: formatInteger(falsePositive),
983
- detail: 'loser approved',
984
- tone: falsePositive > 0 ? 'warning' : 'neutral',
985
- },
986
- {
987
- id: 'trueNegative',
988
- label: 'TN',
989
- value: formatInteger(trueNegative),
990
- detail: 'loser blocked',
991
- tone: 'success',
992
- },
993
- {
994
- id: 'falseNegative',
995
- label: 'FN',
996
- value: formatInteger(falseNegative),
997
- detail: 'winner blocked',
998
- tone: falseNegative > 0 ? 'warning' : 'neutral',
999
- },
1000
- ],
1001
- });
1002
- }
1003
-
1004
- const liftMetrics: AiDiagnosticMetric[] = [];
1005
-
1006
- if (avgProfitAll) {
1007
- liftMetrics.push({
1008
- id: avgProfitAll.id,
1009
- label: 'Avg all candidates',
1010
- value: avgProfitAll.value,
1011
- detail: 'before AI approval',
1012
- tone: avgProfitAll.tone,
1013
- });
1014
- }
1015
-
1016
- if (expectancyDelta) {
1017
- liftMetrics.push({
1018
- id: expectancyDelta.id,
1019
- label: 'Expectancy lift',
1020
- value: expectancyDelta.value,
1021
- detail: 'approved avg minus all avg',
1022
- tone: expectancyDelta.tone,
1023
- });
1024
- }
1025
-
1026
- if (liftMetrics.length) {
1027
- groups.push({
1028
- id: 'lift',
1029
- title: 'Gate lift',
1030
- description: 'what approval changes',
1031
- columns: 2,
1032
- metrics: liftMetrics,
1033
- });
1034
- }
1035
-
1036
- return groups;
1037
- };
1038
-
1039
- const AiDiagnosticCard = ({ metric }: { metric: AiDiagnosticMetric }) => (
1040
- <Box
1041
- p={3}
1042
- borderWidth="1px"
1043
- borderColor="gray.800"
1044
- borderRadius="md"
1045
- bg="blackAlpha.200"
1046
- minW="0"
1047
- >
1048
- <Text
1049
- fontSize="2xs"
1050
- color="gray.500"
1051
- fontWeight="bold"
1052
- textTransform="uppercase"
1053
- lineHeight="1.2"
1054
- >
1055
- {metric.label}
1056
- </Text>
1057
- <Text
1058
- mt={2}
1059
- fontSize="lg"
1060
- color={getMetricColor(metric.tone)}
1061
- fontWeight="bold"
1062
- fontFamily="mono"
1063
- lineHeight="1.15"
1064
- whiteSpace="nowrap"
1065
- overflow="hidden"
1066
- textOverflow="ellipsis"
1067
- >
1068
- {metric.value}
1069
- </Text>
1070
- {metric.detail ? (
1071
- <Text
1072
- mt={2}
1073
- fontSize="xs"
1074
- color="gray.500"
1075
- lineHeight="1.25"
1076
- whiteSpace="nowrap"
1077
- overflow="hidden"
1078
- textOverflow="ellipsis"
1079
- >
1080
- {metric.detail}
1081
- </Text>
1082
- ) : null}
1083
- </Box>
1084
- );
1085
-
1086
- const AiDiagnosticGroupBlock = ({ group }: { group: AiDiagnosticGroup }) => (
1087
- <Box>
1088
- <Flex justify="space-between" align="baseline" gap={3} mb={3}>
1089
- <Text color="gray.300" fontSize="sm" fontWeight="semibold">
1090
- {group.title}
1091
- </Text>
1092
- <Text color="gray.600" fontSize="xs" textAlign="right">
1093
- {group.description}
1094
- </Text>
1095
- </Flex>
1096
- <SimpleGrid
1097
- columns={{ base: 1, md: Math.min(group.columns, 3), xl: group.columns }}
1098
- gap={3}
1099
- >
1100
- {group.metrics.map((metric) => (
1101
- <AiDiagnosticCard key={metric.id} metric={metric} />
1102
- ))}
1103
- </SimpleGrid>
1104
- </Box>
1105
- );
1106
-
1107
- const AiDiagnosticsPanel = ({ groups }: { groups: AiDiagnosticGroup[] }) => {
1108
- if (!groups.length) {
1109
- return null;
1110
- }
1111
-
1112
- return (
1113
- <Box
1114
- p={4}
1115
- borderWidth="1px"
1116
- borderColor="gray.800"
1117
- borderRadius="md"
1118
- bg="gray.900"
1119
- >
1120
- <Flex justify="space-between" align="baseline" gap={4}>
1121
- <Text fontSize="md" fontWeight="semibold" color="gray.100">
1122
- AI diagnostics
1123
- </Text>
1124
- <Text color="gray.600" fontSize="xs" textAlign="right">
1125
- classifier-only details
1126
- </Text>
1127
- </Flex>
1128
- <Flex direction="column" gap={5} mt={4}>
1129
- {groups.map((group) => (
1130
- <AiDiagnosticGroupBlock key={group.id} group={group} />
1131
- ))}
1132
- </Flex>
1133
- </Box>
1134
- );
1135
- };
1136
-
1137
- const buildSymbolPnlRanking = (
1138
- details: StrategyChartDetail[] | undefined,
1139
- ): SymbolPnlRank[] => {
1140
- const grouped = new Map<string, Partial<SymbolPnlRank>>();
1141
-
1142
- for (const detail of details ?? []) {
1143
- if (!isSymbolDetail(detail)) {
1144
- continue;
1145
- }
1146
-
1147
- const [, symbol, metricId] = detail.id.split(':');
1148
- if (!symbol || !metricId) {
1149
- continue;
1150
- }
1151
-
1152
- const current = grouped.get(symbol) ?? { symbol };
1153
- if (metricId === 'pnl') {
1154
- const pnl = parseFormattedNumber(detail.value);
1155
- if (pnl != null) {
1156
- current.pnl = pnl;
1157
- }
1158
- }
1159
- if (metricId === 'orders') {
1160
- current.orders = parseFormattedNumber(detail.value);
1161
- }
1162
- if (metricId === 'winRate') {
1163
- current.winRate = parseFormattedNumber(detail.value);
1164
- }
1165
-
1166
- grouped.set(symbol, current);
1167
- }
1168
-
1169
- return [...grouped.values()]
1170
- .filter(
1171
- (rank): rank is SymbolPnlRank =>
1172
- typeof rank.symbol === 'string' &&
1173
- typeof rank.pnl === 'number' &&
1174
- Number.isFinite(rank.pnl),
1175
- )
1176
- .map((rank) => ({
1177
- symbol: rank.symbol,
1178
- pnl: rank.pnl,
1179
- orders: rank.orders ?? null,
1180
- winRate: rank.winRate ?? null,
1181
- avgPnl:
1182
- typeof rank.orders === 'number' && rank.orders > 0
1183
- ? rank.pnl / rank.orders
1184
- : null,
1185
- }))
1186
- .sort(
1187
- (left, right) =>
1188
- Math.abs(right.pnl) - Math.abs(left.pnl) ||
1189
- right.pnl - left.pnl ||
1190
- left.symbol.localeCompare(right.symbol),
1191
- );
1192
- };
1193
-
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);
1202
- const drawerBaseMetrics =
1203
- mode === 'ai'
1204
- ? snapshot.metrics
1205
- .filter((metric) => metric.id !== 'pnl')
1206
- .map((metric) =>
1207
- metric.id === 'quality' || metric.label === 'Quality'
1208
- ? {
1209
- id: 'maxDrawdown',
1210
- label: 'Max drawdown',
1211
- value:
1212
- calculateMaxDrawdownPercent(snapshot.orderLog) ?? 'n/a',
1213
- tone: 'warning' as const,
1214
- }
1215
- : metric,
1216
- )
1217
- : snapshot.metrics;
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:
1258
- mode === 'ai'
1259
- ? sortAiDrawerMetrics([
1260
- ...drawerBaseMetrics,
1261
- {
1262
- id: 'maxLossStreak',
1263
- label: 'Max loss streak',
1264
- value: formatInteger(maxLossStreak),
1265
- tone:
1266
- maxLossStreak > 0 ? ('warning' as const) : ('success' as const),
1267
- },
1268
- ])
1269
- : drawerBaseMetrics,
1270
- advancedMetrics: calculateAdvancedTradeMetrics({
1271
- trades: buildSnapshotAdvancedTrades(snapshot),
1272
- orderLog: snapshot.orderLog,
1273
- startTimestamp: firstPoint?.[0] ?? null,
1274
- endTimestamp: lastPoint?.[0] ?? null,
1275
- }),
1276
- hasOrdersDrawer: snapshotOrders.length > 0,
1277
- hasStatDrawer: mode === 'ai' || Boolean(snapshot.details?.length),
1278
- };
1279
- };
24
+ buildStrategySnapshotCardViewModel,
25
+ getMetricColor,
26
+ SNAPSHOT_ORDER_ROW_HEIGHT,
27
+ } from './StrategySnapshotCard.presenter';
1280
28
 
1281
29
  export const StrategySnapshotCard = ({
1282
30
  snapshot,
@@ -1303,30 +51,14 @@ export const StrategySnapshotCard = ({
1303
51
  );
1304
52
  const {
1305
53
  snapshotOrders,
1306
- aiDiagnosticGroups,
1307
- directionStatGroups,
1308
- topSymbolPnlRanking,
1309
- worstSymbolPnlRanking,
1310
- symbolRankingMaxAbsPnl,
1311
54
  sourceLabel,
1312
55
  sourceValue,
1313
56
  tagsLabel,
1314
57
  displaySubtitle,
1315
58
  metrics,
1316
- drawerMetrics,
1317
- advancedMetrics,
1318
59
  hasOrdersDrawer,
1319
60
  hasStatDrawer,
1320
61
  } = viewModel;
1321
- const {
1322
- monthlyStats,
1323
- tradePoints: snapshotTradePoints,
1324
- drawdownPoints,
1325
- rollingPerformancePoints,
1326
- pnlDistributionBins,
1327
- sessionPnlStats,
1328
- hourlyPnlStats,
1329
- } = viewModel.performance;
1330
62
 
1331
63
  const handleDelete = async () => {
1332
64
  if (isDeleting) {
@@ -1360,128 +92,6 @@ export const StrategySnapshotCard = ({
1360
92
  }
1361
93
  };
1362
94
 
1363
- const renderSymbolPnlRanking = ({
1364
- title,
1365
- subtitle,
1366
- ranking,
1367
- }: {
1368
- title: string;
1369
- subtitle: string;
1370
- ranking: SymbolPnlRank[];
1371
- }) => (
1372
- <Box
1373
- p={4}
1374
- borderWidth="1px"
1375
- borderColor="gray.800"
1376
- borderRadius="md"
1377
- bg="gray.900"
1378
- >
1379
- <Flex justify="space-between" align="center" mb={4}>
1380
- <Text fontSize="sm" color="gray.300" fontWeight="semibold">
1381
- {title}
1382
- </Text>
1383
- <Text fontSize="xs" color="gray.500">
1384
- {subtitle}
1385
- </Text>
1386
- </Flex>
1387
-
1388
- {ranking.length ? (
1389
- <>
1390
- <Flex align="center" gap={4} mb={2}>
1391
- <Text
1392
- flex="0 0 220px"
1393
- fontSize="xs"
1394
- color="gray.500"
1395
- fontWeight="semibold"
1396
- >
1397
- Contracts
1398
- </Text>
1399
- <Box flex="1" />
1400
- <Text
1401
- flex="0 0 96px"
1402
- fontSize="xs"
1403
- color="gray.500"
1404
- fontWeight="semibold"
1405
- textAlign="right"
1406
- >
1407
- P&L (USDT)
1408
- </Text>
1409
- </Flex>
1410
-
1411
- <Flex direction="column" gap={3}>
1412
- {ranking.map((rank) => {
1413
- const barWidth = Math.max(
1414
- 6,
1415
- (Math.abs(rank.pnl) / symbolRankingMaxAbsPnl) * 100,
1416
- );
1417
-
1418
- return (
1419
- <Flex key={rank.symbol} align="center" gap={4} minH="34px">
1420
- <Box flex="0 0 220px" minW={0}>
1421
- <Text
1422
- fontSize="sm"
1423
- color="gray.100"
1424
- fontWeight="semibold"
1425
- lineHeight="1.2"
1426
- overflow="hidden"
1427
- textOverflow="ellipsis"
1428
- whiteSpace="nowrap"
1429
- >
1430
- {rank.symbol}
1431
- </Text>
1432
- <Text
1433
- mt={1}
1434
- fontSize="xs"
1435
- color="gray.500"
1436
- fontFamily="mono"
1437
- >
1438
- {formatInteger(rank.orders)} orders · win{' '}
1439
- {formatPercent(rank.winRate)} · avg{' '}
1440
- {rank.avgPnl == null
1441
- ? 'n/a'
1442
- : formatSignedNumber(rank.avgPnl)}
1443
- </Text>
1444
- </Box>
1445
-
1446
- <Box flex="1" h="12px" bg="gray.800">
1447
- <Box
1448
- h="full"
1449
- w={`${barWidth}%`}
1450
- bg={getPnlBarColor(rank.pnl)}
1451
- />
1452
- </Box>
1453
-
1454
- <Text
1455
- flex="0 0 96px"
1456
- color={getPnlColor(rank.pnl)}
1457
- fontSize="lg"
1458
- fontFamily="mono"
1459
- fontWeight="bold"
1460
- textAlign="right"
1461
- >
1462
- {formatSignedNumber(rank.pnl)}
1463
- </Text>
1464
- </Flex>
1465
- );
1466
- })}
1467
- </Flex>
1468
- </>
1469
- ) : (
1470
- <Box
1471
- p={3}
1472
- borderWidth="1px"
1473
- borderColor="gray.800"
1474
- borderRadius="md"
1475
- bg="blackAlpha.300"
1476
- >
1477
- <Text fontSize="sm" color="gray.500">
1478
- No symbol P&L data
1479
- </Text>
1480
- </Box>
1481
- )}
1482
- </Box>
1483
- );
1484
-
1485
95
  return (
1486
96
  <Box
1487
97
  p={2}
@@ -1614,403 +224,13 @@ export const StrategySnapshotCard = ({
1614
224
  onOpenChange={setOrdersOpen}
1615
225
  />
1616
226
 
1617
- <Drawer.Root
1618
- size="xl"
227
+ <StrategySnapshotCardDetailsDrawer
228
+ snapshot={snapshot}
229
+ mode={mode}
1619
230
  open={detailsOpen}
1620
- onOpenChange={(e) => setDetailsOpen(e.open)}
1621
- >
1622
- <Portal>
1623
- <Drawer.Backdrop />
1624
- <Drawer.Positioner>
1625
- <Drawer.Content
1626
- display="flex"
1627
- flexDirection="column"
1628
- w="50vw"
1629
- minW="640px"
1630
- maxW="50vw"
1631
- bg="gray.950"
1632
- >
1633
- <Drawer.Header>
1634
- <Drawer.Title>{snapshot.title}</Drawer.Title>
1635
- <Drawer.CloseTrigger asChild>
1636
- <CloseButton size="sm" />
1637
- </Drawer.CloseTrigger>
1638
- </Drawer.Header>
1639
-
1640
- <Drawer.Body
1641
- display="flex"
1642
- flexDirection="column"
1643
- gap={4}
1644
- overflowY="auto"
1645
- flex="1"
1646
- minH="0"
1647
- w="full"
1648
- >
1649
- <Box
1650
- p={4}
1651
- borderWidth="1px"
1652
- borderColor="gray.800"
1653
- borderRadius="md"
1654
- bg="gray.900"
1655
- >
1656
- <Text fontSize="sm" color="gray.500" mb={3}>
1657
- {snapshot.subtitle || 'AI train details'}
1658
- </Text>
1659
-
1660
- <SimpleGrid columns={{ base: 2, md: 4 }} gap={3}>
1661
- {drawerMetrics.map((metric) => (
1662
- <Box
1663
- key={metric.id}
1664
- p={3}
1665
- borderWidth="1px"
1666
- borderColor="gray.800"
1667
- borderRadius="md"
1668
- bg="blackAlpha.300"
1669
- >
1670
- <Text
1671
- fontSize="xs"
1672
- color="gray.400"
1673
- fontWeight="semibold"
1674
- textTransform="uppercase"
1675
- >
1676
- {metric.label}
1677
- </Text>
1678
- <Text
1679
- mt={1}
1680
- fontSize="xl"
1681
- color={getMetricColor(metric.tone)}
1682
- fontWeight="bold"
1683
- fontFamily="mono"
1684
- lineHeight="1.2"
1685
- >
1686
- {metric.value}
1687
- </Text>
1688
- </Box>
1689
- ))}
1690
- </SimpleGrid>
1691
- </Box>
1692
-
1693
- <AdvancedMetricsPanel metrics={advancedMetrics} />
1694
-
1695
- {monthlyStats.length ? (
1696
- <Box
1697
- p={4}
1698
- borderWidth="1px"
1699
- borderColor="gray.800"
1700
- borderRadius="md"
1701
- bg="gray.900"
1702
- >
1703
- <Text
1704
- fontSize="sm"
1705
- color="gray.300"
1706
- fontWeight="semibold"
1707
- mb={3}
1708
- >
1709
- Monthly Performance
1710
- </Text>
1711
-
1712
- <Flex direction="column" gap={4}>
1713
- {monthlyStats.map((yearGroup) => (
1714
- <Box key={yearGroup.year}>
1715
- <Flex align="center" gap={3} mb={3}>
1716
- <Text
1717
- fontSize="lg"
1718
- color="gray.100"
1719
- fontWeight="bold"
1720
- fontFamily="mono"
1721
- >
1722
- {yearGroup.year}
1723
- </Text>
1724
- <Box flex="1" h="1px" bg="gray.800" />
1725
- </Flex>
1726
-
1727
- <Flex direction="column" gap={3}>
1728
- {buildQuarterlyMonthlyStats(yearGroup.months).map(
1729
- (quarter) => (
1730
- <Flex
1731
- key={`${yearGroup.year}-${quarter.label}`}
1732
- align="stretch"
1733
- gap={3}
1734
- >
1735
- <Flex
1736
- w="34px"
1737
- flexShrink={0}
1738
- align="center"
1739
- justify="center"
1740
- >
1741
- <Text
1742
- fontSize="xs"
1743
- color="gray.500"
1744
- fontFamily="mono"
1745
- fontWeight="bold"
1746
- >
1747
- {quarter.label}
1748
- </Text>
1749
- </Flex>
1750
-
1751
- <SimpleGrid columns={3} gap={3} flex="1">
1752
- {quarter.months.map(
1753
- (month, monthOffset) => {
1754
- const monthIndex =
1755
- quarter.monthIndexes[monthOffset] ??
1756
- 0;
1757
-
1758
- if (!month) {
1759
- return (
1760
- <Box
1761
- key={`${quarter.label}-${monthIndex}-empty`}
1762
- minH="116px"
1763
- visibility="hidden"
1764
- />
1765
- );
1766
- }
1767
-
1768
- const winRate =
1769
- month.orders > 0
1770
- ? (month.wins / month.orders) *
1771
- 100
1772
- : null;
1773
-
1774
- return (
1775
- <Box
1776
- key={month.id}
1777
- p={3}
1778
- minH="116px"
1779
- borderWidth="1px"
1780
- borderColor="gray.800"
1781
- borderLeftWidth="3px"
1782
- borderLeftColor={getPnlColor(
1783
- month.pnl,
1784
- )}
1785
- borderRadius="md"
1786
- bg="blackAlpha.300"
1787
- >
1788
- <Flex
1789
- justify="space-between"
1790
- align="baseline"
1791
- gap={2}
1792
- >
1793
- <Text
1794
- fontSize="sm"
1795
- color="gray.200"
1796
- fontWeight="bold"
1797
- >
1798
- {month.monthLabel}
1799
- </Text>
1800
- <Text
1801
- fontSize="xs"
1802
- color="gray.500"
1803
- fontFamily="mono"
1804
- >
1805
- {String(
1806
- month.monthIndex,
1807
- ).padStart(2, '0')}
1808
- </Text>
1809
- </Flex>
1810
- <Text
1811
- mt={3}
1812
- fontSize="xl"
1813
- color={getPnlColor(month.pnl)}
1814
- fontWeight="bold"
1815
- fontFamily="mono"
1816
- lineHeight="1.2"
1817
- >
1818
- {formatSignedNumber(month.pnl)}
1819
- </Text>
1820
- <Flex
1821
- mt={3}
1822
- justify="space-between"
1823
- gap={3}
1824
- >
1825
- <Box>
1826
- <Text
1827
- fontSize="xs"
1828
- color="gray.500"
1829
- >
1830
- Orders
1831
- </Text>
1832
- <Text
1833
- fontSize="sm"
1834
- color="gray.300"
1835
- fontFamily="mono"
1836
- fontWeight="semibold"
1837
- >
1838
- {formatInteger(
1839
- month.orders,
1840
- )}
1841
- </Text>
1842
- </Box>
1843
- <Box textAlign="right">
1844
- <Text
1845
- fontSize="xs"
1846
- color="gray.500"
1847
- >
1848
- Win rate
1849
- </Text>
1850
- <Text
1851
- fontSize="sm"
1852
- color="gray.300"
1853
- fontFamily="mono"
1854
- fontWeight="semibold"
1855
- >
1856
- {formatPercent(winRate)}
1857
- </Text>
1858
- </Box>
1859
- </Flex>
1860
- </Box>
1861
- );
1862
- },
1863
- )}
1864
- </SimpleGrid>
1865
- </Flex>
1866
- ),
1867
- )}
1868
- </Flex>
1869
- </Box>
1870
- ))}
1871
- </Flex>
1872
- </Box>
1873
- ) : null}
1874
-
1875
- {mode === 'ai' ? (
1876
- <Flex direction="column" gap={4}>
1877
- <ChartPanel
1878
- title="Drawdown Timeline"
1879
- subtitle="equity peak to current equity"
1880
- >
1881
- <DrawdownTimelineChart points={drawdownPoints} />
1882
- </ChartPanel>
1883
-
1884
- <ChartPanel
1885
- title="Rolling Performance"
1886
- subtitle="last 50 trades"
1887
- >
1888
- <RollingPerformanceChart
1889
- points={rollingPerformancePoints}
1890
- />
1891
- </ChartPanel>
1892
-
1893
- <ChartPanel
1894
- title="Win / Loss Streak Timeline"
1895
- subtitle="trade sequence"
1896
- >
1897
- <WinLossStreakTimelineChart
1898
- trades={snapshotTradePoints}
1899
- />
1900
- </ChartPanel>
1901
-
1902
- <ChartPanel
1903
- title="P&L Distribution"
1904
- subtitle="trade result buckets"
1905
- >
1906
- <PnlDistributionChart bins={pnlDistributionBins} />
1907
- </ChartPanel>
1908
-
1909
- <ChartPanel
1910
- title="P&L by Time of Day / Session"
1911
- subtitle="UTC"
1912
- >
1913
- <TimeOfDaySessionChart
1914
- sessions={sessionPnlStats}
1915
- hours={hourlyPnlStats}
1916
- />
1917
- </ChartPanel>
1918
- </Flex>
1919
- ) : null}
1920
-
1921
- {mode === 'ai' ? (
1922
- <Flex direction="column" gap={4}>
1923
- {renderSymbolPnlRanking({
1924
- title: 'P&L Ranking',
1925
- subtitle: 'Top 10 contracts',
1926
- ranking: topSymbolPnlRanking,
1927
- })}
1928
- {renderSymbolPnlRanking({
1929
- title: 'Worst Contracts',
1930
- subtitle: 'Worst 10 contracts',
1931
- ranking: worstSymbolPnlRanking,
1932
- })}
1933
- </Flex>
1934
- ) : null}
1935
-
1936
- <Box
1937
- p={4}
1938
- borderWidth="1px"
1939
- borderColor="gray.800"
1940
- borderRadius="md"
1941
- bg="gray.900"
1942
- >
1943
- <Text
1944
- fontSize="sm"
1945
- color="gray.300"
1946
- fontWeight="semibold"
1947
- mb={3}
1948
- >
1949
- LONG / SHORT
1950
- </Text>
1951
- <SimpleGrid columns={{ base: 1, md: 2 }} gap={3}>
1952
- {directionStatGroups.map((group) => (
1953
- <Box
1954
- key={group.direction}
1955
- p={3}
1956
- borderWidth="1px"
1957
- borderColor="gray.800"
1958
- borderRadius="md"
1959
- bg="blackAlpha.300"
1960
- >
1961
- <Flex justify="space-between" align="center" mb={3}>
1962
- <Text
1963
- fontSize="sm"
1964
- color={
1965
- group.direction === 'LONG'
1966
- ? 'teal.400'
1967
- : 'pink.300'
1968
- }
1969
- fontWeight="bold"
1970
- >
1971
- {group.direction}
1972
- </Text>
1973
- {!group.hasData ? (
1974
- <Text fontSize="xs" color="gray.500">
1975
- no data
1976
- </Text>
1977
- ) : null}
1978
- </Flex>
1979
-
1980
- <SimpleGrid columns={1} gap={2}>
1981
- {group.metrics.map((metric) => (
1982
- <Flex
1983
- key={metric.id}
1984
- justify="space-between"
1985
- align="baseline"
1986
- gap={3}
1987
- >
1988
- <Text fontSize="xs" color="gray.500">
1989
- {metric.label}
1990
- </Text>
1991
- <Text
1992
- fontSize="sm"
1993
- color={getMetricColor(metric.tone)}
1994
- fontFamily="mono"
1995
- fontWeight="semibold"
1996
- textAlign="right"
1997
- >
1998
- {metric.value}
1999
- </Text>
2000
- </Flex>
2001
- ))}
2002
- </SimpleGrid>
2003
- </Box>
2004
- ))}
2005
- </SimpleGrid>
2006
- </Box>
2007
-
2008
- <AiDiagnosticsPanel groups={aiDiagnosticGroups} />
2009
- </Drawer.Body>
2010
- </Drawer.Content>
2011
- </Drawer.Positioner>
2012
- </Portal>
2013
- </Drawer.Root>
231
+ onOpenChange={setDetailsOpen}
232
+ viewModel={viewModel}
233
+ />
2014
234
 
2015
235
  <Dialog.Root
2016
236
  open={deleteOpen}