@tradejs/core 1.0.9 → 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-2ORZC66W.mjs → chunk-DXJ4NCFJ.mjs} +63 -6
- package/dist/chunk-OJPHC3S2.mjs +8 -0
- package/dist/{chunk-FNLPYYML.mjs → chunk-PNBS6J3G.mjs} +22 -1
- package/dist/constants.d.mts +25 -5
- package/dist/constants.d.ts +25 -5
- package/dist/constants.js +78 -9
- package/dist/constants.mjs +29 -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-UK6VTOUX.mjs +0 -1810
- package/dist/indicators-B-GGjP5F.d.mts +0 -65
- package/dist/indicators-B-GGjP5F.d.ts +0 -65
package/dist/backtest.js
CHANGED
|
@@ -30,27 +30,45 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/backtest.ts
|
|
31
31
|
var backtest_exports = {};
|
|
32
32
|
__export(backtest_exports, {
|
|
33
|
+
calculateAdvancedTradeMetrics: () => calculateAdvancedTradeMetrics,
|
|
33
34
|
calculateMaxDrawdown: () => calculateMaxDrawdown,
|
|
34
35
|
calculateStatsFull: () => calculateStatsFull,
|
|
35
36
|
classifyMetric: () => classifyMetric,
|
|
36
37
|
compactOrderLog: () => compactOrderLog,
|
|
37
|
-
createTestSuite: () => createTestSuite,
|
|
38
|
-
generateName: () => generateName,
|
|
39
|
-
generateParamGrid: () => generateParamGrid,
|
|
40
38
|
getBacktestScore: () => getBacktestScore,
|
|
41
39
|
getFormatted: () => getFormatted,
|
|
42
40
|
getTimeline: () => getTimeline,
|
|
43
|
-
mergeConfigs: () => mergeConfigs,
|
|
44
41
|
parseTestName: () => parseTestName,
|
|
45
42
|
sortBestTests: () => sortBestTests
|
|
46
43
|
});
|
|
47
44
|
module.exports = __toCommonJS(backtest_exports);
|
|
48
45
|
|
|
49
|
-
// src/utils/
|
|
50
|
-
var
|
|
46
|
+
// src/utils/tests.ts
|
|
47
|
+
var parseTestName = (testName) => {
|
|
48
|
+
const [symbol, testSuiteId, testId] = testName.split("_");
|
|
49
|
+
return { symbol, testSuiteId, testId };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// src/utils/stat.ts
|
|
53
|
+
var import_date_fns = require("date-fns");
|
|
51
54
|
|
|
52
55
|
// src/constants/index.ts
|
|
56
|
+
var BACKTEST_EXECUTION_DELAY_MS = 5 * 6e4;
|
|
53
57
|
var BACKTEST_DEFAULT_DAYS = 160;
|
|
58
|
+
var DERIVATIVES_CONTEXT_BASE_REFERENCE_SYMBOLS = [
|
|
59
|
+
"BTCUSDT",
|
|
60
|
+
"ETHUSDT"
|
|
61
|
+
];
|
|
62
|
+
var DERIVATIVES_CONTEXT_DEFAULT_EXTRA_REFERENCE_SYMBOLS = [
|
|
63
|
+
"BNBUSDT",
|
|
64
|
+
"SOLUSDT",
|
|
65
|
+
"TRXUSDT",
|
|
66
|
+
"XRPUSDT"
|
|
67
|
+
];
|
|
68
|
+
var DERIVATIVES_CONTEXT_REFERENCE_SYMBOLS = [
|
|
69
|
+
...DERIVATIVES_CONTEXT_BASE_REFERENCE_SYMBOLS,
|
|
70
|
+
...DERIVATIVES_CONTEXT_DEFAULT_EXTRA_REFERENCE_SYMBOLS
|
|
71
|
+
];
|
|
54
72
|
var TestThresholdsConfig = {
|
|
55
73
|
// Период и частота — используем как требования к качеству теста, в скоринг не влияют
|
|
56
74
|
periodDays: {
|
|
@@ -109,9 +127,10 @@ var TestThresholdsConfig = {
|
|
|
109
127
|
precision: 2
|
|
110
128
|
},
|
|
111
129
|
netProfit: {
|
|
112
|
-
thresholds: [
|
|
130
|
+
thresholds: [0, 0],
|
|
113
131
|
direction: "higher",
|
|
114
132
|
isAmount: true,
|
|
133
|
+
neutralValue: 0,
|
|
115
134
|
precision: 2
|
|
116
135
|
},
|
|
117
136
|
totalReturn: {
|
|
@@ -179,135 +198,8 @@ var TestThresholdsConfig = {
|
|
|
179
198
|
}
|
|
180
199
|
};
|
|
181
200
|
|
|
182
|
-
// src/utils/timestamp.ts
|
|
183
|
-
var import_date_fns = require("date-fns");
|
|
184
|
-
var import_date_fns2 = require("date-fns");
|
|
185
|
-
var TIMELINE_STEP = 864e5;
|
|
186
|
-
var getTimestamp = (days = 0) => {
|
|
187
|
-
if (days > 0) {
|
|
188
|
-
return (0, import_date_fns2.getUnixTime)((0, import_date_fns2.subDays)(/* @__PURE__ */ new Date(), days)) * 1e3;
|
|
189
|
-
}
|
|
190
|
-
return (0, import_date_fns2.getUnixTime)(/* @__PURE__ */ new Date()) * 1e3;
|
|
191
|
-
};
|
|
192
|
-
var getTimeline = (start = getTimestamp(BACKTEST_DEFAULT_DAYS), end = getTimestamp(), step = TIMELINE_STEP) => {
|
|
193
|
-
const res = new Array();
|
|
194
|
-
for (let ind = start; ind <= end; ind += step) {
|
|
195
|
-
res.push(ind);
|
|
196
|
-
}
|
|
197
|
-
return res;
|
|
198
|
-
};
|
|
199
|
-
var compactOrderLog = (timeline, orderLog) => {
|
|
200
|
-
const result = [];
|
|
201
|
-
let currentAmount = orderLog.length > 0 && orderLog[0].amount != null ? orderLog[0].amount : 100;
|
|
202
|
-
let orderLogCursor = 0;
|
|
203
|
-
for (let timelineIndex = 0; timelineIndex < timeline.length; timelineIndex++) {
|
|
204
|
-
const currentTimestamp = timeline[timelineIndex];
|
|
205
|
-
let lastApplicableOrderIndex = -1;
|
|
206
|
-
let nextCursor = orderLogCursor;
|
|
207
|
-
for (let checkIndex = orderLogCursor; checkIndex < orderLog.length; checkIndex++) {
|
|
208
|
-
const checkOrder = orderLog[checkIndex];
|
|
209
|
-
if (checkOrder.timestamp <= currentTimestamp) {
|
|
210
|
-
lastApplicableOrderIndex = checkIndex;
|
|
211
|
-
nextCursor = checkIndex + 1;
|
|
212
|
-
} else {
|
|
213
|
-
break;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
if (lastApplicableOrderIndex !== -1) {
|
|
217
|
-
currentAmount = orderLog[lastApplicableOrderIndex].amount;
|
|
218
|
-
orderLogCursor = nextCursor;
|
|
219
|
-
}
|
|
220
|
-
result.push([currentTimestamp, currentAmount]);
|
|
221
|
-
}
|
|
222
|
-
return result;
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
// src/utils/uuid.ts
|
|
226
|
-
var import_node_crypto = require("crypto");
|
|
227
|
-
var uuid = (len = 12) => {
|
|
228
|
-
const uuid2 = (0, import_node_crypto.randomUUID)();
|
|
229
|
-
return uuid2.slice(-len);
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
// src/utils/grid.ts
|
|
233
|
-
var generateParamGrid = (paramOptions) => {
|
|
234
|
-
const keys = Object.keys(paramOptions);
|
|
235
|
-
const combinations = [];
|
|
236
|
-
const helper = (index = 0, current = {}) => {
|
|
237
|
-
if (index === keys.length) {
|
|
238
|
-
combinations.push(current);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
const key = keys[index];
|
|
242
|
-
for (const value of paramOptions[key] || []) {
|
|
243
|
-
const copiedValue = typeof value === "object" && value !== null ? structuredClone(value) : value;
|
|
244
|
-
helper(index + 1, {
|
|
245
|
-
...current,
|
|
246
|
-
[key]: copiedValue
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
};
|
|
250
|
-
helper();
|
|
251
|
-
return combinations;
|
|
252
|
-
};
|
|
253
|
-
var generateName = (prefix) => `${prefix}_${uuid(6)}`;
|
|
254
|
-
var mergeConfigs = (configs) => {
|
|
255
|
-
const result = {};
|
|
256
|
-
for (const config of configs) {
|
|
257
|
-
for (const [key, value] of Object.entries(config)) {
|
|
258
|
-
if (!result[key]) {
|
|
259
|
-
result[key] = [];
|
|
260
|
-
}
|
|
261
|
-
const clonedValue = typeof value === "object" && value !== null ? import_lodash.default.cloneDeep(value) : value;
|
|
262
|
-
const isDuplicate = result[key].some(
|
|
263
|
-
(existing) => import_lodash.default.isEqual(existing, value)
|
|
264
|
-
);
|
|
265
|
-
if (!isDuplicate) {
|
|
266
|
-
result[key].push(clonedValue);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
for (const key in result) {
|
|
271
|
-
if (result[key].every((v) => typeof v === "number")) {
|
|
272
|
-
result[key] = import_lodash.default.sortBy(result[key]);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
return result;
|
|
276
|
-
};
|
|
277
|
-
var createTestSuite = (userName, tickers, strategyName, backtestConfig, connectorName) => {
|
|
278
|
-
const start = getTimestamp(BACKTEST_DEFAULT_DAYS);
|
|
279
|
-
const end = getTimestamp();
|
|
280
|
-
const testSuiteId = uuid(6);
|
|
281
|
-
const paramGrid = generateParamGrid(backtestConfig);
|
|
282
|
-
return tickers.flatMap(
|
|
283
|
-
(symbol) => paramGrid.map((params) => {
|
|
284
|
-
const testId = uuid(6);
|
|
285
|
-
return {
|
|
286
|
-
userName,
|
|
287
|
-
name: `${symbol}_${testSuiteId}_${testId}`,
|
|
288
|
-
testId,
|
|
289
|
-
testSuiteId,
|
|
290
|
-
symbol,
|
|
291
|
-
options: { start, end },
|
|
292
|
-
strategyName,
|
|
293
|
-
strategyConfig: params,
|
|
294
|
-
connectorName
|
|
295
|
-
};
|
|
296
|
-
})
|
|
297
|
-
);
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
// src/utils/tests.ts
|
|
301
|
-
var parseTestName = (testName) => {
|
|
302
|
-
const [symbol, testSuiteId, testId] = testName.split("_");
|
|
303
|
-
return { symbol, testSuiteId, testId };
|
|
304
|
-
};
|
|
305
|
-
|
|
306
|
-
// src/utils/stat.ts
|
|
307
|
-
var import_date_fns3 = require("date-fns");
|
|
308
|
-
|
|
309
201
|
// src/utils/math.ts
|
|
310
|
-
var
|
|
202
|
+
var import_lodash = __toESM(require("lodash"));
|
|
311
203
|
var round = (value, precision = 2) => precision > 0 ? Math.round(value * 10 ** precision) / 10 ** precision : Math.round(value);
|
|
312
204
|
var sum = (xs) => xs.reduce((a, b) => a + b, 0);
|
|
313
205
|
var mean = (xs) => xs.length ? sum(xs) / xs.length : 0;
|
|
@@ -351,6 +243,412 @@ var calculateMaxDrawdown = (amounts) => {
|
|
|
351
243
|
}
|
|
352
244
|
return maxDrawdown;
|
|
353
245
|
};
|
|
246
|
+
var MS_IN_DAY = 24 * 60 * 60 * 1e3;
|
|
247
|
+
var DAYS_IN_YEAR = 365;
|
|
248
|
+
var isFiniteMetric = (value) => typeof value === "number" && Number.isFinite(value);
|
|
249
|
+
var safeRatio = (numerator, denominator) => {
|
|
250
|
+
if (!isFiniteMetric(numerator) || !isFiniteMetric(denominator)) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
if (Math.abs(denominator) <= Number.EPSILON) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
return numerator / denominator;
|
|
257
|
+
};
|
|
258
|
+
var percentile = (values, p) => {
|
|
259
|
+
if (!values.length) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
263
|
+
const index = (sorted.length - 1) * p;
|
|
264
|
+
const lower = Math.floor(index);
|
|
265
|
+
const upper = Math.ceil(index);
|
|
266
|
+
if (lower === upper) {
|
|
267
|
+
return sorted[lower];
|
|
268
|
+
}
|
|
269
|
+
const weight = index - lower;
|
|
270
|
+
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
|
271
|
+
};
|
|
272
|
+
var medianValue = (values) => percentile(values, 0.5);
|
|
273
|
+
var getMonthKey = (timestamp) => {
|
|
274
|
+
const date = new Date(timestamp);
|
|
275
|
+
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
276
|
+
};
|
|
277
|
+
var getQuarterKey = (timestamp) => {
|
|
278
|
+
const date = new Date(timestamp);
|
|
279
|
+
return `${date.getUTCFullYear()} Q${Math.floor(date.getUTCMonth() / 3) + 1}`;
|
|
280
|
+
};
|
|
281
|
+
var resolveTradeSession = (timestamp) => {
|
|
282
|
+
const hour = new Date(timestamp).getUTCHours();
|
|
283
|
+
if (hour < 8) {
|
|
284
|
+
return "Asia";
|
|
285
|
+
}
|
|
286
|
+
if (hour < 16) {
|
|
287
|
+
return "Europe";
|
|
288
|
+
}
|
|
289
|
+
return "US";
|
|
290
|
+
};
|
|
291
|
+
var getDateKey = (timestamp) => {
|
|
292
|
+
const date = new Date(timestamp);
|
|
293
|
+
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
|
|
294
|
+
};
|
|
295
|
+
var calculateDrawdownStats = (trades, orderLog) => {
|
|
296
|
+
const explicitPoints = (orderLog ?? []).map((point) => ({ timestamp: point[0], amount: point[1] })).filter(
|
|
297
|
+
(point) => isFiniteMetric(point.timestamp) && isFiniteMetric(point.amount)
|
|
298
|
+
).sort((a, b) => a.timestamp - b.timestamp);
|
|
299
|
+
const points = explicitPoints.length ? explicitPoints : trades.slice().sort((a, b) => a.timestamp - b.timestamp).reduce(
|
|
300
|
+
(acc, trade) => {
|
|
301
|
+
const previous = acc[acc.length - 1]?.amount ?? 0;
|
|
302
|
+
acc.push({
|
|
303
|
+
timestamp: trade.timestamp,
|
|
304
|
+
amount: previous + trade.pnl
|
|
305
|
+
});
|
|
306
|
+
return acc;
|
|
307
|
+
},
|
|
308
|
+
[{ timestamp: trades[0]?.timestamp ?? 0, amount: 0 }]
|
|
309
|
+
);
|
|
310
|
+
if (!points.length) {
|
|
311
|
+
return { absolute: null, percent: null };
|
|
312
|
+
}
|
|
313
|
+
let peak = points[0].amount;
|
|
314
|
+
let maxAbsolute = 0;
|
|
315
|
+
let maxPercent = 0;
|
|
316
|
+
for (const point of points) {
|
|
317
|
+
if (point.amount > peak) {
|
|
318
|
+
peak = point.amount;
|
|
319
|
+
}
|
|
320
|
+
const absolute = peak - point.amount;
|
|
321
|
+
maxAbsolute = Math.max(maxAbsolute, absolute);
|
|
322
|
+
if (peak > 0) {
|
|
323
|
+
maxPercent = Math.max(maxPercent, absolute / peak * 100);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return { absolute: maxAbsolute, percent: maxPercent };
|
|
327
|
+
};
|
|
328
|
+
var calculateWorstRollingPnl = (trades, days) => {
|
|
329
|
+
if (!trades.length) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
const sorted = trades.slice().sort((a, b) => a.timestamp - b.timestamp);
|
|
333
|
+
const windowMs = days * MS_IN_DAY;
|
|
334
|
+
let start = 0;
|
|
335
|
+
let rollingPnl = 0;
|
|
336
|
+
let worstPnl = 0;
|
|
337
|
+
for (let end = 0; end < sorted.length; end += 1) {
|
|
338
|
+
rollingPnl += sorted[end].pnl;
|
|
339
|
+
while (start <= end && sorted[end].timestamp - sorted[start].timestamp > windowMs) {
|
|
340
|
+
rollingPnl -= sorted[start].pnl;
|
|
341
|
+
start += 1;
|
|
342
|
+
}
|
|
343
|
+
worstPnl = Math.min(worstPnl, rollingPnl);
|
|
344
|
+
}
|
|
345
|
+
return worstPnl;
|
|
346
|
+
};
|
|
347
|
+
var calculateLossStreak = (trades) => {
|
|
348
|
+
let current = 0;
|
|
349
|
+
let max = 0;
|
|
350
|
+
for (const trade of trades.slice().sort((a, b) => a.timestamp - b.timestamp)) {
|
|
351
|
+
if (trade.pnl < 0) {
|
|
352
|
+
current += 1;
|
|
353
|
+
max = Math.max(max, current);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
current = 0;
|
|
357
|
+
}
|
|
358
|
+
return max;
|
|
359
|
+
};
|
|
360
|
+
var normalizeExitReason = (reason) => {
|
|
361
|
+
const normalized = String(reason ?? "").trim().toLowerCase();
|
|
362
|
+
if (normalized === "tp" || normalized === "take_profit") {
|
|
363
|
+
return "takeProfit";
|
|
364
|
+
}
|
|
365
|
+
if (normalized === "sl" || normalized === "stop_loss") {
|
|
366
|
+
return "stopLoss";
|
|
367
|
+
}
|
|
368
|
+
if (normalized === "exit" || normalized === "close" || normalized === "closed") {
|
|
369
|
+
return "exit";
|
|
370
|
+
}
|
|
371
|
+
return "unknown";
|
|
372
|
+
};
|
|
373
|
+
var calculateExitBreakdown = (trades) => {
|
|
374
|
+
const counts = {
|
|
375
|
+
takeProfit: 0,
|
|
376
|
+
stopLoss: 0,
|
|
377
|
+
exit: 0,
|
|
378
|
+
unknown: 0
|
|
379
|
+
};
|
|
380
|
+
for (const trade of trades) {
|
|
381
|
+
counts[normalizeExitReason(trade.exitReason)] += 1;
|
|
382
|
+
}
|
|
383
|
+
const total = trades.length;
|
|
384
|
+
const bucket = (count) => ({
|
|
385
|
+
count,
|
|
386
|
+
share: total ? count / total * 100 : null
|
|
387
|
+
});
|
|
388
|
+
return {
|
|
389
|
+
takeProfit: bucket(counts.takeProfit),
|
|
390
|
+
stopLoss: bucket(counts.stopLoss),
|
|
391
|
+
exit: bucket(counts.exit),
|
|
392
|
+
unknown: bucket(counts.unknown)
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
var calculateDailyPnlSeries = (trades, startTimestamp, endTimestamp) => {
|
|
396
|
+
const approvedTrades = trades.filter((trade) => trade.approved !== false);
|
|
397
|
+
if (!approvedTrades.length) {
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
400
|
+
const firstTimestamp = startTimestamp ?? Math.min(...approvedTrades.map((trade) => trade.timestamp));
|
|
401
|
+
const lastTimestamp = endTimestamp ?? Math.max(...approvedTrades.map((trade) => trade.timestamp));
|
|
402
|
+
if (!isFiniteMetric(firstTimestamp) || !isFiniteMetric(lastTimestamp) || lastTimestamp < firstTimestamp) {
|
|
403
|
+
return [];
|
|
404
|
+
}
|
|
405
|
+
const startDate = Date.UTC(
|
|
406
|
+
new Date(firstTimestamp).getUTCFullYear(),
|
|
407
|
+
new Date(firstTimestamp).getUTCMonth(),
|
|
408
|
+
new Date(firstTimestamp).getUTCDate()
|
|
409
|
+
);
|
|
410
|
+
const endDate = Date.UTC(
|
|
411
|
+
new Date(lastTimestamp).getUTCFullYear(),
|
|
412
|
+
new Date(lastTimestamp).getUTCMonth(),
|
|
413
|
+
new Date(lastTimestamp).getUTCDate()
|
|
414
|
+
);
|
|
415
|
+
const daily = /* @__PURE__ */ new Map();
|
|
416
|
+
for (let ts = startDate; ts <= endDate; ts += MS_IN_DAY) {
|
|
417
|
+
daily.set(getDateKey(ts), 0);
|
|
418
|
+
}
|
|
419
|
+
for (const trade of approvedTrades) {
|
|
420
|
+
const key = getDateKey(trade.timestamp);
|
|
421
|
+
daily.set(key, (daily.get(key) ?? 0) + trade.pnl);
|
|
422
|
+
}
|
|
423
|
+
return [...daily.values()];
|
|
424
|
+
};
|
|
425
|
+
var calculateStd = (values, valueMean) => {
|
|
426
|
+
if (!values.length) {
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
429
|
+
return Math.sqrt(
|
|
430
|
+
values.reduce((acc, value) => acc + (value - valueMean) ** 2, 0) / values.length
|
|
431
|
+
);
|
|
432
|
+
};
|
|
433
|
+
var calculateSkewness = (values) => {
|
|
434
|
+
if (values.length < 3) {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
const valueMean = mean(values);
|
|
438
|
+
const std = calculateStd(values, valueMean);
|
|
439
|
+
if (std <= Number.EPSILON) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
return values.reduce((acc, value) => acc + ((value - valueMean) / std) ** 3, 0) / values.length;
|
|
443
|
+
};
|
|
444
|
+
var sumTopPositiveProfitShare = (pnls, count) => {
|
|
445
|
+
const grossProfit = pnls.filter((pnl) => pnl > 0).reduce((acc, pnl) => acc + pnl, 0);
|
|
446
|
+
if (grossProfit <= 0) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
const topProfit = pnls.filter((pnl) => pnl > 0).sort((a, b) => b - a).slice(0, count).reduce((acc, pnl) => acc + pnl, 0);
|
|
450
|
+
return topProfit / grossProfit * 100;
|
|
451
|
+
};
|
|
452
|
+
var concentrationPercent = (items, limit) => {
|
|
453
|
+
const totals = /* @__PURE__ */ new Map();
|
|
454
|
+
for (const item of items) {
|
|
455
|
+
totals.set(item.key, (totals.get(item.key) ?? 0) + Math.abs(item.pnl));
|
|
456
|
+
}
|
|
457
|
+
const totalAbsPnl = [...totals.values()].reduce(
|
|
458
|
+
(acc, value) => acc + value,
|
|
459
|
+
0
|
|
460
|
+
);
|
|
461
|
+
if (totalAbsPnl <= 0) {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
const topAbsPnl = [...totals.values()].sort((a, b) => b - a).slice(0, limit).reduce((acc, value) => acc + value, 0);
|
|
465
|
+
return topAbsPnl / totalAbsPnl * 100;
|
|
466
|
+
};
|
|
467
|
+
var calculateAdvancedTradeMetrics = ({
|
|
468
|
+
trades,
|
|
469
|
+
orderLog,
|
|
470
|
+
startTimestamp,
|
|
471
|
+
endTimestamp
|
|
472
|
+
}) => {
|
|
473
|
+
const normalizedTrades = trades.filter(
|
|
474
|
+
(trade) => isFiniteMetric(trade.timestamp) && isFiniteMetric(trade.pnl) && trade.timestamp > 0
|
|
475
|
+
).sort((a, b) => a.timestamp - b.timestamp);
|
|
476
|
+
const pnls = normalizedTrades.map((trade) => trade.pnl);
|
|
477
|
+
const wins = pnls.filter((pnl) => pnl > 0).length;
|
|
478
|
+
const losses = pnls.filter((pnl) => pnl < 0).length;
|
|
479
|
+
const totalPnl = sum(pnls);
|
|
480
|
+
const grossProfit = pnls.filter((pnl) => pnl > 0).reduce((acc, pnl) => acc + pnl, 0);
|
|
481
|
+
const grossLoss = Math.abs(
|
|
482
|
+
pnls.filter((pnl) => pnl < 0).reduce((acc, pnl) => acc + pnl, 0)
|
|
483
|
+
);
|
|
484
|
+
const avgWin = wins ? grossProfit / wins : null;
|
|
485
|
+
const avgLoss = losses ? grossLoss / losses : null;
|
|
486
|
+
const firstTimestamp = startTimestamp ?? normalizedTrades[0]?.timestamp ?? null;
|
|
487
|
+
const lastTimestamp = endTimestamp ?? normalizedTrades[normalizedTrades.length - 1]?.timestamp ?? null;
|
|
488
|
+
const periodDays = isFiniteMetric(firstTimestamp) && isFiniteMetric(lastTimestamp) && lastTimestamp > firstTimestamp ? (lastTimestamp - firstTimestamp) / MS_IN_DAY : null;
|
|
489
|
+
const drawdown = calculateDrawdownStats(normalizedTrades, orderLog);
|
|
490
|
+
const monthly = /* @__PURE__ */ new Map();
|
|
491
|
+
const quarterly = /* @__PURE__ */ new Map();
|
|
492
|
+
for (const trade of normalizedTrades) {
|
|
493
|
+
const monthKey = getMonthKey(trade.timestamp);
|
|
494
|
+
const quarterKey = getQuarterKey(trade.timestamp);
|
|
495
|
+
const month = monthly.get(monthKey) ?? {
|
|
496
|
+
pnl: 0,
|
|
497
|
+
orders: 0,
|
|
498
|
+
wins: 0,
|
|
499
|
+
timestamp: trade.timestamp
|
|
500
|
+
};
|
|
501
|
+
month.pnl += trade.pnl;
|
|
502
|
+
month.orders += 1;
|
|
503
|
+
month.wins += trade.pnl > 0 ? 1 : 0;
|
|
504
|
+
month.timestamp = Math.min(month.timestamp, trade.timestamp);
|
|
505
|
+
monthly.set(monthKey, month);
|
|
506
|
+
quarterly.set(quarterKey, (quarterly.get(quarterKey) ?? 0) + trade.pnl);
|
|
507
|
+
}
|
|
508
|
+
const monthlyStats = [...monthly.entries()].sort(
|
|
509
|
+
([a], [b]) => a.localeCompare(b)
|
|
510
|
+
);
|
|
511
|
+
const monthlyPnls = monthlyStats.map(([, stat]) => stat.pnl);
|
|
512
|
+
const monthlyWinRates = monthlyStats.map(
|
|
513
|
+
([, stat]) => stat.orders ? stat.wins / stat.orders * 100 : 0
|
|
514
|
+
);
|
|
515
|
+
const positiveMonths = monthlyStats.filter(([, stat]) => stat.pnl > 0).length;
|
|
516
|
+
const p25Monthly = percentile(monthlyPnls, 0.25);
|
|
517
|
+
const p75Monthly = percentile(monthlyPnls, 0.75);
|
|
518
|
+
const dailyPnls = calculateDailyPnlSeries(
|
|
519
|
+
normalizedTrades,
|
|
520
|
+
startTimestamp,
|
|
521
|
+
endTimestamp
|
|
522
|
+
);
|
|
523
|
+
const dailyMean = dailyPnls.length ? mean(dailyPnls) : null;
|
|
524
|
+
const dailyStd = dailyMean === null ? null : calculateStd(dailyPnls, dailyMean);
|
|
525
|
+
const downsideDailyPnls = dailyPnls.map((pnl) => Math.min(pnl, 0));
|
|
526
|
+
const downsideStd = downsideDailyPnls.some((pnl) => pnl < 0) ? Math.sqrt(
|
|
527
|
+
downsideDailyPnls.reduce((acc, pnl) => acc + pnl ** 2, 0) / downsideDailyPnls.length
|
|
528
|
+
) : null;
|
|
529
|
+
const annualizedPnl = dailyMean === null ? null : dailyMean * DAYS_IN_YEAR;
|
|
530
|
+
const approvedFlags = normalizedTrades.filter(
|
|
531
|
+
(trade) => typeof trade.approved === "boolean"
|
|
532
|
+
);
|
|
533
|
+
const slippageCosts = normalizedTrades.map((trade) => trade.slippageCost).filter(isFiniteMetric);
|
|
534
|
+
const pnlBeforeSlippage = normalizedTrades.reduce((acc, trade) => {
|
|
535
|
+
if (isFiniteMetric(trade.grossPnl)) {
|
|
536
|
+
return acc + trade.grossPnl;
|
|
537
|
+
}
|
|
538
|
+
if (isFiniteMetric(trade.slippageCost)) {
|
|
539
|
+
return acc + trade.pnl + trade.slippageCost;
|
|
540
|
+
}
|
|
541
|
+
return acc + trade.pnl;
|
|
542
|
+
}, 0);
|
|
543
|
+
const directionStats = normalizedTrades.reduce(
|
|
544
|
+
(acc, trade) => {
|
|
545
|
+
const direction = String(trade.direction ?? "").toUpperCase();
|
|
546
|
+
if (direction === "LONG") {
|
|
547
|
+
acc.longTrades += 1;
|
|
548
|
+
acc.longPnl += trade.pnl;
|
|
549
|
+
}
|
|
550
|
+
if (direction === "SHORT") {
|
|
551
|
+
acc.shortTrades += 1;
|
|
552
|
+
acc.shortPnl += trade.pnl;
|
|
553
|
+
}
|
|
554
|
+
return acc;
|
|
555
|
+
},
|
|
556
|
+
{ longTrades: 0, shortTrades: 0, longPnl: 0, shortPnl: 0 }
|
|
557
|
+
);
|
|
558
|
+
return {
|
|
559
|
+
core: {
|
|
560
|
+
trades: normalizedTrades.length,
|
|
561
|
+
wins,
|
|
562
|
+
losses,
|
|
563
|
+
winRate: normalizedTrades.length ? wins / normalizedTrades.length * 100 : null,
|
|
564
|
+
totalPnl,
|
|
565
|
+
avgTrade: normalizedTrades.length ? totalPnl / normalizedTrades.length : null,
|
|
566
|
+
grossProfit,
|
|
567
|
+
grossLoss,
|
|
568
|
+
profitFactor: safeRatio(grossProfit, grossLoss),
|
|
569
|
+
payoffRatio: safeRatio(avgWin, avgLoss),
|
|
570
|
+
expectancy: normalizedTrades.length ? totalPnl / normalizedTrades.length : null,
|
|
571
|
+
tradesPerDay: periodDays && periodDays > 0 ? normalizedTrades.length / periodDays : null,
|
|
572
|
+
tradesPerWeek: periodDays && periodDays > 0 ? normalizedTrades.length / periodDays * 7 : null
|
|
573
|
+
},
|
|
574
|
+
risk: {
|
|
575
|
+
maxDrawdown: drawdown.absolute,
|
|
576
|
+
maxDrawdownPercent: drawdown.percent,
|
|
577
|
+
maxDrawdownToTotalProfit: totalPnl > 0 ? safeRatio(drawdown.absolute, totalPnl) : null,
|
|
578
|
+
maxDrawdownToGrossProfit: grossProfit > 0 ? safeRatio(drawdown.absolute, grossProfit) : null,
|
|
579
|
+
recoveryFactor: safeRatio(totalPnl, drawdown.absolute),
|
|
580
|
+
maxLossStreak: calculateLossStreak(normalizedTrades),
|
|
581
|
+
losingMonthsCount: monthlyStats.filter(([, stat]) => stat.pnl < 0).length,
|
|
582
|
+
worstMonthPnl: monthlyPnls.length ? Math.min(...monthlyPnls) : null,
|
|
583
|
+
worstRolling30dPnl: calculateWorstRollingPnl(normalizedTrades, 30),
|
|
584
|
+
worstRolling90dPnl: calculateWorstRollingPnl(normalizedTrades, 90)
|
|
585
|
+
},
|
|
586
|
+
stability: {
|
|
587
|
+
monthlyWinRate: monthlyWinRates.length ? mean(monthlyWinRates) : null,
|
|
588
|
+
positiveMonthsPercent: monthlyStats.length ? positiveMonths / monthlyStats.length * 100 : null,
|
|
589
|
+
quarterlyPnl: [...quarterly.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([quarter, pnl]) => ({ quarter, pnl })),
|
|
590
|
+
rolling365Pnl: normalizedTrades.length && isFiniteMetric(lastTimestamp) ? normalizedTrades.filter(
|
|
591
|
+
(trade) => lastTimestamp - trade.timestamp <= 365 * MS_IN_DAY
|
|
592
|
+
).reduce((acc, trade) => acc + trade.pnl, 0) : null,
|
|
593
|
+
medianMonthlyPnl: medianValue(monthlyPnls),
|
|
594
|
+
iqrMonthlyPnl: p25Monthly === null || p75Monthly === null ? null : p75Monthly - p25Monthly,
|
|
595
|
+
top5ProfitShare: sumTopPositiveProfitShare(pnls, 5),
|
|
596
|
+
top10ProfitShare: sumTopPositiveProfitShare(pnls, 10)
|
|
597
|
+
},
|
|
598
|
+
distribution: {
|
|
599
|
+
medianTrade: medianValue(pnls),
|
|
600
|
+
p10Trade: percentile(pnls, 0.1),
|
|
601
|
+
p25Trade: percentile(pnls, 0.25),
|
|
602
|
+
p75Trade: percentile(pnls, 0.75),
|
|
603
|
+
p90Trade: percentile(pnls, 0.9),
|
|
604
|
+
largestWin: wins ? Math.max(...pnls.filter((pnl) => pnl > 0)) : null,
|
|
605
|
+
largestLoss: losses ? Math.min(...pnls.filter((pnl) => pnl < 0)) : null,
|
|
606
|
+
tailRatio: safeRatio(
|
|
607
|
+
percentile(pnls, 0.95),
|
|
608
|
+
Math.abs(percentile(pnls, 0.05) ?? 0)
|
|
609
|
+
),
|
|
610
|
+
skewness: calculateSkewness(pnls)
|
|
611
|
+
},
|
|
612
|
+
riskAdjusted: {
|
|
613
|
+
sharpeDaily: dailyMean !== null && dailyStd !== null && dailyStd > 0 ? dailyMean / dailyStd * Math.sqrt(DAYS_IN_YEAR) : null,
|
|
614
|
+
sortinoDaily: dailyMean !== null && downsideStd !== null && downsideStd > 0 ? dailyMean / downsideStd * Math.sqrt(DAYS_IN_YEAR) : null,
|
|
615
|
+
calmar: safeRatio(annualizedPnl, drawdown.absolute),
|
|
616
|
+
mar: safeRatio(annualizedPnl, drawdown.absolute)
|
|
617
|
+
},
|
|
618
|
+
operational: {
|
|
619
|
+
avgSlippageCost: slippageCosts.length ? mean(slippageCosts) : null,
|
|
620
|
+
pnlBeforeSlippage: normalizedTrades.length ? pnlBeforeSlippage : null,
|
|
621
|
+
pnlAfterSlippage: totalPnl,
|
|
622
|
+
approvalRate: approvedFlags.length ? approvedFlags.filter((trade) => trade.approved).length / approvedFlags.length * 100 : null,
|
|
623
|
+
blockedProfitableTrades: normalizedTrades.filter(
|
|
624
|
+
(trade) => trade.blocked && trade.pnl > 0
|
|
625
|
+
).length,
|
|
626
|
+
approvedLosingTrades: normalizedTrades.filter(
|
|
627
|
+
(trade) => trade.approved && trade.pnl < 0
|
|
628
|
+
).length,
|
|
629
|
+
symbolConcentrationTop1: concentrationPercent(
|
|
630
|
+
normalizedTrades.filter((trade) => trade.symbol).map((trade) => ({ key: String(trade.symbol), pnl: trade.pnl })),
|
|
631
|
+
1
|
|
632
|
+
),
|
|
633
|
+
symbolConcentrationTop5: concentrationPercent(
|
|
634
|
+
normalizedTrades.filter((trade) => trade.symbol).map((trade) => ({ key: String(trade.symbol), pnl: trade.pnl })),
|
|
635
|
+
5
|
|
636
|
+
),
|
|
637
|
+
sessionConcentrationTop1: concentrationPercent(
|
|
638
|
+
normalizedTrades.map((trade) => ({
|
|
639
|
+
key: trade.session ?? resolveTradeSession(trade.timestamp),
|
|
640
|
+
pnl: trade.pnl
|
|
641
|
+
})),
|
|
642
|
+
1
|
|
643
|
+
),
|
|
644
|
+
longTrades: directionStats.longTrades,
|
|
645
|
+
shortTrades: directionStats.shortTrades,
|
|
646
|
+
longPnl: directionStats.longPnl,
|
|
647
|
+
shortPnl: directionStats.shortPnl,
|
|
648
|
+
exitBreakdown: calculateExitBreakdown(normalizedTrades)
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
};
|
|
354
652
|
var computeMonthlyEquityStats = (positionLogData, opts) => {
|
|
355
653
|
const MAR = opts?.mar ?? 0;
|
|
356
654
|
const useSample = !!opts?.sampleStd;
|
|
@@ -378,12 +676,12 @@ var computeMonthlyEquityStats = (positionLogData, opts) => {
|
|
|
378
676
|
const startTs = equityPoints2[0].ts;
|
|
379
677
|
const endTs = equityPoints2[equityPoints2.length - 1].ts;
|
|
380
678
|
const eomSeries = [];
|
|
381
|
-
let monthCursor = (0,
|
|
382
|
-
const lastMonth = (0,
|
|
679
|
+
let monthCursor = (0, import_date_fns.startOfMonth)(new Date(startTs));
|
|
680
|
+
const lastMonth = (0, import_date_fns.endOfMonth)(new Date(endTs));
|
|
383
681
|
let i = 0;
|
|
384
682
|
let lastAmount = equityPoints2[0].amount;
|
|
385
683
|
while (monthCursor <= lastMonth) {
|
|
386
|
-
const eom = (0,
|
|
684
|
+
const eom = (0, import_date_fns.endOfMonth)(monthCursor);
|
|
387
685
|
const eomTs = eom.getTime();
|
|
388
686
|
while (i < equityPoints2.length && equityPoints2[i].ts <= eomTs) {
|
|
389
687
|
lastAmount = equityPoints2[i].amount;
|
|
@@ -391,7 +689,7 @@ var computeMonthlyEquityStats = (positionLogData, opts) => {
|
|
|
391
689
|
}
|
|
392
690
|
const key = `${eom.getFullYear()}-${String(eom.getMonth() + 1).padStart(2, "0")}`;
|
|
393
691
|
eomSeries.push({ month: key, ts: eomTs, amount: lastAmount });
|
|
394
|
-
monthCursor = (0,
|
|
692
|
+
monthCursor = (0, import_date_fns.addMonths)(monthCursor, 1);
|
|
395
693
|
}
|
|
396
694
|
const monthlyReturns = [];
|
|
397
695
|
for (let k = 1; k < eomSeries.length; k++) {
|
|
@@ -436,7 +734,7 @@ var calculateStatsFull = (positionLogData) => {
|
|
|
436
734
|
const points = equityPoints(positionLogData);
|
|
437
735
|
const startTs = points[0].ts;
|
|
438
736
|
const endTs = points[points.length - 1].ts;
|
|
439
|
-
const periodMs = (0,
|
|
737
|
+
const periodMs = (0, import_date_fns.differenceInMilliseconds)(new Date(endTs), new Date(startTs));
|
|
440
738
|
const periodDays = periodMs / (1e3 * 60 * 60 * 24);
|
|
441
739
|
const periodMonths = periodDays / 30.4375;
|
|
442
740
|
const trades = positionLogData.length;
|
|
@@ -509,7 +807,10 @@ var calculateStatsFull = (positionLogData) => {
|
|
|
509
807
|
};
|
|
510
808
|
};
|
|
511
809
|
var classifyMetric = (name, value) => {
|
|
512
|
-
const { thresholds, direction } = TestThresholdsConfig[name];
|
|
810
|
+
const { thresholds, direction, neutralValue } = TestThresholdsConfig[name];
|
|
811
|
+
if (neutralValue !== void 0 && value === neutralValue) {
|
|
812
|
+
return "neutral";
|
|
813
|
+
}
|
|
513
814
|
if (direction === "higher") {
|
|
514
815
|
if (value >= thresholds[1]) return "success";
|
|
515
816
|
if (value >= thresholds[0]) return "warning";
|
|
@@ -556,19 +857,60 @@ var getFormatted = (stat, key) => {
|
|
|
556
857
|
level
|
|
557
858
|
};
|
|
558
859
|
};
|
|
860
|
+
|
|
861
|
+
// src/utils/timestamp.ts
|
|
862
|
+
var import_date_fns2 = require("date-fns");
|
|
863
|
+
var import_date_fns3 = require("date-fns");
|
|
864
|
+
var TIMELINE_STEP = 864e5;
|
|
865
|
+
var RUNTIME_STORAGE_DAY_OFFSET_MS = 6 * 60 * 60 * 1e3;
|
|
866
|
+
var getTimestamp = (days = 0) => {
|
|
867
|
+
if (days > 0) {
|
|
868
|
+
return (0, import_date_fns3.getUnixTime)((0, import_date_fns3.subDays)(/* @__PURE__ */ new Date(), days)) * 1e3;
|
|
869
|
+
}
|
|
870
|
+
return (0, import_date_fns3.getUnixTime)(/* @__PURE__ */ new Date()) * 1e3;
|
|
871
|
+
};
|
|
872
|
+
var getTimeline = (start = getTimestamp(BACKTEST_DEFAULT_DAYS), end = getTimestamp(), step = TIMELINE_STEP) => {
|
|
873
|
+
const res = new Array();
|
|
874
|
+
for (let ind = start; ind <= end; ind += step) {
|
|
875
|
+
res.push(ind);
|
|
876
|
+
}
|
|
877
|
+
return res;
|
|
878
|
+
};
|
|
879
|
+
var compactOrderLog = (timeline, orderLog) => {
|
|
880
|
+
const result = [];
|
|
881
|
+
let currentAmount = orderLog.length > 0 && orderLog[0].amount != null ? orderLog[0].amount : 100;
|
|
882
|
+
let orderLogCursor = 0;
|
|
883
|
+
for (let timelineIndex = 0; timelineIndex < timeline.length; timelineIndex++) {
|
|
884
|
+
const currentTimestamp = timeline[timelineIndex];
|
|
885
|
+
let lastApplicableOrderIndex = -1;
|
|
886
|
+
let nextCursor = orderLogCursor;
|
|
887
|
+
for (let checkIndex = orderLogCursor; checkIndex < orderLog.length; checkIndex++) {
|
|
888
|
+
const checkOrder = orderLog[checkIndex];
|
|
889
|
+
if (checkOrder.timestamp <= currentTimestamp) {
|
|
890
|
+
lastApplicableOrderIndex = checkIndex;
|
|
891
|
+
nextCursor = checkIndex + 1;
|
|
892
|
+
} else {
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (lastApplicableOrderIndex !== -1) {
|
|
897
|
+
currentAmount = orderLog[lastApplicableOrderIndex].amount;
|
|
898
|
+
orderLogCursor = nextCursor;
|
|
899
|
+
}
|
|
900
|
+
result.push([currentTimestamp, currentAmount]);
|
|
901
|
+
}
|
|
902
|
+
return result;
|
|
903
|
+
};
|
|
559
904
|
// Annotate the CommonJS export names for ESM import in node:
|
|
560
905
|
0 && (module.exports = {
|
|
906
|
+
calculateAdvancedTradeMetrics,
|
|
561
907
|
calculateMaxDrawdown,
|
|
562
908
|
calculateStatsFull,
|
|
563
909
|
classifyMetric,
|
|
564
910
|
compactOrderLog,
|
|
565
|
-
createTestSuite,
|
|
566
|
-
generateName,
|
|
567
|
-
generateParamGrid,
|
|
568
911
|
getBacktestScore,
|
|
569
912
|
getFormatted,
|
|
570
913
|
getTimeline,
|
|
571
|
-
mergeConfigs,
|
|
572
914
|
parseTestName,
|
|
573
915
|
sortBestTests
|
|
574
916
|
});
|