tradelab 0.1.2 → 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 CHANGED
@@ -2,21 +2,27 @@
2
2
 
3
3
  # tradelab
4
4
 
5
- `tradelab` is a candle-based backtesting toolkit for Node.js. It is built for two use cases:
6
- - you already have candles and want a solid execution/backtest engine
7
- - you want to fetch Yahoo Finance data or import CSVs and backtest with minimal setup
5
+ `tradelab` is a Node.js backtesting toolkit for trading strategy research. It lets you:
6
+ - load candles from Yahoo Finance or CSV
7
+ - run candle-based backtests with sizing, exits, and risk controls
8
+ - export trades, metrics, and HTML reports
8
9
 
9
- The package stays focused on historical research and testing, and is not trying to be a broker adapter or a live trading framework.
10
+ The package is modular by design, so you can use just the parts you need: data loading, backtesting, reporting, or the utility layer on its own.
11
+
12
+ It is built for historical research and testing, not broker connectivity or live trading.
10
13
 
11
14
  ## Features
12
15
 
13
- - Backtest engine with pending entries, OCO exits, scale-outs, pyramiding, cooldowns, daily risk limits, and optional replay data
14
- - Yahoo Finance historical downloader with local caching
15
- - Flexible CSV import for common OHLCV layouts
16
- - Metrics for positions and realized legs
17
- - CSV trade export
18
- - Self-contained HTML report export
19
- - Utility indicators and session helpers for strategy code
16
+ - Modular structure: use the full workflow or just the engine, data layer, reporting, or helpers
17
+ - Backtest engine with pending entries, OCO exits, scale-outs, pyramiding, cooldowns, daily loss limits, optional replay/equity capture, and configurable slippage/commission modeling
18
+ - Historical data loading from Yahoo Finance, with local caching to avoid repeated downloads
19
+ - CSV import for common OHLCV formats and custom column mappings
20
+ - Position-level and leg-level metrics, including drawdown, expectancy, hold-time stats, and side breakdowns
21
+ - Multi-symbol portfolio aggregation and rolling walk-forward optimization helpers
22
+ - HTML report export, metrics JSON export, and trade CSV export
23
+ - Utility indicators and session helpers for strategy development
24
+ - CLI entrypoint for fetching data and running quick backtests from the terminal
25
+ - TypeScript definitions for the public API
20
26
 
21
27
  ## Installation
22
28
 
@@ -26,6 +32,23 @@ npm install tradelab
26
32
 
27
33
  Node `18+` is required.
28
34
 
35
+ ## Importing
36
+
37
+
38
+ ### ESM (recommended)
39
+
40
+ ```js
41
+ import { backtest, getHistoricalCandles, ema } from "tradelab";
42
+ import { fetchHistorical } from "tradelab/data";
43
+ ```
44
+
45
+ ### CommonJS
46
+
47
+ ```js
48
+ const { backtest, getHistoricalCandles, ema } = require("tradelab");
49
+ const { fetchHistorical } = require("tradelab/data");
50
+ ```
51
+
29
52
  ## Quick Start
30
53
 
31
54
  ```js
@@ -72,7 +95,7 @@ exportBacktestArtifacts({
72
95
 
73
96
  ## Getting Historical Data
74
97
 
75
- The simplest entry point is `getHistoricalCandles()`.
98
+ The simplest entry point is `getHistoricalCandles()`. For most users, it is the only data-loading function you need.
76
99
 
77
100
  ### Yahoo Finance
78
101
 
@@ -154,6 +177,7 @@ Quality-of-life behavior:
154
177
  - `takeProfit` can be omitted if `rr` or `_rr` is provided
155
178
  - `qty` or `size` can override risk-based sizing
156
179
  - `riskPct` or `riskFraction` can override the global risk setting per signal
180
+ - `strict: true` throws if the strategy directly accesses candles beyond the current index
157
181
 
158
182
  Optional engine hints:
159
183
 
@@ -173,24 +197,27 @@ Optional engine hints:
173
197
 
174
198
  - `trades`: every realized leg, including scale-outs
175
199
  - `positions`: completed positions only
176
- - `metrics`: aggregate performance stats
177
- - `eqSeries`: realized equity history
200
+ - `metrics`: aggregate stats including `winRate`, `expectancy`, `profitFactor`, `maxDrawdown`, `sharpe`, `avgHold`, and `sideBreakdown`
201
+ - `eqSeries`: realized equity history as `{ time, timestamp, equity }`
178
202
  - `replay`: chart-friendly frame and event data
179
203
 
180
204
  ## Main Exports
181
205
 
182
206
  - `backtest(options)`
207
+ - `backtestPortfolio({ systems, equity })`
208
+ - `walkForwardOptimize({ candles, signalFactory, parameterSets, trainBars, testBars })`
183
209
  - `backtestHistorical({ data, backtestOptions })`
184
210
  - `getHistoricalCandles(options)`
185
211
  - `fetchHistorical(symbol, interval, period)`
186
212
  - `loadCandlesFromCSV(filePath, options)`
187
213
  - `saveCandlesToCache(candles, meta)`
188
214
  - `loadCandlesFromCache(symbol, interval, period, outDir)`
215
+ - `exportMetricsJSON({ result, outDir })`
189
216
  - `exportBacktestArtifacts({ result, outDir })`
190
217
 
191
218
  ## Reports
192
219
 
193
- The HTML report is self-contained apart from the Plotly CDN script. Report markup, CSS, and client-side chart code live under `templates/`, not inline in the report renderer.
220
+ The HTML report is self-contained apart from the Plotly CDN script. Report markup, CSS, and client-side chart code live under `templates/`.
194
221
 
195
222
  Export helpers default CSV output to completed positions. Use `csvSource: "trades"` if you want every realized leg in the CSV.
196
223
 
@@ -201,8 +228,17 @@ node examples/emaCross.js
201
228
  node examples/yahooEmaCross.js SPY 1d 1y
202
229
  ```
203
230
 
231
+ ## CLI
232
+
233
+ ```bash
234
+ npx tradelab backtest --source yahoo --symbol SPY --interval 1d --period 1y
235
+ npx tradelab backtest --source csv --csvPath ./data/btc.csv --strategy buy-hold --holdBars 3
236
+ npx tradelab walk-forward --source yahoo --symbol QQQ --interval 1d --period 2y --trainBars 180 --testBars 60
237
+ ```
238
+
204
239
  ## Notes
205
240
 
206
241
  - Yahoo downloads can be cached under `output/data` by default.
207
242
  - The engine is intended for historical research, not brokerage execution.
208
243
  - File output only happens through the reporting and cache helpers.
244
+ - CommonJS and ESM are both supported.
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env node
2
+ import path from "path";
3
+ import { pathToFileURL } from "url";
4
+
5
+ import {
6
+ backtest,
7
+ backtestPortfolio,
8
+ ema,
9
+ exportBacktestArtifacts,
10
+ exportMetricsJSON,
11
+ getHistoricalCandles,
12
+ loadCandlesFromCSV,
13
+ saveCandlesToCache,
14
+ walkForwardOptimize,
15
+ } from "../src/index.js";
16
+
17
+ function parseArgs(argv) {
18
+ const args = { _: [] };
19
+ for (let index = 0; index < argv.length; index += 1) {
20
+ const token = argv[index];
21
+ if (!token.startsWith("--")) {
22
+ args._.push(token);
23
+ continue;
24
+ }
25
+
26
+ const key = token.slice(2);
27
+ const next = argv[index + 1];
28
+ if (next && !next.startsWith("--")) {
29
+ args[key] = next;
30
+ index += 1;
31
+ } else {
32
+ args[key] = true;
33
+ }
34
+ }
35
+ return args;
36
+ }
37
+
38
+ function toNumber(value, fallback) {
39
+ const numeric = Number(value);
40
+ return Number.isFinite(numeric) ? numeric : fallback;
41
+ }
42
+
43
+ function toList(value, fallback) {
44
+ if (!value) return fallback;
45
+ return String(value)
46
+ .split(",")
47
+ .map((item) => Number(item.trim()))
48
+ .filter((item) => Number.isFinite(item));
49
+ }
50
+
51
+ function createEmaCrossSignal({
52
+ fast = 10,
53
+ slow = 30,
54
+ rr = 2,
55
+ stopLookback = 15,
56
+ } = {}) {
57
+ return ({ candles }) => {
58
+ if (candles.length < Math.max(fast, slow) + 2) return null;
59
+
60
+ const closes = candles.map((bar) => bar.close);
61
+ const fastLine = ema(closes, fast);
62
+ const slowLine = ema(closes, slow);
63
+ const last = closes.length - 1;
64
+
65
+ if (
66
+ fastLine[last - 1] <= slowLine[last - 1] &&
67
+ fastLine[last] > slowLine[last]
68
+ ) {
69
+ const entry = candles[last].close;
70
+ const stop = Math.min(
71
+ ...candles.slice(-stopLookback).map((bar) => bar.low)
72
+ );
73
+ if (entry > stop) {
74
+ return { side: "long", entry, stop, rr };
75
+ }
76
+ }
77
+
78
+ if (
79
+ fastLine[last - 1] >= slowLine[last - 1] &&
80
+ fastLine[last] < slowLine[last]
81
+ ) {
82
+ const entry = candles[last].close;
83
+ const stop = Math.max(
84
+ ...candles.slice(-stopLookback).map((bar) => bar.high)
85
+ );
86
+ if (entry < stop) {
87
+ return { side: "short", entry, stop, rr };
88
+ }
89
+ }
90
+
91
+ return null;
92
+ };
93
+ }
94
+
95
+ function createBuyHoldSignal({ holdBars = 5, stopPct = 0.05 } = {}) {
96
+ let entered = false;
97
+
98
+ return ({ bar }) => {
99
+ if (entered) return null;
100
+ entered = true;
101
+ return {
102
+ side: "long",
103
+ entry: bar.close,
104
+ stop: bar.close * (1 - stopPct),
105
+ rr: 100,
106
+ _maxBarsInTrade: holdBars,
107
+ };
108
+ };
109
+ }
110
+
111
+ async function loadStrategy(strategyArg, args) {
112
+ if (!strategyArg || strategyArg === "ema-cross") {
113
+ return createEmaCrossSignal({
114
+ fast: toNumber(args.fast, 10),
115
+ slow: toNumber(args.slow, 30),
116
+ rr: toNumber(args.rr, 2),
117
+ stopLookback: toNumber(args.stopLookback, 15),
118
+ });
119
+ }
120
+
121
+ if (strategyArg === "buy-hold") {
122
+ return createBuyHoldSignal({
123
+ holdBars: toNumber(args.holdBars, 5),
124
+ stopPct: toNumber(args.stopPct, 0.05),
125
+ });
126
+ }
127
+
128
+ const resolved = path.resolve(process.cwd(), strategyArg);
129
+ const module = await import(pathToFileURL(resolved).href);
130
+ if (typeof module.default === "function") return module.default(args);
131
+ if (typeof module.createSignal === "function") return module.createSignal(args);
132
+ if (typeof module.signal === "function") return module.signal;
133
+ throw new Error(`Strategy module "${strategyArg}" must export default, createSignal, or signal`);
134
+ }
135
+
136
+ function printHelp() {
137
+ console.log(`tradelab
138
+
139
+ Commands:
140
+ backtest Run a one-off backtest from Yahoo or CSV data
141
+ 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
143
+ prefetch Download Yahoo candles into the local cache
144
+ import-csv Normalize a CSV and save it into the local cache
145
+
146
+ Examples:
147
+ tradelab backtest --source yahoo --symbol SPY --interval 1d --period 1y
148
+ tradelab backtest --source csv --csvPath ./data/btc.csv --strategy buy-hold --holdBars 3
149
+ tradelab walk-forward --source csv --csvPath ./data/spy.csv --trainBars 120 --testBars 40
150
+ `);
151
+ }
152
+
153
+ async function commandBacktest(args) {
154
+ const candles = await getHistoricalCandles({
155
+ source: args.source || (args.csvPath ? "csv" : "yahoo"),
156
+ symbol: args.symbol,
157
+ interval: args.interval || "1d",
158
+ period: args.period || "1y",
159
+ csvPath: args.csvPath,
160
+ cache: args.cache !== "false",
161
+ });
162
+ const signal = await loadStrategy(args.strategy, args);
163
+ const result = backtest({
164
+ candles,
165
+ symbol: args.symbol || "DATA",
166
+ interval: args.interval || "1d",
167
+ range: args.period || "custom",
168
+ equity: toNumber(args.equity, 10_000),
169
+ riskPct: toNumber(args.riskPct, 1),
170
+ warmupBars: toNumber(args.warmupBars, 20),
171
+ flattenAtClose: args.flattenAtClose === true || args.flattenAtClose === "true",
172
+ signal,
173
+ });
174
+
175
+ const outputs = exportBacktestArtifacts({
176
+ result,
177
+ outDir: args.outDir || "output",
178
+ });
179
+
180
+ console.log(
181
+ JSON.stringify(
182
+ {
183
+ symbol: result.symbol,
184
+ trades: result.metrics.trades,
185
+ winRate: result.metrics.winRate,
186
+ profitFactor: result.metrics.profitFactor,
187
+ finalEquity: result.metrics.finalEquity,
188
+ outputs,
189
+ },
190
+ null,
191
+ 2
192
+ )
193
+ );
194
+ }
195
+
196
+ function parsePortfolioInputs(args) {
197
+ const csvPaths = String(args.csvPaths || "")
198
+ .split(",")
199
+ .map((value) => value.trim())
200
+ .filter(Boolean);
201
+ const symbols = String(args.symbols || "")
202
+ .split(",")
203
+ .map((value) => value.trim())
204
+ .filter(Boolean);
205
+
206
+ return csvPaths.map((csvPath, index) => ({
207
+ symbol: symbols[index] || `asset-${index + 1}`,
208
+ candles: loadCandlesFromCSV(csvPath),
209
+ }));
210
+ }
211
+
212
+ async function commandPortfolio(args) {
213
+ const baseSystems = parsePortfolioInputs(args);
214
+ const systems = await Promise.all(
215
+ baseSystems.map(async (system) => ({
216
+ ...system,
217
+ signal: await loadStrategy(args.strategy || "buy-hold", args),
218
+ warmupBars: toNumber(args.warmupBars, 1),
219
+ flattenAtClose: false,
220
+ }))
221
+ );
222
+
223
+ const result = backtestPortfolio({
224
+ systems,
225
+ equity: toNumber(args.equity, 10_000),
226
+ collectReplay: false,
227
+ collectEqSeries: true,
228
+ });
229
+ const metricsPath = exportMetricsJSON({
230
+ result,
231
+ outDir: args.outDir || "output",
232
+ symbol: "PORTFOLIO",
233
+ interval: args.interval || "mixed",
234
+ range: args.period || "custom",
235
+ });
236
+
237
+ console.log(
238
+ JSON.stringify(
239
+ {
240
+ systems: result.systems.length,
241
+ positions: result.positions.length,
242
+ finalEquity: result.metrics.finalEquity,
243
+ metricsPath,
244
+ },
245
+ null,
246
+ 2
247
+ )
248
+ );
249
+ }
250
+
251
+ async function commandWalkForward(args) {
252
+ const candles = await getHistoricalCandles({
253
+ source: args.source || (args.csvPath ? "csv" : "yahoo"),
254
+ symbol: args.symbol,
255
+ interval: args.interval || "1d",
256
+ period: args.period || "1y",
257
+ csvPath: args.csvPath,
258
+ cache: args.cache !== "false",
259
+ });
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
+ }
273
+
274
+ const result = walkForwardOptimize({
275
+ candles,
276
+ parameterSets,
277
+ trainBars: toNumber(args.trainBars, 120),
278
+ testBars: toNumber(args.testBars, 40),
279
+ stepBars: toNumber(args.stepBars, toNumber(args.testBars, 40)),
280
+ scoreBy: args.scoreBy || "profitFactor",
281
+ backtestOptions: {
282
+ symbol: args.symbol || "DATA",
283
+ interval: args.interval || "1d",
284
+ range: args.period || "custom",
285
+ equity: toNumber(args.equity, 10_000),
286
+ riskPct: toNumber(args.riskPct, 1),
287
+ warmupBars: toNumber(args.warmupBars, 20),
288
+ },
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
+ },
297
+ });
298
+
299
+ const metricsPath = exportMetricsJSON({
300
+ result,
301
+ outDir: args.outDir || "output",
302
+ symbol: args.symbol || "DATA",
303
+ interval: args.interval || "1d",
304
+ range: `${args.trainBars || 120}-${args.testBars || 40}`,
305
+ });
306
+
307
+ console.log(
308
+ JSON.stringify(
309
+ {
310
+ windows: result.windows.length,
311
+ positions: result.positions.length,
312
+ finalEquity: result.metrics.finalEquity,
313
+ metricsPath,
314
+ },
315
+ null,
316
+ 2
317
+ )
318
+ );
319
+ }
320
+
321
+ async function commandPrefetch(args) {
322
+ const candles = await getHistoricalCandles({
323
+ source: "yahoo",
324
+ symbol: args.symbol || "SPY",
325
+ interval: args.interval || "1d",
326
+ period: args.period || "1y",
327
+ cache: false,
328
+ });
329
+ const outputPath = saveCandlesToCache(candles, {
330
+ symbol: args.symbol || "SPY",
331
+ interval: args.interval || "1d",
332
+ period: args.period || "1y",
333
+ outDir: args.outDir || "output/data",
334
+ source: "yahoo",
335
+ });
336
+ console.log(`Saved ${candles.length} candles to ${outputPath}`);
337
+ }
338
+
339
+ async function commandImportCsv(args) {
340
+ const csvPath = args.csvPath || args._[1];
341
+ if (!csvPath) {
342
+ throw new Error("import-csv requires --csvPath or a positional CSV file path");
343
+ }
344
+
345
+ const candles = loadCandlesFromCSV(csvPath, {});
346
+ const outputPath = saveCandlesToCache(candles, {
347
+ symbol: args.symbol || "DATA",
348
+ interval: args.interval || "1d",
349
+ period: args.period || "custom",
350
+ outDir: args.outDir || "output/data",
351
+ source: "csv",
352
+ });
353
+ console.log(`Saved ${candles.length} candles to ${outputPath}`);
354
+ }
355
+
356
+ const commands = {
357
+ backtest: commandBacktest,
358
+ portfolio: commandPortfolio,
359
+ "walk-forward": commandWalkForward,
360
+ prefetch: commandPrefetch,
361
+ "import-csv": commandImportCsv,
362
+ };
363
+
364
+ async function main() {
365
+ const args = parseArgs(process.argv.slice(2));
366
+ const command = args._[0];
367
+
368
+ if (!command || command === "help" || args.help) {
369
+ printHelp();
370
+ return;
371
+ }
372
+
373
+ const handler = commands[command];
374
+ if (!handler) {
375
+ throw new Error(`Unknown command "${command}". Run "tradelab help".`);
376
+ }
377
+
378
+ await handler(args);
379
+ }
380
+
381
+ main().catch((error) => {
382
+ console.error(error.message);
383
+ process.exit(1);
384
+ });