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.
@@ -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
- let effectiveSlippageBps = slippageBps;
9
- if (kind === "limit") effectiveSlippageBps *= 0.25;
10
- if (kind === "stop") effectiveSlippageBps *= 1.25;
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 feePerUnit = (feeBps / 10000) * Math.abs(filledPrice);
15
- return { price: filledPrice, fee: feePerUnit };
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
@@ -1,4 +1,6 @@
1
1
  export { backtest } from "./engine/backtest.js";
2
+ export { backtestPortfolio } from "./engine/portfolio.js";
3
+ export { walkForwardOptimize } from "./engine/walkForward.js";
2
4
 
3
5
  export { buildMetrics } from "./metrics/buildMetrics.js";
4
6
  export {
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;