@tradejs/app 1.0.8 → 1.0.10

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 (119) hide show
  1. package/README.md +7 -2
  2. package/bin/tradejs-app.mjs +146 -2
  3. package/next.config.mjs +0 -14
  4. package/package.json +35 -19
  5. package/src/app/actions/backtest.ts +54 -0
  6. package/src/app/actions/kline.ts +10 -5
  7. package/src/app/actions/scanner.ts +8 -3
  8. package/src/app/actions/strategies.ts +39 -0
  9. package/src/app/api/ai/route.ts +5 -4
  10. package/src/app/api/auth/[...nextauth]/route.ts +1 -1
  11. package/src/app/api/backtest/configs/route.ts +25 -0
  12. package/src/app/api/backtest/files/route.ts +5 -2
  13. package/src/app/api/backtest/order-log/[strategy]/[name]/route.ts +26 -2
  14. package/src/app/api/backtest/result/[strategy]/[name]/route.ts +27 -2
  15. package/src/app/api/backtest/runs/[jobId]/route.ts +124 -0
  16. package/src/app/api/backtest/runs/route.ts +45 -0
  17. package/src/app/api/backtest/test/[strategy]/[name]/route.ts +19 -2
  18. package/src/app/api/derivatives/summary/route.ts +8 -1
  19. package/src/app/api/kline/[provider]/[universe]/[symbol]/[interval]/route.ts +1 -0
  20. package/src/app/api/kline/{[provider]/[symbol]/[interval]/route.ts → handler.ts} +61 -39
  21. package/src/app/api/scanner/[provider]/[universe]/route.ts +1 -0
  22. package/src/app/api/scanner/[provider]/route.ts +30 -12
  23. package/src/app/api/scanner/route.ts +3 -3
  24. package/src/app/api/signal/[symbol]/[signalId]/route.ts +4 -7
  25. package/src/app/api/strategies/ai/[cardId]/route.ts +22 -0
  26. package/src/app/api/strategies/ai/route.ts +139 -0
  27. package/src/app/api/strategies/replay/[cardId]/route.ts +18 -0
  28. package/src/app/api/strategies/replay/route.ts +44 -0
  29. package/src/app/api/strategies/runtime/route.ts +831 -0
  30. package/src/app/api/user/runtime-deployments/[deploymentId]/route.ts +25 -0
  31. package/src/app/api/user/runtime-deployments/route.ts +94 -0
  32. package/src/app/api/user/runtime-strategy-configs/route.ts +241 -0
  33. package/src/app/api/user/settings/route.ts +18 -1
  34. package/src/app/api/user/trading-accounts/[accountId]/route.ts +35 -0
  35. package/src/app/api/user/trading-accounts/route.ts +134 -0
  36. package/src/app/components/Backtest/CompareList/index.tsx +1 -1
  37. package/src/app/components/Backtest/ResultsPageClient.tsx +376 -0
  38. package/src/app/components/Backtest/TestCard/ActionsMenu/index.tsx +123 -0
  39. package/src/app/components/Backtest/TestCard/Chart/index.tsx +1 -1
  40. package/src/app/components/Backtest/TestCard/CompareButton/index.tsx +1 -1
  41. package/src/app/components/Backtest/TestCard/ConfigDrawer/index.tsx +28 -12
  42. package/src/app/components/Backtest/TestCard/DeleteButton/index.tsx +33 -17
  43. package/src/app/components/Backtest/TestCard/FavoriteIndicator/index.tsx +2 -2
  44. package/src/app/components/Backtest/TestCard/OpenDashboardButton/index.tsx +10 -1
  45. package/src/app/components/Backtest/TestCard/OrdersDrawer/index.tsx +128 -0
  46. package/src/app/components/Backtest/TestCard/Root/index.tsx +1 -1
  47. package/src/app/components/Backtest/TestCard/Stat/index.tsx +2 -0
  48. package/src/app/components/Backtest/TestCard/StatDrawer/index.tsx +66 -0
  49. package/src/app/components/Backtest/TestCard/index.ts +6 -2
  50. package/src/app/components/Backtest/TestList/index.tsx +4 -9
  51. package/src/app/components/Dashboard/AiDrawer/index.tsx +1 -1
  52. package/src/app/components/Dashboard/KlineChart/hooks/useBacktest.ts +1 -1
  53. package/src/app/components/Dashboard/KlineChart/hooks/useBbIndicator.ts +10 -5
  54. package/src/app/components/Dashboard/KlineChart/hooks/useBtcCorrelation.ts +1 -1
  55. package/src/app/components/Dashboard/KlineChart/hooks/useBtcIndicator.ts +4 -1
  56. package/src/app/components/Dashboard/KlineChart/hooks/useEmaIndicator.ts +3 -1
  57. package/src/app/components/Dashboard/KlineChart/hooks/usePluginIndicators.ts +1 -1
  58. package/src/app/components/Dashboard/KlineChart/hooks/useSetup.ts +1 -1
  59. package/src/app/components/Dashboard/KlineChart/hooks/useSignal.ts +1 -1
  60. package/src/app/components/Dashboard/KlineChart/hooks/useSpreadIndicator.ts +1 -1
  61. package/src/app/components/Dashboard/KlineChart/hooks/useTrendLine.ts +1 -1
  62. package/src/app/components/Dashboard/KlineChart/hooks/useWmaIndicator.ts +3 -1
  63. package/src/app/components/Dashboard/KlineChart/index.tsx +16 -6
  64. package/src/app/components/Dashboard/MainChart/index.tsx +3 -22
  65. package/src/app/components/Shared/AppShell.tsx +1 -1
  66. package/src/app/components/Shared/BulkSelection/index.tsx +140 -0
  67. package/src/app/components/Shared/BulkSelection/useBulkSelection.ts +97 -0
  68. package/src/app/components/Shared/BulkSelection/utils.ts +126 -0
  69. package/src/app/components/Shared/Filters/Backtest/index.tsx +33 -48
  70. package/src/app/components/Shared/Filters/FavoriteIndicator/index.tsx +5 -3
  71. package/src/app/components/Shared/Filters/Indicators/index.tsx +2 -2
  72. package/src/app/components/Shared/Filters/Interval/index.tsx +1 -1
  73. package/src/app/components/Shared/Filters/Provider/index.tsx +8 -2
  74. package/src/app/components/Shared/Filters/Symbol/index.tsx +4 -3
  75. package/src/app/components/Shared/Filters/Universe/index.tsx +36 -0
  76. package/src/app/components/Shared/Filters/index.ts +2 -0
  77. package/src/app/components/Shared/OrdersDrawer.tsx +629 -0
  78. package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +55 -28
  79. package/src/app/components/Shared/Sidebar/TradingAccountsPanel.tsx +445 -0
  80. package/src/app/components/Shared/Sidebar/index.tsx +17 -3
  81. package/src/app/components/Strategies/AdvancedMetricsPanel.tsx +727 -0
  82. package/src/app/components/Strategies/RuntimeStrategyCard.tsx +2204 -0
  83. package/src/app/components/Strategies/RuntimeStrategyCardSkeleton.tsx +40 -0
  84. package/src/app/components/Strategies/RuntimeStrategyChart.tsx +121 -0
  85. package/src/app/components/Strategies/RuntimeStrategyConfigDrawer.tsx +458 -0
  86. package/src/app/components/Strategies/StrategySnapshotCard.tsx +2890 -0
  87. package/src/app/components/Strategies/StrategySnapshotChart.tsx +94 -0
  88. package/src/app/components/Strategies/StrategySnapshotList.tsx +82 -0
  89. package/src/app/components/UI/Segment/index.tsx +8 -1
  90. package/src/app/components/UI/Select/index.tsx +24 -13
  91. package/src/app/components/UI/SelectWithSearch/index.tsx +46 -5
  92. package/src/app/layout.tsx +1 -1
  93. package/src/app/lib/backtestJobs.ts +792 -0
  94. package/src/app/lib/cardPageLayout.ts +1 -0
  95. package/src/app/lib/connectorCreator.ts +28 -0
  96. package/src/app/lib/currentUser.ts +1 -1
  97. package/src/app/lib/errorMessage.ts +34 -0
  98. package/src/app/lib/marketDefaults.ts +12 -0
  99. package/src/app/lib/marketRoutes.ts +58 -0
  100. package/src/app/lib/runtimeStrategies.ts +1376 -0
  101. package/src/app/lib/runtimeStrategyConfigForm.ts +86 -0
  102. package/src/app/provider.tsx +1 -1
  103. package/src/app/routes/backtest/page.tsx +1051 -304
  104. package/src/app/routes/dashboard/{[provider]/[symbol]/[interval]/page.tsx → Dashboard.tsx} +35 -15
  105. package/src/app/routes/dashboard/[provider]/[universe]/[symbol]/[interval]/page.tsx +1 -0
  106. package/src/app/routes/dashboard/page.tsx +15 -3
  107. package/src/app/routes/derivatives/page.tsx +1222 -165
  108. package/src/app/routes/strategies/StrategiesPageClient.tsx +593 -0
  109. package/src/app/routes/strategies/[mode]/page.tsx +20 -0
  110. package/src/app/routes/strategies/page.tsx +7 -0
  111. package/src/app/store/ai.ts +1 -1
  112. package/src/app/store/data.ts +73 -17
  113. package/src/app/store/filters.ts +1 -0
  114. package/src/app/store/marketKlineStream.ts +87 -0
  115. package/src/app/store/tests.ts +1 -1
  116. package/src/app/store/tickers.ts +59 -22
  117. package/tsconfig.json +17 -12
  118. package/src/app/components/Backtest/TestCard/OpenReportButton/index.tsx +0 -24
  119. package/src/app/routes/backtest/[test]/page.tsx +0 -33
@@ -0,0 +1,1376 @@
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
+ ClosedPnlRecord,
9
+ ExchangeEntryRecord,
10
+ PositionPnlSnapshot,
11
+ RuntimeTradeRecord,
12
+ SimpleOrderLogData,
13
+ StrategyConfig,
14
+ TestStat,
15
+ MarketUniverse,
16
+ Interval,
17
+ } from '@tradejs/types';
18
+
19
+ const MS_IN_DAY = 24 * 60 * 60 * 1000;
20
+ const AVG_DAYS_IN_MONTH = 30.4375;
21
+
22
+ type ClosedPnlRecordWithOrderLinkId = ClosedPnlRecord & {
23
+ direction?: RuntimeTradeRecord['direction'];
24
+ entryTimestamp?: number;
25
+ orderLinkId?: string;
26
+ };
27
+
28
+ type RuntimeTradeWithResolvedPnl = RuntimeTradeRecord & {
29
+ resolvedPnl: number;
30
+ resolvedTimestamp: number;
31
+ };
32
+
33
+ export interface RuntimeStrategyTradeSummary {
34
+ totalTrades: number;
35
+ activeTrades: number;
36
+ closedTrades: number;
37
+ wins: number;
38
+ losses: number;
39
+ activePnl: number;
40
+ closedPnl: number;
41
+ totalPnl: number;
42
+ symbolConcentrationTop1: number | null;
43
+ symbolConcentrationTop5: number | null;
44
+ }
45
+
46
+ export interface RuntimeStrategyTradeView {
47
+ orderId: string;
48
+ symbol: string;
49
+ direction: RuntimeTradeRecord['direction'];
50
+ status: RuntimeTradeRecord['status'];
51
+ qty: number;
52
+ entryTimestamp: number;
53
+ entryPrice: number;
54
+ actualEntryPrice: number | null;
55
+ exitTimestamp: number | null;
56
+ exitPrice: number | null;
57
+ actualExitPrice: number | null;
58
+ currentPrice: number | null;
59
+ pnl: number | null;
60
+ durationHours: number | null;
61
+ entrySlippagePercent: number | null;
62
+ exitSlippagePercent: number | null;
63
+ exitType: RuntimeTradeRecord['exitType'] | null;
64
+ takeProfitPrice: number | null;
65
+ stopLossPrice: number | null;
66
+ takeProfitPercent: number | null;
67
+ stopLossPercent: number | null;
68
+ openFee: number | null;
69
+ closeFee: number | null;
70
+ fundingFee: number | null;
71
+ totalFee: number | null;
72
+ lastSyncedAt: number | null;
73
+ }
74
+
75
+ export interface RuntimeStrategyView {
76
+ runtimeKey: string;
77
+ strategyName: string;
78
+ configId: string;
79
+ interval: Interval;
80
+ universe: MarketUniverse;
81
+ accountId?: string;
82
+ accountLabel?: string;
83
+ deploymentId?: string;
84
+ policyProfileId?: string;
85
+ connected: boolean;
86
+ enabled: boolean;
87
+ config: StrategyConfig | null;
88
+ symbols: string[];
89
+ stat: TestStat;
90
+ summary: RuntimeStrategyTradeSummary;
91
+ orderLog: SimpleOrderLogData;
92
+ recentTrades: RuntimeStrategyTradeView[];
93
+ orders: RuntimeStrategyTradeView[];
94
+ }
95
+
96
+ export const buildRuntimeStrategyIdentityKey = ({
97
+ strategyName,
98
+ configId,
99
+ universe,
100
+ accountId,
101
+ deploymentId,
102
+ policyProfileId,
103
+ }: {
104
+ strategyName: string;
105
+ configId?: string;
106
+ universe?: MarketUniverse;
107
+ accountId?: string;
108
+ deploymentId?: string;
109
+ policyProfileId?: string;
110
+ }) =>
111
+ [
112
+ strategyName,
113
+ configId ?? 'config',
114
+ universe ?? 'crypto',
115
+ accountId ?? 'default',
116
+ deploymentId ?? 'default',
117
+ policyProfileId ?? 'default',
118
+ ].join(':');
119
+
120
+ export interface RuntimeStrategyAccountScope {
121
+ strategyName: string;
122
+ configId: string;
123
+ universe: MarketUniverse;
124
+ accountId?: string;
125
+ }
126
+
127
+ export const assignLegacyRuntimeTradeAccountScopes = (
128
+ trades: RuntimeTradeRecord[],
129
+ scopes: RuntimeStrategyAccountScope[],
130
+ ): RuntimeTradeRecord[] =>
131
+ trades.map((trade) => {
132
+ if (trade.accountId || trade.deploymentId) {
133
+ return trade;
134
+ }
135
+
136
+ const matchingAccountIds = new Set(
137
+ scopes
138
+ .filter(
139
+ (scope) =>
140
+ scope.strategyName === trade.strategy &&
141
+ scope.configId === (trade.runtimeConfigId ?? 'config') &&
142
+ scope.universe === (trade.universe ?? 'crypto'),
143
+ )
144
+ .map((scope) => scope.accountId)
145
+ .filter((accountId): accountId is string => Boolean(accountId)),
146
+ );
147
+
148
+ if (matchingAccountIds.size !== 1) {
149
+ return trade;
150
+ }
151
+
152
+ return { ...trade, accountId: [...matchingAccountIds][0] };
153
+ });
154
+
155
+ export interface RuntimeStrategiesResponse {
156
+ provider: string;
157
+ hours: number;
158
+ generatedAt: number;
159
+ dataSources?: {
160
+ localTrades: number;
161
+ exchangeFallbackTrades: number;
162
+ exchangeErrors: string[];
163
+ };
164
+ strategies: RuntimeStrategyView[];
165
+ }
166
+
167
+ const roundValue = (value: number, digits = 2) => {
168
+ if (!Number.isFinite(value)) {
169
+ return 0;
170
+ }
171
+
172
+ const factor = 10 ** digits;
173
+ return Math.round(value * factor) / factor;
174
+ };
175
+
176
+ const toNonEmptyString = (value: unknown) =>
177
+ typeof value === 'string' && value.trim() ? value.trim() : null;
178
+
179
+ const toFiniteNumberOrNull = (value: unknown) =>
180
+ typeof value === 'number' && Number.isFinite(value) ? value : null;
181
+
182
+ const getTradePnl = (trade: RuntimeTradeRecord) =>
183
+ trade.status === 'closed'
184
+ ? trade.closedPnl ?? trade.currentPnl ?? null
185
+ : trade.currentPnl ?? null;
186
+
187
+ const getTradeLevelPercent = ({
188
+ direction,
189
+ entryPrice,
190
+ levelPrice,
191
+ kind,
192
+ }: {
193
+ direction: RuntimeTradeRecord['direction'];
194
+ entryPrice: number;
195
+ levelPrice: unknown;
196
+ kind: 'takeProfit' | 'stopLoss';
197
+ }) => {
198
+ if (
199
+ typeof levelPrice !== 'number' ||
200
+ !Number.isFinite(levelPrice) ||
201
+ !Number.isFinite(entryPrice) ||
202
+ entryPrice <= 0
203
+ ) {
204
+ return null;
205
+ }
206
+
207
+ const rawPercent =
208
+ direction === 'LONG'
209
+ ? ((levelPrice - entryPrice) / entryPrice) * 100
210
+ : ((entryPrice - levelPrice) / entryPrice) * 100;
211
+ const percent = kind === 'stopLoss' ? Math.abs(rawPercent) : rawPercent;
212
+
213
+ return Number.isFinite(percent) ? roundValue(percent) : null;
214
+ };
215
+
216
+ const getPriceSlippagePercent = ({
217
+ expectedPrice,
218
+ actualPrice,
219
+ }: {
220
+ expectedPrice: unknown;
221
+ actualPrice: unknown;
222
+ }) => {
223
+ if (
224
+ typeof expectedPrice !== 'number' ||
225
+ !Number.isFinite(expectedPrice) ||
226
+ expectedPrice <= 0 ||
227
+ typeof actualPrice !== 'number' ||
228
+ !Number.isFinite(actualPrice)
229
+ ) {
230
+ return null;
231
+ }
232
+
233
+ return roundValue(((actualPrice - expectedPrice) / expectedPrice) * 100, 4);
234
+ };
235
+
236
+ const getExpectedExitPrice = (trade: RuntimeTradeRecord) => {
237
+ if (trade.exitType === 'tp') {
238
+ return toFiniteNumberOrNull(trade.aiAnalysis?.takeProfitPrice);
239
+ }
240
+
241
+ if (trade.exitType === 'sl') {
242
+ return toFiniteNumberOrNull(trade.aiAnalysis?.stopLossPrice);
243
+ }
244
+
245
+ return null;
246
+ };
247
+
248
+ const getTradeDurationHours = (trade: RuntimeTradeRecord, endTime: number) => {
249
+ const resolvedTimestamp = getTradeResolvedTimestamp(trade, endTime);
250
+ if (
251
+ !Number.isFinite(trade.entryTimestamp) ||
252
+ !Number.isFinite(resolvedTimestamp) ||
253
+ resolvedTimestamp < trade.entryTimestamp
254
+ ) {
255
+ return null;
256
+ }
257
+
258
+ return roundValue((resolvedTimestamp - trade.entryTimestamp) / 3_600_000, 2);
259
+ };
260
+
261
+ const getTradeTotalFee = (trade: RuntimeTradeRecord) => {
262
+ const explicitTotal = toFiniteNumberOrNull(trade.totalFee);
263
+ if (explicitTotal != null) {
264
+ return explicitTotal;
265
+ }
266
+
267
+ const fees = [trade.openFee, trade.closeFee, trade.fundingFee]
268
+ .map(toFiniteNumberOrNull)
269
+ .filter((fee): fee is number => fee != null);
270
+
271
+ return fees.length
272
+ ? Number(fees.reduce((sum, fee) => sum + fee, 0).toFixed(12))
273
+ : null;
274
+ };
275
+
276
+ const buildRiskLevelsAnalysis = ({
277
+ takeProfitPrice,
278
+ stopLossPrice,
279
+ }: {
280
+ takeProfitPrice?: number | null;
281
+ stopLossPrice?: number | null;
282
+ }): RuntimeTradeRecord['aiAnalysis'] | null => {
283
+ const resolvedTakeProfitPrice = toFiniteNumberOrNull(takeProfitPrice);
284
+ const resolvedStopLossPrice = toFiniteNumberOrNull(stopLossPrice);
285
+
286
+ if (resolvedTakeProfitPrice == null && resolvedStopLossPrice == null) {
287
+ return null;
288
+ }
289
+
290
+ return {
291
+ ...(resolvedTakeProfitPrice != null
292
+ ? { takeProfitPrice: resolvedTakeProfitPrice }
293
+ : {}),
294
+ ...(resolvedStopLossPrice != null
295
+ ? { stopLossPrice: resolvedStopLossPrice }
296
+ : {}),
297
+ };
298
+ };
299
+
300
+ const getTradeResolvedTimestamp = (
301
+ trade: RuntimeTradeRecord,
302
+ endTime: number,
303
+ ) => {
304
+ if (
305
+ typeof trade.exitTimestamp === 'number' &&
306
+ Number.isFinite(trade.exitTimestamp)
307
+ ) {
308
+ return trade.exitTimestamp;
309
+ }
310
+
311
+ return endTime;
312
+ };
313
+
314
+ const resolveTradesWithKnownPnl = (
315
+ trades: RuntimeTradeRecord[],
316
+ endTime: number,
317
+ ): RuntimeTradeWithResolvedPnl[] =>
318
+ trades
319
+ .map((trade) => {
320
+ const pnl = getTradePnl(trade);
321
+ const resolvedTimestamp = getTradeResolvedTimestamp(trade, endTime);
322
+
323
+ if (
324
+ typeof pnl !== 'number' ||
325
+ !Number.isFinite(pnl) ||
326
+ !Number.isFinite(resolvedTimestamp)
327
+ ) {
328
+ return null;
329
+ }
330
+
331
+ return {
332
+ ...trade,
333
+ resolvedPnl: pnl,
334
+ resolvedTimestamp: Math.max(trade.entryTimestamp, resolvedTimestamp),
335
+ };
336
+ })
337
+ .filter((trade): trade is RuntimeTradeWithResolvedPnl => trade != null)
338
+ .sort((left, right) => {
339
+ if (left.resolvedTimestamp !== right.resolvedTimestamp) {
340
+ return left.resolvedTimestamp - right.resolvedTimestamp;
341
+ }
342
+
343
+ return left.entryTimestamp - right.entryTimestamp;
344
+ });
345
+
346
+ const calculateMaxDrawdown = (amounts: number[]) => {
347
+ if (!amounts.length) {
348
+ return 0;
349
+ }
350
+
351
+ let peak = amounts[0];
352
+ let maxDrawdown = 0;
353
+
354
+ for (const amount of amounts) {
355
+ if (amount > peak) {
356
+ peak = amount;
357
+ }
358
+
359
+ if (peak <= 0) {
360
+ continue;
361
+ }
362
+
363
+ const drawdown = ((peak - amount) / peak) * 100;
364
+ if (drawdown > maxDrawdown) {
365
+ maxDrawdown = drawdown;
366
+ }
367
+ }
368
+
369
+ return roundValue(maxDrawdown);
370
+ };
371
+
372
+ const calculateSharpeRatio = (
373
+ orderLog: SimpleOrderLogData,
374
+ startTime: number,
375
+ endTime: number,
376
+ ) => {
377
+ if (!orderLog.length || endTime <= startTime) {
378
+ return null;
379
+ }
380
+
381
+ const points = [...orderLog]
382
+ .map(([timestamp, amount]) => ({ ts: timestamp, amount }))
383
+ .sort((left, right) => left.ts - right.ts);
384
+
385
+ const eomSeries: number[] = [];
386
+ let pointIndex = 0;
387
+ let monthCursor = startOfMonth(new Date(startTime));
388
+ const lastMonth = endOfMonth(new Date(endTime));
389
+ let lastAmount = points[0]?.amount ?? INITIAL_BACKTEST_AMOUNT;
390
+
391
+ while (monthCursor <= lastMonth) {
392
+ const eomTs = endOfMonth(monthCursor).getTime();
393
+
394
+ while (pointIndex < points.length && points[pointIndex].ts <= eomTs) {
395
+ lastAmount = points[pointIndex].amount;
396
+ pointIndex += 1;
397
+ }
398
+
399
+ eomSeries.push(lastAmount);
400
+ monthCursor = addMonths(monthCursor, 1);
401
+ }
402
+
403
+ if (eomSeries.length < 2) {
404
+ return null;
405
+ }
406
+
407
+ const monthlyReturns: number[] = [];
408
+ for (let index = 1; index < eomSeries.length; index += 1) {
409
+ const previous = eomSeries[index - 1];
410
+ const current = eomSeries[index];
411
+ monthlyReturns.push(previous > 0 ? current / previous - 1 : 0);
412
+ }
413
+
414
+ if (!monthlyReturns.length) {
415
+ return null;
416
+ }
417
+
418
+ const mean =
419
+ monthlyReturns.reduce((sum, value) => sum + value, 0) /
420
+ monthlyReturns.length;
421
+ const variance =
422
+ monthlyReturns.reduce((sum, value) => sum + (value - mean) ** 2, 0) /
423
+ monthlyReturns.length;
424
+ const std = Math.sqrt(variance);
425
+
426
+ if (!Number.isFinite(std) || std === 0) {
427
+ return null;
428
+ }
429
+
430
+ return roundValue((mean / std) * Math.sqrt(12));
431
+ };
432
+
433
+ const calculateExposurePercent = (
434
+ trades: RuntimeTradeRecord[],
435
+ startTime: number,
436
+ endTime: number,
437
+ ) => {
438
+ if (endTime <= startTime) {
439
+ return 0;
440
+ }
441
+
442
+ const intervals = trades
443
+ .map((trade) => ({
444
+ start: Math.max(startTime, trade.entryTimestamp),
445
+ end: Math.min(endTime, getTradeResolvedTimestamp(trade, endTime)),
446
+ }))
447
+ .filter((interval) => interval.end > interval.start)
448
+ .sort((left, right) => left.start - right.start);
449
+
450
+ if (!intervals.length) {
451
+ return 0;
452
+ }
453
+
454
+ const merged: Array<{ start: number; end: number }> = [];
455
+
456
+ for (const interval of intervals) {
457
+ const last = merged[merged.length - 1];
458
+
459
+ if (!last || interval.start > last.end) {
460
+ merged.push({ ...interval });
461
+ continue;
462
+ }
463
+
464
+ last.end = Math.max(last.end, interval.end);
465
+ }
466
+
467
+ const coveredMs = merged.reduce(
468
+ (sum, interval) => sum + (interval.end - interval.start),
469
+ 0,
470
+ );
471
+
472
+ return roundValue((coveredMs / (endTime - startTime)) * 100);
473
+ };
474
+
475
+ const calculateStreaks = (pnls: number[]) => {
476
+ let currentWins = 0;
477
+ let currentLosses = 0;
478
+ let maxWins = 0;
479
+ let maxLosses = 0;
480
+
481
+ for (const pnl of pnls) {
482
+ if (pnl > 0) {
483
+ currentWins += 1;
484
+ currentLosses = 0;
485
+ maxWins = Math.max(maxWins, currentWins);
486
+ continue;
487
+ }
488
+
489
+ if (pnl < 0) {
490
+ currentLosses += 1;
491
+ currentWins = 0;
492
+ maxLosses = Math.max(maxLosses, currentLosses);
493
+ continue;
494
+ }
495
+
496
+ currentWins = 0;
497
+ currentLosses = 0;
498
+ }
499
+
500
+ return {
501
+ maxConsecutiveWins: maxWins,
502
+ maxConsecutiveLosses: maxLosses,
503
+ };
504
+ };
505
+
506
+ const calculateSymbolConcentration = (
507
+ trades: RuntimeTradeWithResolvedPnl[],
508
+ limit: number,
509
+ ) => {
510
+ const totals = new Map<string, number>();
511
+
512
+ for (const trade of trades) {
513
+ totals.set(
514
+ trade.symbol,
515
+ (totals.get(trade.symbol) ?? 0) + Math.abs(trade.resolvedPnl),
516
+ );
517
+ }
518
+
519
+ const totalAbsPnl = [...totals.values()].reduce(
520
+ (sum, value) => sum + value,
521
+ 0,
522
+ );
523
+ if (totalAbsPnl <= 0) {
524
+ return null;
525
+ }
526
+
527
+ const topAbsPnl = [...totals.values()]
528
+ .sort((left, right) => right - left)
529
+ .slice(0, limit)
530
+ .reduce((sum, value) => sum + value, 0);
531
+
532
+ return roundValue((topAbsPnl / totalAbsPnl) * 100);
533
+ };
534
+
535
+ const createEmptyRuntimeStat = (
536
+ startTime: number,
537
+ endTime: number,
538
+ ): TestStat => {
539
+ const periodDays = Math.max(0, (endTime - startTime) / MS_IN_DAY);
540
+ const periodMonths = periodDays / AVG_DAYS_IN_MONTH;
541
+
542
+ return {
543
+ periodDays: roundValue(periodDays),
544
+ periodMonths: roundValue(periodMonths),
545
+ orders: 0,
546
+ wins: 0,
547
+ losses: 0,
548
+ ordersPerMonth: 0,
549
+ exposure: 0,
550
+ amount: INITIAL_BACKTEST_AMOUNT,
551
+ maxAmount: INITIAL_BACKTEST_AMOUNT,
552
+ minAmount: INITIAL_BACKTEST_AMOUNT,
553
+ netProfit: 0,
554
+ totalReturn: 0,
555
+ cagr: 0,
556
+ maxDrawdown: 0,
557
+ calmar: null,
558
+ winRate: 0,
559
+ riskRewardRatio: null,
560
+ expectancy: 0,
561
+ maxConsecutiveWins: 0,
562
+ maxConsecutiveLosses: 0,
563
+ sharpeRatio: null,
564
+ score: 0,
565
+ };
566
+ };
567
+
568
+ export const resolveStrategyNameByConfigKey = (
569
+ userName: string,
570
+ key: string,
571
+ ): string | null => {
572
+ return (
573
+ resolveStrategyConfigIdentityByKey(userName, key)?.strategyName ?? null
574
+ );
575
+ };
576
+
577
+ export const resolveStrategyConfigIdentityByKey = (
578
+ userName: string,
579
+ key: string,
580
+ ): { strategyName: string; configId: string } | null => {
581
+ const parts = key.split(':');
582
+ if (parts.length !== 5) return null;
583
+ const [users, keyUserName, strategies, strategyName, configId] = parts;
584
+ if (
585
+ users !== 'users' ||
586
+ keyUserName !== userName ||
587
+ strategies !== 'strategies' ||
588
+ !strategyName ||
589
+ strategyName === 'charts' ||
590
+ !configId ||
591
+ configId === 'results' ||
592
+ !/^[a-zA-Z0-9_-]+$/.test(configId)
593
+ ) {
594
+ return null;
595
+ }
596
+ return { strategyName, configId };
597
+ };
598
+
599
+ const buildStrategyNameKeyMap = (strategyNames: string[]) => {
600
+ const map = new Map<string, string>();
601
+
602
+ for (const strategyName of strategyNames) {
603
+ const key = normalizeStrategyOrderLinkKey(strategyName);
604
+
605
+ if (key && !map.has(key)) {
606
+ map.set(key, strategyName);
607
+ }
608
+ }
609
+
610
+ return map;
611
+ };
612
+
613
+ export const resolveStrategyNameByOrderLinkId = ({
614
+ orderLinkId,
615
+ strategyNames,
616
+ }: {
617
+ orderLinkId: string | null | undefined;
618
+ strategyNames: string[];
619
+ }) => {
620
+ const strategyKey = parseStrategyOrderLinkKey(orderLinkId);
621
+
622
+ if (!strategyKey) {
623
+ return null;
624
+ }
625
+
626
+ return buildStrategyNameKeyMap(strategyNames).get(strategyKey) ?? null;
627
+ };
628
+
629
+ export const isRuntimeTradeRecord = (
630
+ value: unknown,
631
+ ): value is RuntimeTradeRecord => {
632
+ if (!value || typeof value !== 'object') {
633
+ return false;
634
+ }
635
+
636
+ const record = value as Record<string, unknown>;
637
+ return (
638
+ typeof record.orderId === 'string' &&
639
+ typeof record.strategy === 'string' &&
640
+ typeof record.symbol === 'string' &&
641
+ typeof record.entryTimestamp === 'number' &&
642
+ typeof record.entryPrice === 'number' &&
643
+ typeof record.qty === 'number'
644
+ );
645
+ };
646
+
647
+ export const selectTradesForWindow = (
648
+ trades: RuntimeTradeRecord[],
649
+ startTime: number,
650
+ activeOrderIds: Set<string> = new Set(),
651
+ ) =>
652
+ trades.filter((trade) => {
653
+ if (trade.status === 'active') {
654
+ if (activeOrderIds.has(trade.orderId)) {
655
+ return true;
656
+ }
657
+
658
+ return trade.entryTimestamp >= startTime;
659
+ }
660
+
661
+ const exitTimestamp =
662
+ typeof trade.exitTimestamp === 'number' ? trade.exitTimestamp : 0;
663
+
664
+ return trade.entryTimestamp >= startTime || exitTimestamp >= startTime;
665
+ });
666
+
667
+ export const buildRuntimeStrategyAnalytics = ({
668
+ trades,
669
+ startTime,
670
+ endTime,
671
+ }: {
672
+ trades: RuntimeTradeRecord[];
673
+ startTime: number;
674
+ endTime: number;
675
+ }) => {
676
+ const resolvedTrades = resolveTradesWithKnownPnl(trades, endTime);
677
+ const orderLog: SimpleOrderLogData = [[startTime, INITIAL_BACKTEST_AMOUNT]];
678
+ const tradePnls = resolvedTrades.map((trade) => trade.resolvedPnl);
679
+ let runningAmount = INITIAL_BACKTEST_AMOUNT;
680
+
681
+ for (const trade of resolvedTrades) {
682
+ runningAmount = roundValue(runningAmount + trade.resolvedPnl);
683
+ orderLog.push([trade.resolvedTimestamp, runningAmount]);
684
+ }
685
+
686
+ if (orderLog[orderLog.length - 1]?.[0] !== endTime) {
687
+ orderLog.push([endTime, runningAmount]);
688
+ }
689
+
690
+ const amounts = orderLog.map(([, amount]) => amount);
691
+ const wins = tradePnls.filter((pnl) => pnl > 0).length;
692
+ const losses = tradePnls.filter((pnl) => pnl < 0).length;
693
+ const averageWin =
694
+ wins > 0
695
+ ? tradePnls.filter((pnl) => pnl > 0).reduce((sum, pnl) => sum + pnl, 0) /
696
+ wins
697
+ : 0;
698
+ const averageLossAbs =
699
+ losses > 0
700
+ ? Math.abs(
701
+ tradePnls
702
+ .filter((pnl) => pnl < 0)
703
+ .reduce((sum, pnl) => sum + pnl, 0) / losses,
704
+ )
705
+ : 0;
706
+ const returnSeries: number[] = [];
707
+ let amountBeforeTrade = INITIAL_BACKTEST_AMOUNT;
708
+
709
+ for (const trade of resolvedTrades) {
710
+ returnSeries.push(
711
+ amountBeforeTrade > 0 ? trade.resolvedPnl / amountBeforeTrade : 0,
712
+ );
713
+ amountBeforeTrade += trade.resolvedPnl;
714
+ }
715
+
716
+ const periodDays = Math.max(0, (endTime - startTime) / MS_IN_DAY);
717
+ const periodMonths = periodDays / AVG_DAYS_IN_MONTH;
718
+ const amount = amounts[amounts.length - 1] ?? INITIAL_BACKTEST_AMOUNT;
719
+ const netProfit = amount - INITIAL_BACKTEST_AMOUNT;
720
+ const totalReturn =
721
+ INITIAL_BACKTEST_AMOUNT > 0
722
+ ? ((amount - INITIAL_BACKTEST_AMOUNT) / INITIAL_BACKTEST_AMOUNT) * 100
723
+ : 0;
724
+ const cagr =
725
+ periodMonths > 0
726
+ ? (Math.pow(amount / INITIAL_BACKTEST_AMOUNT, 12 / periodMonths) - 1) *
727
+ 100
728
+ : 0;
729
+ const maxDrawdown = calculateMaxDrawdown(amounts);
730
+ const calmar = maxDrawdown > 0 ? cagr / maxDrawdown : null;
731
+ const riskRewardRatio =
732
+ averageLossAbs > 0 ? averageWin / averageLossAbs : null;
733
+ const expectancy =
734
+ returnSeries.length > 0
735
+ ? roundValue(
736
+ (returnSeries.reduce((sum, value) => sum + value, 0) /
737
+ returnSeries.length) *
738
+ 100,
739
+ )
740
+ : 0;
741
+ const sharpeRatio = calculateSharpeRatio(orderLog, startTime, endTime);
742
+ const exposure = calculateExposurePercent(trades, startTime, endTime);
743
+ const { maxConsecutiveWins, maxConsecutiveLosses } =
744
+ calculateStreaks(tradePnls);
745
+ const symbolConcentrationTop1 = calculateSymbolConcentration(
746
+ resolvedTrades,
747
+ 1,
748
+ );
749
+ const symbolConcentrationTop5 = calculateSymbolConcentration(
750
+ resolvedTrades,
751
+ 5,
752
+ );
753
+
754
+ const stat: TestStat =
755
+ trades.length === 0
756
+ ? createEmptyRuntimeStat(startTime, endTime)
757
+ : {
758
+ periodDays: roundValue(periodDays),
759
+ periodMonths: roundValue(periodMonths),
760
+ orders: trades.length,
761
+ wins,
762
+ losses,
763
+ ordersPerMonth:
764
+ periodMonths > 0 ? roundValue(trades.length / periodMonths) : 0,
765
+ exposure,
766
+ amount: roundValue(amount),
767
+ maxAmount: roundValue(Math.max(...amounts)),
768
+ minAmount: roundValue(Math.min(...amounts)),
769
+ netProfit: roundValue(netProfit),
770
+ totalReturn: roundValue(totalReturn),
771
+ cagr: roundValue(cagr),
772
+ maxDrawdown,
773
+ calmar: calmar == null ? null : roundValue(calmar),
774
+ winRate:
775
+ trades.length > 0 ? roundValue((wins / trades.length) * 100) : 0,
776
+ riskRewardRatio:
777
+ riskRewardRatio == null ? null : roundValue(riskRewardRatio),
778
+ expectancy,
779
+ maxConsecutiveWins,
780
+ maxConsecutiveLosses,
781
+ sharpeRatio,
782
+ score: 0,
783
+ };
784
+
785
+ const activeTrades = trades.filter((trade) => trade.status === 'active');
786
+ const closedTrades = trades.filter((trade) => trade.status === 'closed');
787
+ const activePnl = roundValue(
788
+ activeTrades.reduce(
789
+ (sum, trade) =>
790
+ sum +
791
+ (typeof trade.currentPnl === 'number' &&
792
+ Number.isFinite(trade.currentPnl)
793
+ ? trade.currentPnl
794
+ : 0),
795
+ 0,
796
+ ),
797
+ );
798
+ const closedPnl = roundValue(
799
+ closedTrades.reduce(
800
+ (sum, trade) =>
801
+ sum +
802
+ (typeof trade.closedPnl === 'number' && Number.isFinite(trade.closedPnl)
803
+ ? trade.closedPnl
804
+ : typeof trade.currentPnl === 'number' &&
805
+ Number.isFinite(trade.currentPnl)
806
+ ? trade.currentPnl
807
+ : 0),
808
+ 0,
809
+ ),
810
+ );
811
+
812
+ return {
813
+ orderLog,
814
+ stat,
815
+ summary: {
816
+ totalTrades: trades.length,
817
+ activeTrades: activeTrades.length,
818
+ closedTrades: closedTrades.length,
819
+ wins,
820
+ losses,
821
+ activePnl,
822
+ closedPnl,
823
+ totalPnl: roundValue(activePnl + closedPnl),
824
+ symbolConcentrationTop1,
825
+ symbolConcentrationTop5,
826
+ },
827
+ };
828
+ };
829
+
830
+ export const toRuntimeTradeView = (
831
+ trade: RuntimeTradeRecord,
832
+ endTime = Date.now(),
833
+ ): RuntimeStrategyTradeView => ({
834
+ orderId: trade.orderId,
835
+ symbol: trade.symbol,
836
+ direction: trade.direction,
837
+ status: trade.status,
838
+ qty: trade.qty,
839
+ entryTimestamp: trade.entryTimestamp,
840
+ entryPrice: trade.entryPrice,
841
+ actualEntryPrice: toFiniteNumberOrNull(trade.actualEntryPrice),
842
+ exitTimestamp:
843
+ typeof trade.exitTimestamp === 'number' ? trade.exitTimestamp : null,
844
+ exitPrice: typeof trade.exitPrice === 'number' ? trade.exitPrice : null,
845
+ actualExitPrice: toFiniteNumberOrNull(trade.actualExitPrice),
846
+ currentPrice: toFiniteNumberOrNull(trade.currentPrice),
847
+ pnl: getTradePnl(trade),
848
+ durationHours: getTradeDurationHours(trade, endTime),
849
+ entrySlippagePercent: getPriceSlippagePercent({
850
+ expectedPrice: trade.entryPrice,
851
+ actualPrice: trade.actualEntryPrice,
852
+ }),
853
+ exitSlippagePercent: getPriceSlippagePercent({
854
+ expectedPrice: getExpectedExitPrice(trade),
855
+ actualPrice: trade.actualExitPrice ?? trade.exitPrice,
856
+ }),
857
+ exitType: trade.exitType ?? null,
858
+ takeProfitPrice: toFiniteNumberOrNull(trade.aiAnalysis?.takeProfitPrice),
859
+ stopLossPrice: toFiniteNumberOrNull(trade.aiAnalysis?.stopLossPrice),
860
+ takeProfitPercent: getTradeLevelPercent({
861
+ direction: trade.direction,
862
+ entryPrice: trade.entryPrice,
863
+ levelPrice: trade.aiAnalysis?.takeProfitPrice,
864
+ kind: 'takeProfit',
865
+ }),
866
+ stopLossPercent: getTradeLevelPercent({
867
+ direction: trade.direction,
868
+ entryPrice: trade.entryPrice,
869
+ levelPrice: trade.aiAnalysis?.stopLossPrice,
870
+ kind: 'stopLoss',
871
+ }),
872
+ openFee: toFiniteNumberOrNull(trade.openFee),
873
+ closeFee: toFiniteNumberOrNull(trade.closeFee),
874
+ fundingFee: toFiniteNumberOrNull(trade.fundingFee),
875
+ totalFee: getTradeTotalFee(trade),
876
+ lastSyncedAt:
877
+ typeof trade.lastSyncedAt === 'number' ? trade.lastSyncedAt : null,
878
+ });
879
+
880
+ const removeClosedPnlFromExactMaps = ({
881
+ exactByOrderLinkId,
882
+ exactByOrderId,
883
+ row,
884
+ }: {
885
+ exactByOrderLinkId: Map<string, ClosedPnlRecordWithOrderLinkId>;
886
+ exactByOrderId: Map<string, ClosedPnlRecordWithOrderLinkId>;
887
+ row: ClosedPnlRecordWithOrderLinkId;
888
+ }) => {
889
+ if (row.orderLinkId) {
890
+ exactByOrderLinkId.delete(row.orderLinkId);
891
+ }
892
+
893
+ if (row.orderId) {
894
+ exactByOrderId.delete(row.orderId);
895
+ }
896
+ };
897
+
898
+ const removeClosedPnlFromSymbolBuckets = (
899
+ buckets: Map<string, ClosedPnlRecordWithOrderLinkId[]>,
900
+ row: ClosedPnlRecordWithOrderLinkId,
901
+ ) => {
902
+ const rows = buckets.get(row.symbol);
903
+ if (!rows?.length) {
904
+ return;
905
+ }
906
+
907
+ const index = rows.findIndex((candidate) => candidate === row);
908
+ if (index >= 0) {
909
+ rows.splice(index, 1);
910
+ }
911
+ };
912
+
913
+ const takeExactClosedPnlMatch = ({
914
+ exactByOrderLinkId,
915
+ exactByOrderId,
916
+ symbolBuckets,
917
+ orderLinkId,
918
+ orderId,
919
+ }: {
920
+ exactByOrderLinkId: Map<string, ClosedPnlRecordWithOrderLinkId>;
921
+ exactByOrderId: Map<string, ClosedPnlRecordWithOrderLinkId>;
922
+ symbolBuckets: Map<string, ClosedPnlRecordWithOrderLinkId[]>;
923
+ orderLinkId?: string | null;
924
+ orderId?: string | null;
925
+ }) => {
926
+ const exactKeys: Array<
927
+ [Map<string, ClosedPnlRecordWithOrderLinkId>, string | null | undefined]
928
+ > = [
929
+ [exactByOrderLinkId, orderLinkId],
930
+ [exactByOrderId, orderId],
931
+ ];
932
+
933
+ for (const [bucket, key] of exactKeys) {
934
+ const normalizedKey = toNonEmptyString(key);
935
+
936
+ if (!normalizedKey) {
937
+ continue;
938
+ }
939
+
940
+ const exactMatch = bucket.get(normalizedKey);
941
+ if (!exactMatch) {
942
+ continue;
943
+ }
944
+
945
+ removeClosedPnlFromExactMaps({
946
+ exactByOrderLinkId,
947
+ exactByOrderId,
948
+ row: exactMatch,
949
+ });
950
+ removeClosedPnlFromSymbolBuckets(symbolBuckets, exactMatch);
951
+ return exactMatch;
952
+ }
953
+
954
+ return null;
955
+ };
956
+
957
+ export const takeClosedPnlMatch = ({
958
+ exactByOrderLinkId,
959
+ exactByOrderId = new Map<string, ClosedPnlRecordWithOrderLinkId>(),
960
+ symbolBuckets,
961
+ trade,
962
+ }: {
963
+ exactByOrderLinkId: Map<string, ClosedPnlRecordWithOrderLinkId>;
964
+ exactByOrderId?: Map<string, ClosedPnlRecordWithOrderLinkId>;
965
+ symbolBuckets: Map<string, ClosedPnlRecordWithOrderLinkId[]>;
966
+ trade: RuntimeTradeRecord;
967
+ }) => {
968
+ const exactMatch = takeExactClosedPnlMatch({
969
+ exactByOrderLinkId,
970
+ exactByOrderId,
971
+ symbolBuckets,
972
+ orderLinkId: trade.orderId,
973
+ orderId: trade.orderId,
974
+ });
975
+
976
+ if (exactMatch) {
977
+ return exactMatch;
978
+ }
979
+
980
+ const rows = symbolBuckets.get(trade.symbol);
981
+ if (!rows?.length) {
982
+ return null;
983
+ }
984
+
985
+ const minimumClosedAt = trade.entryTimestamp - 5 * 60_000;
986
+ const matchIndex = rows.reduce((bestIndex, row, index) => {
987
+ if (
988
+ !Number.isFinite(row.closedAt) ||
989
+ row.closedAt < minimumClosedAt ||
990
+ (row.direction && row.direction !== trade.direction)
991
+ ) {
992
+ return bestIndex;
993
+ }
994
+
995
+ if (bestIndex < 0) {
996
+ return index;
997
+ }
998
+
999
+ const best = rows[bestIndex];
1000
+ return row.closedAt < best.closedAt ? index : bestIndex;
1001
+ }, -1);
1002
+
1003
+ if (matchIndex < 0) {
1004
+ return null;
1005
+ }
1006
+
1007
+ const [row] = rows.splice(matchIndex, 1);
1008
+ if (row) {
1009
+ removeClosedPnlFromExactMaps({
1010
+ exactByOrderLinkId,
1011
+ exactByOrderId,
1012
+ row,
1013
+ });
1014
+ }
1015
+
1016
+ return row ?? null;
1017
+ };
1018
+
1019
+ const takeClosedPnlMatchForExchangeEntry = ({
1020
+ exactByOrderLinkId,
1021
+ exactByOrderId,
1022
+ symbolBuckets,
1023
+ entry,
1024
+ }: {
1025
+ exactByOrderLinkId: Map<string, ClosedPnlRecordWithOrderLinkId>;
1026
+ exactByOrderId: Map<string, ClosedPnlRecordWithOrderLinkId>;
1027
+ symbolBuckets: Map<string, ClosedPnlRecordWithOrderLinkId[]>;
1028
+ entry: ExchangeEntryRecord;
1029
+ }) => {
1030
+ const exactMatch = takeExactClosedPnlMatch({
1031
+ exactByOrderLinkId,
1032
+ exactByOrderId,
1033
+ symbolBuckets,
1034
+ orderLinkId: entry.orderLinkId,
1035
+ orderId: entry.orderId,
1036
+ });
1037
+
1038
+ if (exactMatch) {
1039
+ return exactMatch;
1040
+ }
1041
+
1042
+ const rows = symbolBuckets.get(entry.symbol);
1043
+ if (!rows?.length) {
1044
+ return null;
1045
+ }
1046
+
1047
+ const minimumClosedAt = entry.entryTimestamp - 5 * 60_000;
1048
+ const matchIndex = rows.reduce((bestIndex, row, index) => {
1049
+ if (
1050
+ !Number.isFinite(row.closedAt) ||
1051
+ row.closedAt < minimumClosedAt ||
1052
+ (row.direction && row.direction !== entry.direction)
1053
+ ) {
1054
+ return bestIndex;
1055
+ }
1056
+
1057
+ if (bestIndex < 0) {
1058
+ return index;
1059
+ }
1060
+
1061
+ const best = rows[bestIndex];
1062
+ return row.closedAt < best.closedAt ? index : bestIndex;
1063
+ }, -1);
1064
+
1065
+ if (matchIndex < 0) {
1066
+ return null;
1067
+ }
1068
+
1069
+ const [row] = rows.splice(matchIndex, 1);
1070
+ if (row) {
1071
+ removeClosedPnlFromExactMaps({
1072
+ exactByOrderLinkId,
1073
+ exactByOrderId,
1074
+ row,
1075
+ });
1076
+ }
1077
+
1078
+ return row ?? null;
1079
+ };
1080
+
1081
+ const aggregateExchangeEntriesByOrder = (entryRows: ExchangeEntryRecord[]) => {
1082
+ const grouped = new Map<
1083
+ string,
1084
+ ExchangeEntryRecord & {
1085
+ _qtyForPricing: number;
1086
+ _notionalForPricing: number;
1087
+ }
1088
+ >();
1089
+
1090
+ entryRows.forEach((entry, index) => {
1091
+ const orderLinkId = toNonEmptyString(entry.orderLinkId);
1092
+ const orderId = toNonEmptyString(entry.orderId);
1093
+ const groupKey =
1094
+ orderLinkId ||
1095
+ orderId ||
1096
+ `${entry.symbol}:${entry.direction}:${entry.entryTimestamp}:${index}`;
1097
+ const existing = grouped.get(groupKey);
1098
+
1099
+ if (!existing) {
1100
+ grouped.set(groupKey, {
1101
+ ...entry,
1102
+ qty: Number.isFinite(entry.qty) ? entry.qty : 0,
1103
+ _qtyForPricing:
1104
+ Number.isFinite(entry.qty) &&
1105
+ Number.isFinite(entry.entryPrice) &&
1106
+ entry.entryPrice != null
1107
+ ? entry.qty
1108
+ : 0,
1109
+ _notionalForPricing:
1110
+ Number.isFinite(entry.qty) &&
1111
+ Number.isFinite(entry.entryPrice) &&
1112
+ entry.entryPrice != null
1113
+ ? entry.qty * entry.entryPrice
1114
+ : 0,
1115
+ });
1116
+ return;
1117
+ }
1118
+
1119
+ existing.qty += Number.isFinite(entry.qty) ? entry.qty : 0;
1120
+ existing.entryTimestamp = Math.min(
1121
+ existing.entryTimestamp,
1122
+ entry.entryTimestamp,
1123
+ );
1124
+
1125
+ if (
1126
+ Number.isFinite(entry.qty) &&
1127
+ Number.isFinite(entry.entryPrice) &&
1128
+ entry.entryPrice != null
1129
+ ) {
1130
+ existing._qtyForPricing += entry.qty;
1131
+ existing._notionalForPricing += entry.qty * entry.entryPrice;
1132
+ }
1133
+ });
1134
+
1135
+ return [...grouped.values()]
1136
+ .map(({ _qtyForPricing, _notionalForPricing, ...entry }) => ({
1137
+ ...entry,
1138
+ qty: roundValue(entry.qty, 8),
1139
+ entryPrice:
1140
+ _qtyForPricing > 0
1141
+ ? roundValue(_notionalForPricing / _qtyForPricing, 8)
1142
+ : null,
1143
+ }))
1144
+ .sort((left, right) => left.entryTimestamp - right.entryTimestamp);
1145
+ };
1146
+
1147
+ export const buildExchangeFallbackRuntimeTrades = ({
1148
+ entryRows,
1149
+ closedPnlRows,
1150
+ openPositions,
1151
+ strategyNames,
1152
+ existingTrades,
1153
+ endTime,
1154
+ }: {
1155
+ entryRows: ExchangeEntryRecord[];
1156
+ closedPnlRows: ClosedPnlRecordWithOrderLinkId[];
1157
+ openPositions: PositionPnlSnapshot[];
1158
+ strategyNames: string[];
1159
+ existingTrades: RuntimeTradeRecord[];
1160
+ endTime: number;
1161
+ }) => {
1162
+ if (!entryRows.length && !closedPnlRows.length) {
1163
+ return [];
1164
+ }
1165
+
1166
+ const strategyNameByOrderId = new Map(
1167
+ existingTrades
1168
+ .filter(
1169
+ (trade): trade is RuntimeTradeRecord & { strategy: string } =>
1170
+ typeof trade.orderId === 'string' &&
1171
+ trade.orderId.trim().length > 0 &&
1172
+ typeof trade.strategy === 'string' &&
1173
+ trade.strategy.trim().length > 0,
1174
+ )
1175
+ .map((trade) => [trade.orderId, trade.strategy]),
1176
+ );
1177
+ const strategyNamesPool = [
1178
+ ...new Set([
1179
+ ...strategyNames,
1180
+ ...existingTrades.map((trade) => trade.strategy).filter(Boolean),
1181
+ ]),
1182
+ ];
1183
+ const openPositionBySymbol = new Map(
1184
+ openPositions.map((position) => [position.symbol, position]),
1185
+ );
1186
+ const existingOrderIds = new Set(
1187
+ existingTrades
1188
+ .map((trade) => toNonEmptyString(trade.orderId))
1189
+ .filter((value): value is string => value != null),
1190
+ );
1191
+ const exactByOrderLinkId = new Map(
1192
+ closedPnlRows
1193
+ .filter(
1194
+ (
1195
+ row,
1196
+ ): row is ClosedPnlRecordWithOrderLinkId & { orderLinkId: string } =>
1197
+ typeof row.orderLinkId === 'string' && row.orderLinkId.length > 0,
1198
+ )
1199
+ .map((row) => [row.orderLinkId, row]),
1200
+ );
1201
+ const exactByOrderId = new Map(
1202
+ closedPnlRows
1203
+ .filter(
1204
+ (row): row is ClosedPnlRecordWithOrderLinkId & { orderId: string } =>
1205
+ typeof row.orderId === 'string' && row.orderId.length > 0,
1206
+ )
1207
+ .map((row) => [row.orderId, row]),
1208
+ );
1209
+ const symbolBuckets = new Map<string, ClosedPnlRecordWithOrderLinkId[]>();
1210
+
1211
+ for (const row of closedPnlRows) {
1212
+ const bucket = symbolBuckets.get(row.symbol) ?? [];
1213
+ bucket.push(row);
1214
+ symbolBuckets.set(row.symbol, bucket);
1215
+ }
1216
+
1217
+ const fallbackTrades = aggregateExchangeEntriesByOrder(entryRows)
1218
+ .map<RuntimeTradeRecord | null>((entry) => {
1219
+ const normalizedOrderLinkId = toNonEmptyString(entry.orderLinkId);
1220
+ const normalizedOrderId = toNonEmptyString(entry.orderId);
1221
+ const runtimeOrderId = normalizedOrderLinkId ?? normalizedOrderId;
1222
+
1223
+ if (!runtimeOrderId || existingOrderIds.has(runtimeOrderId)) {
1224
+ return null;
1225
+ }
1226
+
1227
+ const strategyName =
1228
+ (normalizedOrderLinkId
1229
+ ? strategyNameByOrderId.get(normalizedOrderLinkId)
1230
+ : null) ??
1231
+ (normalizedOrderId
1232
+ ? strategyNameByOrderId.get(normalizedOrderId)
1233
+ : null) ??
1234
+ resolveStrategyNameByOrderLinkId({
1235
+ orderLinkId: normalizedOrderLinkId,
1236
+ strategyNames: strategyNamesPool,
1237
+ });
1238
+
1239
+ if (!strategyName) {
1240
+ return null;
1241
+ }
1242
+
1243
+ const matchedClosedPnl = takeClosedPnlMatchForExchangeEntry({
1244
+ exactByOrderLinkId,
1245
+ exactByOrderId,
1246
+ symbolBuckets,
1247
+ entry,
1248
+ });
1249
+ const openPosition = openPositionBySymbol.get(entry.symbol);
1250
+ const isActive =
1251
+ !matchedClosedPnl &&
1252
+ openPosition?.direction === entry.direction &&
1253
+ Number.isFinite(openPosition.currentPrice) &&
1254
+ Number.isFinite(openPosition.unrealizedPnl);
1255
+ const entryPrice =
1256
+ typeof entry.entryPrice === 'number' &&
1257
+ Number.isFinite(entry.entryPrice)
1258
+ ? entry.entryPrice
1259
+ : typeof matchedClosedPnl?.entryPrice === 'number' &&
1260
+ Number.isFinite(matchedClosedPnl.entryPrice)
1261
+ ? matchedClosedPnl.entryPrice
1262
+ : null;
1263
+
1264
+ if (entryPrice == null) {
1265
+ return null;
1266
+ }
1267
+
1268
+ return {
1269
+ orderId: runtimeOrderId,
1270
+ strategy: strategyName,
1271
+ symbol: entry.symbol,
1272
+ direction: entry.direction,
1273
+ qty: entry.qty,
1274
+ entryPrice,
1275
+ actualEntryPrice:
1276
+ matchedClosedPnl?.entryPrice ?? entry.entryPrice ?? null,
1277
+ entryTimestamp: entry.entryTimestamp,
1278
+ status: isActive ? 'active' : 'closed',
1279
+ currentPrice: isActive
1280
+ ? openPosition?.currentPrice ?? null
1281
+ : matchedClosedPnl?.exitPrice ?? null,
1282
+ currentPnl: isActive
1283
+ ? openPosition?.unrealizedPnl ?? null
1284
+ : matchedClosedPnl?.closedPnl ?? null,
1285
+ closedPnl: isActive ? null : matchedClosedPnl?.closedPnl ?? null,
1286
+ exitPrice: isActive ? null : matchedClosedPnl?.exitPrice ?? null,
1287
+ actualExitPrice: isActive ? null : matchedClosedPnl?.exitPrice ?? null,
1288
+ exitTimestamp: isActive ? null : matchedClosedPnl?.closedAt ?? null,
1289
+ aiAnalysis: isActive
1290
+ ? buildRiskLevelsAnalysis({
1291
+ takeProfitPrice: openPosition?.takeProfitPrice,
1292
+ stopLossPrice: openPosition?.stopLossPrice,
1293
+ })
1294
+ : null,
1295
+ openFee: matchedClosedPnl?.openFee ?? entry.openFee ?? null,
1296
+ closeFee: matchedClosedPnl?.closeFee ?? entry.closeFee ?? null,
1297
+ fundingFee: matchedClosedPnl?.fundingFee ?? entry.fundingFee ?? null,
1298
+ totalFee: matchedClosedPnl?.totalFee ?? entry.totalFee ?? null,
1299
+ lastSyncedAt: endTime,
1300
+ };
1301
+ })
1302
+ .filter((trade): trade is RuntimeTradeRecord => trade != null);
1303
+
1304
+ const fallbackOrderIds = new Set([
1305
+ ...existingOrderIds,
1306
+ ...fallbackTrades.map((trade) => trade.orderId),
1307
+ ]);
1308
+ const remainingClosedPnlRows = [...symbolBuckets.values()].flat();
1309
+ const closedPnlFallbackTrades = remainingClosedPnlRows
1310
+ .map<RuntimeTradeRecord | null>((row) => {
1311
+ const normalizedOrderLinkId = toNonEmptyString(row.orderLinkId);
1312
+ const normalizedOrderId = toNonEmptyString(row.orderId);
1313
+ const runtimeOrderId = normalizedOrderLinkId ?? normalizedOrderId;
1314
+
1315
+ if (!runtimeOrderId || fallbackOrderIds.has(runtimeOrderId)) {
1316
+ return null;
1317
+ }
1318
+
1319
+ const strategyName =
1320
+ (normalizedOrderLinkId
1321
+ ? strategyNameByOrderId.get(normalizedOrderLinkId)
1322
+ : null) ??
1323
+ (normalizedOrderId
1324
+ ? strategyNameByOrderId.get(normalizedOrderId)
1325
+ : null) ??
1326
+ resolveStrategyNameByOrderLinkId({
1327
+ orderLinkId: normalizedOrderLinkId,
1328
+ strategyNames: strategyNamesPool,
1329
+ });
1330
+
1331
+ if (
1332
+ !strategyName ||
1333
+ row.entryPrice == null ||
1334
+ !Number.isFinite(row.entryPrice)
1335
+ ) {
1336
+ return null;
1337
+ }
1338
+
1339
+ const direction = row.direction ?? null;
1340
+ if (!direction) {
1341
+ return null;
1342
+ }
1343
+
1344
+ return {
1345
+ orderId: runtimeOrderId,
1346
+ strategy: strategyName,
1347
+ symbol: row.symbol,
1348
+ direction,
1349
+ qty: row.qty,
1350
+ entryPrice: row.entryPrice,
1351
+ actualEntryPrice: row.entryPrice,
1352
+ entryTimestamp:
1353
+ typeof row.entryTimestamp === 'number' &&
1354
+ Number.isFinite(row.entryTimestamp)
1355
+ ? row.entryTimestamp
1356
+ : row.closedAt,
1357
+ status: 'closed',
1358
+ currentPrice: row.exitPrice,
1359
+ currentPnl: row.closedPnl,
1360
+ closedPnl: row.closedPnl,
1361
+ exitPrice: row.exitPrice,
1362
+ actualExitPrice: row.exitPrice,
1363
+ exitTimestamp: row.closedAt,
1364
+ openFee: row.openFee ?? null,
1365
+ closeFee: row.closeFee ?? null,
1366
+ fundingFee: row.fundingFee ?? null,
1367
+ totalFee: row.totalFee ?? null,
1368
+ lastSyncedAt: endTime,
1369
+ };
1370
+ })
1371
+ .filter((trade): trade is RuntimeTradeRecord => trade != null);
1372
+
1373
+ return [...fallbackTrades, ...closedPnlFallbackTrades].sort(
1374
+ (left, right) => left.entryTimestamp - right.entryTimestamp,
1375
+ );
1376
+ };