@tradejs/app 3.0.1 → 3.1.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.
@@ -1,1107 +0,0 @@
1
- import { endOfMonth, addMonths, startOfMonth } from 'date-fns';
2
- import { INITIAL_BACKTEST_AMOUNT } from '@tradejs/core/constants';
3
- import {
4
- normalizeStrategyOrderLinkKey,
5
- parseStrategyOrderLinkKey,
6
- } from '@tradejs/core/trade';
7
- import type {
8
- ExchangeEntryRecord,
9
- PositionPnlSnapshot,
10
- RuntimeTradeRecord,
11
- SimpleOrderLogData,
12
- TestStat,
13
- } from '@tradejs/types';
14
- import type { RuntimeStrategyTradeView } from './runtimeStrategyContracts';
15
- export type {
16
- RuntimeStrategiesResponse,
17
- RuntimeStrategyTradeSummary,
18
- RuntimeStrategyTradeView,
19
- RuntimeStrategyView,
20
- } from './runtimeStrategyContracts';
21
- import {
22
- takeExactClosedPnlMatch,
23
- type ClosedPnlRecordWithOrderLinkId,
24
- } from './runtimeTradeReconciliation';
25
- export {
26
- assignLegacyRuntimeTradeAccountScopes,
27
- buildRuntimeStrategyAiGateChanges,
28
- buildRuntimeStrategyIdentityKey,
29
- buildRuntimeStrategyMaxLossValueTimeline,
30
- getRuntimeStrategyAiGateObservedFrom,
31
- isRuntimeStrategyLineageScope,
32
- } from './runtimeStrategyLineage';
33
- export type {
34
- RuntimeStrategyAccountScope,
35
- RuntimeStrategyAiGateChange,
36
- RuntimeStrategyLineageScope,
37
- RuntimeStrategyMaxLossValueChange,
38
- RuntimeStrategyMaxLossValueTimeline,
39
- } from './runtimeStrategyLineage';
40
- export { takeClosedPnlMatch } from './runtimeTradeReconciliation';
41
-
42
- const MS_IN_DAY = 24 * 60 * 60 * 1000;
43
- const AVG_DAYS_IN_MONTH = 30.4375;
44
-
45
- type RuntimeTradeWithResolvedPnl = RuntimeTradeRecord & {
46
- resolvedPnl: number;
47
- resolvedTimestamp: number;
48
- };
49
-
50
- const roundValue = (value: number, digits = 2) => {
51
- if (!Number.isFinite(value)) {
52
- return 0;
53
- }
54
-
55
- const factor = 10 ** digits;
56
- return Math.round(value * factor) / factor;
57
- };
58
-
59
- const toNonEmptyString = (value: unknown) =>
60
- typeof value === 'string' && value.trim() ? value.trim() : null;
61
-
62
- const toFiniteNumberOrNull = (value: unknown) =>
63
- typeof value === 'number' && Number.isFinite(value) ? value : null;
64
-
65
- const getTradePnl = (trade: RuntimeTradeRecord) =>
66
- trade.status === 'closed'
67
- ? trade.closedPnl ?? trade.currentPnl ?? null
68
- : trade.currentPnl ?? null;
69
-
70
- const getTradeLevelPercent = ({
71
- direction,
72
- entryPrice,
73
- levelPrice,
74
- kind,
75
- }: {
76
- direction: RuntimeTradeRecord['direction'];
77
- entryPrice: number;
78
- levelPrice: unknown;
79
- kind: 'takeProfit' | 'stopLoss';
80
- }) => {
81
- if (
82
- typeof levelPrice !== 'number' ||
83
- !Number.isFinite(levelPrice) ||
84
- !Number.isFinite(entryPrice) ||
85
- entryPrice <= 0
86
- ) {
87
- return null;
88
- }
89
-
90
- const rawPercent =
91
- direction === 'LONG'
92
- ? ((levelPrice - entryPrice) / entryPrice) * 100
93
- : ((entryPrice - levelPrice) / entryPrice) * 100;
94
- const percent = kind === 'stopLoss' ? Math.abs(rawPercent) : rawPercent;
95
-
96
- return Number.isFinite(percent) ? roundValue(percent) : null;
97
- };
98
-
99
- const getPriceSlippagePercent = ({
100
- expectedPrice,
101
- actualPrice,
102
- }: {
103
- expectedPrice: unknown;
104
- actualPrice: unknown;
105
- }) => {
106
- if (
107
- typeof expectedPrice !== 'number' ||
108
- !Number.isFinite(expectedPrice) ||
109
- expectedPrice <= 0 ||
110
- typeof actualPrice !== 'number' ||
111
- !Number.isFinite(actualPrice)
112
- ) {
113
- return null;
114
- }
115
-
116
- return roundValue(((actualPrice - expectedPrice) / expectedPrice) * 100, 4);
117
- };
118
-
119
- const getExpectedExitPrice = (trade: RuntimeTradeRecord) => {
120
- if (trade.exitType === 'tp') {
121
- return toFiniteNumberOrNull(trade.aiAnalysis?.takeProfitPrice);
122
- }
123
-
124
- if (trade.exitType === 'sl') {
125
- return toFiniteNumberOrNull(trade.aiAnalysis?.stopLossPrice);
126
- }
127
-
128
- return null;
129
- };
130
-
131
- const getTradeDurationHours = (trade: RuntimeTradeRecord, endTime: number) => {
132
- const resolvedTimestamp = getTradeResolvedTimestamp(trade, endTime);
133
- if (
134
- !Number.isFinite(trade.entryTimestamp) ||
135
- !Number.isFinite(resolvedTimestamp) ||
136
- resolvedTimestamp < trade.entryTimestamp
137
- ) {
138
- return null;
139
- }
140
-
141
- return roundValue((resolvedTimestamp - trade.entryTimestamp) / 3_600_000, 2);
142
- };
143
-
144
- const getTradeTotalFee = (trade: RuntimeTradeRecord) => {
145
- const explicitTotal = toFiniteNumberOrNull(trade.totalFee);
146
- if (explicitTotal != null) {
147
- return explicitTotal;
148
- }
149
-
150
- const fees = [trade.openFee, trade.closeFee, trade.fundingFee]
151
- .map(toFiniteNumberOrNull)
152
- .filter((fee): fee is number => fee != null);
153
-
154
- return fees.length
155
- ? Number(fees.reduce((sum, fee) => sum + fee, 0).toFixed(12))
156
- : null;
157
- };
158
-
159
- const buildRiskLevelsAnalysis = ({
160
- takeProfitPrice,
161
- stopLossPrice,
162
- }: {
163
- takeProfitPrice?: number | null;
164
- stopLossPrice?: number | null;
165
- }): RuntimeTradeRecord['aiAnalysis'] | null => {
166
- const resolvedTakeProfitPrice = toFiniteNumberOrNull(takeProfitPrice);
167
- const resolvedStopLossPrice = toFiniteNumberOrNull(stopLossPrice);
168
-
169
- if (resolvedTakeProfitPrice == null && resolvedStopLossPrice == null) {
170
- return null;
171
- }
172
-
173
- return {
174
- ...(resolvedTakeProfitPrice != null
175
- ? { takeProfitPrice: resolvedTakeProfitPrice }
176
- : {}),
177
- ...(resolvedStopLossPrice != null
178
- ? { stopLossPrice: resolvedStopLossPrice }
179
- : {}),
180
- };
181
- };
182
-
183
- const getTradeResolvedTimestamp = (
184
- trade: RuntimeTradeRecord,
185
- endTime: number,
186
- ) => {
187
- if (
188
- typeof trade.exitTimestamp === 'number' &&
189
- Number.isFinite(trade.exitTimestamp)
190
- ) {
191
- return trade.exitTimestamp;
192
- }
193
-
194
- return endTime;
195
- };
196
-
197
- const resolveTradesWithKnownPnl = (
198
- trades: RuntimeTradeRecord[],
199
- endTime: number,
200
- ): RuntimeTradeWithResolvedPnl[] =>
201
- trades
202
- .map((trade) => {
203
- const pnl = getTradePnl(trade);
204
- const resolvedTimestamp = getTradeResolvedTimestamp(trade, endTime);
205
-
206
- if (
207
- typeof pnl !== 'number' ||
208
- !Number.isFinite(pnl) ||
209
- !Number.isFinite(resolvedTimestamp)
210
- ) {
211
- return null;
212
- }
213
-
214
- return {
215
- ...trade,
216
- resolvedPnl: pnl,
217
- resolvedTimestamp: Math.max(trade.entryTimestamp, resolvedTimestamp),
218
- };
219
- })
220
- .filter((trade): trade is RuntimeTradeWithResolvedPnl => trade != null)
221
- .sort((left, right) => {
222
- if (left.resolvedTimestamp !== right.resolvedTimestamp) {
223
- return left.resolvedTimestamp - right.resolvedTimestamp;
224
- }
225
-
226
- return left.entryTimestamp - right.entryTimestamp;
227
- });
228
-
229
- const calculateMaxDrawdown = (amounts: number[]) => {
230
- if (!amounts.length) {
231
- return 0;
232
- }
233
-
234
- let peak = amounts[0];
235
- let maxDrawdown = 0;
236
-
237
- for (const amount of amounts) {
238
- if (amount > peak) {
239
- peak = amount;
240
- }
241
-
242
- if (peak <= 0) {
243
- continue;
244
- }
245
-
246
- const drawdown = ((peak - amount) / peak) * 100;
247
- if (drawdown > maxDrawdown) {
248
- maxDrawdown = drawdown;
249
- }
250
- }
251
-
252
- return roundValue(maxDrawdown);
253
- };
254
-
255
- const calculateSharpeRatio = (
256
- orderLog: SimpleOrderLogData,
257
- startTime: number,
258
- endTime: number,
259
- ) => {
260
- if (!orderLog.length || endTime <= startTime) {
261
- return null;
262
- }
263
-
264
- const points = [...orderLog]
265
- .map(([timestamp, amount]) => ({ ts: timestamp, amount }))
266
- .sort((left, right) => left.ts - right.ts);
267
-
268
- const eomSeries: number[] = [];
269
- let pointIndex = 0;
270
- let monthCursor = startOfMonth(new Date(startTime));
271
- const lastMonth = endOfMonth(new Date(endTime));
272
- let lastAmount = points[0]?.amount ?? INITIAL_BACKTEST_AMOUNT;
273
-
274
- while (monthCursor <= lastMonth) {
275
- const eomTs = endOfMonth(monthCursor).getTime();
276
-
277
- while (pointIndex < points.length && points[pointIndex].ts <= eomTs) {
278
- lastAmount = points[pointIndex].amount;
279
- pointIndex += 1;
280
- }
281
-
282
- eomSeries.push(lastAmount);
283
- monthCursor = addMonths(monthCursor, 1);
284
- }
285
-
286
- if (eomSeries.length < 2) {
287
- return null;
288
- }
289
-
290
- const monthlyReturns: number[] = [];
291
- for (let index = 1; index < eomSeries.length; index += 1) {
292
- const previous = eomSeries[index - 1];
293
- const current = eomSeries[index];
294
- monthlyReturns.push(previous > 0 ? current / previous - 1 : 0);
295
- }
296
-
297
- if (!monthlyReturns.length) {
298
- return null;
299
- }
300
-
301
- const mean =
302
- monthlyReturns.reduce((sum, value) => sum + value, 0) /
303
- monthlyReturns.length;
304
- const variance =
305
- monthlyReturns.reduce((sum, value) => sum + (value - mean) ** 2, 0) /
306
- monthlyReturns.length;
307
- const std = Math.sqrt(variance);
308
-
309
- if (!Number.isFinite(std) || std === 0) {
310
- return null;
311
- }
312
-
313
- return roundValue((mean / std) * Math.sqrt(12));
314
- };
315
-
316
- const calculateExposurePercent = (
317
- trades: RuntimeTradeRecord[],
318
- startTime: number,
319
- endTime: number,
320
- ) => {
321
- if (endTime <= startTime) {
322
- return 0;
323
- }
324
-
325
- const intervals = trades
326
- .map((trade) => ({
327
- start: Math.max(startTime, trade.entryTimestamp),
328
- end: Math.min(endTime, getTradeResolvedTimestamp(trade, endTime)),
329
- }))
330
- .filter((interval) => interval.end > interval.start)
331
- .sort((left, right) => left.start - right.start);
332
-
333
- if (!intervals.length) {
334
- return 0;
335
- }
336
-
337
- const merged: Array<{ start: number; end: number }> = [];
338
-
339
- for (const interval of intervals) {
340
- const last = merged[merged.length - 1];
341
-
342
- if (!last || interval.start > last.end) {
343
- merged.push({ ...interval });
344
- continue;
345
- }
346
-
347
- last.end = Math.max(last.end, interval.end);
348
- }
349
-
350
- const coveredMs = merged.reduce(
351
- (sum, interval) => sum + (interval.end - interval.start),
352
- 0,
353
- );
354
-
355
- return roundValue((coveredMs / (endTime - startTime)) * 100);
356
- };
357
-
358
- const calculateStreaks = (pnls: number[]) => {
359
- let currentWins = 0;
360
- let currentLosses = 0;
361
- let maxWins = 0;
362
- let maxLosses = 0;
363
-
364
- for (const pnl of pnls) {
365
- if (pnl > 0) {
366
- currentWins += 1;
367
- currentLosses = 0;
368
- maxWins = Math.max(maxWins, currentWins);
369
- continue;
370
- }
371
-
372
- if (pnl < 0) {
373
- currentLosses += 1;
374
- currentWins = 0;
375
- maxLosses = Math.max(maxLosses, currentLosses);
376
- continue;
377
- }
378
-
379
- currentWins = 0;
380
- currentLosses = 0;
381
- }
382
-
383
- return {
384
- maxConsecutiveWins: maxWins,
385
- maxConsecutiveLosses: maxLosses,
386
- };
387
- };
388
-
389
- const calculateSymbolConcentration = (
390
- trades: RuntimeTradeWithResolvedPnl[],
391
- limit: number,
392
- ) => {
393
- const totals = new Map<string, number>();
394
-
395
- for (const trade of trades) {
396
- totals.set(
397
- trade.symbol,
398
- (totals.get(trade.symbol) ?? 0) + Math.abs(trade.resolvedPnl),
399
- );
400
- }
401
-
402
- const totalAbsPnl = [...totals.values()].reduce(
403
- (sum, value) => sum + value,
404
- 0,
405
- );
406
- if (totalAbsPnl <= 0) {
407
- return null;
408
- }
409
-
410
- const topAbsPnl = [...totals.values()]
411
- .sort((left, right) => right - left)
412
- .slice(0, limit)
413
- .reduce((sum, value) => sum + value, 0);
414
-
415
- return roundValue((topAbsPnl / totalAbsPnl) * 100);
416
- };
417
-
418
- const createEmptyRuntimeStat = (
419
- startTime: number,
420
- endTime: number,
421
- ): TestStat => {
422
- const periodDays = Math.max(0, (endTime - startTime) / MS_IN_DAY);
423
- const periodMonths = periodDays / AVG_DAYS_IN_MONTH;
424
-
425
- return {
426
- periodDays: roundValue(periodDays),
427
- periodMonths: roundValue(periodMonths),
428
- orders: 0,
429
- wins: 0,
430
- losses: 0,
431
- ordersPerMonth: 0,
432
- exposure: 0,
433
- amount: INITIAL_BACKTEST_AMOUNT,
434
- maxAmount: INITIAL_BACKTEST_AMOUNT,
435
- minAmount: INITIAL_BACKTEST_AMOUNT,
436
- netProfit: 0,
437
- totalReturn: 0,
438
- cagr: 0,
439
- maxDrawdown: 0,
440
- calmar: null,
441
- winRate: 0,
442
- riskRewardRatio: null,
443
- expectancy: 0,
444
- maxConsecutiveWins: 0,
445
- maxConsecutiveLosses: 0,
446
- sharpeRatio: null,
447
- score: 0,
448
- };
449
- };
450
-
451
- const buildStrategyNameKeyMap = (strategyNames: string[]) => {
452
- const map = new Map<string, string>();
453
-
454
- for (const strategyName of strategyNames) {
455
- const key = normalizeStrategyOrderLinkKey(strategyName);
456
-
457
- if (key && !map.has(key)) {
458
- map.set(key, strategyName);
459
- }
460
- }
461
-
462
- return map;
463
- };
464
-
465
- export const resolveStrategyNameByOrderLinkId = ({
466
- orderLinkId,
467
- strategyNames,
468
- }: {
469
- orderLinkId: string | null | undefined;
470
- strategyNames: string[];
471
- }) => {
472
- const strategyKey = parseStrategyOrderLinkKey(orderLinkId);
473
-
474
- if (!strategyKey) {
475
- return null;
476
- }
477
-
478
- return buildStrategyNameKeyMap(strategyNames).get(strategyKey) ?? null;
479
- };
480
-
481
- export const isRuntimeTradeRecord = (
482
- value: unknown,
483
- ): value is RuntimeTradeRecord => {
484
- if (!value || typeof value !== 'object') {
485
- return false;
486
- }
487
-
488
- const record = value as Record<string, unknown>;
489
- return (
490
- typeof record.orderId === 'string' &&
491
- typeof record.strategy === 'string' &&
492
- typeof record.symbol === 'string' &&
493
- typeof record.entryTimestamp === 'number' &&
494
- typeof record.entryPrice === 'number' &&
495
- typeof record.qty === 'number'
496
- );
497
- };
498
-
499
- export const selectTradesForWindow = (
500
- trades: RuntimeTradeRecord[],
501
- startTime: number,
502
- activeOrderIds: Set<string> = new Set(),
503
- ) =>
504
- trades.filter((trade) => {
505
- if (trade.status === 'active') {
506
- if (activeOrderIds.has(trade.orderId)) {
507
- return true;
508
- }
509
-
510
- return trade.entryTimestamp >= startTime;
511
- }
512
-
513
- const exitTimestamp =
514
- typeof trade.exitTimestamp === 'number' ? trade.exitTimestamp : 0;
515
-
516
- return trade.entryTimestamp >= startTime || exitTimestamp >= startTime;
517
- });
518
-
519
- export const buildRuntimeStrategyAnalytics = ({
520
- trades,
521
- startTime,
522
- endTime,
523
- }: {
524
- trades: RuntimeTradeRecord[];
525
- startTime: number;
526
- endTime: number;
527
- }) => {
528
- const resolvedTrades = resolveTradesWithKnownPnl(trades, endTime);
529
- const orderLog: SimpleOrderLogData = [[startTime, INITIAL_BACKTEST_AMOUNT]];
530
- const tradePnls = resolvedTrades.map((trade) => trade.resolvedPnl);
531
- let runningAmount = INITIAL_BACKTEST_AMOUNT;
532
-
533
- for (const trade of resolvedTrades) {
534
- runningAmount = roundValue(runningAmount + trade.resolvedPnl);
535
- orderLog.push([trade.resolvedTimestamp, runningAmount]);
536
- }
537
-
538
- if (orderLog[orderLog.length - 1]?.[0] !== endTime) {
539
- orderLog.push([endTime, runningAmount]);
540
- }
541
-
542
- const amounts = orderLog.map(([, amount]) => amount);
543
- const wins = tradePnls.filter((pnl) => pnl > 0).length;
544
- const losses = tradePnls.filter((pnl) => pnl < 0).length;
545
- const averageWin =
546
- wins > 0
547
- ? tradePnls.filter((pnl) => pnl > 0).reduce((sum, pnl) => sum + pnl, 0) /
548
- wins
549
- : 0;
550
- const averageLossAbs =
551
- losses > 0
552
- ? Math.abs(
553
- tradePnls
554
- .filter((pnl) => pnl < 0)
555
- .reduce((sum, pnl) => sum + pnl, 0) / losses,
556
- )
557
- : 0;
558
- const returnSeries: number[] = [];
559
- let amountBeforeTrade = INITIAL_BACKTEST_AMOUNT;
560
-
561
- for (const trade of resolvedTrades) {
562
- returnSeries.push(
563
- amountBeforeTrade > 0 ? trade.resolvedPnl / amountBeforeTrade : 0,
564
- );
565
- amountBeforeTrade += trade.resolvedPnl;
566
- }
567
-
568
- const periodDays = Math.max(0, (endTime - startTime) / MS_IN_DAY);
569
- const periodMonths = periodDays / AVG_DAYS_IN_MONTH;
570
- const amount = amounts[amounts.length - 1] ?? INITIAL_BACKTEST_AMOUNT;
571
- const netProfit = amount - INITIAL_BACKTEST_AMOUNT;
572
- const totalReturn =
573
- INITIAL_BACKTEST_AMOUNT > 0
574
- ? ((amount - INITIAL_BACKTEST_AMOUNT) / INITIAL_BACKTEST_AMOUNT) * 100
575
- : 0;
576
- const cagr =
577
- periodMonths > 0
578
- ? (Math.pow(amount / INITIAL_BACKTEST_AMOUNT, 12 / periodMonths) - 1) *
579
- 100
580
- : 0;
581
- const maxDrawdown = calculateMaxDrawdown(amounts);
582
- const calmar = maxDrawdown > 0 ? cagr / maxDrawdown : null;
583
- const riskRewardRatio =
584
- averageLossAbs > 0 ? averageWin / averageLossAbs : null;
585
- const expectancy =
586
- returnSeries.length > 0
587
- ? roundValue(
588
- (returnSeries.reduce((sum, value) => sum + value, 0) /
589
- returnSeries.length) *
590
- 100,
591
- )
592
- : 0;
593
- const sharpeRatio = calculateSharpeRatio(orderLog, startTime, endTime);
594
- const exposure = calculateExposurePercent(trades, startTime, endTime);
595
- const { maxConsecutiveWins, maxConsecutiveLosses } =
596
- calculateStreaks(tradePnls);
597
- const symbolConcentrationTop1 = calculateSymbolConcentration(
598
- resolvedTrades,
599
- 1,
600
- );
601
- const symbolConcentrationTop5 = calculateSymbolConcentration(
602
- resolvedTrades,
603
- 5,
604
- );
605
-
606
- const stat: TestStat =
607
- trades.length === 0
608
- ? createEmptyRuntimeStat(startTime, endTime)
609
- : {
610
- periodDays: roundValue(periodDays),
611
- periodMonths: roundValue(periodMonths),
612
- orders: trades.length,
613
- wins,
614
- losses,
615
- ordersPerMonth:
616
- periodMonths > 0 ? roundValue(trades.length / periodMonths) : 0,
617
- exposure,
618
- amount: roundValue(amount),
619
- maxAmount: roundValue(Math.max(...amounts)),
620
- minAmount: roundValue(Math.min(...amounts)),
621
- netProfit: roundValue(netProfit),
622
- totalReturn: roundValue(totalReturn),
623
- cagr: roundValue(cagr),
624
- maxDrawdown,
625
- calmar: calmar == null ? null : roundValue(calmar),
626
- winRate:
627
- trades.length > 0 ? roundValue((wins / trades.length) * 100) : 0,
628
- riskRewardRatio:
629
- riskRewardRatio == null ? null : roundValue(riskRewardRatio),
630
- expectancy,
631
- maxConsecutiveWins,
632
- maxConsecutiveLosses,
633
- sharpeRatio,
634
- score: 0,
635
- };
636
-
637
- const activeTrades = trades.filter((trade) => trade.status === 'active');
638
- const closedTrades = trades.filter((trade) => trade.status === 'closed');
639
- const activePnl = roundValue(
640
- activeTrades.reduce(
641
- (sum, trade) =>
642
- sum +
643
- (typeof trade.currentPnl === 'number' &&
644
- Number.isFinite(trade.currentPnl)
645
- ? trade.currentPnl
646
- : 0),
647
- 0,
648
- ),
649
- );
650
- const closedPnl = roundValue(
651
- closedTrades.reduce(
652
- (sum, trade) =>
653
- sum +
654
- (typeof trade.closedPnl === 'number' && Number.isFinite(trade.closedPnl)
655
- ? trade.closedPnl
656
- : typeof trade.currentPnl === 'number' &&
657
- Number.isFinite(trade.currentPnl)
658
- ? trade.currentPnl
659
- : 0),
660
- 0,
661
- ),
662
- );
663
-
664
- return {
665
- orderLog,
666
- stat,
667
- summary: {
668
- totalTrades: trades.length,
669
- activeTrades: activeTrades.length,
670
- closedTrades: closedTrades.length,
671
- wins,
672
- losses,
673
- activePnl,
674
- closedPnl,
675
- totalPnl: roundValue(activePnl + closedPnl),
676
- symbolConcentrationTop1,
677
- symbolConcentrationTop5,
678
- },
679
- };
680
- };
681
-
682
- export const toRuntimeTradeView = (
683
- trade: RuntimeTradeRecord,
684
- endTime = Date.now(),
685
- ): RuntimeStrategyTradeView => ({
686
- orderId: trade.orderId,
687
- symbol: trade.symbol,
688
- direction: trade.direction,
689
- status: trade.status,
690
- qty: trade.qty,
691
- entryTimestamp: trade.entryTimestamp,
692
- entryPrice: trade.entryPrice,
693
- actualEntryPrice: toFiniteNumberOrNull(trade.actualEntryPrice),
694
- exitTimestamp:
695
- typeof trade.exitTimestamp === 'number' ? trade.exitTimestamp : null,
696
- exitPrice: typeof trade.exitPrice === 'number' ? trade.exitPrice : null,
697
- actualExitPrice: toFiniteNumberOrNull(trade.actualExitPrice),
698
- currentPrice: toFiniteNumberOrNull(trade.currentPrice),
699
- pnl: getTradePnl(trade),
700
- durationHours: getTradeDurationHours(trade, endTime),
701
- entrySlippagePercent: getPriceSlippagePercent({
702
- expectedPrice: trade.entryPrice,
703
- actualPrice: trade.actualEntryPrice,
704
- }),
705
- exitSlippagePercent: getPriceSlippagePercent({
706
- expectedPrice: getExpectedExitPrice(trade),
707
- actualPrice: trade.actualExitPrice ?? trade.exitPrice,
708
- }),
709
- exitType: trade.exitType ?? null,
710
- takeProfitPrice: toFiniteNumberOrNull(trade.aiAnalysis?.takeProfitPrice),
711
- stopLossPrice: toFiniteNumberOrNull(trade.aiAnalysis?.stopLossPrice),
712
- takeProfitPercent: getTradeLevelPercent({
713
- direction: trade.direction,
714
- entryPrice: trade.entryPrice,
715
- levelPrice: trade.aiAnalysis?.takeProfitPrice,
716
- kind: 'takeProfit',
717
- }),
718
- stopLossPercent: getTradeLevelPercent({
719
- direction: trade.direction,
720
- entryPrice: trade.entryPrice,
721
- levelPrice: trade.aiAnalysis?.stopLossPrice,
722
- kind: 'stopLoss',
723
- }),
724
- openFee: toFiniteNumberOrNull(trade.openFee),
725
- closeFee: toFiniteNumberOrNull(trade.closeFee),
726
- fundingFee: toFiniteNumberOrNull(trade.fundingFee),
727
- totalFee: getTradeTotalFee(trade),
728
- lastSyncedAt:
729
- typeof trade.lastSyncedAt === 'number' ? trade.lastSyncedAt : null,
730
- });
731
-
732
- const removeClosedPnlFromExactMaps = ({
733
- exactByOrderLinkId,
734
- exactByOrderId,
735
- row,
736
- }: {
737
- exactByOrderLinkId: Map<string, ClosedPnlRecordWithOrderLinkId>;
738
- exactByOrderId: Map<string, ClosedPnlRecordWithOrderLinkId>;
739
- row: ClosedPnlRecordWithOrderLinkId;
740
- }) => {
741
- if (row.orderLinkId) {
742
- exactByOrderLinkId.delete(row.orderLinkId);
743
- }
744
-
745
- if (row.orderId) {
746
- exactByOrderId.delete(row.orderId);
747
- }
748
- };
749
-
750
- const takeClosedPnlMatchForExchangeEntry = ({
751
- exactByOrderLinkId,
752
- exactByOrderId,
753
- symbolBuckets,
754
- entry,
755
- }: {
756
- exactByOrderLinkId: Map<string, ClosedPnlRecordWithOrderLinkId>;
757
- exactByOrderId: Map<string, ClosedPnlRecordWithOrderLinkId>;
758
- symbolBuckets: Map<string, ClosedPnlRecordWithOrderLinkId[]>;
759
- entry: ExchangeEntryRecord;
760
- }) => {
761
- const exactMatch = takeExactClosedPnlMatch({
762
- exactByOrderLinkId,
763
- exactByOrderId,
764
- symbolBuckets,
765
- orderLinkId: entry.orderLinkId,
766
- orderId: entry.orderId,
767
- });
768
-
769
- if (exactMatch) {
770
- return exactMatch;
771
- }
772
-
773
- const rows = symbolBuckets.get(entry.symbol);
774
- if (!rows?.length) {
775
- return null;
776
- }
777
-
778
- const minimumClosedAt = entry.entryTimestamp - 5 * 60_000;
779
- const matchIndex = rows.reduce((bestIndex, row, index) => {
780
- if (
781
- !Number.isFinite(row.closedAt) ||
782
- row.closedAt < minimumClosedAt ||
783
- (row.direction && row.direction !== entry.direction)
784
- ) {
785
- return bestIndex;
786
- }
787
-
788
- if (bestIndex < 0) {
789
- return index;
790
- }
791
-
792
- const best = rows[bestIndex];
793
- return row.closedAt < best.closedAt ? index : bestIndex;
794
- }, -1);
795
-
796
- if (matchIndex < 0) {
797
- return null;
798
- }
799
-
800
- const [row] = rows.splice(matchIndex, 1);
801
- if (row) {
802
- removeClosedPnlFromExactMaps({
803
- exactByOrderLinkId,
804
- exactByOrderId,
805
- row,
806
- });
807
- }
808
-
809
- return row ?? null;
810
- };
811
-
812
- const aggregateExchangeEntriesByOrder = (entryRows: ExchangeEntryRecord[]) => {
813
- const grouped = new Map<
814
- string,
815
- ExchangeEntryRecord & {
816
- _qtyForPricing: number;
817
- _notionalForPricing: number;
818
- }
819
- >();
820
-
821
- entryRows.forEach((entry, index) => {
822
- const orderLinkId = toNonEmptyString(entry.orderLinkId);
823
- const orderId = toNonEmptyString(entry.orderId);
824
- const groupKey =
825
- orderLinkId ||
826
- orderId ||
827
- `${entry.symbol}:${entry.direction}:${entry.entryTimestamp}:${index}`;
828
- const existing = grouped.get(groupKey);
829
-
830
- if (!existing) {
831
- grouped.set(groupKey, {
832
- ...entry,
833
- qty: Number.isFinite(entry.qty) ? entry.qty : 0,
834
- _qtyForPricing:
835
- Number.isFinite(entry.qty) &&
836
- Number.isFinite(entry.entryPrice) &&
837
- entry.entryPrice != null
838
- ? entry.qty
839
- : 0,
840
- _notionalForPricing:
841
- Number.isFinite(entry.qty) &&
842
- Number.isFinite(entry.entryPrice) &&
843
- entry.entryPrice != null
844
- ? entry.qty * entry.entryPrice
845
- : 0,
846
- });
847
- return;
848
- }
849
-
850
- existing.qty += Number.isFinite(entry.qty) ? entry.qty : 0;
851
- existing.entryTimestamp = Math.min(
852
- existing.entryTimestamp,
853
- entry.entryTimestamp,
854
- );
855
-
856
- if (
857
- Number.isFinite(entry.qty) &&
858
- Number.isFinite(entry.entryPrice) &&
859
- entry.entryPrice != null
860
- ) {
861
- existing._qtyForPricing += entry.qty;
862
- existing._notionalForPricing += entry.qty * entry.entryPrice;
863
- }
864
- });
865
-
866
- return [...grouped.values()]
867
- .map(({ _qtyForPricing, _notionalForPricing, ...entry }) => ({
868
- ...entry,
869
- qty: roundValue(entry.qty, 8),
870
- entryPrice:
871
- _qtyForPricing > 0
872
- ? roundValue(_notionalForPricing / _qtyForPricing, 8)
873
- : null,
874
- }))
875
- .sort((left, right) => left.entryTimestamp - right.entryTimestamp);
876
- };
877
-
878
- export const buildExchangeFallbackRuntimeTrades = ({
879
- entryRows,
880
- closedPnlRows,
881
- openPositions,
882
- strategyNames,
883
- existingTrades,
884
- endTime,
885
- }: {
886
- entryRows: ExchangeEntryRecord[];
887
- closedPnlRows: ClosedPnlRecordWithOrderLinkId[];
888
- openPositions: PositionPnlSnapshot[];
889
- strategyNames: string[];
890
- existingTrades: RuntimeTradeRecord[];
891
- endTime: number;
892
- }) => {
893
- if (!entryRows.length && !closedPnlRows.length) {
894
- return [];
895
- }
896
-
897
- const strategyNameByOrderId = new Map(
898
- existingTrades
899
- .filter(
900
- (trade): trade is RuntimeTradeRecord & { strategy: string } =>
901
- typeof trade.orderId === 'string' &&
902
- trade.orderId.trim().length > 0 &&
903
- typeof trade.strategy === 'string' &&
904
- trade.strategy.trim().length > 0,
905
- )
906
- .map((trade) => [trade.orderId, trade.strategy]),
907
- );
908
- const strategyNamesPool = [
909
- ...new Set([
910
- ...strategyNames,
911
- ...existingTrades.map((trade) => trade.strategy).filter(Boolean),
912
- ]),
913
- ];
914
- const openPositionBySymbol = new Map(
915
- openPositions.map((position) => [position.symbol, position]),
916
- );
917
- const existingOrderIds = new Set(
918
- existingTrades
919
- .map((trade) => toNonEmptyString(trade.orderId))
920
- .filter((value): value is string => value != null),
921
- );
922
- const exactByOrderLinkId = new Map(
923
- closedPnlRows
924
- .filter(
925
- (
926
- row,
927
- ): row is ClosedPnlRecordWithOrderLinkId & { orderLinkId: string } =>
928
- typeof row.orderLinkId === 'string' && row.orderLinkId.length > 0,
929
- )
930
- .map((row) => [row.orderLinkId, row]),
931
- );
932
- const exactByOrderId = new Map(
933
- closedPnlRows
934
- .filter(
935
- (row): row is ClosedPnlRecordWithOrderLinkId & { orderId: string } =>
936
- typeof row.orderId === 'string' && row.orderId.length > 0,
937
- )
938
- .map((row) => [row.orderId, row]),
939
- );
940
- const symbolBuckets = new Map<string, ClosedPnlRecordWithOrderLinkId[]>();
941
-
942
- for (const row of closedPnlRows) {
943
- const bucket = symbolBuckets.get(row.symbol) ?? [];
944
- bucket.push(row);
945
- symbolBuckets.set(row.symbol, bucket);
946
- }
947
-
948
- const fallbackTrades = aggregateExchangeEntriesByOrder(entryRows)
949
- .map<RuntimeTradeRecord | null>((entry) => {
950
- const normalizedOrderLinkId = toNonEmptyString(entry.orderLinkId);
951
- const normalizedOrderId = toNonEmptyString(entry.orderId);
952
- const runtimeOrderId = normalizedOrderLinkId ?? normalizedOrderId;
953
-
954
- if (!runtimeOrderId || existingOrderIds.has(runtimeOrderId)) {
955
- return null;
956
- }
957
-
958
- const strategyName =
959
- (normalizedOrderLinkId
960
- ? strategyNameByOrderId.get(normalizedOrderLinkId)
961
- : null) ??
962
- (normalizedOrderId
963
- ? strategyNameByOrderId.get(normalizedOrderId)
964
- : null) ??
965
- resolveStrategyNameByOrderLinkId({
966
- orderLinkId: normalizedOrderLinkId,
967
- strategyNames: strategyNamesPool,
968
- });
969
-
970
- if (!strategyName) {
971
- return null;
972
- }
973
-
974
- const matchedClosedPnl = takeClosedPnlMatchForExchangeEntry({
975
- exactByOrderLinkId,
976
- exactByOrderId,
977
- symbolBuckets,
978
- entry,
979
- });
980
- const openPosition = openPositionBySymbol.get(entry.symbol);
981
- const isActive =
982
- !matchedClosedPnl &&
983
- openPosition?.direction === entry.direction &&
984
- Number.isFinite(openPosition.currentPrice) &&
985
- Number.isFinite(openPosition.unrealizedPnl);
986
- const entryPrice =
987
- typeof entry.entryPrice === 'number' &&
988
- Number.isFinite(entry.entryPrice)
989
- ? entry.entryPrice
990
- : typeof matchedClosedPnl?.entryPrice === 'number' &&
991
- Number.isFinite(matchedClosedPnl.entryPrice)
992
- ? matchedClosedPnl.entryPrice
993
- : null;
994
-
995
- if (entryPrice == null) {
996
- return null;
997
- }
998
-
999
- return {
1000
- orderId: runtimeOrderId,
1001
- strategy: strategyName,
1002
- symbol: entry.symbol,
1003
- direction: entry.direction,
1004
- qty: entry.qty,
1005
- entryPrice,
1006
- actualEntryPrice:
1007
- matchedClosedPnl?.entryPrice ?? entry.entryPrice ?? null,
1008
- entryTimestamp: entry.entryTimestamp,
1009
- status: isActive ? 'active' : 'closed',
1010
- currentPrice: isActive
1011
- ? openPosition?.currentPrice ?? null
1012
- : matchedClosedPnl?.exitPrice ?? null,
1013
- currentPnl: isActive
1014
- ? openPosition?.unrealizedPnl ?? null
1015
- : matchedClosedPnl?.closedPnl ?? null,
1016
- closedPnl: isActive ? null : matchedClosedPnl?.closedPnl ?? null,
1017
- exitPrice: isActive ? null : matchedClosedPnl?.exitPrice ?? null,
1018
- actualExitPrice: isActive ? null : matchedClosedPnl?.exitPrice ?? null,
1019
- exitTimestamp: isActive ? null : matchedClosedPnl?.closedAt ?? null,
1020
- aiAnalysis: isActive
1021
- ? buildRiskLevelsAnalysis({
1022
- takeProfitPrice: openPosition?.takeProfitPrice,
1023
- stopLossPrice: openPosition?.stopLossPrice,
1024
- })
1025
- : null,
1026
- openFee: matchedClosedPnl?.openFee ?? entry.openFee ?? null,
1027
- closeFee: matchedClosedPnl?.closeFee ?? entry.closeFee ?? null,
1028
- fundingFee: matchedClosedPnl?.fundingFee ?? entry.fundingFee ?? null,
1029
- totalFee: matchedClosedPnl?.totalFee ?? entry.totalFee ?? null,
1030
- lastSyncedAt: endTime,
1031
- };
1032
- })
1033
- .filter((trade): trade is RuntimeTradeRecord => trade != null);
1034
-
1035
- const fallbackOrderIds = new Set([
1036
- ...existingOrderIds,
1037
- ...fallbackTrades.map((trade) => trade.orderId),
1038
- ]);
1039
- const remainingClosedPnlRows = [...symbolBuckets.values()].flat();
1040
- const closedPnlFallbackTrades = remainingClosedPnlRows
1041
- .map<RuntimeTradeRecord | null>((row) => {
1042
- const normalizedOrderLinkId = toNonEmptyString(row.orderLinkId);
1043
- const normalizedOrderId = toNonEmptyString(row.orderId);
1044
- const runtimeOrderId = normalizedOrderLinkId ?? normalizedOrderId;
1045
-
1046
- if (!runtimeOrderId || fallbackOrderIds.has(runtimeOrderId)) {
1047
- return null;
1048
- }
1049
-
1050
- const strategyName =
1051
- (normalizedOrderLinkId
1052
- ? strategyNameByOrderId.get(normalizedOrderLinkId)
1053
- : null) ??
1054
- (normalizedOrderId
1055
- ? strategyNameByOrderId.get(normalizedOrderId)
1056
- : null) ??
1057
- resolveStrategyNameByOrderLinkId({
1058
- orderLinkId: normalizedOrderLinkId,
1059
- strategyNames: strategyNamesPool,
1060
- });
1061
-
1062
- if (
1063
- !strategyName ||
1064
- row.entryPrice == null ||
1065
- !Number.isFinite(row.entryPrice)
1066
- ) {
1067
- return null;
1068
- }
1069
-
1070
- const direction = row.direction ?? null;
1071
- if (!direction) {
1072
- return null;
1073
- }
1074
-
1075
- return {
1076
- orderId: runtimeOrderId,
1077
- strategy: strategyName,
1078
- symbol: row.symbol,
1079
- direction,
1080
- qty: row.qty,
1081
- entryPrice: row.entryPrice,
1082
- actualEntryPrice: row.entryPrice,
1083
- entryTimestamp:
1084
- typeof row.entryTimestamp === 'number' &&
1085
- Number.isFinite(row.entryTimestamp)
1086
- ? row.entryTimestamp
1087
- : row.closedAt,
1088
- status: 'closed',
1089
- currentPrice: row.exitPrice,
1090
- currentPnl: row.closedPnl,
1091
- closedPnl: row.closedPnl,
1092
- exitPrice: row.exitPrice,
1093
- actualExitPrice: row.exitPrice,
1094
- exitTimestamp: row.closedAt,
1095
- openFee: row.openFee ?? null,
1096
- closeFee: row.closeFee ?? null,
1097
- fundingFee: row.fundingFee ?? null,
1098
- totalFee: row.totalFee ?? null,
1099
- lastSyncedAt: endTime,
1100
- };
1101
- })
1102
- .filter((trade): trade is RuntimeTradeRecord => trade != null);
1103
-
1104
- return [...fallbackTrades, ...closedPnlFallbackTrades].sort(
1105
- (left, right) => left.entryTimestamp - right.entryTimestamp,
1106
- );
1107
- };