@tradejs/app 2.0.21 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/package.json +8 -8
  2. package/src/app/actions/backtest.ts +1 -1
  3. package/src/app/actions/strategies.ts +1 -1
  4. package/src/app/api/ai/route.ts +13 -6
  5. package/src/app/api/user/settings/route.ts +48 -28
  6. package/src/app/components/Shared/OrdersDrawer.tsx +4 -2
  7. package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +3 -3
  8. package/src/app/components/Strategies/RuntimeStrategyCard.presenter.ts +536 -0
  9. package/src/app/components/Strategies/RuntimeStrategyCard.tsx +16 -1207
  10. package/src/app/components/Strategies/RuntimeStrategyConfigDrawer.tsx +1 -1
  11. package/src/app/components/Strategies/RuntimeStrategyStatsDrawer.tsx +677 -0
  12. package/src/app/components/Strategies/StrategySnapshotCard.details.presenter.ts +38 -0
  13. package/src/app/components/Strategies/StrategySnapshotCard.diagnostics.presenter.ts +263 -0
  14. package/src/app/components/Strategies/StrategySnapshotCard.orders.presenter.ts +680 -0
  15. package/src/app/components/Strategies/StrategySnapshotCard.presenter.ts +119 -0
  16. package/src/app/components/Strategies/StrategySnapshotCard.ranking.presenter.ts +76 -0
  17. package/src/app/components/Strategies/StrategySnapshotCard.tsx +13 -1793
  18. package/src/app/components/Strategies/StrategySnapshotCardDetailsDrawer.tsx +682 -0
  19. package/src/app/lib/runtimeStrategies.ts +7 -80
  20. package/src/app/lib/runtimeStrategyContracts.ts +85 -0
  21. package/src/app/routes/backtest/BacktestJobItem.tsx +275 -0
  22. package/src/app/routes/backtest/BacktestRunForm.tsx +502 -0
  23. package/src/app/routes/backtest/page.tsx +16 -1099
  24. package/src/app/routes/backtest/useBacktestRunsController.ts +441 -0
  25. package/src/app/routes/strategies/StrategiesPageClient.tsx +1 -1
@@ -5,113 +5,28 @@ import {
5
5
  Badge,
6
6
  Box,
7
7
  Button,
8
- CloseButton,
9
- Drawer,
10
8
  Flex,
11
- Grid,
12
9
  Menu,
13
10
  Portal,
14
11
  SimpleGrid,
15
12
  Stat,
16
13
  Text,
17
14
  } from '@chakra-ui/react';
15
+ import { getFormatted } from '@tradejs/core/backtest';
16
+ import type { TestThresholdsKey } from '@tradejs/types';
18
17
  import {
19
- calculateAdvancedTradeMetrics,
20
- getFormatted,
21
- type AdvancedTradeInput,
22
- } from '@tradejs/core/backtest';
23
- import type { TestThresholdsKey, ThresholdLevel } from '@tradejs/types';
24
- import {
25
- formatCompactNumber,
26
18
  formatDateTime,
27
- formatFee,
28
- formatInteger,
29
- formatPercent,
30
- formatPriceUsdt,
31
- formatSignedNumber,
32
- formatUsdt,
33
- getPnlColor,
34
19
  OrdersDrawerPanel,
35
- type OrdersDrawerOrder,
36
- type OrdersDrawerSummaryItem,
37
20
  } from '#components/Shared/OrdersDrawer';
38
- import type { RuntimeStrategyView } from '#app/lib/runtimeStrategies';
39
- import {
40
- buildQuarterlyMonthlyStats,
41
- buildStrategyPerformanceViewModel,
42
- calculateMaxLossStreak,
43
- } from '#app/lib/strategyPerformance';
44
- import { AdvancedMetricsPanel } from './AdvancedMetricsPanel';
21
+ import type { RuntimeStrategyView } from '#app/lib/runtimeStrategyContracts';
45
22
  import { RuntimeStrategyChart } from './RuntimeStrategyChart';
46
- import {
47
- ChartPanel,
48
- DrawdownTimelineChart,
49
- PnlDistributionChart,
50
- RollingPerformanceChart,
51
- TimeOfDaySessionChart,
52
- WinLossStreakTimelineChart,
53
- } from './StrategyPerformanceCharts';
54
23
  import { RuntimeStrategyConfigDrawer } from './RuntimeStrategyConfigDrawer';
55
-
56
- type RuntimeOrderView = RuntimeStrategyView['orders'][number];
57
- const RUNTIME_ORDER_ROW_HEIGHT = 306;
58
-
59
- interface RuntimeSymbolPnlRank {
60
- symbol: string;
61
- pnl: number;
62
- orders: number;
63
- winRate: number | null;
64
- avgPnl: number | null;
65
- }
66
-
67
- interface RuntimeSymbolConcentrationRow extends RuntimeSymbolPnlRank {
68
- absPnl: number;
69
- absPnlShare: number;
70
- orderShare: number;
71
- }
72
-
73
- interface RuntimeDirectionStats {
74
- direction: RuntimeOrderView['direction'];
75
- orders: number;
76
- active: number;
77
- closed: number;
78
- wins: number;
79
- pnl: number;
80
- avgPnl: number | null;
81
- }
82
-
83
- interface RuntimeDrawerMetric {
84
- id: string;
85
- label: string;
86
- value: string;
87
- level: ThresholdLevel;
88
- }
89
-
90
- const getColorByLevel = (level: ThresholdLevel) => {
91
- switch (level) {
92
- case 'success':
93
- return 'teal.500';
94
- case 'warning':
95
- return 'fg.warning';
96
- case 'neutral':
97
- return 'gray.300';
98
- case 'error':
99
- default:
100
- return 'fg.error';
101
- }
102
- };
103
-
104
- const getMetricColor = (level: ThresholdLevel) => getColorByLevel(level);
105
-
106
- const getPnlBarColor = (value: number) => {
107
- if (value > 0) {
108
- return 'teal.500';
109
- }
110
- if (value < 0) {
111
- return 'red.500';
112
- }
113
- return 'gray.500';
114
- };
24
+ import { RuntimeStrategyStatsDrawer } from './RuntimeStrategyStatsDrawer';
25
+ import {
26
+ buildRuntimeStrategyCardViewModel,
27
+ getColorByLevel,
28
+ RUNTIME_ORDER_ROW_HEIGHT,
29
+ } from './RuntimeStrategyCard.presenter';
115
30
 
116
31
  const StatItem = ({
117
32
  stat,
@@ -134,589 +49,6 @@ const StatItem = ({
134
49
  );
135
50
  };
136
51
 
137
- const getOrderAccentColor = (order: RuntimeOrderView) => {
138
- if (order.status === 'active') {
139
- return 'orange.300';
140
- }
141
-
142
- if (typeof order.pnl !== 'number' || !Number.isFinite(order.pnl)) {
143
- return 'gray.600';
144
- }
145
-
146
- return order.pnl >= 0 ? 'teal.300' : 'red.300';
147
- };
148
-
149
- const formatExitType = (order: RuntimeOrderView) => {
150
- if (order.status === 'active') {
151
- return 'active';
152
- }
153
-
154
- return order.exitType ? order.exitType.toUpperCase() : 'closed';
155
- };
156
-
157
- const formatOrderReference = (orderId: string) => {
158
- const normalized = orderId.trim();
159
- const separatorIndex = normalized.indexOf('--');
160
- const suffix =
161
- separatorIndex >= 0 ? normalized.slice(separatorIndex + 2) : normalized;
162
-
163
- return suffix.length > 12 ? suffix.slice(-12) : suffix || 'n/a';
164
- };
165
-
166
- const RiskLevelsDetail = ({ order }: { order: RuntimeOrderView }) => (
167
- <Box>
168
- <Text>
169
- TP {formatPriceUsdt(order.takeProfitPrice)} (
170
- {formatPercent(order.takeProfitPercent, { signed: true })})
171
- </Text>
172
- <Text>
173
- SL {formatPriceUsdt(order.stopLossPrice)} (
174
- {formatPercent(order.stopLossPercent)})
175
- </Text>
176
- </Box>
177
- );
178
-
179
- const FeesDetail = ({ order }: { order: RuntimeOrderView }) => (
180
- <Box>
181
- <Text>open {formatFee(order.openFee)}</Text>
182
- <Text>close {formatFee(order.closeFee)}</Text>
183
- <Text>funding {formatFee(order.fundingFee)}</Text>
184
- </Box>
185
- );
186
-
187
- const getRuntimeOrderNotional = (order: RuntimeOrderView) => {
188
- const entryPrice = order.actualEntryPrice ?? order.entryPrice;
189
-
190
- if (
191
- typeof order.qty !== 'number' ||
192
- !Number.isFinite(order.qty) ||
193
- typeof entryPrice !== 'number' ||
194
- !Number.isFinite(entryPrice)
195
- ) {
196
- return null;
197
- }
198
-
199
- return order.qty * entryPrice;
200
- };
201
-
202
- const getRuntimeOrderSlippageCost = (order: RuntimeOrderView) => {
203
- const notional = getRuntimeOrderNotional(order);
204
-
205
- if (notional == null) {
206
- return null;
207
- }
208
-
209
- const entrySlippagePercent =
210
- typeof order.entrySlippagePercent === 'number' &&
211
- Number.isFinite(order.entrySlippagePercent)
212
- ? Math.abs(order.entrySlippagePercent)
213
- : 0;
214
- const exitSlippagePercent =
215
- typeof order.exitSlippagePercent === 'number' &&
216
- Number.isFinite(order.exitSlippagePercent)
217
- ? Math.abs(order.exitSlippagePercent)
218
- : 0;
219
- const totalSlippagePercent = entrySlippagePercent + exitSlippagePercent;
220
-
221
- return totalSlippagePercent > 0
222
- ? (notional * totalSlippagePercent) / 100
223
- : null;
224
- };
225
-
226
- const buildRuntimeAdvancedTrades = (
227
- orders: RuntimeOrderView[],
228
- ): AdvancedTradeInput[] =>
229
- orders.flatMap((order): AdvancedTradeInput[] => {
230
- const timestamp = order.exitTimestamp ?? order.entryTimestamp;
231
-
232
- if (
233
- typeof timestamp !== 'number' ||
234
- !Number.isFinite(timestamp) ||
235
- typeof order.pnl !== 'number' ||
236
- !Number.isFinite(order.pnl)
237
- ) {
238
- return [];
239
- }
240
-
241
- const slippageCost = getRuntimeOrderSlippageCost(order);
242
-
243
- return [
244
- {
245
- id: order.orderId,
246
- timestamp,
247
- pnl: order.pnl,
248
- symbol: order.symbol,
249
- direction: order.direction,
250
- exitReason: order.exitType ?? null,
251
- slippageCost,
252
- grossPnl: slippageCost == null ? order.pnl : order.pnl + slippageCost,
253
- approved: true,
254
- blocked: false,
255
- },
256
- ];
257
- });
258
-
259
- const getOrdersSummary = (orders: RuntimeOrderView[]) => {
260
- const closedOrders = orders.filter((order) => order.status === 'closed');
261
- const winningOrders = closedOrders.filter(
262
- (order) =>
263
- typeof order.pnl === 'number' &&
264
- Number.isFinite(order.pnl) &&
265
- order.pnl > 0,
266
- );
267
-
268
- const sumPnl = (direction: RuntimeOrderView['direction']) =>
269
- closedOrders.reduce((total, order) => {
270
- if (
271
- order.direction !== direction ||
272
- typeof order.pnl !== 'number' ||
273
- !Number.isFinite(order.pnl)
274
- ) {
275
- return total;
276
- }
277
-
278
- return total + order.pnl;
279
- }, 0);
280
-
281
- return {
282
- closedOrders: closedOrders.length,
283
- winRate:
284
- closedOrders.length > 0
285
- ? (winningOrders.length / closedOrders.length) * 100
286
- : 0,
287
- longPnl: sumPnl('LONG'),
288
- shortPnl: sumPnl('SHORT'),
289
- };
290
- };
291
-
292
- const getRuntimeOrdersSummaryItems = (
293
- orders: RuntimeOrderView[],
294
- ): OrdersDrawerSummaryItem[] => {
295
- const summary = getOrdersSummary(orders);
296
-
297
- return [
298
- {
299
- title: 'Total Closed Orders',
300
- value: formatInteger(summary.closedOrders),
301
- },
302
- {
303
- title: 'Win Rate',
304
- value: formatPercent(summary.winRate, { signed: false }),
305
- },
306
- {
307
- title: 'P&L of Closed Long Orders (USDT)',
308
- value: formatSignedNumber(summary.longPnl),
309
- color: getPnlColor(summary.longPnl),
310
- },
311
- {
312
- title: 'P&L of Closed Short Orders (USDT)',
313
- value: formatSignedNumber(summary.shortPnl),
314
- color: getPnlColor(summary.shortPnl),
315
- },
316
- ];
317
- };
318
-
319
- const RuntimeOrdersSummaryBlock = ({
320
- items,
321
- }: {
322
- items: OrdersDrawerSummaryItem[];
323
- }) => (
324
- <Box
325
- p={4}
326
- borderWidth="1px"
327
- borderColor="gray.800"
328
- borderRadius="md"
329
- bg="gray.900"
330
- >
331
- <SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
332
- {items.map((item) => (
333
- <Box key={item.title}>
334
- <Text fontSize="sm" color="gray.500">
335
- {item.title}
336
- </Text>
337
- <Text
338
- mt={1}
339
- fontSize="2xl"
340
- color={item.color ?? 'gray.100'}
341
- fontWeight="bold"
342
- fontFamily="mono"
343
- lineHeight="1.1"
344
- >
345
- {item.value}
346
- </Text>
347
- </Box>
348
- ))}
349
- </SimpleGrid>
350
- </Box>
351
- );
352
-
353
- const buildRuntimeDrawerMetrics = (
354
- strategy: RuntimeStrategyView,
355
- ): RuntimeDrawerMetric[] => {
356
- const getStatMetric = (
357
- id: TestThresholdsKey,
358
- label: string,
359
- ): RuntimeDrawerMetric => {
360
- const { formatted, level } = getFormatted(strategy.stat, id);
361
-
362
- return {
363
- id,
364
- label,
365
- value: formatted,
366
- level,
367
- };
368
- };
369
-
370
- const maxLossStreak = calculateMaxLossStreak(strategy.orderLog);
371
-
372
- return [
373
- getStatMetric('netProfit', 'P&L'),
374
- {
375
- id: 'closedPnl',
376
- label: 'Closed P&L',
377
- value: formatSignedNumber(strategy.summary.closedPnl),
378
- level:
379
- strategy.summary.closedPnl > 0
380
- ? 'success'
381
- : strategy.summary.closedPnl < 0
382
- ? 'error'
383
- : 'neutral',
384
- },
385
- {
386
- id: 'activePnl',
387
- label: 'Active P&L',
388
- value: formatSignedNumber(strategy.summary.activePnl),
389
- level:
390
- strategy.summary.activePnl > 0
391
- ? 'success'
392
- : strategy.summary.activePnl < 0
393
- ? 'error'
394
- : 'neutral',
395
- },
396
- getStatMetric('maxDrawdown', 'Max drawdown'),
397
- {
398
- id: 'maxLossStreak',
399
- label: 'Max loss streak',
400
- value: formatInteger(maxLossStreak),
401
- level: maxLossStreak > 0 ? 'warning' : 'success',
402
- },
403
- {
404
- id: 'totalTrades',
405
- label: 'Trades',
406
- value: formatInteger(strategy.summary.totalTrades),
407
- level: 'neutral',
408
- },
409
- {
410
- id: 'activeTrades',
411
- label: 'Active',
412
- value: formatInteger(strategy.summary.activeTrades),
413
- level: strategy.summary.activeTrades > 0 ? 'warning' : 'neutral',
414
- },
415
- {
416
- id: 'symbolTop1',
417
- label: 'Symbol top 1',
418
- value: formatPercent(strategy.summary.symbolConcentrationTop1),
419
- level:
420
- (strategy.summary.symbolConcentrationTop1 ?? 0) >= 60
421
- ? 'warning'
422
- : 'neutral',
423
- },
424
- {
425
- id: 'symbolTop5',
426
- label: 'Symbol top 5',
427
- value: formatPercent(strategy.summary.symbolConcentrationTop5),
428
- level: 'neutral',
429
- },
430
- getStatMetric('winRate', 'Win Rate'),
431
- ];
432
- };
433
-
434
- const buildRuntimeSymbolPnlRanking = (
435
- orders: RuntimeOrderView[],
436
- ): RuntimeSymbolPnlRank[] => {
437
- const grouped = new Map<
438
- string,
439
- { symbol: string; pnl: number; orders: number; wins: number }
440
- >();
441
-
442
- for (const order of orders) {
443
- if (typeof order.pnl !== 'number' || !Number.isFinite(order.pnl)) {
444
- continue;
445
- }
446
-
447
- const existing = grouped.get(order.symbol) ?? {
448
- symbol: order.symbol,
449
- pnl: 0,
450
- orders: 0,
451
- wins: 0,
452
- };
453
-
454
- existing.pnl += order.pnl;
455
- existing.orders += 1;
456
- existing.wins += order.pnl > 0 ? 1 : 0;
457
- grouped.set(order.symbol, existing);
458
- }
459
-
460
- return [...grouped.values()]
461
- .map((rank) => ({
462
- symbol: rank.symbol,
463
- pnl: rank.pnl,
464
- orders: rank.orders,
465
- winRate: rank.orders > 0 ? (rank.wins / rank.orders) * 100 : null,
466
- avgPnl: rank.orders > 0 ? rank.pnl / rank.orders : null,
467
- }))
468
- .sort(
469
- (left, right) =>
470
- Math.abs(right.pnl) - Math.abs(left.pnl) ||
471
- right.pnl - left.pnl ||
472
- left.symbol.localeCompare(right.symbol),
473
- );
474
- };
475
-
476
- const buildRuntimeSymbolConcentration = (
477
- orders: RuntimeOrderView[],
478
- ): RuntimeSymbolConcentrationRow[] => {
479
- const ranking = buildRuntimeSymbolPnlRanking(orders).map((rank) => ({
480
- ...rank,
481
- absPnl: Math.abs(rank.pnl),
482
- }));
483
- const totalAbsPnl = ranking.reduce((sum, rank) => sum + rank.absPnl, 0);
484
- const totalOrders = ranking.reduce((sum, rank) => sum + rank.orders, 0);
485
-
486
- if (totalAbsPnl <= 0 || totalOrders <= 0) {
487
- return [];
488
- }
489
-
490
- return ranking
491
- .map((rank) => ({
492
- ...rank,
493
- absPnlShare: (rank.absPnl / totalAbsPnl) * 100,
494
- orderShare: (rank.orders / totalOrders) * 100,
495
- }))
496
- .sort(
497
- (left, right) =>
498
- right.absPnlShare - left.absPnlShare ||
499
- right.orderShare - left.orderShare ||
500
- left.symbol.localeCompare(right.symbol),
501
- );
502
- };
503
-
504
- const buildRuntimeDirectionStats = (
505
- orders: RuntimeOrderView[],
506
- ): RuntimeDirectionStats[] =>
507
- (['LONG', 'SHORT'] as const).map((direction) => {
508
- const directionOrders = orders.filter(
509
- (order) => order.direction === direction,
510
- );
511
- const ordersWithPnl = directionOrders.filter(
512
- (order) => typeof order.pnl === 'number' && Number.isFinite(order.pnl),
513
- );
514
- const pnl = ordersWithPnl.reduce((sum, order) => sum + (order.pnl ?? 0), 0);
515
-
516
- return {
517
- direction,
518
- orders: directionOrders.length,
519
- active: directionOrders.filter((order) => order.status === 'active')
520
- .length,
521
- closed: directionOrders.filter((order) => order.status === 'closed')
522
- .length,
523
- wins: ordersWithPnl.filter((order) => (order.pnl ?? 0) > 0).length,
524
- pnl,
525
- avgPnl: ordersWithPnl.length > 0 ? pnl / ordersWithPnl.length : null,
526
- };
527
- });
528
-
529
- const renderPnlRanking = ({
530
- title,
531
- subtitle,
532
- ranking,
533
- maxAbsPnl,
534
- }: {
535
- title: string;
536
- subtitle: string;
537
- ranking: RuntimeSymbolPnlRank[];
538
- maxAbsPnl: number;
539
- }) => (
540
- <Box
541
- p={4}
542
- borderWidth="1px"
543
- borderColor="gray.800"
544
- borderRadius="md"
545
- bg="gray.900"
546
- >
547
- <Flex justify="space-between" align="baseline" gap={3} mb={4}>
548
- <Text fontSize="sm" color="gray.300" fontWeight="semibold">
549
- {title}
550
- </Text>
551
- <Text fontSize="xs" color="gray.500">
552
- {subtitle}
553
- </Text>
554
- </Flex>
555
-
556
- {ranking.length ? (
557
- <Flex direction="column" gap={3}>
558
- {ranking.map((rank) => {
559
- const width = Math.max(4, (Math.abs(rank.pnl) / maxAbsPnl) * 100);
560
-
561
- return (
562
- <Grid
563
- key={rank.symbol}
564
- templateColumns="1.1fr 2fr 0.8fr"
565
- alignItems="center"
566
- gap={3}
567
- >
568
- <Box minW={0}>
569
- <Text
570
- fontSize="sm"
571
- color="gray.200"
572
- fontWeight="bold"
573
- overflow="hidden"
574
- textOverflow="ellipsis"
575
- whiteSpace="nowrap"
576
- >
577
- {rank.symbol}
578
- </Text>
579
- <Text fontSize="xs" color="gray.500" fontFamily="mono">
580
- {formatInteger(rank.orders)} orders ·{' '}
581
- {formatPercent(rank.winRate)}
582
- </Text>
583
- <Text fontSize="xs" color="gray.600" fontFamily="mono">
584
- avg {formatSignedNumber(rank.avgPnl)}
585
- </Text>
586
- </Box>
587
- <Box h="10px" bg="gray.800">
588
- <Box h="full" w={`${width}%`} bg={getPnlBarColor(rank.pnl)} />
589
- </Box>
590
- <Text
591
- fontSize="sm"
592
- color={getPnlColor(rank.pnl)}
593
- fontWeight="bold"
594
- fontFamily="mono"
595
- textAlign="right"
596
- >
597
- {formatSignedNumber(rank.pnl)}
598
- </Text>
599
- </Grid>
600
- );
601
- })}
602
- </Flex>
603
- ) : (
604
- <Text fontSize="sm" color="gray.500">
605
- No symbol P&L data
606
- </Text>
607
- )}
608
- </Box>
609
- );
610
-
611
- const mapRuntimeOrder = (order: RuntimeOrderView): OrdersDrawerOrder => {
612
- const displayEntryPrice = order.actualEntryPrice ?? order.entryPrice;
613
- const displayExitPrice =
614
- order.status === 'active'
615
- ? order.currentPrice
616
- : order.actualExitPrice ?? order.exitPrice;
617
- const notional =
618
- typeof order.qty === 'number' &&
619
- Number.isFinite(order.qty) &&
620
- typeof displayEntryPrice === 'number' &&
621
- Number.isFinite(displayEntryPrice)
622
- ? order.qty * displayEntryPrice
623
- : null;
624
-
625
- return {
626
- id: order.orderId,
627
- title: order.symbol,
628
- reference: formatOrderReference(order.orderId),
629
- period: {
630
- start: order.entryTimestamp,
631
- end: order.status === 'active' ? null : order.exitTimestamp,
632
- durationHours: order.durationHours,
633
- },
634
- direction: order.direction,
635
- status: order.status,
636
- statusLabel: formatExitType(order).toUpperCase(),
637
- statusColor: order.status === 'active' ? 'orange' : 'gray',
638
- pnl: order.pnl,
639
- accentColor: getOrderAccentColor(order),
640
- metrics: [
641
- {
642
- title: 'Entry',
643
- value: formatPriceUsdt(displayEntryPrice),
644
- detail:
645
- order.actualEntryPrice == null
646
- ? 'actual n/a'
647
- : `plan ${formatPriceUsdt(order.entryPrice)} / slip ${formatPercent(order.entrySlippagePercent, { signed: true })}`,
648
- },
649
- {
650
- title: order.status === 'active' ? 'Current' : 'Exit',
651
- value: formatPriceUsdt(displayExitPrice),
652
- detail:
653
- order.status === 'active' ? (
654
- <RiskLevelsDetail order={order} />
655
- ) : (
656
- `slip ${formatPercent(order.exitSlippagePercent, { signed: true })}`
657
- ),
658
- },
659
- {
660
- title: 'Notional',
661
- value: formatUsdt(notional),
662
- },
663
- {
664
- title: 'Fees',
665
- value: formatFee(order.totalFee),
666
- detail: <FeesDetail order={order} />,
667
- },
668
- {
669
- title: 'Qty',
670
- value: formatCompactNumber(order.qty),
671
- },
672
- ],
673
- };
674
- };
675
-
676
- export const buildRuntimeStrategyCardViewModel = (
677
- strategy: RuntimeStrategyView,
678
- ) => {
679
- const symbolPnlRanking = buildRuntimeSymbolPnlRanking(strategy.orders);
680
- const firstPoint = strategy.orderLog[0];
681
- const lastPoint = strategy.orderLog[strategy.orderLog.length - 1];
682
-
683
- return {
684
- lastTrade: strategy.recentTrades[0],
685
- runtimeOrders: strategy.orders.map(mapRuntimeOrder),
686
- runtimeOrderSummaryItems: getRuntimeOrdersSummaryItems(strategy.orders),
687
- drawerMetrics: buildRuntimeDrawerMetrics(strategy),
688
- performance: buildStrategyPerformanceViewModel(strategy.orderLog),
689
- symbolPnlRanking,
690
- symbolConcentration: buildRuntimeSymbolConcentration(strategy.orders).slice(
691
- 0,
692
- 8,
693
- ),
694
- topSymbolPnlRanking: [...symbolPnlRanking]
695
- .sort(
696
- (left, right) =>
697
- right.pnl - left.pnl || left.symbol.localeCompare(right.symbol),
698
- )
699
- .slice(0, 10),
700
- worstSymbolPnlRanking: [...symbolPnlRanking]
701
- .sort(
702
- (left, right) =>
703
- left.pnl - right.pnl || left.symbol.localeCompare(right.symbol),
704
- )
705
- .slice(0, 10),
706
- symbolRankingMaxAbsPnl: Math.max(
707
- ...symbolPnlRanking.map((rank) => Math.abs(rank.pnl)),
708
- 1,
709
- ),
710
- directionStats: buildRuntimeDirectionStats(strategy.orders),
711
- advancedMetrics: calculateAdvancedTradeMetrics({
712
- trades: buildRuntimeAdvancedTrades(strategy.orders),
713
- orderLog: strategy.orderLog,
714
- startTimestamp: firstPoint?.[0] ?? null,
715
- endTimestamp: lastPoint?.[0] ?? null,
716
- }),
717
- };
718
- };
719
-
720
52
  export const RuntimeStrategyCard = ({
721
53
  strategy,
722
54
  provider,
@@ -737,27 +69,7 @@ export const RuntimeStrategyCard = ({
737
69
  () => buildRuntimeStrategyCardViewModel(strategy),
738
70
  [strategy],
739
71
  );
740
- const {
741
- lastTrade,
742
- runtimeOrders,
743
- runtimeOrderSummaryItems,
744
- drawerMetrics,
745
- symbolConcentration,
746
- topSymbolPnlRanking,
747
- worstSymbolPnlRanking,
748
- symbolRankingMaxAbsPnl,
749
- directionStats,
750
- advancedMetrics,
751
- } = viewModel;
752
- const {
753
- monthlyStats,
754
- tradePoints: runtimeTradePoints,
755
- drawdownPoints,
756
- rollingPerformancePoints,
757
- pnlDistributionBins,
758
- sessionPnlStats,
759
- hourlyPnlStats,
760
- } = viewModel.performance;
72
+ const { lastTrade, runtimeOrders } = viewModel;
761
73
 
762
74
  return (
763
75
  <Box
@@ -891,516 +203,13 @@ export const RuntimeStrategyCard = ({
891
203
  onSaved={onUpdated}
892
204
  />
893
205
 
894
- <Drawer.Root
895
- size="xl"
206
+ <RuntimeStrategyStatsDrawer
207
+ strategy={strategy}
208
+ provider={provider}
896
209
  open={statsOpen}
897
- onOpenChange={(e) => setStatsOpen(e.open)}
898
- >
899
- <Portal>
900
- <Drawer.Backdrop />
901
- <Drawer.Positioner>
902
- <Drawer.Content
903
- display="flex"
904
- flexDirection="column"
905
- w="50vw"
906
- minW="640px"
907
- maxW="50vw"
908
- bg="gray.950"
909
- >
910
- <Drawer.Header>
911
- <Drawer.Title>{strategy.strategyName}</Drawer.Title>
912
- <Drawer.CloseTrigger asChild>
913
- <CloseButton size="sm" />
914
- </Drawer.CloseTrigger>
915
- </Drawer.Header>
916
-
917
- <Drawer.Body
918
- display="flex"
919
- flexDirection="column"
920
- gap={4}
921
- overflowY="auto"
922
- flex="1"
923
- minH="0"
924
- w="full"
925
- >
926
- <RuntimeOrdersSummaryBlock items={runtimeOrderSummaryItems} />
927
-
928
- <Box
929
- p={4}
930
- borderWidth="1px"
931
- borderColor="gray.800"
932
- borderRadius="md"
933
- bg="gray.900"
934
- >
935
- <Text fontSize="sm" color="gray.500" mb={3}>
936
- connector: {provider}
937
- </Text>
938
-
939
- <SimpleGrid columns={{ base: 2, md: 4 }} gap={3}>
940
- {drawerMetrics.map((metric) => (
941
- <Box
942
- key={metric.id}
943
- p={3}
944
- borderWidth="1px"
945
- borderColor="gray.800"
946
- borderRadius="md"
947
- bg="blackAlpha.300"
948
- >
949
- <Text
950
- fontSize="xs"
951
- color="gray.400"
952
- fontWeight="semibold"
953
- textTransform="uppercase"
954
- >
955
- {metric.label}
956
- </Text>
957
- <Text
958
- mt={1}
959
- fontSize="xl"
960
- color={getMetricColor(metric.level)}
961
- fontWeight="bold"
962
- fontFamily="mono"
963
- lineHeight="1.2"
964
- >
965
- {metric.value}
966
- </Text>
967
- </Box>
968
- ))}
969
- </SimpleGrid>
970
- </Box>
971
-
972
- <AdvancedMetricsPanel metrics={advancedMetrics} />
973
-
974
- {monthlyStats.length ? (
975
- <Box
976
- p={4}
977
- borderWidth="1px"
978
- borderColor="gray.800"
979
- borderRadius="md"
980
- bg="gray.900"
981
- >
982
- <Text
983
- fontSize="sm"
984
- color="gray.300"
985
- fontWeight="semibold"
986
- mb={3}
987
- >
988
- Monthly Performance
989
- </Text>
990
-
991
- <Flex direction="column" gap={4}>
992
- {monthlyStats.map((yearGroup) => (
993
- <Box key={yearGroup.year}>
994
- <Flex align="center" gap={3} mb={3}>
995
- <Text
996
- fontSize="lg"
997
- color="gray.100"
998
- fontWeight="bold"
999
- fontFamily="mono"
1000
- >
1001
- {yearGroup.year}
1002
- </Text>
1003
- <Box flex="1" h="1px" bg="gray.800" />
1004
- </Flex>
1005
-
1006
- <Flex direction="column" gap={3}>
1007
- {buildQuarterlyMonthlyStats(yearGroup.months).map(
1008
- (quarter) => (
1009
- <Flex
1010
- key={`${yearGroup.year}-${quarter.label}`}
1011
- align="stretch"
1012
- gap={3}
1013
- >
1014
- <Flex
1015
- w="34px"
1016
- flexShrink={0}
1017
- align="center"
1018
- justify="center"
1019
- >
1020
- <Text
1021
- fontSize="xs"
1022
- color="gray.500"
1023
- fontFamily="mono"
1024
- fontWeight="bold"
1025
- >
1026
- {quarter.label}
1027
- </Text>
1028
- </Flex>
1029
-
1030
- <SimpleGrid columns={3} gap={3} flex="1">
1031
- {quarter.months.map(
1032
- (month, monthOffset) => {
1033
- const monthIndex =
1034
- quarter.monthIndexes[monthOffset] ??
1035
- 0;
1036
-
1037
- if (!month) {
1038
- return (
1039
- <Box
1040
- key={`${quarter.label}-${monthIndex}-empty`}
1041
- minH="116px"
1042
- visibility="hidden"
1043
- />
1044
- );
1045
- }
1046
-
1047
- const winRate =
1048
- month.orders > 0
1049
- ? (month.wins / month.orders) * 100
1050
- : null;
1051
-
1052
- return (
1053
- <Box
1054
- key={month.id}
1055
- p={3}
1056
- minH="116px"
1057
- borderWidth="1px"
1058
- borderColor="gray.800"
1059
- borderLeftWidth="3px"
1060
- borderLeftColor={getPnlColor(
1061
- month.pnl,
1062
- )}
1063
- borderRadius="md"
1064
- bg="blackAlpha.300"
1065
- >
1066
- <Flex
1067
- justify="space-between"
1068
- align="baseline"
1069
- gap={2}
1070
- >
1071
- <Text
1072
- fontSize="sm"
1073
- color="gray.200"
1074
- fontWeight="bold"
1075
- >
1076
- {month.monthLabel}
1077
- </Text>
1078
- <Text
1079
- fontSize="xs"
1080
- color="gray.500"
1081
- fontFamily="mono"
1082
- >
1083
- {String(
1084
- month.monthIndex,
1085
- ).padStart(2, '0')}
1086
- </Text>
1087
- </Flex>
1088
- <Text
1089
- mt={3}
1090
- fontSize="xl"
1091
- color={getPnlColor(month.pnl)}
1092
- fontWeight="bold"
1093
- fontFamily="mono"
1094
- lineHeight="1.2"
1095
- >
1096
- {formatSignedNumber(month.pnl)}
1097
- </Text>
1098
- <Flex
1099
- mt={3}
1100
- justify="space-between"
1101
- gap={3}
1102
- >
1103
- <Box>
1104
- <Text
1105
- fontSize="xs"
1106
- color="gray.500"
1107
- >
1108
- Orders
1109
- </Text>
1110
- <Text
1111
- fontSize="sm"
1112
- color="gray.300"
1113
- fontFamily="mono"
1114
- fontWeight="semibold"
1115
- >
1116
- {formatInteger(month.orders)}
1117
- </Text>
1118
- </Box>
1119
- <Box textAlign="right">
1120
- <Text
1121
- fontSize="xs"
1122
- color="gray.500"
1123
- >
1124
- Win rate
1125
- </Text>
1126
- <Text
1127
- fontSize="sm"
1128
- color="gray.300"
1129
- fontFamily="mono"
1130
- fontWeight="semibold"
1131
- >
1132
- {formatPercent(winRate)}
1133
- </Text>
1134
- </Box>
1135
- </Flex>
1136
- </Box>
1137
- );
1138
- },
1139
- )}
1140
- </SimpleGrid>
1141
- </Flex>
1142
- ),
1143
- )}
1144
- </Flex>
1145
- </Box>
1146
- ))}
1147
- </Flex>
1148
- </Box>
1149
- ) : null}
1150
-
1151
- <Flex direction="column" gap={4}>
1152
- <ChartPanel
1153
- title="Drawdown Timeline"
1154
- subtitle="equity peak to current equity"
1155
- >
1156
- <DrawdownTimelineChart points={drawdownPoints} />
1157
- </ChartPanel>
1158
-
1159
- <ChartPanel
1160
- title="Rolling Performance"
1161
- subtitle="last 50 trades"
1162
- >
1163
- <RollingPerformanceChart
1164
- points={rollingPerformancePoints}
1165
- />
1166
- </ChartPanel>
1167
-
1168
- <ChartPanel
1169
- title="Win / Loss Streak Timeline"
1170
- subtitle="trade sequence"
1171
- >
1172
- <WinLossStreakTimelineChart trades={runtimeTradePoints} />
1173
- </ChartPanel>
1174
-
1175
- <ChartPanel
1176
- title="P&L Distribution"
1177
- subtitle="trade result buckets"
1178
- >
1179
- <PnlDistributionChart bins={pnlDistributionBins} />
1180
- </ChartPanel>
1181
-
1182
- <ChartPanel
1183
- title="P&L by Time of Day / Session"
1184
- subtitle="UTC"
1185
- >
1186
- <TimeOfDaySessionChart
1187
- sessions={sessionPnlStats}
1188
- hours={hourlyPnlStats}
1189
- />
1190
- </ChartPanel>
1191
- </Flex>
1192
-
1193
- <Flex direction="column" gap={4}>
1194
- {renderPnlRanking({
1195
- title: 'P&L Ranking',
1196
- subtitle: 'Top 10 contracts',
1197
- ranking: topSymbolPnlRanking,
1198
- maxAbsPnl: symbolRankingMaxAbsPnl,
1199
- })}
1200
- {renderPnlRanking({
1201
- title: 'Worst Contracts',
1202
- subtitle: 'Worst 10 contracts',
1203
- ranking: worstSymbolPnlRanking,
1204
- maxAbsPnl: symbolRankingMaxAbsPnl,
1205
- })}
1206
- </Flex>
1207
-
1208
- {symbolConcentration.length ? (
1209
- <Box
1210
- p={4}
1211
- borderWidth="1px"
1212
- borderColor="gray.800"
1213
- borderRadius="md"
1214
- bg="gray.900"
1215
- >
1216
- <Flex justify="space-between" align="baseline" mb={3}>
1217
- <Text
1218
- fontSize="sm"
1219
- color="gray.300"
1220
- fontWeight="semibold"
1221
- >
1222
- Symbol Concentration
1223
- </Text>
1224
- <Text fontSize="xs" color="gray.600" textAlign="right">
1225
- absolute P&L and order share
1226
- </Text>
1227
- </Flex>
1228
-
1229
- <Flex direction="column" gap={2}>
1230
- {symbolConcentration.map((row) => (
1231
- <Box
1232
- key={row.symbol}
1233
- p={3}
1234
- borderWidth="1px"
1235
- borderColor="gray.800"
1236
- borderRadius="md"
1237
- bg="blackAlpha.300"
1238
- >
1239
- <Flex justify="space-between" align="center" gap={3}>
1240
- <Box minW="0">
1241
- <Text
1242
- color="gray.100"
1243
- fontSize="sm"
1244
- fontWeight="bold"
1245
- >
1246
- {row.symbol}
1247
- </Text>
1248
- <Text
1249
- mt={1}
1250
- color="gray.500"
1251
- fontSize="xs"
1252
- fontFamily="mono"
1253
- >
1254
- {formatInteger(row.orders)} trades / order share{' '}
1255
- {formatPercent(row.orderShare)}
1256
- </Text>
1257
- </Box>
1258
- <Box textAlign="right" flexShrink={0}>
1259
- <Text
1260
- color={getPnlColor(row.pnl)}
1261
- fontSize="sm"
1262
- fontWeight="bold"
1263
- fontFamily="mono"
1264
- >
1265
- {formatSignedNumber(row.pnl)}
1266
- </Text>
1267
- <Text
1268
- mt={1}
1269
- color="gray.400"
1270
- fontSize="xs"
1271
- fontFamily="mono"
1272
- >
1273
- abs {formatPercent(row.absPnlShare)}
1274
- </Text>
1275
- </Box>
1276
- </Flex>
1277
- </Box>
1278
- ))}
1279
- </Flex>
1280
- </Box>
1281
- ) : null}
1282
-
1283
- <Box
1284
- p={4}
1285
- borderWidth="1px"
1286
- borderColor="gray.800"
1287
- borderRadius="md"
1288
- bg="gray.900"
1289
- >
1290
- <Text
1291
- fontSize="sm"
1292
- color="gray.300"
1293
- fontWeight="semibold"
1294
- mb={3}
1295
- >
1296
- LONG / SHORT
1297
- </Text>
1298
- <SimpleGrid columns={{ base: 1, md: 2 }} gap={3}>
1299
- {directionStats.map((group) => {
1300
- const ordersWithPnl = group.closed + group.active;
1301
- const winRate =
1302
- ordersWithPnl > 0
1303
- ? (group.wins / ordersWithPnl) * 100
1304
- : null;
1305
-
1306
- return (
1307
- <Box
1308
- key={group.direction}
1309
- p={3}
1310
- borderWidth="1px"
1311
- borderColor="gray.800"
1312
- borderRadius="md"
1313
- bg="blackAlpha.300"
1314
- >
1315
- <Flex justify="space-between" align="center" mb={3}>
1316
- <Text
1317
- fontSize="sm"
1318
- color={
1319
- group.direction === 'LONG'
1320
- ? 'teal.400'
1321
- : 'pink.300'
1322
- }
1323
- fontWeight="bold"
1324
- >
1325
- {group.direction}
1326
- </Text>
1327
- {!group.orders ? (
1328
- <Text fontSize="xs" color="gray.500">
1329
- no data
1330
- </Text>
1331
- ) : null}
1332
- </Flex>
1333
-
1334
- <SimpleGrid columns={1} gap={2}>
1335
- {[
1336
- [
1337
- 'Orders',
1338
- formatInteger(group.orders),
1339
- 'neutral',
1340
- ],
1341
- [
1342
- 'Active',
1343
- formatInteger(group.active),
1344
- 'warning',
1345
- ],
1346
- [
1347
- 'Closed',
1348
- formatInteger(group.closed),
1349
- 'neutral',
1350
- ],
1351
- ['Win rate', formatPercent(winRate), 'neutral'],
1352
- [
1353
- 'P&L',
1354
- formatSignedNumber(group.pnl),
1355
- group.pnl > 0
1356
- ? 'success'
1357
- : group.pnl < 0
1358
- ? 'error'
1359
- : 'neutral',
1360
- ],
1361
- [
1362
- 'Avg Profit',
1363
- formatSignedNumber(group.avgPnl),
1364
- (group.avgPnl ?? 0) > 0
1365
- ? 'success'
1366
- : (group.avgPnl ?? 0) < 0
1367
- ? 'error'
1368
- : 'neutral',
1369
- ],
1370
- ].map(([label, value, level]) => (
1371
- <Flex
1372
- key={label}
1373
- justify="space-between"
1374
- align="baseline"
1375
- gap={3}
1376
- >
1377
- <Text fontSize="xs" color="gray.500">
1378
- {label}
1379
- </Text>
1380
- <Text
1381
- fontSize="sm"
1382
- color={getMetricColor(
1383
- level as ThresholdLevel,
1384
- )}
1385
- fontFamily="mono"
1386
- fontWeight="semibold"
1387
- textAlign="right"
1388
- >
1389
- {value}
1390
- </Text>
1391
- </Flex>
1392
- ))}
1393
- </SimpleGrid>
1394
- </Box>
1395
- );
1396
- })}
1397
- </SimpleGrid>
1398
- </Box>
1399
- </Drawer.Body>
1400
- </Drawer.Content>
1401
- </Drawer.Positioner>
1402
- </Portal>
1403
- </Drawer.Root>
210
+ onOpenChange={setStatsOpen}
211
+ viewModel={viewModel}
212
+ />
1404
213
 
1405
214
  <RuntimeStrategyChart
1406
215
  orderLog={strategy.orderLog}