@tradejs/core 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.
- package/dist/backtest.d.mts +109 -9
- package/dist/backtest.d.ts +109 -9
- package/dist/backtest.js +487 -145
- package/dist/backtest.mjs +414 -82
- package/dist/chunk-7P2KNFD4.mjs +11847 -0
- package/dist/{chunk-JLORHLL6.mjs → chunk-DXJ4NCFJ.mjs} +65 -6
- package/dist/chunk-OJPHC3S2.mjs +8 -0
- package/dist/{chunk-PQETJ42A.mjs → chunk-PNBS6J3G.mjs} +22 -1
- package/dist/constants.d.mts +26 -5
- package/dist/constants.d.ts +26 -5
- package/dist/constants.js +81 -9
- package/dist/constants.mjs +31 -5
- package/dist/data.mjs +3 -5
- package/dist/grid.d.mts +9 -0
- package/dist/grid.d.ts +9 -0
- package/dist/grid.js +168 -0
- package/dist/grid.mjs +98 -0
- package/dist/indicators-Da_i06-8.d.mts +288 -0
- package/dist/indicators-Da_i06-8.d.ts +288 -0
- package/dist/indicators.d.mts +4 -39
- package/dist/indicators.d.ts +4 -39
- package/dist/indicators.js +10488 -423
- package/dist/indicators.mjs +11 -3
- package/dist/strategies.d.mts +31 -12
- package/dist/strategies.d.ts +31 -12
- package/dist/strategies.js +11283 -336
- package/dist/strategies.mjs +1246 -119
- package/dist/{time-BMkFD4Kd.d.mts → time-BQ3AXmxo.d.mts} +3 -1
- package/dist/{time-BMkFD4Kd.d.ts → time-BQ3AXmxo.d.ts} +3 -1
- package/dist/time.d.mts +1 -1
- package/dist/time.d.ts +1 -1
- package/dist/time.js +38 -0
- package/dist/time.mjs +6 -2
- package/dist/trade.d.mts +54 -0
- package/dist/trade.d.ts +54 -0
- package/dist/trade.js +352 -0
- package/dist/trade.mjs +264 -0
- package/package.json +19 -5
- package/dist/chunk-622V7IAT.mjs +0 -1810
- package/dist/indicators-B-GGjP5F.d.mts +0 -65
- package/dist/indicators-B-GGjP5F.d.ts +0 -65
package/dist/backtest.mjs
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
uuid
|
|
3
|
-
} from "./chunk-AJK4NS7Y.mjs";
|
|
4
1
|
import {
|
|
5
2
|
absReturns,
|
|
6
3
|
equityPoints,
|
|
@@ -11,82 +8,11 @@ import {
|
|
|
11
8
|
} from "./chunk-AYC2QVKI.mjs";
|
|
12
9
|
import {
|
|
13
10
|
compactOrderLog,
|
|
14
|
-
getTimeline
|
|
15
|
-
|
|
16
|
-
} from "./chunk-PQETJ42A.mjs";
|
|
11
|
+
getTimeline
|
|
12
|
+
} from "./chunk-PNBS6J3G.mjs";
|
|
17
13
|
import {
|
|
18
|
-
BACKTEST_DEFAULT_DAYS,
|
|
19
14
|
TestThresholdsConfig
|
|
20
|
-
} from "./chunk-
|
|
21
|
-
|
|
22
|
-
// src/utils/grid.ts
|
|
23
|
-
import _ from "lodash";
|
|
24
|
-
var generateParamGrid = (paramOptions) => {
|
|
25
|
-
const keys = Object.keys(paramOptions);
|
|
26
|
-
const combinations = [];
|
|
27
|
-
const helper = (index = 0, current = {}) => {
|
|
28
|
-
if (index === keys.length) {
|
|
29
|
-
combinations.push(current);
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
const key = keys[index];
|
|
33
|
-
for (const value of paramOptions[key] || []) {
|
|
34
|
-
const copiedValue = typeof value === "object" && value !== null ? structuredClone(value) : value;
|
|
35
|
-
helper(index + 1, {
|
|
36
|
-
...current,
|
|
37
|
-
[key]: copiedValue
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
helper();
|
|
42
|
-
return combinations;
|
|
43
|
-
};
|
|
44
|
-
var generateName = (prefix) => `${prefix}_${uuid(6)}`;
|
|
45
|
-
var mergeConfigs = (configs) => {
|
|
46
|
-
const result = {};
|
|
47
|
-
for (const config of configs) {
|
|
48
|
-
for (const [key, value] of Object.entries(config)) {
|
|
49
|
-
if (!result[key]) {
|
|
50
|
-
result[key] = [];
|
|
51
|
-
}
|
|
52
|
-
const clonedValue = typeof value === "object" && value !== null ? _.cloneDeep(value) : value;
|
|
53
|
-
const isDuplicate = result[key].some(
|
|
54
|
-
(existing) => _.isEqual(existing, value)
|
|
55
|
-
);
|
|
56
|
-
if (!isDuplicate) {
|
|
57
|
-
result[key].push(clonedValue);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
for (const key in result) {
|
|
62
|
-
if (result[key].every((v) => typeof v === "number")) {
|
|
63
|
-
result[key] = _.sortBy(result[key]);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return result;
|
|
67
|
-
};
|
|
68
|
-
var createTestSuite = (userName, tickers, strategyName, backtestConfig, connectorName) => {
|
|
69
|
-
const start = getTimestamp(BACKTEST_DEFAULT_DAYS);
|
|
70
|
-
const end = getTimestamp();
|
|
71
|
-
const testSuiteId = uuid(6);
|
|
72
|
-
const paramGrid = generateParamGrid(backtestConfig);
|
|
73
|
-
return tickers.flatMap(
|
|
74
|
-
(symbol) => paramGrid.map((params) => {
|
|
75
|
-
const testId = uuid(6);
|
|
76
|
-
return {
|
|
77
|
-
userName,
|
|
78
|
-
name: `${symbol}_${testSuiteId}_${testId}`,
|
|
79
|
-
testId,
|
|
80
|
-
testSuiteId,
|
|
81
|
-
symbol,
|
|
82
|
-
options: { start, end },
|
|
83
|
-
strategyName,
|
|
84
|
-
strategyConfig: params,
|
|
85
|
-
connectorName
|
|
86
|
-
};
|
|
87
|
-
})
|
|
88
|
-
);
|
|
89
|
-
};
|
|
15
|
+
} from "./chunk-DXJ4NCFJ.mjs";
|
|
90
16
|
|
|
91
17
|
// src/utils/tests.ts
|
|
92
18
|
var parseTestName = (testName) => {
|
|
@@ -133,6 +59,412 @@ var calculateMaxDrawdown = (amounts) => {
|
|
|
133
59
|
}
|
|
134
60
|
return maxDrawdown;
|
|
135
61
|
};
|
|
62
|
+
var MS_IN_DAY = 24 * 60 * 60 * 1e3;
|
|
63
|
+
var DAYS_IN_YEAR = 365;
|
|
64
|
+
var isFiniteMetric = (value) => typeof value === "number" && Number.isFinite(value);
|
|
65
|
+
var safeRatio = (numerator, denominator) => {
|
|
66
|
+
if (!isFiniteMetric(numerator) || !isFiniteMetric(denominator)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (Math.abs(denominator) <= Number.EPSILON) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return numerator / denominator;
|
|
73
|
+
};
|
|
74
|
+
var percentile = (values, p) => {
|
|
75
|
+
if (!values.length) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
79
|
+
const index = (sorted.length - 1) * p;
|
|
80
|
+
const lower = Math.floor(index);
|
|
81
|
+
const upper = Math.ceil(index);
|
|
82
|
+
if (lower === upper) {
|
|
83
|
+
return sorted[lower];
|
|
84
|
+
}
|
|
85
|
+
const weight = index - lower;
|
|
86
|
+
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
|
87
|
+
};
|
|
88
|
+
var medianValue = (values) => percentile(values, 0.5);
|
|
89
|
+
var getMonthKey = (timestamp) => {
|
|
90
|
+
const date = new Date(timestamp);
|
|
91
|
+
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
92
|
+
};
|
|
93
|
+
var getQuarterKey = (timestamp) => {
|
|
94
|
+
const date = new Date(timestamp);
|
|
95
|
+
return `${date.getUTCFullYear()} Q${Math.floor(date.getUTCMonth() / 3) + 1}`;
|
|
96
|
+
};
|
|
97
|
+
var resolveTradeSession = (timestamp) => {
|
|
98
|
+
const hour = new Date(timestamp).getUTCHours();
|
|
99
|
+
if (hour < 8) {
|
|
100
|
+
return "Asia";
|
|
101
|
+
}
|
|
102
|
+
if (hour < 16) {
|
|
103
|
+
return "Europe";
|
|
104
|
+
}
|
|
105
|
+
return "US";
|
|
106
|
+
};
|
|
107
|
+
var getDateKey = (timestamp) => {
|
|
108
|
+
const date = new Date(timestamp);
|
|
109
|
+
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
|
|
110
|
+
};
|
|
111
|
+
var calculateDrawdownStats = (trades, orderLog) => {
|
|
112
|
+
const explicitPoints = (orderLog ?? []).map((point) => ({ timestamp: point[0], amount: point[1] })).filter(
|
|
113
|
+
(point) => isFiniteMetric(point.timestamp) && isFiniteMetric(point.amount)
|
|
114
|
+
).sort((a, b) => a.timestamp - b.timestamp);
|
|
115
|
+
const points = explicitPoints.length ? explicitPoints : trades.slice().sort((a, b) => a.timestamp - b.timestamp).reduce(
|
|
116
|
+
(acc, trade) => {
|
|
117
|
+
const previous = acc[acc.length - 1]?.amount ?? 0;
|
|
118
|
+
acc.push({
|
|
119
|
+
timestamp: trade.timestamp,
|
|
120
|
+
amount: previous + trade.pnl
|
|
121
|
+
});
|
|
122
|
+
return acc;
|
|
123
|
+
},
|
|
124
|
+
[{ timestamp: trades[0]?.timestamp ?? 0, amount: 0 }]
|
|
125
|
+
);
|
|
126
|
+
if (!points.length) {
|
|
127
|
+
return { absolute: null, percent: null };
|
|
128
|
+
}
|
|
129
|
+
let peak = points[0].amount;
|
|
130
|
+
let maxAbsolute = 0;
|
|
131
|
+
let maxPercent = 0;
|
|
132
|
+
for (const point of points) {
|
|
133
|
+
if (point.amount > peak) {
|
|
134
|
+
peak = point.amount;
|
|
135
|
+
}
|
|
136
|
+
const absolute = peak - point.amount;
|
|
137
|
+
maxAbsolute = Math.max(maxAbsolute, absolute);
|
|
138
|
+
if (peak > 0) {
|
|
139
|
+
maxPercent = Math.max(maxPercent, absolute / peak * 100);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { absolute: maxAbsolute, percent: maxPercent };
|
|
143
|
+
};
|
|
144
|
+
var calculateWorstRollingPnl = (trades, days) => {
|
|
145
|
+
if (!trades.length) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const sorted = trades.slice().sort((a, b) => a.timestamp - b.timestamp);
|
|
149
|
+
const windowMs = days * MS_IN_DAY;
|
|
150
|
+
let start = 0;
|
|
151
|
+
let rollingPnl = 0;
|
|
152
|
+
let worstPnl = 0;
|
|
153
|
+
for (let end = 0; end < sorted.length; end += 1) {
|
|
154
|
+
rollingPnl += sorted[end].pnl;
|
|
155
|
+
while (start <= end && sorted[end].timestamp - sorted[start].timestamp > windowMs) {
|
|
156
|
+
rollingPnl -= sorted[start].pnl;
|
|
157
|
+
start += 1;
|
|
158
|
+
}
|
|
159
|
+
worstPnl = Math.min(worstPnl, rollingPnl);
|
|
160
|
+
}
|
|
161
|
+
return worstPnl;
|
|
162
|
+
};
|
|
163
|
+
var calculateLossStreak = (trades) => {
|
|
164
|
+
let current = 0;
|
|
165
|
+
let max = 0;
|
|
166
|
+
for (const trade of trades.slice().sort((a, b) => a.timestamp - b.timestamp)) {
|
|
167
|
+
if (trade.pnl < 0) {
|
|
168
|
+
current += 1;
|
|
169
|
+
max = Math.max(max, current);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
current = 0;
|
|
173
|
+
}
|
|
174
|
+
return max;
|
|
175
|
+
};
|
|
176
|
+
var normalizeExitReason = (reason) => {
|
|
177
|
+
const normalized = String(reason ?? "").trim().toLowerCase();
|
|
178
|
+
if (normalized === "tp" || normalized === "take_profit") {
|
|
179
|
+
return "takeProfit";
|
|
180
|
+
}
|
|
181
|
+
if (normalized === "sl" || normalized === "stop_loss") {
|
|
182
|
+
return "stopLoss";
|
|
183
|
+
}
|
|
184
|
+
if (normalized === "exit" || normalized === "close" || normalized === "closed") {
|
|
185
|
+
return "exit";
|
|
186
|
+
}
|
|
187
|
+
return "unknown";
|
|
188
|
+
};
|
|
189
|
+
var calculateExitBreakdown = (trades) => {
|
|
190
|
+
const counts = {
|
|
191
|
+
takeProfit: 0,
|
|
192
|
+
stopLoss: 0,
|
|
193
|
+
exit: 0,
|
|
194
|
+
unknown: 0
|
|
195
|
+
};
|
|
196
|
+
for (const trade of trades) {
|
|
197
|
+
counts[normalizeExitReason(trade.exitReason)] += 1;
|
|
198
|
+
}
|
|
199
|
+
const total = trades.length;
|
|
200
|
+
const bucket = (count) => ({
|
|
201
|
+
count,
|
|
202
|
+
share: total ? count / total * 100 : null
|
|
203
|
+
});
|
|
204
|
+
return {
|
|
205
|
+
takeProfit: bucket(counts.takeProfit),
|
|
206
|
+
stopLoss: bucket(counts.stopLoss),
|
|
207
|
+
exit: bucket(counts.exit),
|
|
208
|
+
unknown: bucket(counts.unknown)
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
var calculateDailyPnlSeries = (trades, startTimestamp, endTimestamp) => {
|
|
212
|
+
const approvedTrades = trades.filter((trade) => trade.approved !== false);
|
|
213
|
+
if (!approvedTrades.length) {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
const firstTimestamp = startTimestamp ?? Math.min(...approvedTrades.map((trade) => trade.timestamp));
|
|
217
|
+
const lastTimestamp = endTimestamp ?? Math.max(...approvedTrades.map((trade) => trade.timestamp));
|
|
218
|
+
if (!isFiniteMetric(firstTimestamp) || !isFiniteMetric(lastTimestamp) || lastTimestamp < firstTimestamp) {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
const startDate = Date.UTC(
|
|
222
|
+
new Date(firstTimestamp).getUTCFullYear(),
|
|
223
|
+
new Date(firstTimestamp).getUTCMonth(),
|
|
224
|
+
new Date(firstTimestamp).getUTCDate()
|
|
225
|
+
);
|
|
226
|
+
const endDate = Date.UTC(
|
|
227
|
+
new Date(lastTimestamp).getUTCFullYear(),
|
|
228
|
+
new Date(lastTimestamp).getUTCMonth(),
|
|
229
|
+
new Date(lastTimestamp).getUTCDate()
|
|
230
|
+
);
|
|
231
|
+
const daily = /* @__PURE__ */ new Map();
|
|
232
|
+
for (let ts = startDate; ts <= endDate; ts += MS_IN_DAY) {
|
|
233
|
+
daily.set(getDateKey(ts), 0);
|
|
234
|
+
}
|
|
235
|
+
for (const trade of approvedTrades) {
|
|
236
|
+
const key = getDateKey(trade.timestamp);
|
|
237
|
+
daily.set(key, (daily.get(key) ?? 0) + trade.pnl);
|
|
238
|
+
}
|
|
239
|
+
return [...daily.values()];
|
|
240
|
+
};
|
|
241
|
+
var calculateStd = (values, valueMean) => {
|
|
242
|
+
if (!values.length) {
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
return Math.sqrt(
|
|
246
|
+
values.reduce((acc, value) => acc + (value - valueMean) ** 2, 0) / values.length
|
|
247
|
+
);
|
|
248
|
+
};
|
|
249
|
+
var calculateSkewness = (values) => {
|
|
250
|
+
if (values.length < 3) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
const valueMean = mean(values);
|
|
254
|
+
const std = calculateStd(values, valueMean);
|
|
255
|
+
if (std <= Number.EPSILON) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
return values.reduce((acc, value) => acc + ((value - valueMean) / std) ** 3, 0) / values.length;
|
|
259
|
+
};
|
|
260
|
+
var sumTopPositiveProfitShare = (pnls, count) => {
|
|
261
|
+
const grossProfit = pnls.filter((pnl) => pnl > 0).reduce((acc, pnl) => acc + pnl, 0);
|
|
262
|
+
if (grossProfit <= 0) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const topProfit = pnls.filter((pnl) => pnl > 0).sort((a, b) => b - a).slice(0, count).reduce((acc, pnl) => acc + pnl, 0);
|
|
266
|
+
return topProfit / grossProfit * 100;
|
|
267
|
+
};
|
|
268
|
+
var concentrationPercent = (items, limit) => {
|
|
269
|
+
const totals = /* @__PURE__ */ new Map();
|
|
270
|
+
for (const item of items) {
|
|
271
|
+
totals.set(item.key, (totals.get(item.key) ?? 0) + Math.abs(item.pnl));
|
|
272
|
+
}
|
|
273
|
+
const totalAbsPnl = [...totals.values()].reduce(
|
|
274
|
+
(acc, value) => acc + value,
|
|
275
|
+
0
|
|
276
|
+
);
|
|
277
|
+
if (totalAbsPnl <= 0) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
const topAbsPnl = [...totals.values()].sort((a, b) => b - a).slice(0, limit).reduce((acc, value) => acc + value, 0);
|
|
281
|
+
return topAbsPnl / totalAbsPnl * 100;
|
|
282
|
+
};
|
|
283
|
+
var calculateAdvancedTradeMetrics = ({
|
|
284
|
+
trades,
|
|
285
|
+
orderLog,
|
|
286
|
+
startTimestamp,
|
|
287
|
+
endTimestamp
|
|
288
|
+
}) => {
|
|
289
|
+
const normalizedTrades = trades.filter(
|
|
290
|
+
(trade) => isFiniteMetric(trade.timestamp) && isFiniteMetric(trade.pnl) && trade.timestamp > 0
|
|
291
|
+
).sort((a, b) => a.timestamp - b.timestamp);
|
|
292
|
+
const pnls = normalizedTrades.map((trade) => trade.pnl);
|
|
293
|
+
const wins = pnls.filter((pnl) => pnl > 0).length;
|
|
294
|
+
const losses = pnls.filter((pnl) => pnl < 0).length;
|
|
295
|
+
const totalPnl = sum(pnls);
|
|
296
|
+
const grossProfit = pnls.filter((pnl) => pnl > 0).reduce((acc, pnl) => acc + pnl, 0);
|
|
297
|
+
const grossLoss = Math.abs(
|
|
298
|
+
pnls.filter((pnl) => pnl < 0).reduce((acc, pnl) => acc + pnl, 0)
|
|
299
|
+
);
|
|
300
|
+
const avgWin = wins ? grossProfit / wins : null;
|
|
301
|
+
const avgLoss = losses ? grossLoss / losses : null;
|
|
302
|
+
const firstTimestamp = startTimestamp ?? normalizedTrades[0]?.timestamp ?? null;
|
|
303
|
+
const lastTimestamp = endTimestamp ?? normalizedTrades[normalizedTrades.length - 1]?.timestamp ?? null;
|
|
304
|
+
const periodDays = isFiniteMetric(firstTimestamp) && isFiniteMetric(lastTimestamp) && lastTimestamp > firstTimestamp ? (lastTimestamp - firstTimestamp) / MS_IN_DAY : null;
|
|
305
|
+
const drawdown = calculateDrawdownStats(normalizedTrades, orderLog);
|
|
306
|
+
const monthly = /* @__PURE__ */ new Map();
|
|
307
|
+
const quarterly = /* @__PURE__ */ new Map();
|
|
308
|
+
for (const trade of normalizedTrades) {
|
|
309
|
+
const monthKey = getMonthKey(trade.timestamp);
|
|
310
|
+
const quarterKey = getQuarterKey(trade.timestamp);
|
|
311
|
+
const month = monthly.get(monthKey) ?? {
|
|
312
|
+
pnl: 0,
|
|
313
|
+
orders: 0,
|
|
314
|
+
wins: 0,
|
|
315
|
+
timestamp: trade.timestamp
|
|
316
|
+
};
|
|
317
|
+
month.pnl += trade.pnl;
|
|
318
|
+
month.orders += 1;
|
|
319
|
+
month.wins += trade.pnl > 0 ? 1 : 0;
|
|
320
|
+
month.timestamp = Math.min(month.timestamp, trade.timestamp);
|
|
321
|
+
monthly.set(monthKey, month);
|
|
322
|
+
quarterly.set(quarterKey, (quarterly.get(quarterKey) ?? 0) + trade.pnl);
|
|
323
|
+
}
|
|
324
|
+
const monthlyStats = [...monthly.entries()].sort(
|
|
325
|
+
([a], [b]) => a.localeCompare(b)
|
|
326
|
+
);
|
|
327
|
+
const monthlyPnls = monthlyStats.map(([, stat]) => stat.pnl);
|
|
328
|
+
const monthlyWinRates = monthlyStats.map(
|
|
329
|
+
([, stat]) => stat.orders ? stat.wins / stat.orders * 100 : 0
|
|
330
|
+
);
|
|
331
|
+
const positiveMonths = monthlyStats.filter(([, stat]) => stat.pnl > 0).length;
|
|
332
|
+
const p25Monthly = percentile(monthlyPnls, 0.25);
|
|
333
|
+
const p75Monthly = percentile(monthlyPnls, 0.75);
|
|
334
|
+
const dailyPnls = calculateDailyPnlSeries(
|
|
335
|
+
normalizedTrades,
|
|
336
|
+
startTimestamp,
|
|
337
|
+
endTimestamp
|
|
338
|
+
);
|
|
339
|
+
const dailyMean = dailyPnls.length ? mean(dailyPnls) : null;
|
|
340
|
+
const dailyStd = dailyMean === null ? null : calculateStd(dailyPnls, dailyMean);
|
|
341
|
+
const downsideDailyPnls = dailyPnls.map((pnl) => Math.min(pnl, 0));
|
|
342
|
+
const downsideStd = downsideDailyPnls.some((pnl) => pnl < 0) ? Math.sqrt(
|
|
343
|
+
downsideDailyPnls.reduce((acc, pnl) => acc + pnl ** 2, 0) / downsideDailyPnls.length
|
|
344
|
+
) : null;
|
|
345
|
+
const annualizedPnl = dailyMean === null ? null : dailyMean * DAYS_IN_YEAR;
|
|
346
|
+
const approvedFlags = normalizedTrades.filter(
|
|
347
|
+
(trade) => typeof trade.approved === "boolean"
|
|
348
|
+
);
|
|
349
|
+
const slippageCosts = normalizedTrades.map((trade) => trade.slippageCost).filter(isFiniteMetric);
|
|
350
|
+
const pnlBeforeSlippage = normalizedTrades.reduce((acc, trade) => {
|
|
351
|
+
if (isFiniteMetric(trade.grossPnl)) {
|
|
352
|
+
return acc + trade.grossPnl;
|
|
353
|
+
}
|
|
354
|
+
if (isFiniteMetric(trade.slippageCost)) {
|
|
355
|
+
return acc + trade.pnl + trade.slippageCost;
|
|
356
|
+
}
|
|
357
|
+
return acc + trade.pnl;
|
|
358
|
+
}, 0);
|
|
359
|
+
const directionStats = normalizedTrades.reduce(
|
|
360
|
+
(acc, trade) => {
|
|
361
|
+
const direction = String(trade.direction ?? "").toUpperCase();
|
|
362
|
+
if (direction === "LONG") {
|
|
363
|
+
acc.longTrades += 1;
|
|
364
|
+
acc.longPnl += trade.pnl;
|
|
365
|
+
}
|
|
366
|
+
if (direction === "SHORT") {
|
|
367
|
+
acc.shortTrades += 1;
|
|
368
|
+
acc.shortPnl += trade.pnl;
|
|
369
|
+
}
|
|
370
|
+
return acc;
|
|
371
|
+
},
|
|
372
|
+
{ longTrades: 0, shortTrades: 0, longPnl: 0, shortPnl: 0 }
|
|
373
|
+
);
|
|
374
|
+
return {
|
|
375
|
+
core: {
|
|
376
|
+
trades: normalizedTrades.length,
|
|
377
|
+
wins,
|
|
378
|
+
losses,
|
|
379
|
+
winRate: normalizedTrades.length ? wins / normalizedTrades.length * 100 : null,
|
|
380
|
+
totalPnl,
|
|
381
|
+
avgTrade: normalizedTrades.length ? totalPnl / normalizedTrades.length : null,
|
|
382
|
+
grossProfit,
|
|
383
|
+
grossLoss,
|
|
384
|
+
profitFactor: safeRatio(grossProfit, grossLoss),
|
|
385
|
+
payoffRatio: safeRatio(avgWin, avgLoss),
|
|
386
|
+
expectancy: normalizedTrades.length ? totalPnl / normalizedTrades.length : null,
|
|
387
|
+
tradesPerDay: periodDays && periodDays > 0 ? normalizedTrades.length / periodDays : null,
|
|
388
|
+
tradesPerWeek: periodDays && periodDays > 0 ? normalizedTrades.length / periodDays * 7 : null
|
|
389
|
+
},
|
|
390
|
+
risk: {
|
|
391
|
+
maxDrawdown: drawdown.absolute,
|
|
392
|
+
maxDrawdownPercent: drawdown.percent,
|
|
393
|
+
maxDrawdownToTotalProfit: totalPnl > 0 ? safeRatio(drawdown.absolute, totalPnl) : null,
|
|
394
|
+
maxDrawdownToGrossProfit: grossProfit > 0 ? safeRatio(drawdown.absolute, grossProfit) : null,
|
|
395
|
+
recoveryFactor: safeRatio(totalPnl, drawdown.absolute),
|
|
396
|
+
maxLossStreak: calculateLossStreak(normalizedTrades),
|
|
397
|
+
losingMonthsCount: monthlyStats.filter(([, stat]) => stat.pnl < 0).length,
|
|
398
|
+
worstMonthPnl: monthlyPnls.length ? Math.min(...monthlyPnls) : null,
|
|
399
|
+
worstRolling30dPnl: calculateWorstRollingPnl(normalizedTrades, 30),
|
|
400
|
+
worstRolling90dPnl: calculateWorstRollingPnl(normalizedTrades, 90)
|
|
401
|
+
},
|
|
402
|
+
stability: {
|
|
403
|
+
monthlyWinRate: monthlyWinRates.length ? mean(monthlyWinRates) : null,
|
|
404
|
+
positiveMonthsPercent: monthlyStats.length ? positiveMonths / monthlyStats.length * 100 : null,
|
|
405
|
+
quarterlyPnl: [...quarterly.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([quarter, pnl]) => ({ quarter, pnl })),
|
|
406
|
+
rolling365Pnl: normalizedTrades.length && isFiniteMetric(lastTimestamp) ? normalizedTrades.filter(
|
|
407
|
+
(trade) => lastTimestamp - trade.timestamp <= 365 * MS_IN_DAY
|
|
408
|
+
).reduce((acc, trade) => acc + trade.pnl, 0) : null,
|
|
409
|
+
medianMonthlyPnl: medianValue(monthlyPnls),
|
|
410
|
+
iqrMonthlyPnl: p25Monthly === null || p75Monthly === null ? null : p75Monthly - p25Monthly,
|
|
411
|
+
top5ProfitShare: sumTopPositiveProfitShare(pnls, 5),
|
|
412
|
+
top10ProfitShare: sumTopPositiveProfitShare(pnls, 10)
|
|
413
|
+
},
|
|
414
|
+
distribution: {
|
|
415
|
+
medianTrade: medianValue(pnls),
|
|
416
|
+
p10Trade: percentile(pnls, 0.1),
|
|
417
|
+
p25Trade: percentile(pnls, 0.25),
|
|
418
|
+
p75Trade: percentile(pnls, 0.75),
|
|
419
|
+
p90Trade: percentile(pnls, 0.9),
|
|
420
|
+
largestWin: wins ? Math.max(...pnls.filter((pnl) => pnl > 0)) : null,
|
|
421
|
+
largestLoss: losses ? Math.min(...pnls.filter((pnl) => pnl < 0)) : null,
|
|
422
|
+
tailRatio: safeRatio(
|
|
423
|
+
percentile(pnls, 0.95),
|
|
424
|
+
Math.abs(percentile(pnls, 0.05) ?? 0)
|
|
425
|
+
),
|
|
426
|
+
skewness: calculateSkewness(pnls)
|
|
427
|
+
},
|
|
428
|
+
riskAdjusted: {
|
|
429
|
+
sharpeDaily: dailyMean !== null && dailyStd !== null && dailyStd > 0 ? dailyMean / dailyStd * Math.sqrt(DAYS_IN_YEAR) : null,
|
|
430
|
+
sortinoDaily: dailyMean !== null && downsideStd !== null && downsideStd > 0 ? dailyMean / downsideStd * Math.sqrt(DAYS_IN_YEAR) : null,
|
|
431
|
+
calmar: safeRatio(annualizedPnl, drawdown.absolute),
|
|
432
|
+
mar: safeRatio(annualizedPnl, drawdown.absolute)
|
|
433
|
+
},
|
|
434
|
+
operational: {
|
|
435
|
+
avgSlippageCost: slippageCosts.length ? mean(slippageCosts) : null,
|
|
436
|
+
pnlBeforeSlippage: normalizedTrades.length ? pnlBeforeSlippage : null,
|
|
437
|
+
pnlAfterSlippage: totalPnl,
|
|
438
|
+
approvalRate: approvedFlags.length ? approvedFlags.filter((trade) => trade.approved).length / approvedFlags.length * 100 : null,
|
|
439
|
+
blockedProfitableTrades: normalizedTrades.filter(
|
|
440
|
+
(trade) => trade.blocked && trade.pnl > 0
|
|
441
|
+
).length,
|
|
442
|
+
approvedLosingTrades: normalizedTrades.filter(
|
|
443
|
+
(trade) => trade.approved && trade.pnl < 0
|
|
444
|
+
).length,
|
|
445
|
+
symbolConcentrationTop1: concentrationPercent(
|
|
446
|
+
normalizedTrades.filter((trade) => trade.symbol).map((trade) => ({ key: String(trade.symbol), pnl: trade.pnl })),
|
|
447
|
+
1
|
|
448
|
+
),
|
|
449
|
+
symbolConcentrationTop5: concentrationPercent(
|
|
450
|
+
normalizedTrades.filter((trade) => trade.symbol).map((trade) => ({ key: String(trade.symbol), pnl: trade.pnl })),
|
|
451
|
+
5
|
|
452
|
+
),
|
|
453
|
+
sessionConcentrationTop1: concentrationPercent(
|
|
454
|
+
normalizedTrades.map((trade) => ({
|
|
455
|
+
key: trade.session ?? resolveTradeSession(trade.timestamp),
|
|
456
|
+
pnl: trade.pnl
|
|
457
|
+
})),
|
|
458
|
+
1
|
|
459
|
+
),
|
|
460
|
+
longTrades: directionStats.longTrades,
|
|
461
|
+
shortTrades: directionStats.shortTrades,
|
|
462
|
+
longPnl: directionStats.longPnl,
|
|
463
|
+
shortPnl: directionStats.shortPnl,
|
|
464
|
+
exitBreakdown: calculateExitBreakdown(normalizedTrades)
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
};
|
|
136
468
|
var computeMonthlyEquityStats = (positionLogData, opts) => {
|
|
137
469
|
const MAR = opts?.mar ?? 0;
|
|
138
470
|
const useSample = !!opts?.sampleStd;
|
|
@@ -291,7 +623,10 @@ var calculateStatsFull = (positionLogData) => {
|
|
|
291
623
|
};
|
|
292
624
|
};
|
|
293
625
|
var classifyMetric = (name, value) => {
|
|
294
|
-
const { thresholds, direction } = TestThresholdsConfig[name];
|
|
626
|
+
const { thresholds, direction, neutralValue } = TestThresholdsConfig[name];
|
|
627
|
+
if (neutralValue !== void 0 && value === neutralValue) {
|
|
628
|
+
return "neutral";
|
|
629
|
+
}
|
|
295
630
|
if (direction === "higher") {
|
|
296
631
|
if (value >= thresholds[1]) return "success";
|
|
297
632
|
if (value >= thresholds[0]) return "warning";
|
|
@@ -339,17 +674,14 @@ var getFormatted = (stat, key) => {
|
|
|
339
674
|
};
|
|
340
675
|
};
|
|
341
676
|
export {
|
|
677
|
+
calculateAdvancedTradeMetrics,
|
|
342
678
|
calculateMaxDrawdown,
|
|
343
679
|
calculateStatsFull,
|
|
344
680
|
classifyMetric,
|
|
345
681
|
compactOrderLog,
|
|
346
|
-
createTestSuite,
|
|
347
|
-
generateName,
|
|
348
|
-
generateParamGrid,
|
|
349
682
|
getBacktestScore,
|
|
350
683
|
getFormatted,
|
|
351
684
|
getTimeline,
|
|
352
|
-
mergeConfigs,
|
|
353
685
|
parseTestName,
|
|
354
686
|
sortBestTests
|
|
355
687
|
};
|