@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,536 @@
1
+ import {
2
+ calculateAdvancedTradeMetrics,
3
+ getFormatted,
4
+ type AdvancedTradeInput,
5
+ } from '@tradejs/core/backtest';
6
+ import type { TestThresholdsKey, ThresholdLevel } from '@tradejs/types';
7
+ import {
8
+ formatCompactNumber,
9
+ formatFee,
10
+ formatInteger,
11
+ formatPercent,
12
+ formatPriceUsdt,
13
+ formatSignedNumber,
14
+ formatUsdt,
15
+ getPnlColor,
16
+ type OrdersDrawerOrder,
17
+ type OrdersDrawerSummaryItem,
18
+ } from '#components/Shared/OrdersDrawer';
19
+ import type { RuntimeStrategyView } from '#app/lib/runtimeStrategyContracts';
20
+ import {
21
+ buildStrategyPerformanceViewModel,
22
+ calculateMaxLossStreak,
23
+ } from '#app/lib/strategyPerformance';
24
+
25
+ export type RuntimeOrderView = RuntimeStrategyView['orders'][number];
26
+ export const RUNTIME_ORDER_ROW_HEIGHT = 306;
27
+
28
+ export interface RuntimeSymbolPnlRank {
29
+ symbol: string;
30
+ pnl: number;
31
+ orders: number;
32
+ winRate: number | null;
33
+ avgPnl: number | null;
34
+ }
35
+
36
+ export interface RuntimeSymbolConcentrationRow extends RuntimeSymbolPnlRank {
37
+ absPnl: number;
38
+ absPnlShare: number;
39
+ orderShare: number;
40
+ }
41
+
42
+ export interface RuntimeDirectionStats {
43
+ direction: RuntimeOrderView['direction'];
44
+ orders: number;
45
+ active: number;
46
+ closed: number;
47
+ wins: number;
48
+ pnl: number;
49
+ avgPnl: number | null;
50
+ }
51
+
52
+ export interface RuntimeDrawerMetric {
53
+ id: string;
54
+ label: string;
55
+ value: string;
56
+ level: ThresholdLevel;
57
+ }
58
+
59
+ export const getColorByLevel = (level: ThresholdLevel) => {
60
+ switch (level) {
61
+ case 'success':
62
+ return 'teal.500';
63
+ case 'warning':
64
+ return 'fg.warning';
65
+ case 'neutral':
66
+ return 'gray.300';
67
+ case 'error':
68
+ default:
69
+ return 'fg.error';
70
+ }
71
+ };
72
+
73
+ export const getMetricColor = (level: ThresholdLevel) => getColorByLevel(level);
74
+
75
+ export const getPnlBarColor = (value: number) => {
76
+ if (value > 0) {
77
+ return 'teal.500';
78
+ }
79
+ if (value < 0) {
80
+ return 'red.500';
81
+ }
82
+ return 'gray.500';
83
+ };
84
+
85
+ const getOrderAccentColor = (order: RuntimeOrderView) => {
86
+ if (order.status === 'active') {
87
+ return 'orange.300';
88
+ }
89
+
90
+ if (typeof order.pnl !== 'number' || !Number.isFinite(order.pnl)) {
91
+ return 'gray.600';
92
+ }
93
+
94
+ return order.pnl >= 0 ? 'teal.300' : 'red.300';
95
+ };
96
+
97
+ const formatExitType = (order: RuntimeOrderView) => {
98
+ if (order.status === 'active') {
99
+ return 'active';
100
+ }
101
+
102
+ return order.exitType ? order.exitType.toUpperCase() : 'closed';
103
+ };
104
+
105
+ const formatOrderReference = (orderId: string) => {
106
+ const normalized = orderId.trim();
107
+ const separatorIndex = normalized.indexOf('--');
108
+ const suffix =
109
+ separatorIndex >= 0 ? normalized.slice(separatorIndex + 2) : normalized;
110
+
111
+ return suffix.length > 12 ? suffix.slice(-12) : suffix || 'n/a';
112
+ };
113
+
114
+ const getRuntimeOrderNotional = (order: RuntimeOrderView) => {
115
+ const entryPrice = order.actualEntryPrice ?? order.entryPrice;
116
+
117
+ if (
118
+ typeof order.qty !== 'number' ||
119
+ !Number.isFinite(order.qty) ||
120
+ typeof entryPrice !== 'number' ||
121
+ !Number.isFinite(entryPrice)
122
+ ) {
123
+ return null;
124
+ }
125
+
126
+ return order.qty * entryPrice;
127
+ };
128
+
129
+ const getRuntimeOrderSlippageCost = (order: RuntimeOrderView) => {
130
+ const notional = getRuntimeOrderNotional(order);
131
+
132
+ if (notional == null) {
133
+ return null;
134
+ }
135
+
136
+ const entrySlippagePercent =
137
+ typeof order.entrySlippagePercent === 'number' &&
138
+ Number.isFinite(order.entrySlippagePercent)
139
+ ? Math.abs(order.entrySlippagePercent)
140
+ : 0;
141
+ const exitSlippagePercent =
142
+ typeof order.exitSlippagePercent === 'number' &&
143
+ Number.isFinite(order.exitSlippagePercent)
144
+ ? Math.abs(order.exitSlippagePercent)
145
+ : 0;
146
+ const totalSlippagePercent = entrySlippagePercent + exitSlippagePercent;
147
+
148
+ return totalSlippagePercent > 0
149
+ ? (notional * totalSlippagePercent) / 100
150
+ : null;
151
+ };
152
+
153
+ const buildRuntimeAdvancedTrades = (
154
+ orders: RuntimeOrderView[],
155
+ ): AdvancedTradeInput[] =>
156
+ orders.flatMap((order): AdvancedTradeInput[] => {
157
+ const timestamp = order.exitTimestamp ?? order.entryTimestamp;
158
+
159
+ if (
160
+ typeof timestamp !== 'number' ||
161
+ !Number.isFinite(timestamp) ||
162
+ typeof order.pnl !== 'number' ||
163
+ !Number.isFinite(order.pnl)
164
+ ) {
165
+ return [];
166
+ }
167
+
168
+ const slippageCost = getRuntimeOrderSlippageCost(order);
169
+
170
+ return [
171
+ {
172
+ id: order.orderId,
173
+ timestamp,
174
+ pnl: order.pnl,
175
+ symbol: order.symbol,
176
+ direction: order.direction,
177
+ exitReason: order.exitType ?? null,
178
+ slippageCost,
179
+ grossPnl: slippageCost == null ? order.pnl : order.pnl + slippageCost,
180
+ approved: true,
181
+ blocked: false,
182
+ },
183
+ ];
184
+ });
185
+
186
+ const getOrdersSummary = (orders: RuntimeOrderView[]) => {
187
+ const closedOrders = orders.filter((order) => order.status === 'closed');
188
+ const winningOrders = closedOrders.filter(
189
+ (order) =>
190
+ typeof order.pnl === 'number' &&
191
+ Number.isFinite(order.pnl) &&
192
+ order.pnl > 0,
193
+ );
194
+
195
+ const sumPnl = (direction: RuntimeOrderView['direction']) =>
196
+ closedOrders.reduce((total, order) => {
197
+ if (
198
+ order.direction !== direction ||
199
+ typeof order.pnl !== 'number' ||
200
+ !Number.isFinite(order.pnl)
201
+ ) {
202
+ return total;
203
+ }
204
+
205
+ return total + order.pnl;
206
+ }, 0);
207
+
208
+ return {
209
+ closedOrders: closedOrders.length,
210
+ winRate:
211
+ closedOrders.length > 0
212
+ ? (winningOrders.length / closedOrders.length) * 100
213
+ : 0,
214
+ longPnl: sumPnl('LONG'),
215
+ shortPnl: sumPnl('SHORT'),
216
+ };
217
+ };
218
+
219
+ const getRuntimeOrdersSummaryItems = (
220
+ orders: RuntimeOrderView[],
221
+ ): OrdersDrawerSummaryItem[] => {
222
+ const summary = getOrdersSummary(orders);
223
+
224
+ return [
225
+ {
226
+ title: 'Total Closed Orders',
227
+ value: formatInteger(summary.closedOrders),
228
+ },
229
+ {
230
+ title: 'Win Rate',
231
+ value: formatPercent(summary.winRate, { signed: false }),
232
+ },
233
+ {
234
+ title: 'P&L of Closed Long Orders (USDT)',
235
+ value: formatSignedNumber(summary.longPnl),
236
+ color: getPnlColor(summary.longPnl),
237
+ },
238
+ {
239
+ title: 'P&L of Closed Short Orders (USDT)',
240
+ value: formatSignedNumber(summary.shortPnl),
241
+ color: getPnlColor(summary.shortPnl),
242
+ },
243
+ ];
244
+ };
245
+
246
+ const buildRuntimeDrawerMetrics = (
247
+ strategy: RuntimeStrategyView,
248
+ ): RuntimeDrawerMetric[] => {
249
+ const getStatMetric = (
250
+ id: TestThresholdsKey,
251
+ label: string,
252
+ ): RuntimeDrawerMetric => {
253
+ const { formatted, level } = getFormatted(strategy.stat, id);
254
+
255
+ return {
256
+ id,
257
+ label,
258
+ value: formatted,
259
+ level,
260
+ };
261
+ };
262
+
263
+ const maxLossStreak = calculateMaxLossStreak(strategy.orderLog);
264
+
265
+ return [
266
+ getStatMetric('netProfit', 'P&L'),
267
+ {
268
+ id: 'closedPnl',
269
+ label: 'Closed P&L',
270
+ value: formatSignedNumber(strategy.summary.closedPnl),
271
+ level:
272
+ strategy.summary.closedPnl > 0
273
+ ? 'success'
274
+ : strategy.summary.closedPnl < 0
275
+ ? 'error'
276
+ : 'neutral',
277
+ },
278
+ {
279
+ id: 'activePnl',
280
+ label: 'Active P&L',
281
+ value: formatSignedNumber(strategy.summary.activePnl),
282
+ level:
283
+ strategy.summary.activePnl > 0
284
+ ? 'success'
285
+ : strategy.summary.activePnl < 0
286
+ ? 'error'
287
+ : 'neutral',
288
+ },
289
+ getStatMetric('maxDrawdown', 'Max drawdown'),
290
+ {
291
+ id: 'maxLossStreak',
292
+ label: 'Max loss streak',
293
+ value: formatInteger(maxLossStreak),
294
+ level: maxLossStreak > 0 ? 'warning' : 'success',
295
+ },
296
+ {
297
+ id: 'totalTrades',
298
+ label: 'Trades',
299
+ value: formatInteger(strategy.summary.totalTrades),
300
+ level: 'neutral',
301
+ },
302
+ {
303
+ id: 'activeTrades',
304
+ label: 'Active',
305
+ value: formatInteger(strategy.summary.activeTrades),
306
+ level: strategy.summary.activeTrades > 0 ? 'warning' : 'neutral',
307
+ },
308
+ {
309
+ id: 'symbolTop1',
310
+ label: 'Symbol top 1',
311
+ value: formatPercent(strategy.summary.symbolConcentrationTop1),
312
+ level:
313
+ (strategy.summary.symbolConcentrationTop1 ?? 0) >= 60
314
+ ? 'warning'
315
+ : 'neutral',
316
+ },
317
+ {
318
+ id: 'symbolTop5',
319
+ label: 'Symbol top 5',
320
+ value: formatPercent(strategy.summary.symbolConcentrationTop5),
321
+ level: 'neutral',
322
+ },
323
+ getStatMetric('winRate', 'Win Rate'),
324
+ ];
325
+ };
326
+
327
+ const buildRuntimeSymbolPnlRanking = (
328
+ orders: RuntimeOrderView[],
329
+ ): RuntimeSymbolPnlRank[] => {
330
+ const grouped = new Map<
331
+ string,
332
+ { symbol: string; pnl: number; orders: number; wins: number }
333
+ >();
334
+
335
+ for (const order of orders) {
336
+ if (typeof order.pnl !== 'number' || !Number.isFinite(order.pnl)) {
337
+ continue;
338
+ }
339
+
340
+ const existing = grouped.get(order.symbol) ?? {
341
+ symbol: order.symbol,
342
+ pnl: 0,
343
+ orders: 0,
344
+ wins: 0,
345
+ };
346
+
347
+ existing.pnl += order.pnl;
348
+ existing.orders += 1;
349
+ existing.wins += order.pnl > 0 ? 1 : 0;
350
+ grouped.set(order.symbol, existing);
351
+ }
352
+
353
+ return [...grouped.values()]
354
+ .map((rank) => ({
355
+ symbol: rank.symbol,
356
+ pnl: rank.pnl,
357
+ orders: rank.orders,
358
+ winRate: rank.orders > 0 ? (rank.wins / rank.orders) * 100 : null,
359
+ avgPnl: rank.orders > 0 ? rank.pnl / rank.orders : null,
360
+ }))
361
+ .sort(
362
+ (left, right) =>
363
+ Math.abs(right.pnl) - Math.abs(left.pnl) ||
364
+ right.pnl - left.pnl ||
365
+ left.symbol.localeCompare(right.symbol),
366
+ );
367
+ };
368
+
369
+ const buildRuntimeSymbolConcentration = (
370
+ orders: RuntimeOrderView[],
371
+ ): RuntimeSymbolConcentrationRow[] => {
372
+ const ranking = buildRuntimeSymbolPnlRanking(orders).map((rank) => ({
373
+ ...rank,
374
+ absPnl: Math.abs(rank.pnl),
375
+ }));
376
+ const totalAbsPnl = ranking.reduce((sum, rank) => sum + rank.absPnl, 0);
377
+ const totalOrders = ranking.reduce((sum, rank) => sum + rank.orders, 0);
378
+
379
+ if (totalAbsPnl <= 0 || totalOrders <= 0) {
380
+ return [];
381
+ }
382
+
383
+ return ranking
384
+ .map((rank) => ({
385
+ ...rank,
386
+ absPnlShare: (rank.absPnl / totalAbsPnl) * 100,
387
+ orderShare: (rank.orders / totalOrders) * 100,
388
+ }))
389
+ .sort(
390
+ (left, right) =>
391
+ right.absPnlShare - left.absPnlShare ||
392
+ right.orderShare - left.orderShare ||
393
+ left.symbol.localeCompare(right.symbol),
394
+ );
395
+ };
396
+
397
+ const buildRuntimeDirectionStats = (
398
+ orders: RuntimeOrderView[],
399
+ ): RuntimeDirectionStats[] =>
400
+ (['LONG', 'SHORT'] as const).map((direction) => {
401
+ const directionOrders = orders.filter(
402
+ (order) => order.direction === direction,
403
+ );
404
+ const ordersWithPnl = directionOrders.filter(
405
+ (order) => typeof order.pnl === 'number' && Number.isFinite(order.pnl),
406
+ );
407
+ const pnl = ordersWithPnl.reduce((sum, order) => sum + (order.pnl ?? 0), 0);
408
+
409
+ return {
410
+ direction,
411
+ orders: directionOrders.length,
412
+ active: directionOrders.filter((order) => order.status === 'active')
413
+ .length,
414
+ closed: directionOrders.filter((order) => order.status === 'closed')
415
+ .length,
416
+ wins: ordersWithPnl.filter((order) => (order.pnl ?? 0) > 0).length,
417
+ pnl,
418
+ avgPnl: ordersWithPnl.length > 0 ? pnl / ordersWithPnl.length : null,
419
+ };
420
+ });
421
+
422
+ const mapRuntimeOrder = (order: RuntimeOrderView): OrdersDrawerOrder => {
423
+ const displayEntryPrice = order.actualEntryPrice ?? order.entryPrice;
424
+ const displayExitPrice =
425
+ order.status === 'active'
426
+ ? order.currentPrice
427
+ : order.actualExitPrice ?? order.exitPrice;
428
+ const notional =
429
+ typeof order.qty === 'number' &&
430
+ Number.isFinite(order.qty) &&
431
+ typeof displayEntryPrice === 'number' &&
432
+ Number.isFinite(displayEntryPrice)
433
+ ? order.qty * displayEntryPrice
434
+ : null;
435
+
436
+ return {
437
+ id: order.orderId,
438
+ title: order.symbol,
439
+ reference: formatOrderReference(order.orderId),
440
+ period: {
441
+ start: order.entryTimestamp,
442
+ end: order.status === 'active' ? null : order.exitTimestamp,
443
+ durationHours: order.durationHours,
444
+ },
445
+ direction: order.direction,
446
+ status: order.status,
447
+ statusLabel: formatExitType(order).toUpperCase(),
448
+ statusColor: order.status === 'active' ? 'orange' : 'gray',
449
+ pnl: order.pnl,
450
+ accentColor: getOrderAccentColor(order),
451
+ metrics: [
452
+ {
453
+ title: 'Entry',
454
+ value: formatPriceUsdt(displayEntryPrice),
455
+ detail:
456
+ order.actualEntryPrice == null
457
+ ? 'actual n/a'
458
+ : `plan ${formatPriceUsdt(order.entryPrice)} / slip ${formatPercent(order.entrySlippagePercent, { signed: true })}`,
459
+ },
460
+ {
461
+ title: order.status === 'active' ? 'Current' : 'Exit',
462
+ value: formatPriceUsdt(displayExitPrice),
463
+ detailLines:
464
+ order.status === 'active'
465
+ ? [
466
+ `TP ${formatPriceUsdt(order.takeProfitPrice)} (${formatPercent(order.takeProfitPercent, { signed: true })})`,
467
+ `SL ${formatPriceUsdt(order.stopLossPrice)} (${formatPercent(order.stopLossPercent)})`,
468
+ ]
469
+ : [
470
+ `slip ${formatPercent(order.exitSlippagePercent, { signed: true })}`,
471
+ ],
472
+ },
473
+ {
474
+ title: 'Notional',
475
+ value: formatUsdt(notional),
476
+ },
477
+ {
478
+ title: 'Fees',
479
+ value: formatFee(order.totalFee),
480
+ detailLines: [
481
+ `open ${formatFee(order.openFee)}`,
482
+ `close ${formatFee(order.closeFee)}`,
483
+ `funding ${formatFee(order.fundingFee)}`,
484
+ ],
485
+ },
486
+ {
487
+ title: 'Qty',
488
+ value: formatCompactNumber(order.qty),
489
+ },
490
+ ],
491
+ };
492
+ };
493
+
494
+ export const buildRuntimeStrategyCardViewModel = (
495
+ strategy: RuntimeStrategyView,
496
+ ) => {
497
+ const symbolPnlRanking = buildRuntimeSymbolPnlRanking(strategy.orders);
498
+ const firstPoint = strategy.orderLog[0];
499
+ const lastPoint = strategy.orderLog[strategy.orderLog.length - 1];
500
+
501
+ return {
502
+ lastTrade: strategy.recentTrades[0],
503
+ runtimeOrders: strategy.orders.map(mapRuntimeOrder),
504
+ runtimeOrderSummaryItems: getRuntimeOrdersSummaryItems(strategy.orders),
505
+ drawerMetrics: buildRuntimeDrawerMetrics(strategy),
506
+ performance: buildStrategyPerformanceViewModel(strategy.orderLog),
507
+ symbolPnlRanking,
508
+ symbolConcentration: buildRuntimeSymbolConcentration(strategy.orders).slice(
509
+ 0,
510
+ 8,
511
+ ),
512
+ topSymbolPnlRanking: [...symbolPnlRanking]
513
+ .sort(
514
+ (left, right) =>
515
+ right.pnl - left.pnl || left.symbol.localeCompare(right.symbol),
516
+ )
517
+ .slice(0, 10),
518
+ worstSymbolPnlRanking: [...symbolPnlRanking]
519
+ .sort(
520
+ (left, right) =>
521
+ left.pnl - right.pnl || left.symbol.localeCompare(right.symbol),
522
+ )
523
+ .slice(0, 10),
524
+ symbolRankingMaxAbsPnl: Math.max(
525
+ ...symbolPnlRanking.map((rank) => Math.abs(rank.pnl)),
526
+ 1,
527
+ ),
528
+ directionStats: buildRuntimeDirectionStats(strategy.orders),
529
+ advancedMetrics: calculateAdvancedTradeMetrics({
530
+ trades: buildRuntimeAdvancedTrades(strategy.orders),
531
+ orderLog: strategy.orderLog,
532
+ startTimestamp: firstPoint?.[0] ?? null,
533
+ endTimestamp: lastPoint?.[0] ?? null,
534
+ }),
535
+ };
536
+ };