tradelab 0.2.0 → 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 +20 -7
- package/bin/tradelab.js +384 -0
- package/dist/cjs/data.cjs +59 -27
- package/dist/cjs/index.cjs +291 -29
- package/package.json +5 -1
- package/src/engine/backtest.js +29 -20
- 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 +2 -0
- package/types/index.d.ts +58 -0
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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { buildMetrics } from "../metrics/buildMetrics.js";
|
|
2
|
+
import { backtest } from "./backtest.js";
|
|
3
|
+
import { estimateBarMs } from "./execution.js";
|
|
4
|
+
|
|
5
|
+
function scoreOf(metrics, scoreBy) {
|
|
6
|
+
const value = metrics?.[scoreBy];
|
|
7
|
+
return Number.isFinite(value) ? value : -Infinity;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function stitchEquitySeries(target, source) {
|
|
11
|
+
if (!source?.length) return;
|
|
12
|
+
if (!target.length) {
|
|
13
|
+
target.push(...source);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const lastTime = target[target.length - 1].time;
|
|
18
|
+
const nextPoints = source.filter((point) => point.time > lastTime);
|
|
19
|
+
target.push(...nextPoints);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run rolling walk-forward optimization over a single candle series.
|
|
24
|
+
*
|
|
25
|
+
* Each window selects the best parameter set on the training segment and then
|
|
26
|
+
* evaluates that parameter set on the following out-of-sample segment.
|
|
27
|
+
*/
|
|
28
|
+
export function walkForwardOptimize({
|
|
29
|
+
candles = [],
|
|
30
|
+
signalFactory,
|
|
31
|
+
parameterSets = [],
|
|
32
|
+
trainBars,
|
|
33
|
+
testBars,
|
|
34
|
+
stepBars = testBars,
|
|
35
|
+
scoreBy = "profitFactor",
|
|
36
|
+
backtestOptions = {},
|
|
37
|
+
} = {}) {
|
|
38
|
+
if (!Array.isArray(candles) || candles.length === 0) {
|
|
39
|
+
throw new Error("walkForwardOptimize() requires a non-empty candles array");
|
|
40
|
+
}
|
|
41
|
+
if (typeof signalFactory !== "function") {
|
|
42
|
+
throw new Error("walkForwardOptimize() requires a signalFactory function");
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
|
|
45
|
+
throw new Error("walkForwardOptimize() requires parameterSets");
|
|
46
|
+
}
|
|
47
|
+
if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
|
|
48
|
+
throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const windows = [];
|
|
52
|
+
const allTrades = [];
|
|
53
|
+
const allPositions = [];
|
|
54
|
+
const eqSeries = [];
|
|
55
|
+
let rollingEquity = backtestOptions.equity ?? 10_000;
|
|
56
|
+
|
|
57
|
+
for (
|
|
58
|
+
let start = 0;
|
|
59
|
+
start + trainBars + testBars <= candles.length;
|
|
60
|
+
start += stepBars
|
|
61
|
+
) {
|
|
62
|
+
const trainSlice = candles.slice(start, start + trainBars);
|
|
63
|
+
const testSlice = candles.slice(start + trainBars, start + trainBars + testBars);
|
|
64
|
+
|
|
65
|
+
let best = null;
|
|
66
|
+
for (const params of parameterSets) {
|
|
67
|
+
const trainResult = backtest({
|
|
68
|
+
...backtestOptions,
|
|
69
|
+
candles: trainSlice,
|
|
70
|
+
equity: rollingEquity,
|
|
71
|
+
signal: signalFactory(params),
|
|
72
|
+
});
|
|
73
|
+
const score = scoreOf(trainResult.metrics, scoreBy);
|
|
74
|
+
if (!best || score > best.score) {
|
|
75
|
+
best = { params, score, metrics: trainResult.metrics };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const testResult = backtest({
|
|
80
|
+
...backtestOptions,
|
|
81
|
+
candles: testSlice,
|
|
82
|
+
equity: rollingEquity,
|
|
83
|
+
signal: signalFactory(best.params),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
rollingEquity = testResult.metrics.finalEquity;
|
|
87
|
+
allTrades.push(...testResult.trades);
|
|
88
|
+
allPositions.push(...testResult.positions);
|
|
89
|
+
stitchEquitySeries(eqSeries, testResult.eqSeries);
|
|
90
|
+
|
|
91
|
+
windows.push({
|
|
92
|
+
train: {
|
|
93
|
+
start: trainSlice[0]?.time ?? null,
|
|
94
|
+
end: trainSlice[trainSlice.length - 1]?.time ?? null,
|
|
95
|
+
},
|
|
96
|
+
test: {
|
|
97
|
+
start: testSlice[0]?.time ?? null,
|
|
98
|
+
end: testSlice[testSlice.length - 1]?.time ?? null,
|
|
99
|
+
},
|
|
100
|
+
bestParams: best.params,
|
|
101
|
+
trainScore: best.score,
|
|
102
|
+
trainMetrics: best.metrics,
|
|
103
|
+
testMetrics: testResult.metrics,
|
|
104
|
+
result: testResult,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const metrics = buildMetrics({
|
|
109
|
+
closed: allTrades,
|
|
110
|
+
equityStart: backtestOptions.equity ?? 10_000,
|
|
111
|
+
equityFinal: rollingEquity,
|
|
112
|
+
candles,
|
|
113
|
+
estBarMs: estimateBarMs(candles),
|
|
114
|
+
eqSeries,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
windows,
|
|
119
|
+
trades: allTrades,
|
|
120
|
+
positions: allPositions,
|
|
121
|
+
metrics,
|
|
122
|
+
eqSeries,
|
|
123
|
+
replay: { frames: [], events: [] },
|
|
124
|
+
bestParams: windows.map((window) => window.bestParams),
|
|
125
|
+
};
|
|
126
|
+
}
|
package/src/index.js
CHANGED
package/types/index.d.ts
CHANGED
|
@@ -224,6 +224,16 @@ export interface OCOOptions {
|
|
|
224
224
|
clampEpsBps?: number;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
export interface ExecutionCostOptions {
|
|
228
|
+
slippageBps?: number;
|
|
229
|
+
spreadBps?: number;
|
|
230
|
+
slippageByKind?: Partial<Record<"market" | "limit" | "stop", number>>;
|
|
231
|
+
commissionBps?: number;
|
|
232
|
+
commissionPerUnit?: number;
|
|
233
|
+
commissionPerOrder?: number;
|
|
234
|
+
minCommission?: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
227
237
|
export interface MfeTrailOptions {
|
|
228
238
|
enabled?: boolean;
|
|
229
239
|
armR?: number;
|
|
@@ -265,6 +275,7 @@ export interface BacktestOptions {
|
|
|
265
275
|
warmupBars?: number;
|
|
266
276
|
slippageBps?: number;
|
|
267
277
|
feeBps?: number;
|
|
278
|
+
costs?: ExecutionCostOptions;
|
|
268
279
|
scaleOutAtR?: number;
|
|
269
280
|
scaleOutFrac?: number;
|
|
270
281
|
finalTP_R?: number;
|
|
@@ -307,6 +318,36 @@ export interface BacktestResult {
|
|
|
307
318
|
replay: ReplayPayload;
|
|
308
319
|
}
|
|
309
320
|
|
|
321
|
+
export interface PortfolioSystem extends Omit<BacktestOptions, "equity"> {
|
|
322
|
+
weight?: number;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export interface PortfolioSystemResult {
|
|
326
|
+
symbol: string;
|
|
327
|
+
weight: number;
|
|
328
|
+
equity: number;
|
|
329
|
+
result: BacktestResult;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export interface PortfolioBacktestResult extends BacktestResult {
|
|
333
|
+
systems: PortfolioSystemResult[];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export interface WalkForwardWindow {
|
|
337
|
+
train: { start: number | null; end: number | null };
|
|
338
|
+
test: { start: number | null; end: number | null };
|
|
339
|
+
bestParams: Record<string, unknown>;
|
|
340
|
+
trainScore: number;
|
|
341
|
+
trainMetrics: BacktestMetrics;
|
|
342
|
+
testMetrics: BacktestMetrics;
|
|
343
|
+
result: BacktestResult;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface WalkForwardResult extends BacktestResult {
|
|
347
|
+
windows: WalkForwardWindow[];
|
|
348
|
+
bestParams: Array<Record<string, unknown>>;
|
|
349
|
+
}
|
|
350
|
+
|
|
310
351
|
export interface CsvLoadOptions {
|
|
311
352
|
delimiter?: string;
|
|
312
353
|
skipRows?: number;
|
|
@@ -418,6 +459,23 @@ export interface ArtifactPaths {
|
|
|
418
459
|
* chart-friendly replay frames/events in `replay`.
|
|
419
460
|
*/
|
|
420
461
|
export function backtest(options: BacktestOptions): BacktestResult;
|
|
462
|
+
export function backtestPortfolio(options: {
|
|
463
|
+
systems: PortfolioSystem[];
|
|
464
|
+
equity?: number;
|
|
465
|
+
allocation?: "equal" | "weight";
|
|
466
|
+
collectEqSeries?: boolean;
|
|
467
|
+
collectReplay?: boolean;
|
|
468
|
+
}): PortfolioBacktestResult;
|
|
469
|
+
export function walkForwardOptimize(options: {
|
|
470
|
+
candles: Candle[];
|
|
471
|
+
signalFactory: (params: Record<string, unknown>) => SignalFunction;
|
|
472
|
+
parameterSets: Array<Record<string, unknown>>;
|
|
473
|
+
trainBars: number;
|
|
474
|
+
testBars: number;
|
|
475
|
+
stepBars?: number;
|
|
476
|
+
scoreBy?: keyof BacktestMetrics;
|
|
477
|
+
backtestOptions?: Omit<BacktestOptions, "candles" | "signal">;
|
|
478
|
+
}): WalkForwardResult;
|
|
421
479
|
export function buildMetrics(input: {
|
|
422
480
|
closed: BacktestTrade[];
|
|
423
481
|
equityStart: number;
|