tradelab 0.3.0 → 0.5.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,57 +1,35 @@
1
1
  import { buildMetrics } from "../metrics/buildMetrics.js";
2
- import { backtest } from "./backtest.js";
3
- import { estimateBarMs } from "./execution.js";
2
+ import { estimateBarMs, dayKeyET } from "./execution.js";
3
+ import { BarSystemRunner, defaultSystemCap } from "./barSystemRunner.js";
4
4
 
5
5
  function asWeight(value) {
6
6
  return Number.isFinite(value) && value > 0 ? value : 0;
7
7
  }
8
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
- });
9
+ function buildPortfolioPoint(time, equity, lockedCapital, availableCapital) {
10
+ return {
11
+ time,
12
+ timestamp: time,
13
+ equity,
14
+ lockedCapital,
15
+ availableCapital,
16
+ };
17
+ }
40
18
 
41
- return { time, timestamp: time, equity };
42
- });
19
+ function stableSystemOrder(left, right) {
20
+ return left.index - right.index;
43
21
  }
44
22
 
45
- function combineReplay(systemRuns, eqSeries, collectReplay) {
23
+ function combineReplay(systemResults, eqSeries, collectReplay) {
46
24
  if (!collectReplay) {
47
25
  return { frames: [], events: [] };
48
26
  }
49
27
 
50
- const events = systemRuns
51
- .flatMap((run) =>
52
- (run.result.replay?.events || []).map((event) => ({
28
+ const events = systemResults
29
+ .flatMap((entry) =>
30
+ (entry.result.replay?.events || []).map((event) => ({
53
31
  ...event,
54
- symbol: event.symbol || run.symbol,
32
+ symbol: event.symbol || entry.symbol,
55
33
  }))
56
34
  )
57
35
  .sort((left, right) => new Date(left.t).getTime() - new Date(right.t).getTime());
@@ -62,17 +40,82 @@ function combineReplay(systemRuns, eqSeries, collectReplay) {
62
40
  equity: point.equity,
63
41
  posSide: null,
64
42
  posSize: 0,
43
+ lockedCapital: point.lockedCapital,
44
+ availableCapital: point.availableCapital,
65
45
  }));
66
46
 
67
47
  return { frames, events };
68
48
  }
69
49
 
50
+ function portfolioState(runners, initialEquity) {
51
+ let markedEquity = initialEquity;
52
+ let lockedCapital = 0;
53
+
54
+ for (const { runner, initialReferenceEquity } of runners) {
55
+ markedEquity += runner.getMarkedEquity() - initialReferenceEquity;
56
+ lockedCapital += runner.getLockedCapital();
57
+ }
58
+
59
+ return {
60
+ markedEquity,
61
+ lockedCapital,
62
+ availableCapital: markedEquity - lockedCapital,
63
+ };
64
+ }
65
+
66
+ function findNextTimeAndActive(runners) {
67
+ let nextTime = Infinity;
68
+ const active = [];
69
+
70
+ for (const entry of runners) {
71
+ const time = entry.runner.peekTime();
72
+ if (time < nextTime) {
73
+ nextTime = time;
74
+ active.length = 0;
75
+ active.push(entry);
76
+ continue;
77
+ }
78
+ if (time === nextTime) {
79
+ active.push(entry);
80
+ }
81
+ }
82
+
83
+ return { nextTime, active };
84
+ }
85
+
86
+ function initialPortfolioTime(runners) {
87
+ let time = Infinity;
88
+ for (const { runner } of runners) {
89
+ const next = runner.candles[0]?.time ?? Infinity;
90
+ if (next < time) time = next;
91
+ }
92
+ return Number.isFinite(time) ? time : 0;
93
+ }
94
+
95
+ function resolveSystemCap(systemEntry, totalEquity) {
96
+ return defaultSystemCap(
97
+ Math.max(0, totalEquity),
98
+ systemEntry.defaultCapPct,
99
+ systemEntry.system.maxAllocation,
100
+ systemEntry.system.maxAllocationPct
101
+ );
102
+ }
103
+
104
+ function forceExitAll(runners, time) {
105
+ for (const { runner } of runners) {
106
+ if (!runner.open) continue;
107
+ const price = runner.getMarkPrice();
108
+ if (!Number.isFinite(price)) continue;
109
+ runner.forceExit("PORTFOLIO_DAILY_LOSS", { time, close: price }, price);
110
+ }
111
+ }
112
+
70
113
  /**
71
- * Run multiple symbol/system backtests and aggregate them into a portfolio view.
114
+ * Run multiple systems against a shared capital pool.
72
115
  *
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.
116
+ * Existing allocation weights are preserved as default per-system capital caps,
117
+ * but capital is only locked when a fill actually occurs. Systems therefore
118
+ * compete for the same remaining capital at fill time.
76
119
  */
77
120
  export function backtestPortfolio({
78
121
  systems = [],
@@ -80,6 +123,7 @@ export function backtestPortfolio({
80
123
  allocation = "equal",
81
124
  collectEqSeries = true,
82
125
  collectReplay = false,
126
+ maxDailyLossPct = 0,
83
127
  } = {}) {
84
128
  if (!Array.isArray(systems) || systems.length === 0) {
85
129
  throw new Error("backtestPortfolio() requires a non-empty systems array");
@@ -89,30 +133,115 @@ export function backtestPortfolio({
89
133
  allocation === "equal"
90
134
  ? systems.map(() => 1)
91
135
  : systems.map((system) => asWeight(system.weight || 0));
92
- const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
136
+ const totalWeight = weights.reduce((sumValue, weight) => sumValue + weight, 0);
93
137
 
94
138
  if (!(totalWeight > 0)) {
95
139
  throw new Error("backtestPortfolio() requires positive allocation weights");
96
140
  }
97
141
 
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
-
142
+ const runners = systems.map((system, index) => {
143
+ const defaultCapPct = weights[index] / totalWeight;
144
+ const initialReferenceEquity = equity * defaultCapPct;
107
145
  return {
108
- symbol: system.symbol ?? result.symbol ?? `system-${index + 1}`,
109
- weight: weights[index],
110
- allocationEquity,
111
- result,
146
+ index,
147
+ symbol: system.symbol ?? `system-${index + 1}`,
148
+ system,
149
+ defaultCapPct,
150
+ initialReferenceEquity,
151
+ runner: new BarSystemRunner({
152
+ ...system,
153
+ symbol: system.symbol ?? `system-${index + 1}`,
154
+ equity: initialReferenceEquity,
155
+ collectEqSeries,
156
+ collectReplay,
157
+ }),
112
158
  };
113
159
  });
114
160
 
115
- const trades = systemRuns
161
+ const eqSeries = collectEqSeries ? [] : [];
162
+ let state = portfolioState(runners, equity);
163
+ if (collectEqSeries) {
164
+ eqSeries.push(
165
+ buildPortfolioPoint(
166
+ initialPortfolioTime(runners),
167
+ state.markedEquity,
168
+ state.lockedCapital,
169
+ state.availableCapital
170
+ )
171
+ );
172
+ }
173
+
174
+ let currentDay = null;
175
+ let dayStartEquity = equity;
176
+ let portfolioHalted = false;
177
+
178
+ while (true) {
179
+ const { nextTime, active } = findNextTimeAndActive(runners);
180
+ if (!Number.isFinite(nextTime)) break;
181
+ active.sort(stableSystemOrder);
182
+
183
+ const dayKey = dayKeyET(nextTime);
184
+ if (currentDay === null || dayKey !== currentDay) {
185
+ currentDay = dayKey;
186
+ state = portfolioState(runners, equity);
187
+ dayStartEquity = state.markedEquity;
188
+ portfolioHalted = false;
189
+ }
190
+
191
+ for (const systemEntry of active) {
192
+ state = portfolioState(runners, equity);
193
+ const totalEquity = state.markedEquity;
194
+ const availableCapital = Math.max(0, state.availableCapital);
195
+ const systemLocked = systemEntry.runner.getLockedCapital();
196
+ const systemCap = resolveSystemCap(systemEntry, totalEquity);
197
+ const systemRemainingCapital = Math.max(0, systemCap - systemLocked);
198
+
199
+ systemEntry.runner.step({
200
+ signalEquity: totalEquity,
201
+ canTrade: !portfolioHalted,
202
+ resolveEntrySize({ desiredSize, entryPrice }) {
203
+ const maxLeverage = Math.max(1, systemEntry.runner.options.maxLeverage || 1);
204
+ const byAvailable = (availableCapital * maxLeverage) / Math.max(1e-12, Math.abs(entryPrice));
205
+ const bySystemCap = (systemRemainingCapital * maxLeverage) / Math.max(1e-12, Math.abs(entryPrice));
206
+ return Math.min(desiredSize, byAvailable, bySystemCap);
207
+ },
208
+ });
209
+
210
+ state = portfolioState(runners, equity);
211
+ if (
212
+ !portfolioHalted &&
213
+ maxDailyLossPct > 0 &&
214
+ state.markedEquity <= dayStartEquity * (1 - Math.abs(maxDailyLossPct) / 100)
215
+ ) {
216
+ portfolioHalted = true;
217
+ for (const { runner } of runners) runner.cancelPending();
218
+ forceExitAll(runners, nextTime);
219
+ state = portfolioState(runners, equity);
220
+ }
221
+ }
222
+
223
+ if (collectEqSeries) {
224
+ eqSeries.push(
225
+ buildPortfolioPoint(
226
+ nextTime,
227
+ state.markedEquity,
228
+ state.lockedCapital,
229
+ state.availableCapital
230
+ )
231
+ );
232
+ }
233
+ }
234
+
235
+ const systemResults = runners.map((entry) => ({
236
+ symbol: entry.symbol,
237
+ weight: entry.defaultCapPct,
238
+ equity: entry.initialReferenceEquity,
239
+ allocationCapPct: entry.defaultCapPct,
240
+ allocationCap: resolveSystemCap(entry, equity),
241
+ result: entry.runner.buildResult(),
242
+ }));
243
+
244
+ const trades = systemResults
116
245
  .flatMap((run) =>
117
246
  run.result.trades.map((trade) => ({
118
247
  ...trade,
@@ -120,7 +249,7 @@ export function backtestPortfolio({
120
249
  }))
121
250
  )
122
251
  .sort((left, right) => left.exit.time - right.exit.time);
123
- const positions = systemRuns
252
+ const positions = systemResults
124
253
  .flatMap((run) =>
125
254
  run.result.positions.map((trade) => ({
126
255
  ...trade,
@@ -128,8 +257,7 @@ export function backtestPortfolio({
128
257
  }))
129
258
  )
130
259
  .sort((left, right) => left.exit.time - right.exit.time);
131
- const eqSeries = collectEqSeries ? combineEquitySeries(systemRuns, equity) : [];
132
- const replay = combineReplay(systemRuns, eqSeries, collectReplay);
260
+ const replay = combineReplay(systemResults, eqSeries, collectReplay);
133
261
  const allCandles = systems.flatMap((system) => system.candles || []);
134
262
  const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
135
263
  const metrics = buildMetrics({
@@ -150,11 +278,6 @@ export function backtestPortfolio({
150
278
  metrics,
151
279
  eqSeries,
152
280
  replay,
153
- systems: systemRuns.map((run) => ({
154
- symbol: run.symbol,
155
- weight: run.weight / totalWeight,
156
- equity: run.allocationEquity,
157
- result: run.result,
158
- })),
281
+ systems: systemResults,
159
282
  };
160
283
  }
@@ -19,6 +19,73 @@ function stitchEquitySeries(target, source) {
19
19
  target.push(...nextPoints);
20
20
  }
21
21
 
22
+ function canonicalParams(params) {
23
+ const entries = Object.entries(params || {}).sort(([left], [right]) =>
24
+ left.localeCompare(right)
25
+ );
26
+ return JSON.stringify(Object.fromEntries(entries));
27
+ }
28
+
29
+ function buildWindowRanges(length, trainBars, testBars, stepBars, mode) {
30
+ const ranges = [];
31
+ for (
32
+ let start = 0;
33
+ start + trainBars + testBars <= length;
34
+ start += stepBars
35
+ ) {
36
+ const trainStart = mode === "anchored" ? 0 : start;
37
+ const trainEnd = mode === "anchored" ? trainBars + start : start + trainBars;
38
+ const testStart = trainEnd;
39
+ const testEnd = testStart + testBars;
40
+ if (testEnd > length) break;
41
+ ranges.push({ trainStart, trainEnd, testStart, testEnd });
42
+ }
43
+ return ranges;
44
+ }
45
+
46
+ function summarizeBestParams(windows) {
47
+ const summaryBySignature = new Map();
48
+ let adjacentRepeats = 0;
49
+
50
+ windows.forEach((window, index) => {
51
+ const signature = window.bestParamsSignature ?? canonicalParams(window.bestParams);
52
+ const current = summaryBySignature.get(signature) || {
53
+ params: window.bestParams,
54
+ wins: 0,
55
+ profitableWindows: 0,
56
+ oosTrades: 0,
57
+ };
58
+ current.wins += 1;
59
+ current.profitableWindows += window.profitable ? 1 : 0;
60
+ current.oosTrades += window.oosTrades;
61
+ summaryBySignature.set(signature, current);
62
+
63
+ if (
64
+ index > 0 &&
65
+ (windows[index - 1].bestParamsSignature ??
66
+ canonicalParams(windows[index - 1].bestParams)) === signature
67
+ ) {
68
+ adjacentRepeats += 1;
69
+ }
70
+ });
71
+
72
+ const byFrequency = [...summaryBySignature.values()].sort((left, right) => {
73
+ if (right.wins !== left.wins) return right.wins - left.wins;
74
+ return right.profitableWindows - left.profitableWindows;
75
+ });
76
+ const adjacentPairs = Math.max(0, windows.length - 1);
77
+
78
+ return {
79
+ winners: windows.map((window) => window.bestParams),
80
+ stability: {
81
+ adjacentRepeatRate: adjacentPairs ? adjacentRepeats / adjacentPairs : 0,
82
+ uniqueWinnerCount: summaryBySignature.size,
83
+ dominant: byFrequency[0] || null,
84
+ leaderboard: byFrequency,
85
+ },
86
+ };
87
+ }
88
+
22
89
  /**
23
90
  * Run rolling walk-forward optimization over a single candle series.
24
91
  *
@@ -32,6 +99,7 @@ export function walkForwardOptimize({
32
99
  trainBars,
33
100
  testBars,
34
101
  stepBars = testBars,
102
+ mode = "rolling",
35
103
  scoreBy = "profitFactor",
36
104
  backtestOptions = {},
37
105
  } = {}) {
@@ -47,25 +115,31 @@ export function walkForwardOptimize({
47
115
  if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
48
116
  throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
49
117
  }
118
+ if (mode !== "rolling" && mode !== "anchored") {
119
+ throw new Error('walkForwardOptimize() mode must be "rolling" or "anchored"');
120
+ }
50
121
 
51
122
  const windows = [];
52
123
  const allTrades = [];
53
124
  const allPositions = [];
54
125
  const eqSeries = [];
55
126
  let rollingEquity = backtestOptions.equity ?? 10_000;
127
+ const ranges = buildWindowRanges(candles.length, trainBars, testBars, stepBars, mode);
128
+ const trainBacktestOptions = {
129
+ ...backtestOptions,
130
+ collectEqSeries: false,
131
+ collectReplay: false,
132
+ };
133
+ const testBacktestOptions = { ...backtestOptions };
56
134
 
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);
135
+ for (const range of ranges) {
136
+ const trainSlice = candles.slice(range.trainStart, range.trainEnd);
137
+ const testSlice = candles.slice(range.testStart, range.testEnd);
64
138
 
65
139
  let best = null;
66
140
  for (const params of parameterSets) {
67
141
  const trainResult = backtest({
68
- ...backtestOptions,
142
+ ...trainBacktestOptions,
69
143
  candles: trainSlice,
70
144
  equity: rollingEquity,
71
145
  signal: signalFactory(params),
@@ -77,11 +151,12 @@ export function walkForwardOptimize({
77
151
  }
78
152
 
79
153
  const testResult = backtest({
80
- ...backtestOptions,
154
+ ...testBacktestOptions,
81
155
  candles: testSlice,
82
156
  equity: rollingEquity,
83
157
  signal: signalFactory(best.params),
84
158
  });
159
+ const bestParamsSignature = canonicalParams(best.params);
85
160
 
86
161
  rollingEquity = testResult.metrics.finalEquity;
87
162
  allTrades.push(...testResult.trades);
@@ -101,10 +176,29 @@ export function walkForwardOptimize({
101
176
  trainScore: best.score,
102
177
  trainMetrics: best.metrics,
103
178
  testMetrics: testResult.metrics,
179
+ oosTrades: testResult.metrics.trades,
180
+ profitable: testResult.metrics.totalPnL > 0,
181
+ stabilityScore: 0,
182
+ bestParamsSignature,
104
183
  result: testResult,
105
184
  });
106
185
  }
107
186
 
187
+ for (let index = 0; index < windows.length; index += 1) {
188
+ const currentSignature = windows[index].bestParamsSignature;
189
+ const adjacent = [];
190
+ if (index > 0) {
191
+ adjacent.push(windows[index - 1].bestParamsSignature === currentSignature ? 1 : 0);
192
+ }
193
+ if (index + 1 < windows.length) {
194
+ adjacent.push(windows[index + 1].bestParamsSignature === currentSignature ? 1 : 0);
195
+ }
196
+ windows[index].stabilityScore = adjacent.length
197
+ ? adjacent.reduce((total, value) => total + value, 0) / adjacent.length
198
+ : 1;
199
+ delete windows[index].bestParamsSignature;
200
+ }
201
+
108
202
  const metrics = buildMetrics({
109
203
  closed: allTrades,
110
204
  equityStart: backtestOptions.equity ?? 10_000,
@@ -113,6 +207,7 @@ export function walkForwardOptimize({
113
207
  estBarMs: estimateBarMs(candles),
114
208
  eqSeries,
115
209
  });
210
+ const bestParamsSummary = summarizeBestParams(windows);
116
211
 
117
212
  return {
118
213
  windows,
@@ -121,6 +216,7 @@ export function walkForwardOptimize({
121
216
  metrics,
122
217
  eqSeries,
123
218
  replay: { frames: [], events: [] },
124
- bestParams: windows.map((window) => window.bestParams),
219
+ bestParams: Object.assign(windows.map((window) => window.bestParams), bestParamsSummary),
220
+ bestParamsSummary: bestParamsSummary.stability,
125
221
  };
126
222
  }
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { backtest } from "./engine/backtest.js";
2
+ export { backtestTicks } from "./engine/backtestTicks.js";
2
3
  export { backtestPortfolio } from "./engine/portfolio.js";
3
4
  export { walkForwardOptimize } from "./engine/walkForward.js";
4
5