tradelab 0.4.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.
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  ---
15
15
 
16
- **tradelab** handles the simulation, sizing, exits, costs, and result exports you bring the candles and signal logic.
16
+ **tradelab** handles the simulation, sizing, exits, costs, and result exports; you bring the data and signal logic.
17
17
 
18
18
  It works cleanly for a single-strategy backtest and scales up to portfolio runs, walk-forward testing, and detailed execution modeling. It is not a broker connector or a live trading tool.
19
19
 
@@ -31,6 +31,7 @@ npm install tradelab
31
31
  - [Core concepts](#core-concepts)
32
32
  - [Portfolio mode](#portfolio-mode)
33
33
  - [Walk-forward optimization](#walk-forward-optimization)
34
+ - [Tick backtests](#tick-backtests)
34
35
  - [Execution and cost modeling](#execution-and-cost-modeling)
35
36
  - [Exports and reporting](#exports-and-reporting)
36
37
  - [CLI](#cli)
@@ -43,9 +44,9 @@ npm install tradelab
43
44
 
44
45
  | Area | What you get |
45
46
  |---|---|
46
- | **Engine** | Candle-based backtests with position sizing, exits, risk controls, replay capture |
47
- | **Portfolio** | Multi-symbol aggregation with weight-based capital allocation |
48
- | **Walk-forward** | Rolling train/test validation with parameter search |
47
+ | **Engine** | Candle and tick backtests with position sizing, exits, replay capture, and cost models |
48
+ | **Portfolio** | Multi-system shared-capital simulation with live capital locking and daily loss halts |
49
+ | **Walk-forward** | Rolling and anchored train/test validation with parameter search and stability summaries |
49
50
  | **Data** | Yahoo Finance downloads, CSV import, and local cache helpers |
50
51
  | **Costs** | Slippage, spread, and commission modeling |
51
52
  | **Exports** | HTML reports, metrics JSON, and trade CSV |
@@ -177,19 +178,19 @@ The minimum viable signal is just `side`, `stop`, and `rr`. Start there and add
177
178
  {
178
179
  symbol, interval, range,
179
180
  trades, // every realized leg, including partial exits
180
- positions, // completed positions start here for analysis
181
+ positions, // completed positions - start here for analysis
181
182
  metrics, // winRate, profitFactor, maxDrawdown, sharpe, ...
182
- eqSeries, // [{ time, timestamp, equity }] equity curve
183
+ eqSeries, // [{ time, timestamp, equity }] - equity curve
183
184
  replay, // visualization frames and events
184
185
  }
185
186
  ```
186
187
 
187
188
  **First checks after any run:**
188
189
 
189
- - `metrics.trades` enough sample size to trust the numbers?
190
- - `metrics.profitFactor` do winners beat losers gross of costs?
191
- - `metrics.maxDrawdown` is the equity path survivable?
192
- - `metrics.sideBreakdown` does one side carry the whole result?
190
+ - `metrics.trades` - enough sample size to trust the numbers?
191
+ - `metrics.profitFactor` - do winners beat losers gross of costs?
192
+ - `metrics.maxDrawdown` - is the equity path survivable?
193
+ - `metrics.sideBreakdown` - does one side carry the whole result?
193
194
 
194
195
  ---
195
196
 
@@ -209,19 +210,20 @@ const result = backtestPortfolio({
209
210
  });
210
211
  ```
211
212
 
212
- Capital is allocated up front by weight. Each system runs through the normal single-symbol engine, and the portfolio result merges trades, positions, replay events, and the equity series.
213
+ Weights now act as default per-system allocation caps rather than pre-funded sleeves. Capital is locked only when a fill happens, `eqSeries` includes `lockedCapital` and `availableCapital`, later systems size against remaining live capital, and `maxDailyLossPct` on `backtestPortfolio()` can halt the whole book for the rest of the day.
213
214
 
214
215
  ---
215
216
 
216
217
  ## Walk-forward optimization
217
218
 
218
- Use `walkForwardOptimize()` when one in-sample backtest is not enough. It runs rolling train/test windows across the full candle history.
219
+ Use `walkForwardOptimize()` when one in-sample backtest is not enough. It supports rolling and anchored train/test windows across the full candle history.
219
220
 
220
221
  ```js
221
222
  import { walkForwardOptimize } from "tradelab";
222
223
 
223
224
  const wf = walkForwardOptimize({
224
225
  candles,
226
+ mode: "anchored",
225
227
  trainBars: 180,
226
228
  testBars: 60,
227
229
  stepBars: 60,
@@ -236,7 +238,25 @@ const wf = walkForwardOptimize({
236
238
  });
237
239
  ```
238
240
 
239
- Each window picks the best parameter set in training, then runs it blind on the test slice. The `windows` array in the result shows per-window winners. If the winning parameters swing wildly from window to window, that is a real signal not a formatting detail.
241
+ Each window picks the best parameter set in training, then runs it blind on the test slice. The `windows` array now includes out-of-sample trade count, profitability, and a per-window stability score. `bestParamsSummary` reports how stable the winners were across the full run.
242
+
243
+ ---
244
+
245
+ ## Tick backtests
246
+
247
+ Use `backtestTicks()` when you want event-driven fills on tick or quote data without changing the result shape used by metrics, exports, or replay.
248
+
249
+ ```js
250
+ import { backtestTicks } from "tradelab";
251
+
252
+ const result = backtestTicks({
253
+ ticks,
254
+ queueFillProbability: 0.35,
255
+ signal,
256
+ });
257
+ ```
258
+
259
+ Market entries fill on the next tick, limit orders can fill at the touch with configurable queue probability, and stop exits use the existing cost model with stop-specific slippage if you provide it in `costs.slippageByKind.stop`.
240
260
 
241
261
  ---
242
262
 
@@ -313,7 +333,7 @@ npx tradelab portfolio \
313
333
  # Walk-forward validation
314
334
  npx tradelab walk-forward \
315
335
  --source yahoo --symbol QQQ --interval 1d --period 2y \
316
- --trainBars 180 --testBars 60
336
+ --trainBars 180 --testBars 60 --mode anchored
317
337
 
318
338
  # Prefetch and cache data
319
339
  npx tradelab prefetch --symbol SPY --interval 1d --period 1y
@@ -322,7 +342,7 @@ npx tradelab import-csv --csvPath ./data/spy.csv --symbol SPY --interval 1d
322
342
 
323
343
  **Built-in strategies:** `ema-cross` · `buy-hold`
324
344
 
325
- You can also point `--strategy` at a local module that exports `default(args)`, `createSignal(args)`, or `signal`.
345
+ You can also point `--strategy` at a local module that exports `default(args)`, `createSignal(args)`, or `signal` for `backtest`, or `signalFactory(params, args)` plus `parameterSets`/`createParameterSets(args)` for `walk-forward`.
326
346
 
327
347
  ---
328
348
 
@@ -361,6 +381,7 @@ const { fetchHistorical } = require("tradelab/data");
361
381
  |---|---|
362
382
  | [Backtest engine](docs/backtest-engine.md) | Signal contract, all options, result shape, portfolio mode, walk-forward |
363
383
  | [Data, reporting, and CLI](docs/data-reporting-cli.md) | Data loading, cache behavior, exports, CLI reference |
384
+ | [Strategy examples](docs/examples.md) | Mean reversion, breakout, sentiment, LLM, and portfolio strategy patterns |
364
385
  | [API reference](docs/api-reference.md) | Compact index of every public export |
365
386
 
366
387
  ---
@@ -368,7 +389,7 @@ const { fetchHistorical } = require("tradelab/data");
368
389
  ## Common mistakes
369
390
 
370
391
  - Using unsorted candles or mixed intervals in a single series
371
- - Reading `trades` as if they were always full positions use `positions` for top-line analysis
392
+ - Reading `trades` as if they were always full positions - use `positions` for top-line analysis
372
393
  - Leaving costs at zero and overestimating edge
373
394
  - Trusting one backtest without out-of-sample validation
374
395
  - Debugging a strategy with `strict: false` when lookahead is possible
@@ -380,4 +401,4 @@ const { fetchHistorical } = require("tradelab/data");
380
401
  - Node `18+` is required
381
402
  - Yahoo downloads are cached under `output/data` by default
382
403
  - CommonJS and ESM are both supported
383
- - The engine is built for historical research not brokerage execution, tick-level simulation, or exchange microstructure modeling
404
+ - The engine is built for historical research - not brokerage execution or full exchange microstructure simulation
package/bin/tradelab.js CHANGED
@@ -48,6 +48,11 @@ function toList(value, fallback) {
48
48
  .filter((item) => Number.isFinite(item));
49
49
  }
50
50
 
51
+ function parseJsonValue(value, fallback = null) {
52
+ if (!value) return fallback;
53
+ return JSON.parse(String(value));
54
+ }
55
+
51
56
  function createEmaCrossSignal({
52
57
  fast = 10,
53
58
  slow = 30,
@@ -133,13 +138,70 @@ async function loadStrategy(strategyArg, args) {
133
138
  throw new Error(`Strategy module "${strategyArg}" must export default, createSignal, or signal`);
134
139
  }
135
140
 
141
+ async function loadWalkForwardStrategy(strategyArg, args) {
142
+ if (!strategyArg || strategyArg === "ema-cross") {
143
+ const fasts = toList(args.fasts, [8, 10, 12]);
144
+ const slows = toList(args.slows, [20, 30, 40]);
145
+ const rrs = toList(args.rrs, [1.5, 2, 3]);
146
+ const parameterSets = [];
147
+
148
+ for (const fast of fasts) {
149
+ for (const slow of slows) {
150
+ if (fast >= slow) continue;
151
+ for (const rr of rrs) {
152
+ parameterSets.push({ fast, slow, rr });
153
+ }
154
+ }
155
+ }
156
+
157
+ return {
158
+ parameterSets,
159
+ signalFactory(params) {
160
+ return createEmaCrossSignal({
161
+ fast: params.fast,
162
+ slow: params.slow,
163
+ rr: params.rr,
164
+ stopLookback: toNumber(args.stopLookback, 15),
165
+ });
166
+ },
167
+ };
168
+ }
169
+
170
+ const resolved = path.resolve(process.cwd(), strategyArg);
171
+ const module = await import(pathToFileURL(resolved).href);
172
+ if (typeof module.signalFactory !== "function") {
173
+ throw new Error(
174
+ `Walk-forward strategy module "${strategyArg}" must export signalFactory`
175
+ );
176
+ }
177
+
178
+ const parameterSets =
179
+ parseJsonValue(args.parameterSets) ??
180
+ (typeof module.createParameterSets === "function"
181
+ ? await module.createParameterSets(args)
182
+ : module.parameterSets);
183
+
184
+ if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
185
+ throw new Error(
186
+ `Walk-forward strategy module "${strategyArg}" must provide parameterSets, createParameterSets(args), or --parameterSets`
187
+ );
188
+ }
189
+
190
+ return {
191
+ parameterSets,
192
+ signalFactory(params) {
193
+ return module.signalFactory(params, args);
194
+ },
195
+ };
196
+ }
197
+
136
198
  function printHelp() {
137
199
  console.log(`tradelab
138
200
 
139
201
  Commands:
140
202
  backtest Run a one-off backtest from Yahoo or CSV data
141
203
  portfolio Run multiple CSV datasets as an equal-weight portfolio
142
- walk-forward Run rolling train/test optimization with the built-in ema-cross strategy
204
+ walk-forward Run rolling or anchored train/test optimization
143
205
  prefetch Download Yahoo candles into the local cache
144
206
  import-csv Normalize a CSV and save it into the local cache
145
207
 
@@ -257,26 +319,15 @@ async function commandWalkForward(args) {
257
319
  csvPath: args.csvPath,
258
320
  cache: args.cache !== "false",
259
321
  });
260
- const fasts = toList(args.fasts, [8, 10, 12]);
261
- const slows = toList(args.slows, [20, 30, 40]);
262
- const rrs = toList(args.rrs, [1.5, 2, 3]);
263
- const parameterSets = [];
264
-
265
- for (const fast of fasts) {
266
- for (const slow of slows) {
267
- if (fast >= slow) continue;
268
- for (const rr of rrs) {
269
- parameterSets.push({ fast, slow, rr });
270
- }
271
- }
272
- }
322
+ const walkForwardStrategy = await loadWalkForwardStrategy(args.strategy, args);
273
323
 
274
324
  const result = walkForwardOptimize({
275
325
  candles,
276
- parameterSets,
326
+ parameterSets: walkForwardStrategy.parameterSets,
277
327
  trainBars: toNumber(args.trainBars, 120),
278
328
  testBars: toNumber(args.testBars, 40),
279
329
  stepBars: toNumber(args.stepBars, toNumber(args.testBars, 40)),
330
+ mode: args.mode || "rolling",
280
331
  scoreBy: args.scoreBy || "profitFactor",
281
332
  backtestOptions: {
282
333
  symbol: args.symbol || "DATA",
@@ -286,14 +337,7 @@ async function commandWalkForward(args) {
286
337
  riskPct: toNumber(args.riskPct, 1),
287
338
  warmupBars: toNumber(args.warmupBars, 20),
288
339
  },
289
- signalFactory(params) {
290
- return createEmaCrossSignal({
291
- fast: params.fast,
292
- slow: params.slow,
293
- rr: params.rr,
294
- stopLookback: toNumber(args.stopLookback, 15),
295
- });
296
- },
340
+ signalFactory: walkForwardStrategy.signalFactory,
297
341
  });
298
342
 
299
343
  const metricsPath = exportMetricsJSON({
@@ -310,6 +354,7 @@ async function commandWalkForward(args) {
310
354
  windows: result.windows.length,
311
355
  positions: result.positions.length,
312
356
  finalEquity: result.metrics.finalEquity,
357
+ bestParamsSummary: result.bestParamsSummary,
313
358
  metricsPath,
314
359
  },
315
360
  null,
package/dist/cjs/data.cjs CHANGED
@@ -215,56 +215,87 @@ function buildMetrics({
215
215
  estBarMs,
216
216
  eqSeries
217
217
  }) {
218
- const completedTrades = closed.filter((trade) => trade.exit.reason !== "SCALE");
219
- const winningTrades = completedTrades.filter((trade) => trade.exit.pnl > 0);
220
- const losingTrades = completedTrades.filter((trade) => trade.exit.pnl < 0);
221
- const tradeRs = completedTrades.map(tradeRMultiple);
222
- const totalR = sum(tradeRs);
223
- const avgR = mean(tradeRs);
224
- const labels = completedTrades.map(
225
- (trade) => trade.exit.pnl > 0 ? "win" : trade.exit.pnl < 0 ? "loss" : "flat"
226
- );
227
- const { maxWin, maxLoss } = streaks(labels);
228
- const tradePnls = completedTrades.map((trade) => trade.exit.pnl);
229
- const expectancy = mean(tradePnls);
230
- const tradeReturns = completedTrades.map(
231
- (trade) => trade.exit.pnl / Math.max(1e-12, equityStart)
232
- );
233
- const tradeReturnStd = stddev(tradeReturns);
234
- const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
235
- const sortinoPerTrade = sortino(tradeReturns);
236
- const grossProfitPositions = sum(winningTrades.map((trade) => trade.exit.pnl));
237
- const grossLossPositions = Math.abs(
238
- sum(losingTrades.map((trade) => trade.exit.pnl))
239
- );
240
- const profitFactorPositions = grossLossPositions === 0 ? grossProfitPositions > 0 ? Infinity : 0 : grossProfitPositions / grossLossPositions;
241
218
  const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
242
- const winningLegs = legs.filter((trade) => trade.exit.pnl > 0);
243
- const losingLegs = legs.filter((trade) => trade.exit.pnl < 0);
244
- const grossProfitLegs = sum(winningLegs.map((trade) => trade.exit.pnl));
245
- const grossLossLegs = Math.abs(sum(losingLegs.map((trade) => trade.exit.pnl)));
246
- const profitFactorLegs = grossLossLegs === 0 ? grossProfitLegs > 0 ? Infinity : 0 : grossProfitLegs / grossLossLegs;
219
+ const completedTrades = [];
220
+ const tradeRs = [];
221
+ const tradePnls = [];
222
+ const tradeReturns = [];
223
+ const holdDurationsMinutes = [];
224
+ const labels = [];
225
+ const longRs = [];
226
+ const shortRs = [];
227
+ let totalR = 0;
228
+ let realizedPnL = 0;
229
+ let winningTradeCount = 0;
230
+ let grossProfitPositions = 0;
231
+ let grossLossPositions = 0;
232
+ let grossProfitLegs = 0;
233
+ let grossLossLegs = 0;
234
+ let winningLegCount = 0;
235
+ let openBars = 0;
236
+ let longTradesCount = 0;
237
+ let longTradeWins = 0;
238
+ let longPnLSum = 0;
239
+ let shortTradesCount = 0;
240
+ let shortTradeWins = 0;
241
+ let shortPnLSum = 0;
247
242
  let peakEquity = equityStart;
248
243
  let currentEquity = equityStart;
249
244
  let maxDrawdown = 0;
250
- for (const leg of legs) {
251
- currentEquity += leg.exit.pnl;
245
+ for (const trade of legs) {
246
+ const pnl = trade.exit.pnl;
247
+ realizedPnL += pnl;
248
+ if (pnl > 0) {
249
+ grossProfitLegs += pnl;
250
+ winningLegCount += 1;
251
+ } else if (pnl < 0) {
252
+ grossLossLegs += Math.abs(pnl);
253
+ }
254
+ currentEquity += pnl;
252
255
  if (currentEquity > peakEquity) peakEquity = currentEquity;
253
256
  const drawdown = (peakEquity - currentEquity) / Math.max(1e-12, peakEquity);
254
257
  if (drawdown > maxDrawdown) maxDrawdown = drawdown;
258
+ if (trade.exit.reason === "SCALE") continue;
259
+ completedTrades.push(trade);
260
+ tradePnls.push(pnl);
261
+ tradeReturns.push(pnl / Math.max(1e-12, equityStart));
262
+ const tradeR = tradeRMultiple(trade);
263
+ tradeRs.push(tradeR);
264
+ totalR += tradeR;
265
+ labels.push(pnl > 0 ? "win" : pnl < 0 ? "loss" : "flat");
266
+ const holdMinutes = (trade.exit.time - trade.openTime) / (1e3 * 60);
267
+ holdDurationsMinutes.push(holdMinutes);
268
+ openBars += Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
269
+ if (pnl > 0) {
270
+ winningTradeCount += 1;
271
+ grossProfitPositions += pnl;
272
+ } else if (pnl < 0) {
273
+ grossLossPositions += Math.abs(pnl);
274
+ }
275
+ if (trade.side === "long") {
276
+ longTradesCount += 1;
277
+ longPnLSum += pnl;
278
+ longRs.push(tradeR);
279
+ if (pnl > 0) longTradeWins += 1;
280
+ } else if (trade.side === "short") {
281
+ shortTradesCount += 1;
282
+ shortPnLSum += pnl;
283
+ shortRs.push(tradeR);
284
+ if (pnl > 0) shortTradeWins += 1;
285
+ }
255
286
  }
256
- const realizedPnL = sum(closed.map((trade) => trade.exit.pnl));
287
+ const avgR = mean(tradeRs);
288
+ const { maxWin, maxLoss } = streaks(labels);
289
+ const expectancy = mean(tradePnls);
290
+ const tradeReturnStd = stddev(tradeReturns);
291
+ const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
292
+ const sortinoPerTrade = sortino(tradeReturns);
293
+ const profitFactorPositions = grossLossPositions === 0 ? grossProfitPositions > 0 ? Infinity : 0 : grossProfitPositions / grossLossPositions;
294
+ const profitFactorLegs = grossLossLegs === 0 ? grossProfitLegs > 0 ? Infinity : 0 : grossProfitLegs / grossLossLegs;
257
295
  const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
258
296
  const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
259
297
  const totalBars = Math.max(1, candles.length);
260
- const openBars = completedTrades.reduce((total, trade) => {
261
- const barsHeld = Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
262
- return total + barsHeld;
263
- }, 0);
264
298
  const exposurePct = openBars / totalBars;
265
- const holdDurationsMinutes = completedTrades.map(
266
- (trade) => (trade.exit.time - trade.openTime) / (1e3 * 60)
267
- );
268
299
  const avgHoldMin = mean(holdDurationsMinutes);
269
300
  const equitySeries = eqSeries && eqSeries.length ? eqSeries : buildEquitySeriesFromLegs({ legs, equityStart });
270
301
  const dailyReturnsSeries = dailyReturns(equitySeries);
@@ -272,12 +303,6 @@ function buildMetrics({
272
303
  const sharpeDaily = dailyStd === 0 ? dailyReturnsSeries.length ? Infinity : 0 : mean(dailyReturnsSeries) / dailyStd;
273
304
  const sortinoDaily = sortino(dailyReturnsSeries);
274
305
  const dailyWinRate = dailyReturnsSeries.length ? dailyReturnsSeries.filter((value) => value > 0).length / dailyReturnsSeries.length : 0;
275
- const longTrades = completedTrades.filter((trade) => trade.side === "long");
276
- const shortTrades = completedTrades.filter((trade) => trade.side === "short");
277
- const longRs = longTrades.map(tradeRMultiple);
278
- const shortRs = shortTrades.map(tradeRMultiple);
279
- const longPnls = longTrades.map((trade) => trade.exit.pnl);
280
- const shortPnls = shortTrades.map((trade) => trade.exit.pnl);
281
306
  const rDistribution = {
282
307
  p10: percentile(tradeRs, 0.1),
283
308
  p25: percentile(tradeRs, 0.25),
@@ -294,21 +319,21 @@ function buildMetrics({
294
319
  };
295
320
  const sideBreakdown = {
296
321
  long: {
297
- trades: longTrades.length,
298
- winRate: longTrades.length ? longTrades.filter((trade) => trade.exit.pnl > 0).length / longTrades.length : 0,
299
- avgPnL: mean(longPnls),
322
+ trades: longTradesCount,
323
+ winRate: longTradesCount ? longTradeWins / longTradesCount : 0,
324
+ avgPnL: longTradesCount ? longPnLSum / longTradesCount : 0,
300
325
  avgR: mean(longRs)
301
326
  },
302
327
  short: {
303
- trades: shortTrades.length,
304
- winRate: shortTrades.length ? shortTrades.filter((trade) => trade.exit.pnl > 0).length / shortTrades.length : 0,
305
- avgPnL: mean(shortPnls),
328
+ trades: shortTradesCount,
329
+ winRate: shortTradesCount ? shortTradeWins / shortTradesCount : 0,
330
+ avgPnL: shortTradesCount ? shortPnLSum / shortTradesCount : 0,
306
331
  avgR: mean(shortRs)
307
332
  }
308
333
  };
309
334
  return {
310
335
  trades: completedTrades.length,
311
- winRate: completedTrades.length ? winningTrades.length / completedTrades.length : 0,
336
+ winRate: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
312
337
  profitFactor: profitFactorPositions,
313
338
  expectancy,
314
339
  totalR,
@@ -330,8 +355,8 @@ function buildMetrics({
330
355
  startEquity: equityStart,
331
356
  profitFactor_pos: profitFactorPositions,
332
357
  profitFactor_leg: profitFactorLegs,
333
- winRate_pos: completedTrades.length ? winningTrades.length / completedTrades.length : 0,
334
- winRate_leg: legs.length ? winningLegs.length / legs.length : 0,
358
+ winRate_pos: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
359
+ winRate_leg: legs.length ? winningLegCount / legs.length : 0,
335
360
  sharpeDaily,
336
361
  sortinoDaily,
337
362
  sideBreakdown,