@tradejs/app 3.0.0 → 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.
- package/package.json +6 -10
- package/src/app/actions/strategies.ts +4 -2
- package/src/app/api/ai/route.ts +21 -50
- package/src/app/api/strategies/runtime/route.ts +1 -1
- package/src/app/components/Strategies/RuntimeStrategyCard.presenter.ts +5 -2
- package/src/app/components/Strategies/RuntimeStrategyCard.tsx +1 -2
- package/src/app/components/Strategies/RuntimeStrategyConfigDrawer.tsx +1 -2
- package/src/app/components/Strategies/RuntimeStrategyStatsDrawer.tsx +1 -2
- package/src/app/lib/connectorCreator.ts +1 -10
- package/src/app/routes/derivatives/DerivativesDashboardView.tsx +900 -0
- package/src/app/routes/derivatives/derivativesDashboardConfig.ts +53 -0
- package/src/app/routes/derivatives/derivativesDashboardLoader.ts +74 -0
- package/src/app/routes/derivatives/page.tsx +18 -1087
- package/src/app/routes/derivatives/useDerivativesDashboard.ts +105 -0
- package/src/app/routes/strategies/StrategiesPageClient.tsx +4 -2
- package/src/app/lib/runtimeDashboard.ts +0 -700
- package/src/app/lib/runtimeStrategies.ts +0 -1107
- package/src/app/lib/runtimeStrategyContracts.ts +0 -85
- package/src/app/lib/runtimeStrategyLineage.ts +0 -264
- package/src/app/lib/runtimeTradeReconciliation.ts +0 -113
- package/src/app/lib/runtimeTradeSync.ts +0 -280
- package/src/app/lib/strategyEvidenceTimeline.ts +0 -298
|
@@ -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
|
-
};
|