@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
@@ -0,0 +1,680 @@
1
+ import { getFormatted, type AdvancedTradeInput } from '@tradejs/core/backtest';
2
+ import type {
3
+ StrategyChartOrder,
4
+ StrategyChartSnapshot,
5
+ TestStat,
6
+ TestThresholdsKey,
7
+ } from '@tradejs/types';
8
+ import {
9
+ formatCompactNumber,
10
+ formatFee,
11
+ formatPriceUsdt,
12
+ formatUsdt,
13
+ type OrdersDrawerOrder,
14
+ } from '#components/Shared/OrdersDrawer';
15
+ import {
16
+ calculateMaxDrawdownValue,
17
+ calculateMaxGrossStreak,
18
+ calculateMaxLossStreak,
19
+ getEquityStepPnl as getSnapshotStepPnl,
20
+ } from '#app/lib/strategyPerformance';
21
+
22
+ const MS_IN_HOUR = 60 * 60 * 1000;
23
+ export const SNAPSHOT_ORDER_ROW_HEIGHT = 318;
24
+ const SNAPSHOT_SUMMARY_METRICS: {
25
+ id: TestThresholdsKey;
26
+ label: string;
27
+ }[] = [
28
+ { id: 'netProfit', label: 'P&L' },
29
+ { id: 'minAmount', label: 'Min Amount' },
30
+ { id: 'maxDrawdown', label: 'Drawdown' },
31
+ { id: 'orders', label: 'Orders' },
32
+ { id: 'winRate', label: 'Win Rate' },
33
+ { id: 'riskRewardRatio', label: 'Risk Ratio' },
34
+ { id: 'maxConsecutiveWins', label: 'Max Gross Streak' },
35
+ { id: 'maxConsecutiveLosses', label: 'Max Loss Streak' },
36
+ ];
37
+
38
+ const asFiniteNumber = (value: unknown) =>
39
+ typeof value === 'number' && Number.isFinite(value) ? value : null;
40
+
41
+ const getSnapshotTradePnls = (snapshot: StrategyChartSnapshot) => {
42
+ const orderPnls = snapshot.orders
43
+ .map((order) => asFiniteNumber(order.pnl))
44
+ .filter((pnl): pnl is number => pnl != null);
45
+
46
+ if (orderPnls.length) {
47
+ return orderPnls;
48
+ }
49
+
50
+ return snapshot.orderLog
51
+ .map((_, index) =>
52
+ index === 0
53
+ ? null
54
+ : asFiniteNumber(getSnapshotStepPnl(snapshot.orderLog, index)),
55
+ )
56
+ .filter((pnl): pnl is number => pnl != null);
57
+ };
58
+
59
+ const calculateSnapshotRiskRewardRatio = (pnls: number[]) => {
60
+ const wins = pnls.filter((pnl) => pnl > 0);
61
+ const losses = pnls.filter((pnl) => pnl < 0);
62
+
63
+ if (!wins.length || !losses.length) {
64
+ return null;
65
+ }
66
+
67
+ const avgWin = wins.reduce((sum, pnl) => sum + pnl, 0) / wins.length;
68
+ const avgLossAbs = Math.abs(
69
+ losses.reduce((sum, pnl) => sum + pnl, 0) / losses.length,
70
+ );
71
+
72
+ return avgLossAbs > 0 ? avgWin / avgLossAbs : null;
73
+ };
74
+
75
+ const buildSnapshotSummaryStat = (
76
+ snapshot: StrategyChartSnapshot,
77
+ ): Partial<TestStat> => {
78
+ const amounts = snapshot.orderLog
79
+ .map(([, amount]) => asFiniteNumber(amount))
80
+ .filter((amount): amount is number => amount != null);
81
+ const firstAmount = amounts[0] ?? null;
82
+ const lastAmount = amounts.at(-1) ?? null;
83
+ const pnls = getSnapshotTradePnls(snapshot);
84
+ const wins = pnls.filter((pnl) => pnl > 0).length;
85
+ const orders =
86
+ asFiniteNumber(snapshot.stat?.orders) ??
87
+ (snapshot.orders.length || pnls.length);
88
+ const netProfit =
89
+ asFiniteNumber(snapshot.stat?.netProfit) ??
90
+ (firstAmount != null && lastAmount != null
91
+ ? lastAmount - firstAmount
92
+ : pnls.reduce((sum, pnl) => sum + pnl, 0));
93
+ const minAmount =
94
+ asFiniteNumber(snapshot.stat?.minAmount) ??
95
+ (amounts.length ? Math.min(...amounts) : null);
96
+ const maxDrawdown =
97
+ asFiniteNumber(snapshot.stat?.maxDrawdown) ??
98
+ calculateMaxDrawdownValue(snapshot.orderLog);
99
+ const winRate =
100
+ asFiniteNumber(snapshot.stat?.winRate) ??
101
+ (orders > 0 ? (wins / orders) * 100 : 0);
102
+ const riskRewardRatio =
103
+ asFiniteNumber(snapshot.stat?.riskRewardRatio) ??
104
+ calculateSnapshotRiskRewardRatio(pnls);
105
+ const maxConsecutiveWins =
106
+ asFiniteNumber(snapshot.stat?.maxConsecutiveWins) ??
107
+ calculateMaxGrossStreak(snapshot.orderLog);
108
+ const maxConsecutiveLosses =
109
+ asFiniteNumber(snapshot.stat?.maxConsecutiveLosses) ??
110
+ calculateMaxLossStreak(snapshot.orderLog);
111
+
112
+ return {
113
+ netProfit,
114
+ minAmount: minAmount ?? undefined,
115
+ maxDrawdown: maxDrawdown ?? undefined,
116
+ orders,
117
+ winRate,
118
+ riskRewardRatio,
119
+ maxConsecutiveWins,
120
+ maxConsecutiveLosses,
121
+ };
122
+ };
123
+
124
+ export const buildSnapshotSummaryMetrics = (
125
+ snapshot: StrategyChartSnapshot,
126
+ ) => {
127
+ const stat = buildSnapshotSummaryStat(snapshot);
128
+
129
+ return SNAPSHOT_SUMMARY_METRICS.map(({ id, label }) => {
130
+ const { formatted, level } = getFormatted(stat, id);
131
+
132
+ return {
133
+ id,
134
+ label,
135
+ value: formatted,
136
+ tone: level,
137
+ };
138
+ });
139
+ };
140
+
141
+ const formatPrice = (value: number | null | undefined) =>
142
+ formatPriceUsdt(value);
143
+
144
+ const formatBps = (value: number | null | undefined) => {
145
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
146
+ return 'n/a';
147
+ }
148
+
149
+ return `${formatCompactNumber(value, {
150
+ maximumFractionDigits: 2,
151
+ minimumFractionDigits: 2,
152
+ })} bps`;
153
+ };
154
+
155
+ const formatAiExitReason = (reason: string | null | undefined) =>
156
+ reason ? reason.replace(/_/g, ' ').toUpperCase() : 'CLOSED';
157
+
158
+ const getAiExitReasonColor = (reason: string | null | undefined) => {
159
+ switch (reason) {
160
+ case 'take_profit':
161
+ return 'teal';
162
+ case 'stop_loss':
163
+ return 'red';
164
+ default:
165
+ return 'gray';
166
+ }
167
+ };
168
+
169
+ const buildSlippageDetail = ({
170
+ requestedPrice,
171
+ slippageBps,
172
+ }: {
173
+ requestedPrice?: number | null;
174
+ slippageBps?: number | null;
175
+ }) => [`plan ${formatPrice(requestedPrice)}`, `slip ${formatBps(slippageBps)}`];
176
+
177
+ const buildAiFeesDetail = (order: StrategyChartOrder) => [
178
+ `open ${formatFee(order.openFee)}`,
179
+ `close ${formatFee(order.closeFee)}`,
180
+ `funding ${formatFee(order.fundingFee)}`,
181
+ ];
182
+
183
+ const normalizeSnapshotDirection = (
184
+ direction: StrategyChartOrder['direction'],
185
+ ): OrdersDrawerOrder['direction'] =>
186
+ direction === 'LONG' || direction === 'SHORT' ? direction : null;
187
+
188
+ const getSnapshotOrderTimestamp = (order: StrategyChartOrder) => {
189
+ const timestamp =
190
+ order.timestamp ?? order.entryTimestamp ?? order.exitTimestamp;
191
+ return typeof timestamp === 'number' && Number.isFinite(timestamp)
192
+ ? timestamp
193
+ : null;
194
+ };
195
+
196
+ const getReplayOrderStatus = (type: string | null | undefined) => {
197
+ if (type?.startsWith('OPEN')) {
198
+ return { label: 'ACTIVE', color: 'orange', status: 'active' as const };
199
+ }
200
+
201
+ if (type?.startsWith('TAKE_PROFIT')) {
202
+ return { label: 'TAKE PROFIT', color: 'teal', status: 'closed' as const };
203
+ }
204
+
205
+ if (type?.startsWith('STOP_LOSS')) {
206
+ return { label: 'STOP LOSS', color: 'red', status: 'closed' as const };
207
+ }
208
+
209
+ if (type?.startsWith('CLOSE')) {
210
+ return { label: 'CLOSE', color: 'gray', status: 'closed' as const };
211
+ }
212
+
213
+ return { label: 'REPLAY', color: 'gray', status: 'closed' as const };
214
+ };
215
+
216
+ const isReplayOpenOrder = (order: StrategyChartOrder) =>
217
+ order.exitReason?.startsWith('OPEN') === true;
218
+
219
+ const getReplayPairKey = (order: StrategyChartOrder) => {
220
+ const direction = normalizeSnapshotDirection(order.direction);
221
+ if (!order.symbol || !direction) {
222
+ return null;
223
+ }
224
+
225
+ return `${order.symbol}:${direction}`;
226
+ };
227
+
228
+ const getFiniteNumber = (value: number | null | undefined) =>
229
+ typeof value === 'number' && Number.isFinite(value) ? value : null;
230
+
231
+ const sumFiniteNumbers = (
232
+ ...values: Array<number | null | undefined>
233
+ ): number | null => {
234
+ let total = 0;
235
+ let hasValue = false;
236
+
237
+ for (const value of values) {
238
+ const finiteValue = getFiniteNumber(value);
239
+ if (finiteValue == null) {
240
+ continue;
241
+ }
242
+
243
+ total += finiteValue;
244
+ hasValue = true;
245
+ }
246
+
247
+ return hasValue ? total : null;
248
+ };
249
+
250
+ const multiplyFiniteNumber = (
251
+ value: number | null | undefined,
252
+ multiplier: number,
253
+ ) => {
254
+ const finiteValue = getFiniteNumber(value);
255
+ return finiteValue == null ? null : finiteValue * multiplier;
256
+ };
257
+
258
+ const buildReplayFeesDetail = ({
259
+ openFee,
260
+ closeFee,
261
+ fundingFee,
262
+ }: {
263
+ openFee?: number | null;
264
+ closeFee?: number | null;
265
+ fundingFee?: number | null;
266
+ }) => [
267
+ `open ${formatFee(openFee)}`,
268
+ `close ${formatFee(closeFee)}`,
269
+ `funding ${formatFee(fundingFee)}`,
270
+ ];
271
+
272
+ type ReplayOpenPosition = {
273
+ order: StrategyChartOrder;
274
+ remainingQty: number | null;
275
+ };
276
+
277
+ const getReplayExitShare = (
278
+ entry: ReplayOpenPosition,
279
+ exitOrder: StrategyChartOrder,
280
+ ) => {
281
+ const entryQty = getFiniteNumber(entry.order.qty);
282
+ const exitQty = getFiniteNumber(exitOrder.qty);
283
+
284
+ if (entryQty == null || entryQty <= 0 || exitQty == null || exitQty <= 0) {
285
+ return 1;
286
+ }
287
+
288
+ const remainingQty = entry.remainingQty ?? entryQty;
289
+ return Math.min(exitQty, remainingQty) / entryQty;
290
+ };
291
+
292
+ const consumeReplayEntryQty = (
293
+ entry: ReplayOpenPosition,
294
+ exitOrder: StrategyChartOrder,
295
+ ) => {
296
+ const exitQty = getFiniteNumber(exitOrder.qty);
297
+ if (entry.remainingQty == null || exitQty == null) {
298
+ return;
299
+ }
300
+
301
+ entry.remainingQty = Math.max(0, entry.remainingQty - exitQty);
302
+ };
303
+
304
+ const isReplayEntryConsumed = (entry: ReplayOpenPosition) =>
305
+ entry.remainingQty != null && entry.remainingQty <= 0.00000001;
306
+
307
+ const buildReplayTradeCard = ({
308
+ snapshot,
309
+ entryOrder,
310
+ exitOrder,
311
+ tradeIndex,
312
+ entryShare = 1,
313
+ }: {
314
+ snapshot: StrategyChartSnapshot;
315
+ entryOrder: StrategyChartOrder;
316
+ exitOrder?: StrategyChartOrder;
317
+ tradeIndex: number;
318
+ entryShare?: number;
319
+ }): OrdersDrawerOrder => {
320
+ const orderStatus = getReplayOrderStatus(
321
+ exitOrder?.exitReason ?? entryOrder.exitReason,
322
+ );
323
+ const entryTimestamp = getSnapshotOrderTimestamp(entryOrder);
324
+ const exitTimestamp = exitOrder ? getSnapshotOrderTimestamp(exitOrder) : null;
325
+ const durationHours =
326
+ entryTimestamp != null && exitTimestamp != null
327
+ ? (exitTimestamp - entryTimestamp) / MS_IN_HOUR
328
+ : null;
329
+ const openFee = multiplyFiniteNumber(entryOrder.openFee, entryShare);
330
+ const openPnl = multiplyFiniteNumber(entryOrder.pnl, entryShare);
331
+ const closeFee = exitOrder?.closeFee ?? exitOrder?.totalFee ?? null;
332
+ const fundingFee = sumFiniteNumbers(
333
+ multiplyFiniteNumber(entryOrder.fundingFee, entryShare),
334
+ exitOrder?.fundingFee,
335
+ );
336
+ const totalFee = sumFiniteNumbers(openFee, closeFee, fundingFee);
337
+ const pnl = sumFiniteNumbers(openPnl, exitOrder?.pnl);
338
+ const qty = exitOrder?.qty ?? entryOrder.qty;
339
+ const notional =
340
+ entryOrder.notional != null
341
+ ? multiplyFiniteNumber(entryOrder.notional, entryShare)
342
+ : exitOrder?.notional;
343
+
344
+ return {
345
+ id: `${snapshot.cardId}:replay-trade:${tradeIndex}:${entryOrder.id}:${exitOrder?.id ?? 'active'}`,
346
+ title: entryOrder.symbol
347
+ ? `${entryOrder.symbol} · Replay #${tradeIndex}`
348
+ : `Replay #${tradeIndex}`,
349
+ period: {
350
+ start: entryTimestamp,
351
+ end: exitTimestamp,
352
+ durationHours,
353
+ },
354
+ direction: normalizeSnapshotDirection(entryOrder.direction),
355
+ status: orderStatus.status,
356
+ statusLabel: orderStatus.label,
357
+ statusColor: orderStatus.color,
358
+ pnl,
359
+ metrics: [
360
+ {
361
+ title: 'Entry',
362
+ value: formatPrice(entryOrder.entryPrice),
363
+ detailLines: buildSlippageDetail({
364
+ requestedPrice: entryOrder.requestedEntryPrice,
365
+ slippageBps: entryOrder.entrySlippageBps,
366
+ }),
367
+ },
368
+ {
369
+ title: 'Exit',
370
+ value: formatPrice(exitOrder?.exitPrice),
371
+ detailLines: buildSlippageDetail({
372
+ requestedPrice: exitOrder?.requestedExitPrice,
373
+ slippageBps: exitOrder?.exitSlippageBps,
374
+ }),
375
+ },
376
+ {
377
+ title: 'Notional',
378
+ value: formatUsdt(notional),
379
+ },
380
+ {
381
+ title: 'Fees',
382
+ value: formatFee(totalFee),
383
+ detailLines: buildReplayFeesDetail({ openFee, closeFee, fundingFee }),
384
+ },
385
+ {
386
+ title: 'Qty',
387
+ value: formatCompactNumber(qty, {
388
+ maximumFractionDigits: 8,
389
+ minimumFractionDigits: 0,
390
+ }),
391
+ },
392
+ {
393
+ title: 'Equity',
394
+ value: formatUsdt(exitOrder?.equityAfter ?? entryOrder.equityAfter),
395
+ detail: `prev ${formatUsdt(entryOrder.equityBefore)}`,
396
+ },
397
+ ],
398
+ };
399
+ };
400
+
401
+ const buildReplaySnapshotOrders = (
402
+ snapshot: StrategyChartSnapshot,
403
+ ): OrdersDrawerOrder[] => {
404
+ const persistedOrders = snapshot.orders
405
+ .map((order, index) => ({ order, index }))
406
+ .sort((left, right) => {
407
+ const leftTimestamp =
408
+ getSnapshotOrderTimestamp(left.order) ?? Number.NEGATIVE_INFINITY;
409
+ const rightTimestamp =
410
+ getSnapshotOrderTimestamp(right.order) ?? Number.NEGATIVE_INFINITY;
411
+
412
+ return leftTimestamp - rightTimestamp || left.index - right.index;
413
+ });
414
+
415
+ if (persistedOrders.length) {
416
+ const openPositions = new Map<string, ReplayOpenPosition[]>();
417
+ const tradeCards: OrdersDrawerOrder[] = [];
418
+
419
+ for (const { order } of persistedOrders) {
420
+ const pairKey = getReplayPairKey(order);
421
+ if (!pairKey) {
422
+ continue;
423
+ }
424
+
425
+ if (isReplayOpenOrder(order)) {
426
+ const bucket = openPositions.get(pairKey) ?? [];
427
+ bucket.push({
428
+ order,
429
+ remainingQty: getFiniteNumber(order.qty),
430
+ });
431
+ openPositions.set(pairKey, bucket);
432
+ continue;
433
+ }
434
+
435
+ const bucket = openPositions.get(pairKey);
436
+ const entry = bucket?.[0];
437
+ if (!entry) {
438
+ continue;
439
+ }
440
+
441
+ const entryShare = getReplayExitShare(entry, order);
442
+ tradeCards.push(
443
+ buildReplayTradeCard({
444
+ snapshot,
445
+ entryOrder: entry.order,
446
+ exitOrder: order,
447
+ tradeIndex: tradeCards.length + 1,
448
+ entryShare,
449
+ }),
450
+ );
451
+ consumeReplayEntryQty(entry, order);
452
+
453
+ if (entry.remainingQty == null || isReplayEntryConsumed(entry)) {
454
+ bucket.shift();
455
+ }
456
+ }
457
+
458
+ for (const bucket of openPositions.values()) {
459
+ for (const entry of bucket) {
460
+ if (isReplayEntryConsumed(entry)) {
461
+ continue;
462
+ }
463
+
464
+ tradeCards.push(
465
+ buildReplayTradeCard({
466
+ snapshot,
467
+ entryOrder: entry.order,
468
+ tradeIndex: tradeCards.length + 1,
469
+ }),
470
+ );
471
+ }
472
+ }
473
+
474
+ return tradeCards.reverse();
475
+ }
476
+
477
+ return snapshot.orderLog
478
+ .slice(1)
479
+ .map<OrdersDrawerOrder | null>((current, index) => {
480
+ const previous = snapshot.orderLog[index];
481
+ if (!previous) {
482
+ return null;
483
+ }
484
+
485
+ const [timestamp, equityAfter] = current;
486
+ const [, equityBefore] = previous;
487
+ const pnl = equityAfter - equityBefore;
488
+ if (
489
+ !Number.isFinite(timestamp) ||
490
+ !Number.isFinite(equityBefore) ||
491
+ !Number.isFinite(equityAfter) ||
492
+ !Number.isFinite(pnl)
493
+ ) {
494
+ return null;
495
+ }
496
+
497
+ const orderIndex = index + 1;
498
+
499
+ return {
500
+ id: `${snapshot.cardId}:replay:${orderIndex}:${timestamp}`,
501
+ title: `Replay #${orderIndex}`,
502
+ period: {
503
+ start: timestamp,
504
+ },
505
+ status: 'closed',
506
+ statusLabel: 'REPLAY',
507
+ statusColor: 'gray',
508
+ pnl,
509
+ metrics: [
510
+ {
511
+ title: 'Entry',
512
+ value: 'n/a',
513
+ detailLines: buildSlippageDetail({}),
514
+ },
515
+ {
516
+ title: 'Exit',
517
+ value: 'n/a',
518
+ detailLines: buildSlippageDetail({}),
519
+ },
520
+ {
521
+ title: 'Notional',
522
+ value: formatUsdt(null),
523
+ },
524
+ {
525
+ title: 'Fees',
526
+ value: formatFee(null),
527
+ detailLines: buildReplayFeesDetail({}),
528
+ },
529
+ {
530
+ title: 'Qty',
531
+ value: formatCompactNumber(null),
532
+ },
533
+ {
534
+ title: 'Equity',
535
+ value: formatUsdt(equityAfter),
536
+ detail: `prev ${formatUsdt(equityBefore)}`,
537
+ },
538
+ ],
539
+ };
540
+ })
541
+ .filter((order): order is OrdersDrawerOrder => order != null)
542
+ .reverse();
543
+ };
544
+
545
+ const buildAiSnapshotOrders = (
546
+ snapshot: StrategyChartSnapshot,
547
+ ): OrdersDrawerOrder[] =>
548
+ snapshot.orders
549
+ .map((order, index) => ({ order, index }))
550
+ .sort((left, right) => {
551
+ const leftEntry =
552
+ typeof left.order.entryTimestamp === 'number' &&
553
+ Number.isFinite(left.order.entryTimestamp)
554
+ ? left.order.entryTimestamp
555
+ : Number.NEGATIVE_INFINITY;
556
+ const rightEntry =
557
+ typeof right.order.entryTimestamp === 'number' &&
558
+ Number.isFinite(right.order.entryTimestamp)
559
+ ? right.order.entryTimestamp
560
+ : Number.NEGATIVE_INFINITY;
561
+
562
+ return rightEntry - leftEntry || left.index - right.index;
563
+ })
564
+ .map(({ order, index }) => {
565
+ const orderIndex = order.sequence ?? index + 1;
566
+ const durationHours =
567
+ typeof order.entryTimestamp === 'number' &&
568
+ Number.isFinite(order.entryTimestamp) &&
569
+ typeof order.exitTimestamp === 'number' &&
570
+ Number.isFinite(order.exitTimestamp)
571
+ ? (order.exitTimestamp - order.entryTimestamp) / MS_IN_HOUR
572
+ : null;
573
+ const title = order.symbol
574
+ ? `${order.symbol} · AI step #${orderIndex}`
575
+ : `AI step #${orderIndex}`;
576
+
577
+ return {
578
+ id: `${snapshot.cardId}:${order.id}`,
579
+ title,
580
+ period: {
581
+ start: order.entryTimestamp,
582
+ end: order.exitTimestamp,
583
+ durationHours,
584
+ },
585
+ direction: normalizeSnapshotDirection(order.direction),
586
+ status:
587
+ typeof order.exitTimestamp === 'number' &&
588
+ Number.isFinite(order.exitTimestamp)
589
+ ? 'closed'
590
+ : 'active',
591
+ statusLabel: formatAiExitReason(order.exitReason),
592
+ statusColor: getAiExitReasonColor(order.exitReason),
593
+ pnl: order.pnl,
594
+ metrics: [
595
+ {
596
+ title: 'Entry',
597
+ value: formatPrice(order.entryPrice),
598
+ detailLines: buildSlippageDetail({
599
+ requestedPrice: order.requestedEntryPrice,
600
+ slippageBps: order.entrySlippageBps,
601
+ }),
602
+ },
603
+ {
604
+ title: 'Exit',
605
+ value: formatPrice(order.exitPrice),
606
+ detailLines: buildSlippageDetail({
607
+ requestedPrice: order.requestedExitPrice,
608
+ slippageBps: order.exitSlippageBps,
609
+ }),
610
+ },
611
+ {
612
+ title: 'Notional',
613
+ value: formatUsdt(order.notional),
614
+ },
615
+ {
616
+ title: 'Fees',
617
+ value: formatFee(order.totalFee),
618
+ detailLines: buildAiFeesDetail(order),
619
+ },
620
+ {
621
+ title: 'Qty',
622
+ value: formatCompactNumber(order.qty, {
623
+ maximumFractionDigits: 8,
624
+ minimumFractionDigits: 0,
625
+ }),
626
+ },
627
+ {
628
+ title: 'Equity',
629
+ value: formatUsdt(order.equityAfter),
630
+ detail: `prev ${formatUsdt(order.equityBefore)}`,
631
+ },
632
+ ],
633
+ };
634
+ });
635
+
636
+ export const buildSnapshotOrders = (
637
+ snapshot: StrategyChartSnapshot,
638
+ mode: 'replay' | 'ai',
639
+ ): OrdersDrawerOrder[] =>
640
+ mode === 'replay'
641
+ ? buildReplaySnapshotOrders(snapshot)
642
+ : buildAiSnapshotOrders(snapshot);
643
+
644
+ export const buildSnapshotAdvancedTrades = (
645
+ snapshot: StrategyChartSnapshot,
646
+ ): AdvancedTradeInput[] =>
647
+ snapshot.orders.flatMap((order): AdvancedTradeInput[] => {
648
+ const timestamp =
649
+ order.exitTimestamp ?? order.timestamp ?? order.entryTimestamp;
650
+
651
+ if (
652
+ typeof timestamp !== 'number' ||
653
+ !Number.isFinite(timestamp) ||
654
+ typeof order.pnl !== 'number' ||
655
+ !Number.isFinite(order.pnl)
656
+ ) {
657
+ return [];
658
+ }
659
+
660
+ const slippageCost =
661
+ typeof order.totalSlippageCost === 'number' &&
662
+ Number.isFinite(order.totalSlippageCost)
663
+ ? Math.abs(order.totalSlippageCost)
664
+ : null;
665
+
666
+ return [
667
+ {
668
+ id: order.id,
669
+ timestamp,
670
+ pnl: order.pnl,
671
+ symbol: order.symbol ?? null,
672
+ direction: order.direction ?? null,
673
+ exitReason: order.exitReason ?? null,
674
+ slippageCost,
675
+ grossPnl: slippageCost == null ? order.pnl : order.pnl + slippageCost,
676
+ approved: true,
677
+ blocked: false,
678
+ },
679
+ ];
680
+ });