tradelab 1.1.0 → 1.2.1

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +183 -373
  3. package/dist/cjs/index.cjs +39 -12
  4. package/dist/cjs/live.cjs +457 -18
  5. package/docs/README.md +32 -66
  6. package/docs/api-reference.md +269 -144
  7. package/docs/backtest-engine.md +167 -321
  8. package/docs/data-reporting-cli.md +114 -156
  9. package/docs/examples.md +6 -6
  10. package/docs/live-trading.md +254 -134
  11. package/docs/mcp.md +244 -23
  12. package/docs/research.md +99 -45
  13. package/examples/mcpLiveTrading.js +77 -0
  14. package/package.json +11 -3
  15. package/src/engine/optimize.js +25 -1
  16. package/src/engine/portfolio.js +6 -2
  17. package/src/live/dashboard/server.js +67 -8
  18. package/src/live/engine/paperEngine.js +21 -11
  19. package/src/live/index.js +2 -0
  20. package/src/live/session.js +439 -0
  21. package/src/mcp/liveTools.js +202 -0
  22. package/src/mcp/schemas.js +119 -0
  23. package/src/mcp/server.js +5 -1
  24. package/src/mcp/tools.js +125 -2
  25. package/src/research/monteCarlo.js +6 -2
  26. package/templates/dashboard.html +595 -108
  27. package/types/index.d.ts +25 -0
  28. package/types/live.d.ts +102 -1
  29. package/types/mcp.d.ts +17 -0
  30. package/docs/superpowers/plans/2026-00-overview.md +0 -101
  31. package/docs/superpowers/plans/2026-01-metrics-correctness.md +0 -873
  32. package/docs/superpowers/plans/2026-02-indicator-library.md +0 -677
  33. package/docs/superpowers/plans/2026-03-overfitting-toolkit.md +0 -882
  34. package/docs/superpowers/plans/2026-04-async-signals-seeding.md +0 -981
  35. package/docs/superpowers/plans/2026-05-mcp-server.md +0 -758
  36. package/docs/superpowers/plans/2026-06-parallel-param-sweep.md +0 -508
  37. package/docs/superpowers/plans/2026-07-funding-carry-costs.md +0 -535
  38. package/docs/superpowers/plans/2026-08-live-dashboard.md +0 -547
  39. package/docs/superpowers/plans/HANDOFF.md +0 -88
@@ -1,40 +1,28 @@
1
- # Backtest engine
1
+ # Backtesting
2
2
 
3
- <small>[Back to main page](README.md)</small>
3
+ This guide covers the research engine: `backtest`, `backtestAsync`, `backtestTicks`, `backtestPortfolio`, `walkForwardOptimize`, `optimize`, and `buildMetrics`.
4
4
 
5
- This page covers the simulation layer:
5
+ [Back to docs](README.md)
6
6
 
7
- - `backtest(options)`
8
- - `backtestAsync(options)`
9
- - `backtestTicks(options)`
10
- - `backtestPortfolio(options)`
11
- - `walkForwardOptimize(options)`
12
- - `buildMetrics(input)`
7
+ ## Choose an Entry Point
13
8
 
14
- ## Overview
9
+ | Use this when... | Call |
10
+ | --------------------------------------- | ------------------------------ |
11
+ | You have candles for one symbol | `backtest(options)` |
12
+ | Your signal returns a promise | `backtestAsync(options)` |
13
+ | You have tick or quote data | `backtestTicks(options)` |
14
+ | You want one result across many systems | `backtestPortfolio(options)` |
15
+ | You want rolling train/test validation | `walkForwardOptimize(options)` |
16
+ | You want a worker-pool parameter sweep | `optimize(options)` |
17
+ | You already have trades and equity data | `buildMetrics(input)` |
15
18
 
16
- Use the engine layer when you already have candles and want to simulate strategy behavior, inspect the result, and export or post-process it.
19
+ ## Candle Shape
17
20
 
18
- The same `signal()` contract is used by `LiveEngine` in `tradelab/live`, so strategy logic can move from research to execution without rewriting signal inputs.
19
-
20
- ## Choose the right function
21
-
22
- | Use case | Function |
23
- | ----------------------------------------- | ----------------------- |
24
- | One strategy on one candle series | `backtest()` |
25
- | Async or model-backed candle signal | `backtestAsync()` |
26
- | One strategy on tick or quote data | `backtestTicks()` |
27
- | Multiple symbols with one combined result | `backtestPortfolio()` |
28
- | Rolling or anchored train/test validation | `walkForwardOptimize()` |
29
- | Recompute metrics from realized trades | `buildMetrics()` |
30
-
31
- ## Candle input
32
-
33
- Candles should be sorted in ascending time order.
21
+ Candles should be sorted oldest to newest.
34
22
 
35
23
  ```js
36
24
  {
37
- time: 1735828200000,
25
+ time: 1735828200000, // Unix milliseconds
38
26
  open: 100,
39
27
  high: 102,
40
28
  low: 99,
@@ -43,105 +31,94 @@ Candles should be sorted in ascending time order.
43
31
  }
44
32
  ```
45
33
 
46
- The package also normalizes common aliases such as `timestamp`, `date`, `o`, `h`, `l`, and `c`.
47
-
48
- ## `backtest(options)`
49
-
50
- `backtest()` is the main single-symbol entry point.
34
+ The data loaders normalize common aliases such as `timestamp`, `date`, `o`, `h`, `l`, and `c`.
51
35
 
52
- ### Minimal example
36
+ ## First Backtest
53
37
 
54
38
  ```js
55
39
  import { backtest } from "tradelab";
56
40
 
57
41
  const result = backtest({
58
42
  candles,
59
- signal({ bar, index }) {
60
- if (index !== 20) return null;
43
+ symbol: "SPY",
44
+ interval: "1d",
45
+ equity: 10_000,
46
+ riskPct: 1,
47
+ warmupBars: 50,
48
+ signal({ bar, index, openPosition }) {
49
+ if (openPosition || index < 50) return null;
61
50
  return {
62
51
  side: "long",
63
- entry: bar.close,
64
- stop: bar.close - 2,
52
+ stop: bar.close * 0.97,
65
53
  rr: 2,
66
54
  };
67
55
  },
68
56
  });
69
- ```
70
-
71
- ### Required fields
72
57
 
73
- ```js
74
- {
75
- candles: Candle[],
76
- signal: ({ candles, index, bar, equity, openPosition, pendingOrder }) => Signal | null
77
- }
58
+ console.log(result.metrics);
78
59
  ```
79
60
 
80
- ### Core options
61
+ Set these options first:
81
62
 
82
- | Option | Purpose |
83
- | ---------------------------------- | -------------------------------------------------------------- |
84
- | `symbol`, `interval`, `range` | Labels carried into results and exports |
85
- | `equity` | Starting equity, default `10000` |
86
- | `riskPct` or `riskFraction` | Default risk per trade when `qty` is not provided |
87
- | `warmupBars` | Bars skipped before signal evaluation starts |
88
- | `flattenAtClose` | Forces end-of-day exit when enabled |
89
- | `collectEqSeries`, `collectReplay` | Builds extra output for charts and exports |
90
- | `strict` | Throws on direct lookahead access such as `candles[index + 1]` |
91
- | `costs` | Slippage, spread, and commission model |
63
+ | Option | Why it matters |
64
+ | ------------ | --------------------------------------------- |
65
+ | `symbol` | Labels results and exports |
66
+ | `interval` | Annualizes metrics correctly |
67
+ | `equity` | Starting account value |
68
+ | `riskPct` | Default risk per trade when `qty` is absent |
69
+ | `warmupBars` | Prevents indicators from trading before ready |
70
+ | `costs` | Keeps edge estimates from ignoring friction |
92
71
 
93
- If you are starting from scratch, the most useful options to set explicitly are:
72
+ ## Signal Contract
94
73
 
95
- - `equity`
96
- - `riskPct`
97
- - `warmupBars`
98
- - `flattenAtClose`
99
- - `costs`
74
+ Every engine calls your strategy with the same shape:
100
75
 
101
- ### Signal contract
76
+ ```js
77
+ signal({ candles, index, bar, equity, openPosition, pendingOrder });
78
+ ```
102
79
 
103
- The signal function receives a context object with these fields:
80
+ Return `null` to skip the bar. Return a signal object to enter.
104
81
 
105
- <!-- prettier-ignore -->
106
82
  ```js
107
- { candles, index, bar, equity, openPosition, pendingOrder }
83
+ {
84
+ side: "long",
85
+ entry: 101.25,
86
+ stop: 99.75,
87
+ takeProfit: 104.25
88
+ }
108
89
  ```
109
90
 
110
- Return `null` for no trade, or a signal object:
91
+ You can omit `entry`; the engine uses the current close. You can also omit `takeProfit` when you provide `rr`.
111
92
 
112
93
  ```js
113
- // minimal
114
- { side: "long", stop: 99.75, rr: 2 }
115
-
116
- // explicit targets
117
- { side: "long", entry: 101.25, stop: 99.75, takeProfit: 104.25 }
94
+ { side: "short", stop: 105, rr: 2 }
118
95
  ```
119
96
 
120
- ### Signal conveniences
121
-
122
- | Field | Behavior |
123
- | --------------------------- | ------------------------------------------------ |
124
- | `side` | Accepts `long`, `short`, `buy`, or `sell` |
125
- | `entry` | Defaults to the current close if omitted |
126
- | `takeProfit` | Can be derived from `rr` or `_rr` |
127
- | `qty` or `size` | Overrides risk-based sizing |
128
- | `riskPct` or `riskFraction` | Overrides the global risk setting for that trade |
97
+ Useful signal fields:
129
98
 
130
- Practical rule: return the smallest signal object that expresses the trade clearly. In many strategies that is just `side`, `stop`, and `rr`.
99
+ | Field | Meaning |
100
+ | ---------------------------- | --------------------------------- |
101
+ | `side` | `long`, `short`, `buy`, or `sell` |
102
+ | `entry`, `limit`, `price` | Entry price aliases |
103
+ | `stop`, `stopLoss`, `sl` | Stop price aliases |
104
+ | `takeProfit`, `target`, `tp` | Target price aliases |
105
+ | `rr` or `_rr` | Target in R multiples |
106
+ | `qty` or `size` | Fixed position size |
107
+ | `riskPct` or `riskFraction` | Per-trade risk override |
131
108
 
132
- ## Async signals
109
+ ## Async Signals
133
110
 
134
- Use `backtestAsync()` when `signal()` returns a promise, such as an LLM call, agent decision, remote service lookup, or any async feature computation.
111
+ Use `backtestAsync()` when your signal waits on a model, service, file read, or other async work.
135
112
 
136
113
  ```js
137
114
  import { backtestAsync, LlmSignal } from "tradelab";
138
115
 
139
- const llm = new LlmSignal({
116
+ const modelSignal = new LlmSignal({
140
117
  budgetMs: 2000,
141
118
  onError: "skip",
142
119
  async resolve({ candles, bar }) {
143
- const recent = candles.slice(-5);
144
- return recent.every((c, i) => i === 0 || c.close >= recent[i - 1].close)
120
+ const recent = candles.slice(-10);
121
+ return recent.at(-1).close > recent[0].close
145
122
  ? { side: "long", stop: bar.close * 0.98, rr: 2 }
146
123
  : null;
147
124
  },
@@ -149,48 +126,21 @@ const llm = new LlmSignal({
149
126
 
150
127
  const result = await backtestAsync({
151
128
  candles,
152
- signal: llm.signal,
129
+ signal: modelSignal.signal,
153
130
  signalBudgetMs: 3000,
154
131
  });
155
132
  ```
156
133
 
157
- `backtestAsync()` returns the same result shape as `backtest()`. `signalBudgetMs` races each signal call against a per-bar deadline; set it to `0` or omit it to disable the timeout.
158
-
159
- `LlmSignal` is an optional wrapper for model-backed decisions:
160
-
161
- - caches by bar time, so repeated calls for one bar reuse the same decision
162
- - passes a no-lookahead candle view into `resolve()`
163
- - enforces `budgetMs` with the same timeout primitive as `backtestAsync()`
164
- - records each result or error in `llm.log`
165
- - returns `null` on errors by default, or rethrows with `onError: "throw"`
166
-
167
- Live trading also awaits async signals; see [live trading](live-trading.md).
168
-
169
- ### Optional per-trade hints
170
-
171
- These values are read from the signal object when present:
172
-
173
- - `_entryExpiryBars`
174
- - `_cooldownBars`
175
- - `_breakevenAtR`
176
- - `_trailAfterR`
177
- - `_maxBarsInTrade`
178
- - `_maxHoldMin`
179
- - `_rr`
180
- - `_initRisk`
181
- - `_imb`
134
+ `LlmSignal` caches one decision per bar, blocks lookahead access, records decisions in `log`, and can either skip or throw on errors.
182
135
 
183
- ### Execution and cost model
136
+ ## Costs
184
137
 
185
- Legacy options still work:
186
-
187
- - `slippageBps`
188
- - `feeBps`
189
-
190
- For more control, use `costs`:
138
+ The old top-level `slippageBps` and `feeBps` options still work. Prefer `costs` for new work:
191
139
 
192
140
  ```js
193
- {
141
+ const result = backtest({
142
+ candles,
143
+ signal,
194
144
  costs: {
195
145
  slippageBps: 2,
196
146
  spreadBps: 1,
@@ -200,7 +150,6 @@ For more control, use `costs`:
200
150
  stop: 4,
201
151
  },
202
152
  commissionBps: 1,
203
- commissionPerUnit: 0,
204
153
  commissionPerOrder: 1,
205
154
  minCommission: 1,
206
155
  carry: {
@@ -213,277 +162,174 @@ For more control, use `costs`:
213
162
  anchorMs: 0,
214
163
  },
215
164
  },
216
- }
165
+ });
217
166
  ```
218
167
 
219
- ### Cost model behavior
220
-
221
- - slippage is applied in trade direction
222
- - spread is modeled as half-spread paid on entry and exit
223
- - commission can be percentage-based, per-unit, per-order, or mixed
224
- - `minCommission` floors the fee for that fill
225
- - `carry.longAnnualBps` and `carry.shortAnnualBps` are annualized financing or borrow rates deducted when each leg closes
226
- - `funding.rateBps` applies once per funding boundary in `(openTime, closeTime]`; positive rates charge longs and credit shorts
227
-
228
- This is still a bar-based simulation. It does not model queue position, exchange microstructure, or realistic intrabar order priority.
229
-
230
- Closed trades expose the time-based charge as `trade.exit.financing`. It is already included in `trade.exit.pnl` and aggregate metrics, so use it only when you need attribution.
231
-
232
- ### Advanced trade management
168
+ How the cost model works:
233
169
 
234
- These are optional. Ignore them until the strategy actually needs them.
170
+ - slippage is applied in the trade direction
171
+ - spread is paid as half-spread on entry and exit
172
+ - commission can be bps-based, per-unit, per-order, or mixed
173
+ - `minCommission` applies per fill
174
+ - carry is annualized and deducted when a leg closes
175
+ - funding applies at boundaries in `(openTime, closeTime]`
176
+ - positive funding charges longs and credits shorts
235
177
 
236
- - `scaleOutAtR`, `scaleOutFrac`, `finalTP_R`
237
- - `maxDailyLossPct`, `dailyMaxTrades`, `postLossCooldownBars`
238
- - `atrTrailMult`, `atrTrailPeriod`
239
- - `mfeTrail`
240
- - `pyramiding`
241
- - `volScale`
242
- - `entryChase`
243
- - `qtyStep`, `minQty`, `maxLeverage`
244
- - `reanchorStopOnFill`, `maxSlipROnFill`
245
- - `oco`
246
- - `triggerMode`
178
+ Closed trades include `exit.financing` when carry or funding applies. It is already included in `exit.pnl`.
247
179
 
248
- Recommended order of adoption:
180
+ ## Result Shape
249
181
 
250
- 1. Start with `entry`, `stop`, and `rr`
251
- 2. Add `costs`
252
- 3. Add trailing, scale-outs, or pyramiding only if the real strategy uses them
253
-
254
- ## Result shape
255
-
256
- `backtest()` returns an object with these fields:
257
-
258
- <!-- prettier-ignore -->
259
182
  ```js
260
- { symbol, interval, range, trades, positions, openPositions, metrics, eqSeries, replay }
183
+ {
184
+ (symbol,
185
+ interval,
186
+ range,
187
+ trades, // every realized leg
188
+ positions, // completed positions
189
+ openPositions, // still open at the end
190
+ metrics,
191
+ eqSeries,
192
+ replay);
193
+ }
261
194
  ```
262
195
 
263
- ### `trades`
264
-
265
- Every realized leg, including partial exits and scale-outs.
266
-
267
- ### `positions`
268
-
269
- Completed positions only. This is the collection most users want for top-line analysis.
270
-
271
- If you are unsure whether to use `trades` or `positions`, start with `positions`.
272
-
273
- ### `metrics`
274
-
275
- Most users start with:
276
-
277
- - `trades`
278
- - `winRate`
279
- - `expectancy`
280
- - `profitFactor`
281
- - `maxDrawdown`
282
- - `sharpe`
283
- - `avgHold`
284
- - `returnPct`
285
- - `totalPnL`
286
- - `finalEquity`
287
- - `sideBreakdown`
196
+ Use `positions` for normal trade analysis. Use `trades` when you need partial exits or scale-out legs.
288
197
 
289
- Also included:
198
+ Important metrics:
290
199
 
291
- - position-vs-leg variants such as `profitFactor_pos` and `profitFactor_leg`
292
- - `rDist` percentiles
293
- - `holdDistMin` percentiles
294
- - daily stats under `daily`
200
+ | Metric | Meaning |
201
+ | ---------------------- | ----------------------------------------------------------------------------------- |
202
+ | `trades` | Number of completed positions |
203
+ | `winRate` | Winning completed positions / all positions |
204
+ | `profitFactor` | Gross profit / gross loss |
205
+ | `totalPnL` | Realized PnL |
206
+ | `returnPct` | Return on starting equity |
207
+ | `maxDrawdown` | Max drawdown as a decimal |
208
+ | `sharpeDaily` | Daily-bucketed Sharpe |
209
+ | `sharpeAnnualized` | Annualized Sharpe |
210
+ | `annualizationPeriods` | Periods used for annualization |
211
+ | `sideBreakdown` | Long and short side summaries |
212
+ | `benchmark` | Alpha, beta, correlation, and information ratio when benchmark returns are supplied |
295
213
 
296
- Useful first checks after any run:
214
+ Ratios are clamped to finite numbers before returning, so exported JSON does not contain `Infinity` or `NaN`.
297
215
 
298
- - `metrics.trades`: enough sample size to care
299
- - `metrics.profitFactor`: whether winners beat losers gross of the chosen fill model
300
- - `metrics.maxDrawdown`: whether the path is survivable
301
- - `metrics.sideBreakdown`: whether one side carries the result
216
+ ## Tick Backtests
302
217
 
303
- ### Risk-adjusted metrics
304
-
305
- - `sharpe` / `sortino` are per-period (daily-bucketed).
306
- - `sharpeAnnualized` / `sortinoAnnualized` scale by `sqrt(annualizationPeriods)`,
307
- where `annualizationPeriods` is derived from `interval` (falling back to the
308
- median bar spacing). Use these to compare strategies across timeframes.
309
- - `profitFactor`, `calmar`, and the Sharpe/Sortino family are clamped to a finite
310
- `BIG_NUMBER` (1e9) so `metrics` JSON never contains `Infinity` or `NaN`.
311
- - `benchmark` (`{ alpha, beta, correlation, informationRatio, trackingError }`)
312
- is populated when you pass `benchmarkReturns` (per-day return array aligned to
313
- the strategy's daily equity buckets) to `backtest()`.
314
-
315
- ### `eqSeries`
316
-
317
- Realized equity points:
218
+ `backtestTicks()` uses event-style tick or quote rows:
318
219
 
319
220
  ```js
320
- [{ time, timestamp, equity }];
221
+ const result = backtestTicks({
222
+ ticks,
223
+ symbol: "BTC-USD",
224
+ signal,
225
+ queueFillProbability: 0.4,
226
+ seed: "btc-run-1",
227
+ });
321
228
  ```
322
229
 
323
- `time` and `timestamp` contain the same Unix-millisecond value.
324
-
325
- ### `replay`
326
-
327
- Visualization payload:
328
-
329
- ```js
330
- {
331
- frames: [{ t, price, equity, posSide, posSize }],
332
- events: [{ t, price, type, side, size, tradeId, reason, pnl }]
333
- }
334
- ```
230
+ Supported tick fields include `time`, `price`, `last`, `bid`, `ask`, `high`, `low`, `size`, and `volume`. Market entries fill on the next tick. Limit orders can fill at touch based on `queueFillProbability`. Stops use the stop-specific slippage model when provided.
335
231
 
336
- This is meant for charts and reports, not as a full audit log.
232
+ Use `seed` to make probabilistic queue fills reproducible.
337
233
 
338
- ## `backtestPortfolio(options)`
234
+ ## Portfolio Backtests
339
235
 
340
- Use portfolio mode when you already have one candle array per symbol and want one combined result.
236
+ `backtestPortfolio()` runs multiple systems against one shared account.
341
237
 
342
238
  ```js
343
239
  const result = backtestPortfolio({
344
240
  equity: 100_000,
241
+ interval: "1d",
242
+ maxDailyLossPct: 3,
345
243
  systems: [
346
- { symbol: "SPY", candles: spy, signal: signalA, weight: 2 },
347
- { symbol: "QQQ", candles: qqq, signal: signalB, weight: 1 },
244
+ { symbol: "SPY", candles: spy, signal: spySignal, weight: 2 },
245
+ { symbol: "QQQ", candles: qqq, signal: qqqSignal, weight: 1 },
348
246
  ],
349
247
  });
350
248
  ```
351
249
 
352
- ### How it works
250
+ Weights are default allocation caps, not pre-funded sleeves. Capital is locked when a fill happens, and later fills size against what remains available.
353
251
 
354
- - systems share one live capital pool
355
- - `weight` or `allocation: "equal" | "weight"` defines the default per-system cap, not a pre-funded sleeve
356
- - fills lock capital immediately, later fills size against remaining available capital
357
- - `eqSeries` points include `lockedCapital` and `availableCapital`
358
- - `maxDailyLossPct` can halt all systems for the rest of the day once breached
252
+ Portfolio result extras:
359
253
 
360
- ### What it is not
254
+ - `systems`: per-system backtest results
255
+ - `eqSeries[].lockedCapital`
256
+ - `eqSeries[].availableCapital`
257
+ - portfolio-level `metrics`
361
258
 
362
- - a cross-margin broker simulator
363
- - a prime-broker margin model
364
- - a full portfolio optimizer
259
+ ## Walk-Forward Validation
365
260
 
366
- This mode now does enforce shared capital and cross-system sizing, but it still uses the library's research-oriented execution assumptions rather than full broker accounting.
367
-
368
- ## `backtestTicks(options)`
369
-
370
- Use tick mode when you want event-driven fills while keeping the same result shape as `backtest()`.
371
-
372
- ```js
373
- const result = backtestTicks({
374
- ticks,
375
- queueFillProbability: 0.5,
376
- seed: "experiment-42",
377
- signal,
378
- });
379
- ```
380
-
381
- ### How it works
382
-
383
- - market entries fill on the next tick
384
- - limit orders can fill at the touch based on `queueFillProbability`
385
- - identical `seed` + data + options produce identical probabilistic limit-fill outcomes
386
- - stop exits fill at the stop and use the normal stop slippage model from `costs.slippageByKind.stop`
387
- - results still come back as `trades`, `positions`, `metrics`, `eqSeries`, and `replay`
388
-
389
- ## `walkForwardOptimize(options)`
390
-
391
- Use walk-forward mode when one in-sample backtest is not enough and you want rolling or anchored train/test validation.
261
+ Use `walkForwardOptimize()` to reduce the risk of choosing parameters that only worked in-sample.
392
262
 
393
263
  ```js
394
264
  const wf = walkForwardOptimize({
395
265
  candles,
396
- mode: "anchored",
397
266
  trainBars: 180,
398
267
  testBars: 60,
399
268
  stepBars: 60,
269
+ mode: "anchored",
400
270
  scoreBy: "profitFactor",
401
- parameterSets: [
402
- { fast: 8, slow: 21, rr: 2 },
403
- { fast: 10, slow: 30, rr: 2 },
404
- ],
271
+ parameterSets,
405
272
  signalFactory(params) {
406
- return createSignalFromParams(params);
273
+ return createSignal(params);
407
274
  },
408
275
  });
409
276
  ```
410
277
 
411
- ### How it works
412
-
413
- 1. Evaluate every parameter set on the training slice
414
- 2. Pick the best one by `scoreBy`
415
- 3. Run that parameter set on the next test slice
416
- 4. Repeat for each window using either rolling or anchored training windows
417
-
418
- ### Return value
278
+ For each window, tradelab:
419
279
 
420
- - `windows`: per-window summaries and chosen parameters
421
- - `trades`, `positions`, `metrics`, `eqSeries`
422
- - `bestParams`: chosen parameters for each window plus a stability summary
423
- - `bestParamsSummary`: adjacent repeat rate, dominant winner, and winner leaderboard
280
+ 1. scores every parameter set on the training slice
281
+ 2. chooses the winner
282
+ 3. runs that winner on the test slice
283
+ 4. reports aggregate metrics and winner stability
424
284
 
425
- In practice, the per-window output matters more than the aggregate headline. If the winning parameters swing wildly from one window to the next, treat that as a real signal.
285
+ Read `wf.windows` before trusting `wf.metrics`. A strategy that changes winners every window is less convincing than one with stable winners.
426
286
 
427
- ## Optimization (parallel sweeps)
287
+ ## Parallel Parameter Sweeps
428
288
 
429
- Use `optimize()` for large parameter sweeps that can run independently across a worker pool.
289
+ `optimize()` runs independent parameter sets in worker threads.
430
290
 
431
291
  ```js
432
- import path from "node:path";
433
292
  import { optimize, grid } from "tradelab";
434
293
 
435
294
  const out = await optimize({
436
295
  candles,
437
296
  interval: "1d",
438
- signalModulePath: path.resolve("./strategies/emaSignal.js"),
439
- parameterSets: grid({ fast: [5, 8, 10], slow: [20, 30, 50], rr: 2 }),
297
+ signalModulePath: new URL("../strategies/ema.js", import.meta.url).pathname,
298
+ parameterSets: grid({
299
+ fast: [8, 10, 12],
300
+ slow: [30, 50],
301
+ }),
440
302
  concurrency: 4,
441
303
  scoreBy: "sharpeAnnualized",
442
304
  });
443
-
444
- console.log(out.best?.params, out.best?.metrics);
445
305
  ```
446
306
 
447
- `signalModulePath` must point to an ESM module that exports `createSignal(params)` or a default factory:
307
+ The strategy module should export `createSignal(params)`.
448
308
 
449
309
  ```js
450
310
  export function createSignal(params) {
451
311
  return function signal(context) {
452
- return null;
312
+ // return null or a trade signal
453
313
  };
454
314
  }
455
315
  ```
456
316
 
457
- Functions cannot cross the worker boundary, so the signal is passed as a module path plus JSON-like parameter objects. Candles are copied once per worker, not once per parameter set.
317
+ Functions cannot cross worker boundaries, so the worker receives a module path plus JSON-like parameter objects.
318
+
319
+ ## Recomputing Metrics
458
320
 
459
- The return shape is:
321
+ Use `buildMetrics()` if you have realized trades and an equity curve from another process.
460
322
 
461
323
  ```js
462
- {
463
- (results, // original order, one entry per parameter set
464
- leaderboard, // sorted descending by scoreBy
465
- best); // leaderboard[0] or null
466
- }
324
+ const metrics = buildMetrics({
325
+ closed: trades,
326
+ equityStart: 10_000,
327
+ equityFinal: 11_250,
328
+ candles,
329
+ estBarMs: 86_400_000,
330
+ eqSeries,
331
+ interval: "1d",
332
+ });
467
333
  ```
468
334
 
469
- Each result contains `{ params, metrics }` or `{ params, error }`. Worker IPC only returns compact ranking metrics, not trade logs or replay frames.
470
-
471
- `optimize()` is ESM-only in this release because it starts an ESM `worker_threads` worker via `import.meta.url`. Use it from ESM code, for example `node examples/optimize.js`.
472
-
473
- ## `buildMetrics(input)`
474
-
475
- Most users do not need this directly. Use it when:
476
-
477
- - you generate realized trades outside `backtest()`
478
- - you filter a result and want fresh metrics
479
- - you combine results manually
480
-
481
- ## Common mistakes
482
-
483
- - using unsorted candles or mixed intervals in one series
484
- - reading `trades` as if they were always full positions
485
- - leaving costs at zero and overestimating edge
486
- - trusting one backtest without out-of-sample validation
487
- - debugging a strategy with `strict: false` when lookahead is possible
488
-
489
- <small>[Back to main page](README.md)</small>
335
+ [Back to docs](README.md)