tradelab 0.1.2 → 0.3.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/README.md +51 -15
- package/bin/tradelab.js +384 -0
- package/dist/cjs/data.cjs +1750 -0
- package/dist/cjs/index.cjs +2556 -0
- package/package.json +32 -6
- package/src/data/yahoo.js +40 -17
- package/src/engine/backtest.js +75 -24
- package/src/engine/execution.js +44 -7
- package/src/engine/portfolio.js +160 -0
- package/src/engine/walkForward.js +126 -0
- package/src/index.js +3 -0
- package/src/metrics/buildMetrics.js +32 -16
- package/src/reporting/exportBacktestArtifacts.js +13 -0
- package/src/reporting/exportMetricsJson.js +24 -0
- package/src/reporting/renderHtmlReport.js +26 -9
- package/types/data.d.ts +13 -0
- package/types/index.d.ts +570 -0
package/package.json
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tradelab",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Backtesting toolkit for Node.js with strategy simulation, historical data loading, and report generation",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./
|
|
6
|
+
"main": "./dist/cjs/index.cjs",
|
|
7
|
+
"module": "./src/index.js",
|
|
8
|
+
"types": "./types/index.d.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"tradelab": "./bin/tradelab.js"
|
|
11
|
+
},
|
|
7
12
|
"license": "MIT",
|
|
8
13
|
"repository": {
|
|
9
14
|
"type": "git",
|
|
@@ -18,18 +23,32 @@
|
|
|
18
23
|
},
|
|
19
24
|
"sideEffects": false,
|
|
20
25
|
"exports": {
|
|
21
|
-
".":
|
|
22
|
-
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./types/index.d.ts",
|
|
28
|
+
"import": "./src/index.js",
|
|
29
|
+
"require": "./dist/cjs/index.cjs"
|
|
30
|
+
},
|
|
31
|
+
"./data": {
|
|
32
|
+
"types": "./types/data.d.ts",
|
|
33
|
+
"import": "./src/data/index.js",
|
|
34
|
+
"require": "./dist/cjs/data.cjs"
|
|
35
|
+
},
|
|
23
36
|
"./package.json": "./package.json"
|
|
24
37
|
},
|
|
25
38
|
"files": [
|
|
39
|
+
"bin",
|
|
40
|
+
"dist",
|
|
26
41
|
"src",
|
|
42
|
+
"types",
|
|
27
43
|
"templates",
|
|
28
44
|
"examples/*.js",
|
|
29
45
|
"README.md",
|
|
30
46
|
"LICENSE"
|
|
31
47
|
],
|
|
32
48
|
"scripts": {
|
|
49
|
+
"build": "node scripts/build-cjs.mjs",
|
|
50
|
+
"prepare": "npm run build",
|
|
51
|
+
"prepack": "npm run build",
|
|
33
52
|
"test": "node --test"
|
|
34
53
|
},
|
|
35
54
|
"keywords": [
|
|
@@ -37,9 +56,16 @@
|
|
|
37
56
|
"backtesting",
|
|
38
57
|
"algorithmic-trading",
|
|
39
58
|
"ohlcv",
|
|
40
|
-
"quant"
|
|
59
|
+
"quant",
|
|
60
|
+
"quantitative-finance",
|
|
61
|
+
"yahoo-finance",
|
|
62
|
+
"trading-strategy",
|
|
63
|
+
"market-data"
|
|
41
64
|
],
|
|
42
65
|
"publishConfig": {
|
|
43
66
|
"access": "public"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"esbuild": "^0.27.3"
|
|
44
70
|
}
|
|
45
71
|
}
|
package/src/data/yahoo.js
CHANGED
|
@@ -174,7 +174,16 @@ async function fetchYahooChart(symbol, { period1, period2, interval, includePreP
|
|
|
174
174
|
return candles;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
function formatYahooFailureMessage(symbol, interval, period, error, attempts) {
|
|
178
|
+
const detail = String(error?.message || error || "unknown error");
|
|
179
|
+
return [
|
|
180
|
+
`Unable to reach Yahoo Finance for ${symbol} ${interval} ${period} after ${attempts} attempts.`,
|
|
181
|
+
`Last error: ${detail}`,
|
|
182
|
+
"Try again later, or fall back to a local CSV/cache workflow with getHistoricalCandles({ source: \"csv\", ... }) or loadCandlesFromCache(...).",
|
|
183
|
+
].join(" ");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function fetchYahooChartWithRetry(symbol, params, period, maxRetries = 3) {
|
|
178
187
|
let lastError = null;
|
|
179
188
|
|
|
180
189
|
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
|
@@ -188,14 +197,20 @@ async function fetchYahooChartWithRetry(symbol, params, maxRetries = 5) {
|
|
|
188
197
|
|
|
189
198
|
if (!isRetryable || attempt === maxRetries - 1) break;
|
|
190
199
|
|
|
191
|
-
const delay =
|
|
192
|
-
? Math.min(30_000, 2_000 * 2 ** attempt)
|
|
193
|
-
: Math.min(10_000, 750 * (attempt + 1));
|
|
200
|
+
const delay = Math.min(12_000, 500 * 2 ** attempt);
|
|
194
201
|
await sleep(delay);
|
|
195
202
|
}
|
|
196
203
|
}
|
|
197
204
|
|
|
198
|
-
throw
|
|
205
|
+
throw new Error(
|
|
206
|
+
formatYahooFailureMessage(
|
|
207
|
+
symbol,
|
|
208
|
+
params.interval,
|
|
209
|
+
period,
|
|
210
|
+
lastError,
|
|
211
|
+
maxRetries
|
|
212
|
+
)
|
|
213
|
+
);
|
|
199
214
|
}
|
|
200
215
|
|
|
201
216
|
export async function fetchHistorical(symbol, interval = "5m", period = "60d", options = {}) {
|
|
@@ -207,12 +222,16 @@ export async function fetchHistorical(symbol, interval = "5m", period = "60d", o
|
|
|
207
222
|
if (spanMs <= maxSpanMs) {
|
|
208
223
|
const endSec = nowSec();
|
|
209
224
|
const startSec = Math.max(0, endSec - msToSec(spanMs));
|
|
210
|
-
const candles = await fetchYahooChartWithRetry(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
225
|
+
const candles = await fetchYahooChartWithRetry(
|
|
226
|
+
symbol,
|
|
227
|
+
{
|
|
228
|
+
period1: startSec,
|
|
229
|
+
period2: endSec,
|
|
230
|
+
interval: normalizedInterval,
|
|
231
|
+
includePrePost,
|
|
232
|
+
},
|
|
233
|
+
period
|
|
234
|
+
);
|
|
216
235
|
return sanitizeBars(candles);
|
|
217
236
|
}
|
|
218
237
|
|
|
@@ -223,12 +242,16 @@ export async function fetchHistorical(symbol, interval = "5m", period = "60d", o
|
|
|
223
242
|
while (remainingMs > 0) {
|
|
224
243
|
const takeMs = Math.min(remainingMs, maxSpanMs);
|
|
225
244
|
const chunkStartMs = chunkEndMs - takeMs;
|
|
226
|
-
const candles = await fetchYahooChartWithRetry(
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
245
|
+
const candles = await fetchYahooChartWithRetry(
|
|
246
|
+
symbol,
|
|
247
|
+
{
|
|
248
|
+
period1: msToSec(chunkStartMs),
|
|
249
|
+
period2: msToSec(chunkEndMs),
|
|
250
|
+
interval: normalizedInterval,
|
|
251
|
+
includePrePost,
|
|
252
|
+
},
|
|
253
|
+
period
|
|
254
|
+
);
|
|
232
255
|
chunks.push(...candles);
|
|
233
256
|
chunkEndMs = chunkStartMs - 1000;
|
|
234
257
|
remainingMs -= takeMs;
|
package/src/engine/backtest.js
CHANGED
|
@@ -19,6 +19,29 @@ function asNumber(value) {
|
|
|
19
19
|
return Number.isFinite(numeric) ? numeric : null;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function equityPoint(time, equity) {
|
|
23
|
+
return { time, timestamp: time, equity };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isArrayIndexKey(property) {
|
|
27
|
+
if (typeof property !== "string") return false;
|
|
28
|
+
const numeric = Number(property);
|
|
29
|
+
return Number.isInteger(numeric) && numeric >= 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function strictHistoryView(candles, currentIndex) {
|
|
33
|
+
return new Proxy(candles, {
|
|
34
|
+
get(target, property, receiver) {
|
|
35
|
+
if (isArrayIndexKey(property) && Number(property) >= target.length) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return Reflect.get(target, property, receiver);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
function mergeOptions(options) {
|
|
23
46
|
const normalizedRiskPct = Number.isFinite(options.riskFraction)
|
|
24
47
|
? options.riskFraction * 100
|
|
@@ -35,6 +58,7 @@ function mergeOptions(options) {
|
|
|
35
58
|
warmupBars: options.warmupBars ?? 200,
|
|
36
59
|
slippageBps: options.slippageBps ?? 1,
|
|
37
60
|
feeBps: options.feeBps ?? 0,
|
|
61
|
+
costs: options.costs ?? null,
|
|
38
62
|
scaleOutAtR: options.scaleOutAtR ?? 1,
|
|
39
63
|
scaleOutFrac: options.scaleOutFrac ?? 0.5,
|
|
40
64
|
finalTP_R: options.finalTP_R ?? 3,
|
|
@@ -88,6 +112,7 @@ function mergeOptions(options) {
|
|
|
88
112
|
maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
|
|
89
113
|
collectEqSeries: options.collectEqSeries ?? true,
|
|
90
114
|
collectReplay: options.collectReplay ?? true,
|
|
115
|
+
strict: options.strict ?? false,
|
|
91
116
|
};
|
|
92
117
|
}
|
|
93
118
|
|
|
@@ -135,6 +160,16 @@ function normalizeSignal(signal, bar, fallbackR) {
|
|
|
135
160
|
};
|
|
136
161
|
}
|
|
137
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Run a candle-based backtest.
|
|
165
|
+
*
|
|
166
|
+
* Returns raw realized trade legs in `trades`, completed positions in `positions`,
|
|
167
|
+
* aggregate `metrics`, realized equity points in `eqSeries`, and chart-ready
|
|
168
|
+
* replay data in `replay`.
|
|
169
|
+
*
|
|
170
|
+
* When `strict: true` is enabled, the `candles` array passed to `signal()` throws
|
|
171
|
+
* if the strategy tries to access bars beyond the current index.
|
|
172
|
+
*/
|
|
138
173
|
export function backtest(rawOptions) {
|
|
139
174
|
const options = mergeOptions(rawOptions || {});
|
|
140
175
|
const {
|
|
@@ -145,6 +180,7 @@ export function backtest(rawOptions) {
|
|
|
145
180
|
signal,
|
|
146
181
|
slippageBps,
|
|
147
182
|
feeBps,
|
|
183
|
+
costs,
|
|
148
184
|
scaleOutAtR,
|
|
149
185
|
scaleOutFrac,
|
|
150
186
|
finalTP_R,
|
|
@@ -168,6 +204,7 @@ export function backtest(rawOptions) {
|
|
|
168
204
|
collectEqSeries,
|
|
169
205
|
collectReplay,
|
|
170
206
|
warmupBars,
|
|
207
|
+
strict,
|
|
171
208
|
} = options;
|
|
172
209
|
|
|
173
210
|
if (!Array.isArray(candles) || candles.length === 0) {
|
|
@@ -196,7 +233,7 @@ export function backtest(rawOptions) {
|
|
|
196
233
|
const needAtr = atrTrailMult > 0 || volScale.enabled;
|
|
197
234
|
const atrValues = needAtr ? atr(candles, atrSourcePeriod) : null;
|
|
198
235
|
|
|
199
|
-
const eqSeries = wantEqSeries ? [
|
|
236
|
+
const eqSeries = wantEqSeries ? [equityPoint(candles[0].time, currentEquity)] : [];
|
|
200
237
|
const replayFrames = wantReplay ? [] : [];
|
|
201
238
|
const replayEvents = wantReplay ? [] : [];
|
|
202
239
|
let tradeIdCounter = 0;
|
|
@@ -209,7 +246,7 @@ export function backtest(rawOptions) {
|
|
|
209
246
|
|
|
210
247
|
function recordFrame(bar) {
|
|
211
248
|
if (wantEqSeries) {
|
|
212
|
-
eqSeries.push(
|
|
249
|
+
eqSeries.push(equityPoint(bar.time, currentEquity));
|
|
213
250
|
}
|
|
214
251
|
|
|
215
252
|
if (wantReplay) {
|
|
@@ -223,20 +260,19 @@ export function backtest(rawOptions) {
|
|
|
223
260
|
}
|
|
224
261
|
}
|
|
225
262
|
|
|
226
|
-
function closeLeg({ openPos, qty, exitPx,
|
|
263
|
+
function closeLeg({ openPos, qty, exitPx, exitFeeTotal = 0, time, reason }) {
|
|
227
264
|
const direction = openPos.side === "long" ? 1 : -1;
|
|
228
265
|
const entryFill = openPos.entryFill;
|
|
229
266
|
const grossPnl = (exitPx - entryFill) * direction * qty;
|
|
230
267
|
const entryFeePortion =
|
|
231
268
|
(openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
|
|
232
|
-
const exitFeeTotal = exitFeePerUnit * qty;
|
|
233
269
|
const pnl = grossPnl - entryFeePortion - exitFeeTotal;
|
|
234
270
|
|
|
235
271
|
currentEquity += pnl;
|
|
236
272
|
dayPnl += pnl;
|
|
237
273
|
|
|
238
274
|
if (wantEqSeries) {
|
|
239
|
-
eqSeries.push(
|
|
275
|
+
eqSeries.push(equityPoint(time, currentEquity));
|
|
240
276
|
}
|
|
241
277
|
|
|
242
278
|
const remaining = openPos.size - qty;
|
|
@@ -313,17 +349,19 @@ export function backtest(rawOptions) {
|
|
|
313
349
|
if (!open) return;
|
|
314
350
|
|
|
315
351
|
const exitSide = open.side === "long" ? "short" : "long";
|
|
316
|
-
const { price: filled,
|
|
352
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide, {
|
|
317
353
|
slippageBps,
|
|
318
354
|
feeBps,
|
|
319
355
|
kind: "market",
|
|
356
|
+
qty: open.size,
|
|
357
|
+
costs,
|
|
320
358
|
});
|
|
321
359
|
|
|
322
360
|
closeLeg({
|
|
323
361
|
openPos: open,
|
|
324
362
|
qty: open.size,
|
|
325
363
|
exitPx: filled,
|
|
326
|
-
|
|
364
|
+
exitFeeTotal,
|
|
327
365
|
time: bar.time,
|
|
328
366
|
reason,
|
|
329
367
|
});
|
|
@@ -386,13 +424,15 @@ export function backtest(rawOptions) {
|
|
|
386
424
|
const size = roundStep(rawSize, qtyStep);
|
|
387
425
|
if (size < minQty) return false;
|
|
388
426
|
|
|
389
|
-
const { price: entryFill,
|
|
427
|
+
const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
|
|
390
428
|
entryPrice,
|
|
391
429
|
pending.side,
|
|
392
430
|
{
|
|
393
431
|
slippageBps,
|
|
394
432
|
feeBps,
|
|
395
433
|
kind: fillKind,
|
|
434
|
+
qty: size,
|
|
435
|
+
costs,
|
|
396
436
|
}
|
|
397
437
|
);
|
|
398
438
|
|
|
@@ -407,7 +447,7 @@ export function backtest(rawOptions) {
|
|
|
407
447
|
size,
|
|
408
448
|
openTime: bar.time,
|
|
409
449
|
entryFill,
|
|
410
|
-
entryFeeTotal
|
|
450
|
+
entryFeeTotal,
|
|
411
451
|
initSize: size,
|
|
412
452
|
baseSize: size,
|
|
413
453
|
_mfeR: 0,
|
|
@@ -570,16 +610,16 @@ export function backtest(rawOptions) {
|
|
|
570
610
|
const cutQty = roundStep(open.size * volScale.cutFrac, qtyStep);
|
|
571
611
|
if (cutQty >= minQty && cutQty < open.size) {
|
|
572
612
|
const exitSide = open.side === "long" ? "short" : "long";
|
|
573
|
-
const { price: filled,
|
|
613
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(
|
|
574
614
|
bar.close,
|
|
575
615
|
exitSide,
|
|
576
|
-
{ slippageBps, feeBps, kind: "market" }
|
|
616
|
+
{ slippageBps, feeBps, kind: "market", qty: cutQty, costs }
|
|
577
617
|
);
|
|
578
618
|
closeLeg({
|
|
579
619
|
openPos: open,
|
|
580
620
|
qty: cutQty,
|
|
581
621
|
exitPx: filled,
|
|
582
|
-
|
|
622
|
+
exitFeeTotal,
|
|
583
623
|
time: bar.time,
|
|
584
624
|
reason: "SCALE",
|
|
585
625
|
});
|
|
@@ -614,13 +654,13 @@ export function backtest(rawOptions) {
|
|
|
614
654
|
const baseSize = open.baseSize || open.initSize;
|
|
615
655
|
const addQty = roundStep(baseSize * pyramiding.addFrac, qtyStep);
|
|
616
656
|
if (addQty >= minQty) {
|
|
617
|
-
const { price: addFill,
|
|
657
|
+
const { price: addFill, feeTotal: addFeeTotal } = applyFill(
|
|
618
658
|
triggerPrice,
|
|
619
659
|
open.side,
|
|
620
|
-
{ slippageBps, feeBps, kind: "limit" }
|
|
660
|
+
{ slippageBps, feeBps, kind: "limit", qty: addQty, costs }
|
|
621
661
|
);
|
|
622
662
|
const newSize = open.size + addQty;
|
|
623
|
-
open.entryFeeTotal +=
|
|
663
|
+
open.entryFeeTotal += addFeeTotal;
|
|
624
664
|
open.entryFill =
|
|
625
665
|
(open.entryFill * open.size + addFill * addQty) / newSize;
|
|
626
666
|
open.size = newSize;
|
|
@@ -648,18 +688,20 @@ export function backtest(rawOptions) {
|
|
|
648
688
|
|
|
649
689
|
if (touched) {
|
|
650
690
|
const exitSide = open.side === "long" ? "short" : "long";
|
|
651
|
-
const { price: filled, fee: exitFee } = applyFill(triggerPrice, exitSide, {
|
|
652
|
-
slippageBps,
|
|
653
|
-
feeBps,
|
|
654
|
-
kind: "limit",
|
|
655
|
-
});
|
|
656
691
|
const qty = roundStep(open.size * scaleOutFrac, qtyStep);
|
|
657
692
|
if (qty >= minQty && qty < open.size) {
|
|
693
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(triggerPrice, exitSide, {
|
|
694
|
+
slippageBps,
|
|
695
|
+
feeBps,
|
|
696
|
+
kind: "limit",
|
|
697
|
+
qty,
|
|
698
|
+
costs,
|
|
699
|
+
});
|
|
658
700
|
closeLeg({
|
|
659
701
|
openPos: open,
|
|
660
702
|
qty,
|
|
661
703
|
exitPx: filled,
|
|
662
|
-
|
|
704
|
+
exitFeeTotal,
|
|
663
705
|
time: bar.time,
|
|
664
706
|
reason: "SCALE",
|
|
665
707
|
});
|
|
@@ -686,17 +728,19 @@ export function backtest(rawOptions) {
|
|
|
686
728
|
|
|
687
729
|
if (hit) {
|
|
688
730
|
const exitKind = hit === "TP" ? "limit" : "stop";
|
|
689
|
-
const { price: filled,
|
|
731
|
+
const { price: filled, feeTotal: exitFeeTotal } = applyFill(px, exitSide, {
|
|
690
732
|
slippageBps,
|
|
691
733
|
feeBps,
|
|
692
734
|
kind: exitKind,
|
|
735
|
+
qty: open.size,
|
|
736
|
+
costs,
|
|
693
737
|
});
|
|
694
738
|
const localCooldown = open._cooldownBars || 0;
|
|
695
739
|
closeLeg({
|
|
696
740
|
openPos: open,
|
|
697
741
|
qty: open.size,
|
|
698
742
|
exitPx: filled,
|
|
699
|
-
|
|
743
|
+
exitFeeTotal,
|
|
700
744
|
time: bar.time,
|
|
701
745
|
reason: hit,
|
|
702
746
|
});
|
|
@@ -783,8 +827,15 @@ export function backtest(rawOptions) {
|
|
|
783
827
|
}
|
|
784
828
|
|
|
785
829
|
if (!pending) {
|
|
830
|
+
if (strict && history.length !== index + 1) {
|
|
831
|
+
throw new Error(
|
|
832
|
+
`strict mode: signal() received ${history.length} candles at index ${index}`
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const signalCandles = strict ? strictHistoryView(history, index) : history;
|
|
786
837
|
const rawSignal = signal({
|
|
787
|
-
candles:
|
|
838
|
+
candles: signalCandles,
|
|
788
839
|
index,
|
|
789
840
|
bar,
|
|
790
841
|
equity: currentEquity,
|
package/src/engine/execution.js
CHANGED
|
@@ -1,18 +1,55 @@
|
|
|
1
1
|
import { minutesET } from "../utils/time.js";
|
|
2
2
|
|
|
3
|
+
function resolveSlippageBps(kind, slippageBps, slippageByKind) {
|
|
4
|
+
if (Number.isFinite(slippageByKind?.[kind])) {
|
|
5
|
+
return slippageByKind[kind];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let effectiveSlippageBps = slippageBps;
|
|
9
|
+
if (kind === "limit") effectiveSlippageBps *= 0.25;
|
|
10
|
+
if (kind === "stop") effectiveSlippageBps *= 1.25;
|
|
11
|
+
return effectiveSlippageBps;
|
|
12
|
+
}
|
|
13
|
+
|
|
3
14
|
export function applyFill(
|
|
4
15
|
price,
|
|
5
16
|
side,
|
|
6
|
-
{ slippageBps = 0, feeBps = 0, kind = "market" } = {}
|
|
17
|
+
{ slippageBps = 0, feeBps = 0, kind = "market", qty = 0, costs = {} } = {}
|
|
7
18
|
) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
19
|
+
const model = costs || {};
|
|
20
|
+
const modelSlippageBps = Number.isFinite(model.slippageBps)
|
|
21
|
+
? model.slippageBps
|
|
22
|
+
: slippageBps;
|
|
23
|
+
const modelFeeBps = Number.isFinite(model.commissionBps)
|
|
24
|
+
? model.commissionBps
|
|
25
|
+
: feeBps;
|
|
26
|
+
const effectiveSlippageBps = resolveSlippageBps(
|
|
27
|
+
kind,
|
|
28
|
+
modelSlippageBps,
|
|
29
|
+
model.slippageByKind
|
|
30
|
+
);
|
|
31
|
+
const halfSpreadBps = Number.isFinite(model.spreadBps)
|
|
32
|
+
? model.spreadBps / 2
|
|
33
|
+
: 0;
|
|
11
34
|
|
|
12
|
-
const slippage = (effectiveSlippageBps / 10000) * price;
|
|
35
|
+
const slippage = ((effectiveSlippageBps + halfSpreadBps) / 10000) * price;
|
|
13
36
|
const filledPrice = side === "long" ? price + slippage : price - slippage;
|
|
14
|
-
const
|
|
15
|
-
|
|
37
|
+
const variableFeePerUnit =
|
|
38
|
+
((modelFeeBps || 0) / 10000) * Math.abs(filledPrice) +
|
|
39
|
+
(Number.isFinite(model.commissionPerUnit) ? model.commissionPerUnit : 0);
|
|
40
|
+
const variableFeeTotal = variableFeePerUnit * Math.max(0, qty);
|
|
41
|
+
const fixedFeeTotal = Number.isFinite(model.commissionPerOrder)
|
|
42
|
+
? model.commissionPerOrder
|
|
43
|
+
: 0;
|
|
44
|
+
const grossFeeTotal = variableFeeTotal + fixedFeeTotal;
|
|
45
|
+
const feeTotal = Math.max(
|
|
46
|
+
Number.isFinite(model.minCommission) ? model.minCommission : 0,
|
|
47
|
+
grossFeeTotal
|
|
48
|
+
);
|
|
49
|
+
const feePerUnit =
|
|
50
|
+
qty > 0 ? feeTotal / qty : variableFeePerUnit;
|
|
51
|
+
|
|
52
|
+
return { price: filledPrice, fee: feePerUnit, feeTotal };
|
|
16
53
|
}
|
|
17
54
|
|
|
18
55
|
export function clampStop(marketPrice, proposedStop, side, oco) {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { buildMetrics } from "../metrics/buildMetrics.js";
|
|
2
|
+
import { backtest } from "./backtest.js";
|
|
3
|
+
import { estimateBarMs } from "./execution.js";
|
|
4
|
+
|
|
5
|
+
function asWeight(value) {
|
|
6
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function combineEquitySeries(systemRuns, totalEquity) {
|
|
10
|
+
const timeline = new Set();
|
|
11
|
+
for (const run of systemRuns) {
|
|
12
|
+
for (const point of run.result.eqSeries || []) {
|
|
13
|
+
timeline.add(point.time);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const times = [...timeline].sort((left, right) => left - right);
|
|
18
|
+
if (!times.length) {
|
|
19
|
+
return [{ time: 0, timestamp: 0, equity: totalEquity }];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const states = systemRuns.map((run) => ({
|
|
23
|
+
points: run.result.eqSeries || [],
|
|
24
|
+
index: 0,
|
|
25
|
+
lastEquity: run.allocationEquity,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
return times.map((time) => {
|
|
29
|
+
let equity = 0;
|
|
30
|
+
states.forEach((state) => {
|
|
31
|
+
while (
|
|
32
|
+
state.index < state.points.length &&
|
|
33
|
+
state.points[state.index].time <= time
|
|
34
|
+
) {
|
|
35
|
+
state.lastEquity = state.points[state.index].equity;
|
|
36
|
+
state.index += 1;
|
|
37
|
+
}
|
|
38
|
+
equity += state.lastEquity;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return { time, timestamp: time, equity };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function combineReplay(systemRuns, eqSeries, collectReplay) {
|
|
46
|
+
if (!collectReplay) {
|
|
47
|
+
return { frames: [], events: [] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const events = systemRuns
|
|
51
|
+
.flatMap((run) =>
|
|
52
|
+
(run.result.replay?.events || []).map((event) => ({
|
|
53
|
+
...event,
|
|
54
|
+
symbol: event.symbol || run.symbol,
|
|
55
|
+
}))
|
|
56
|
+
)
|
|
57
|
+
.sort((left, right) => new Date(left.t).getTime() - new Date(right.t).getTime());
|
|
58
|
+
|
|
59
|
+
const frames = eqSeries.map((point) => ({
|
|
60
|
+
t: new Date(point.time).toISOString(),
|
|
61
|
+
price: 0,
|
|
62
|
+
equity: point.equity,
|
|
63
|
+
posSide: null,
|
|
64
|
+
posSize: 0,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
return { frames, events };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run multiple symbol/system backtests and aggregate them into a portfolio view.
|
|
72
|
+
*
|
|
73
|
+
* Capital is allocated up front per system using weights. Each system then runs
|
|
74
|
+
* through the normal single-symbol backtest engine and the portfolio result
|
|
75
|
+
* aggregates trades, positions, equity, replay events, and metrics.
|
|
76
|
+
*/
|
|
77
|
+
export function backtestPortfolio({
|
|
78
|
+
systems = [],
|
|
79
|
+
equity = 10_000,
|
|
80
|
+
allocation = "equal",
|
|
81
|
+
collectEqSeries = true,
|
|
82
|
+
collectReplay = false,
|
|
83
|
+
} = {}) {
|
|
84
|
+
if (!Array.isArray(systems) || systems.length === 0) {
|
|
85
|
+
throw new Error("backtestPortfolio() requires a non-empty systems array");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const weights =
|
|
89
|
+
allocation === "equal"
|
|
90
|
+
? systems.map(() => 1)
|
|
91
|
+
: systems.map((system) => asWeight(system.weight || 0));
|
|
92
|
+
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
|
|
93
|
+
|
|
94
|
+
if (!(totalWeight > 0)) {
|
|
95
|
+
throw new Error("backtestPortfolio() requires positive allocation weights");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const systemRuns = systems.map((system, index) => {
|
|
99
|
+
const allocationEquity = equity * (weights[index] / totalWeight);
|
|
100
|
+
const result = backtest({
|
|
101
|
+
...system,
|
|
102
|
+
equity: allocationEquity,
|
|
103
|
+
collectEqSeries,
|
|
104
|
+
collectReplay,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
symbol: system.symbol ?? result.symbol ?? `system-${index + 1}`,
|
|
109
|
+
weight: weights[index],
|
|
110
|
+
allocationEquity,
|
|
111
|
+
result,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const trades = systemRuns
|
|
116
|
+
.flatMap((run) =>
|
|
117
|
+
run.result.trades.map((trade) => ({
|
|
118
|
+
...trade,
|
|
119
|
+
symbol: trade.symbol || run.symbol,
|
|
120
|
+
}))
|
|
121
|
+
)
|
|
122
|
+
.sort((left, right) => left.exit.time - right.exit.time);
|
|
123
|
+
const positions = systemRuns
|
|
124
|
+
.flatMap((run) =>
|
|
125
|
+
run.result.positions.map((trade) => ({
|
|
126
|
+
...trade,
|
|
127
|
+
symbol: trade.symbol || run.symbol,
|
|
128
|
+
}))
|
|
129
|
+
)
|
|
130
|
+
.sort((left, right) => left.exit.time - right.exit.time);
|
|
131
|
+
const eqSeries = collectEqSeries ? combineEquitySeries(systemRuns, equity) : [];
|
|
132
|
+
const replay = combineReplay(systemRuns, eqSeries, collectReplay);
|
|
133
|
+
const allCandles = systems.flatMap((system) => system.candles || []);
|
|
134
|
+
const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
|
|
135
|
+
const metrics = buildMetrics({
|
|
136
|
+
closed: trades,
|
|
137
|
+
equityStart: equity,
|
|
138
|
+
equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : equity,
|
|
139
|
+
candles: orderedCandles,
|
|
140
|
+
estBarMs: estimateBarMs(orderedCandles),
|
|
141
|
+
eqSeries,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
symbol: "PORTFOLIO",
|
|
146
|
+
interval: undefined,
|
|
147
|
+
range: undefined,
|
|
148
|
+
trades,
|
|
149
|
+
positions,
|
|
150
|
+
metrics,
|
|
151
|
+
eqSeries,
|
|
152
|
+
replay,
|
|
153
|
+
systems: systemRuns.map((run) => ({
|
|
154
|
+
symbol: run.symbol,
|
|
155
|
+
weight: run.weight / totalWeight,
|
|
156
|
+
equity: run.allocationEquity,
|
|
157
|
+
result: run.result,
|
|
158
|
+
})),
|
|
159
|
+
};
|
|
160
|
+
}
|