tradelab 0.4.0 → 1.0.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.
Files changed (54) hide show
  1. package/README.md +121 -52
  2. package/bin/tradelab.js +340 -49
  3. package/dist/cjs/data.cjs +210 -155
  4. package/dist/cjs/index.cjs +1782 -274
  5. package/dist/cjs/live.cjs +3350 -0
  6. package/docs/README.md +26 -9
  7. package/docs/api-reference.md +89 -26
  8. package/docs/backtest-engine.md +74 -60
  9. package/docs/data-reporting-cli.md +66 -36
  10. package/docs/examples.md +275 -0
  11. package/docs/live-trading.md +186 -0
  12. package/examples/yahooEmaCross.js +1 -6
  13. package/package.json +18 -3
  14. package/src/data/csv.js +24 -14
  15. package/src/data/index.js +1 -5
  16. package/src/data/yahoo.js +6 -19
  17. package/src/engine/backtest.js +137 -144
  18. package/src/engine/backtestTicks.js +481 -0
  19. package/src/engine/barSystemRunner.js +1027 -0
  20. package/src/engine/execution.js +11 -39
  21. package/src/engine/portfolio.js +237 -66
  22. package/src/engine/walkForward.js +132 -13
  23. package/src/index.js +3 -11
  24. package/src/live/broker/alpaca.js +254 -0
  25. package/src/live/broker/binance.js +351 -0
  26. package/src/live/broker/coinbase.js +339 -0
  27. package/src/live/broker/interactiveBrokers.js +123 -0
  28. package/src/live/broker/interface.js +74 -0
  29. package/src/live/clock.js +56 -0
  30. package/src/live/engine/candleAggregator.js +154 -0
  31. package/src/live/engine/liveEngine.js +694 -0
  32. package/src/live/engine/paperEngine.js +453 -0
  33. package/src/live/engine/riskManager.js +185 -0
  34. package/src/live/engine/stateManager.js +112 -0
  35. package/src/live/events.js +48 -0
  36. package/src/live/feed/brokerFeed.js +35 -0
  37. package/src/live/feed/interface.js +28 -0
  38. package/src/live/feed/pollingFeed.js +105 -0
  39. package/src/live/index.js +27 -0
  40. package/src/live/logger.js +82 -0
  41. package/src/live/orchestrator.js +133 -0
  42. package/src/live/storage/interface.js +36 -0
  43. package/src/live/storage/jsonFileStorage.js +112 -0
  44. package/src/metrics/buildMetrics.js +103 -100
  45. package/src/reporting/exportBacktestArtifacts.js +1 -4
  46. package/src/reporting/exportTradesCsv.js +2 -7
  47. package/src/reporting/renderHtmlReport.js +8 -13
  48. package/src/utils/indicators.js +1 -2
  49. package/src/utils/positionSizing.js +16 -2
  50. package/src/utils/time.js +4 -12
  51. package/templates/report.html +23 -9
  52. package/templates/report.js +83 -69
  53. package/types/index.d.ts +98 -4
  54. package/types/live.d.ts +382 -0
package/bin/tradelab.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import fs from "node:fs";
2
3
  import path from "path";
3
4
  import { pathToFileURL } from "url";
4
5
 
@@ -13,6 +14,16 @@ import {
13
14
  saveCandlesToCache,
14
15
  walkForwardOptimize,
15
16
  } from "../src/index.js";
17
+ import {
18
+ AlpacaBroker,
19
+ BinanceBroker,
20
+ CoinbaseBroker,
21
+ InteractiveBrokersBroker,
22
+ JsonFileStorage,
23
+ LiveEngine,
24
+ LiveOrchestrator,
25
+ PaperEngine,
26
+ } from "../src/live/index.js";
16
27
 
17
28
  function parseArgs(argv) {
18
29
  const args = { _: [] };
@@ -24,12 +35,15 @@ function parseArgs(argv) {
24
35
  }
25
36
 
26
37
  const key = token.slice(2);
38
+ const camelKey = key.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
27
39
  const next = argv[index + 1];
40
+ const value = next && !next.startsWith("--") ? next : true;
41
+ args[key] = value;
42
+ if (camelKey !== key && args[camelKey] === undefined) {
43
+ args[camelKey] = value;
44
+ }
28
45
  if (next && !next.startsWith("--")) {
29
- args[key] = next;
30
46
  index += 1;
31
- } else {
32
- args[key] = true;
33
47
  }
34
48
  }
35
49
  return args;
@@ -48,12 +62,73 @@ function toList(value, fallback) {
48
62
  .filter((item) => Number.isFinite(item));
49
63
  }
50
64
 
51
- function createEmaCrossSignal({
52
- fast = 10,
53
- slow = 30,
54
- rr = 2,
55
- stopLookback = 15,
56
- } = {}) {
65
+ function parseJsonValue(value, fallback = null) {
66
+ if (!value) return fallback;
67
+ try {
68
+ return JSON.parse(String(value));
69
+ } catch {
70
+ throw new Error(`Invalid JSON value: ${String(value).slice(0, 120)}`);
71
+ }
72
+ }
73
+
74
+ function toBoolean(value, fallback = false) {
75
+ if (value === undefined || value === null) return fallback;
76
+ if (value === true || value === false) return value;
77
+ const normalized = String(value).trim().toLowerCase();
78
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
79
+ if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
80
+ return fallback;
81
+ }
82
+
83
+ function loadJsonFile(filePath) {
84
+ const resolved = path.resolve(process.cwd(), filePath);
85
+ const raw = fs.readFileSync(resolved, "utf8");
86
+ return JSON.parse(raw);
87
+ }
88
+
89
+ function resolveBrokerName(name, paperMode = false) {
90
+ if (paperMode) return "paper";
91
+ return String(name || "paper").toLowerCase();
92
+ }
93
+
94
+ function createBrokerAdapter(args, overrides = {}) {
95
+ const brokerName = resolveBrokerName(
96
+ overrides.broker || args.broker,
97
+ toBoolean(overrides.paper ?? args.paper, false)
98
+ );
99
+
100
+ if (brokerName === "paper") {
101
+ return new PaperEngine({
102
+ equity: toNumber(overrides.equity ?? args.equity, 10_000),
103
+ slippageBps: toNumber(overrides.slippageBps ?? args.slippageBps, 0),
104
+ feeBps: toNumber(overrides.feeBps ?? args.feeBps, 0),
105
+ costs: parseJsonValue(overrides.costs ?? args.costs, null),
106
+ });
107
+ }
108
+
109
+ if (brokerName === "alpaca") return new AlpacaBroker();
110
+ if (brokerName === "binance") return new BinanceBroker();
111
+ if (brokerName === "coinbase") return new CoinbaseBroker();
112
+ if (brokerName === "ib" || brokerName === "interactivebrokers") {
113
+ return new InteractiveBrokersBroker();
114
+ }
115
+
116
+ throw new Error(`Unsupported broker "${brokerName}"`);
117
+ }
118
+
119
+ function brokerConfigFromArgs(args, overrides = {}) {
120
+ return {
121
+ apiKey: overrides.apiKey ?? args.apiKey,
122
+ apiSecret: overrides.apiSecret ?? args.apiSecret,
123
+ passphrase: overrides.passphrase ?? args.passphrase,
124
+ paper: toBoolean(overrides.paper ?? args.paper, false),
125
+ baseUrl: overrides.baseUrl ?? args.baseUrl,
126
+ wsUrl: overrides.wsUrl ?? args.wsUrl,
127
+ futures: toBoolean(overrides.futures ?? args.futures, false),
128
+ };
129
+ }
130
+
131
+ function createEmaCrossSignal({ fast = 10, slow = 30, rr = 2, stopLookback = 15 } = {}) {
57
132
  return ({ candles }) => {
58
133
  if (candles.length < Math.max(fast, slow) + 2) return null;
59
134
 
@@ -62,27 +137,17 @@ function createEmaCrossSignal({
62
137
  const slowLine = ema(closes, slow);
63
138
  const last = closes.length - 1;
64
139
 
65
- if (
66
- fastLine[last - 1] <= slowLine[last - 1] &&
67
- fastLine[last] > slowLine[last]
68
- ) {
140
+ if (fastLine[last - 1] <= slowLine[last - 1] && fastLine[last] > slowLine[last]) {
69
141
  const entry = candles[last].close;
70
- const stop = Math.min(
71
- ...candles.slice(-stopLookback).map((bar) => bar.low)
72
- );
142
+ const stop = Math.min(...candles.slice(-stopLookback).map((bar) => bar.low));
73
143
  if (entry > stop) {
74
144
  return { side: "long", entry, stop, rr };
75
145
  }
76
146
  }
77
147
 
78
- if (
79
- fastLine[last - 1] >= slowLine[last - 1] &&
80
- fastLine[last] < slowLine[last]
81
- ) {
148
+ if (fastLine[last - 1] >= slowLine[last - 1] && fastLine[last] < slowLine[last]) {
82
149
  const entry = candles[last].close;
83
- const stop = Math.max(
84
- ...candles.slice(-stopLookback).map((bar) => bar.high)
85
- );
150
+ const stop = Math.max(...candles.slice(-stopLookback).map((bar) => bar.high));
86
151
  if (entry < stop) {
87
152
  return { side: "short", entry, stop, rr };
88
153
  }
@@ -133,13 +198,73 @@ async function loadStrategy(strategyArg, args) {
133
198
  throw new Error(`Strategy module "${strategyArg}" must export default, createSignal, or signal`);
134
199
  }
135
200
 
201
+ async function loadWalkForwardStrategy(strategyArg, args) {
202
+ if (!strategyArg || strategyArg === "ema-cross") {
203
+ const fasts = toList(args.fasts, [8, 10, 12]);
204
+ const slows = toList(args.slows, [20, 30, 40]);
205
+ const rrs = toList(args.rrs, [1.5, 2, 3]);
206
+ const parameterSets = [];
207
+
208
+ for (const fast of fasts) {
209
+ for (const slow of slows) {
210
+ if (fast >= slow) continue;
211
+ for (const rr of rrs) {
212
+ parameterSets.push({ fast, slow, rr });
213
+ }
214
+ }
215
+ }
216
+
217
+ return {
218
+ parameterSets,
219
+ signalFactory(params) {
220
+ return createEmaCrossSignal({
221
+ fast: params.fast,
222
+ slow: params.slow,
223
+ rr: params.rr,
224
+ stopLookback: toNumber(args.stopLookback, 15),
225
+ });
226
+ },
227
+ };
228
+ }
229
+
230
+ const resolved = path.resolve(process.cwd(), strategyArg);
231
+ const module = await import(pathToFileURL(resolved).href);
232
+ if (typeof module.signalFactory !== "function") {
233
+ throw new Error(`Walk-forward strategy module "${strategyArg}" must export signalFactory`);
234
+ }
235
+
236
+ const parameterSets =
237
+ parseJsonValue(args.parameterSets) ??
238
+ (typeof module.createParameterSets === "function"
239
+ ? await module.createParameterSets(args)
240
+ : module.parameterSets);
241
+
242
+ if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
243
+ throw new Error(
244
+ `Walk-forward strategy module "${strategyArg}" must provide parameterSets, createParameterSets(args), or --parameterSets`
245
+ );
246
+ }
247
+
248
+ return {
249
+ parameterSets,
250
+ signalFactory(params) {
251
+ return module.signalFactory(params, args);
252
+ },
253
+ };
254
+ }
255
+
136
256
  function printHelp() {
137
- console.log(`tradelab
257
+ console.log(`tradelab — backtesting toolkit for Node.js
258
+
259
+ Usage: tradelab <command> [options]
138
260
 
139
261
  Commands:
140
262
  backtest Run a one-off backtest from Yahoo or CSV data
141
263
  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
264
+ walk-forward Run rolling or anchored train/test optimization
265
+ live Run live trading engine (streaming or polling)
266
+ paper Run live engine in paper broker mode
267
+ status Read persisted live state
143
268
  prefetch Download Yahoo candles into the local cache
144
269
  import-csv Normalize a CSV and save it into the local cache
145
270
 
@@ -147,12 +272,21 @@ Examples:
147
272
  tradelab backtest --source yahoo --symbol SPY --interval 1d --period 1y
148
273
  tradelab backtest --source csv --csvPath ./data/btc.csv --strategy buy-hold --holdBars 3
149
274
  tradelab walk-forward --source csv --csvPath ./data/spy.csv --trainBars 120 --testBars 40
275
+ tradelab live --strategy ./mySignal.js --symbol AAPL --interval 5m --broker alpaca --paper
276
+
277
+ Options:
278
+ --help Show this help message
279
+ --version Print version number
150
280
  `);
151
281
  }
152
282
 
153
283
  async function commandBacktest(args) {
284
+ const source = args.source || (args.csvPath ? "csv" : "yahoo");
285
+ if (source === "yahoo" && !args.symbol) {
286
+ throw new Error("backtest with Yahoo source requires --symbol (e.g. --symbol SPY)");
287
+ }
154
288
  const candles = await getHistoricalCandles({
155
- source: args.source || (args.csvPath ? "csv" : "yahoo"),
289
+ source,
156
290
  symbol: args.symbol,
157
291
  interval: args.interval || "1d",
158
292
  period: args.period || "1y",
@@ -249,34 +383,27 @@ async function commandPortfolio(args) {
249
383
  }
250
384
 
251
385
  async function commandWalkForward(args) {
386
+ const wfSource = args.source || (args.csvPath ? "csv" : "yahoo");
387
+ if (wfSource === "yahoo" && !args.symbol) {
388
+ throw new Error("walk-forward with Yahoo source requires --symbol (e.g. --symbol QQQ)");
389
+ }
252
390
  const candles = await getHistoricalCandles({
253
- source: args.source || (args.csvPath ? "csv" : "yahoo"),
391
+ source: wfSource,
254
392
  symbol: args.symbol,
255
393
  interval: args.interval || "1d",
256
394
  period: args.period || "1y",
257
395
  csvPath: args.csvPath,
258
396
  cache: args.cache !== "false",
259
397
  });
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
- }
398
+ const walkForwardStrategy = await loadWalkForwardStrategy(args.strategy, args);
273
399
 
274
400
  const result = walkForwardOptimize({
275
401
  candles,
276
- parameterSets,
402
+ parameterSets: walkForwardStrategy.parameterSets,
277
403
  trainBars: toNumber(args.trainBars, 120),
278
404
  testBars: toNumber(args.testBars, 40),
279
405
  stepBars: toNumber(args.stepBars, toNumber(args.testBars, 40)),
406
+ mode: args.mode || "rolling",
280
407
  scoreBy: args.scoreBy || "profitFactor",
281
408
  backtestOptions: {
282
409
  symbol: args.symbol || "DATA",
@@ -286,14 +413,7 @@ async function commandWalkForward(args) {
286
413
  riskPct: toNumber(args.riskPct, 1),
287
414
  warmupBars: toNumber(args.warmupBars, 20),
288
415
  },
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
- },
416
+ signalFactory: walkForwardStrategy.signalFactory,
297
417
  });
298
418
 
299
419
  const metricsPath = exportMetricsJSON({
@@ -310,6 +430,7 @@ async function commandWalkForward(args) {
310
430
  windows: result.windows.length,
311
431
  positions: result.positions.length,
312
432
  finalEquity: result.metrics.finalEquity,
433
+ bestParamsSummary: result.bestParamsSummary,
313
434
  metricsPath,
314
435
  },
315
436
  null,
@@ -353,10 +474,174 @@ async function commandImportCsv(args) {
353
474
  console.log(`Saved ${candles.length} candles to ${outputPath}`);
354
475
  }
355
476
 
477
+ async function createLiveSystemFromConfig(system, args) {
478
+ const signal = await loadStrategy(system.strategy || args.strategy, {
479
+ ...args,
480
+ ...system,
481
+ });
482
+ return {
483
+ ...system,
484
+ signal,
485
+ interval: system.interval || args.interval || "1m",
486
+ symbol: system.symbol || args.symbol,
487
+ };
488
+ }
489
+
490
+ async function commandLive(args, overrides = {}) {
491
+ const configPath = overrides.config || args.config;
492
+ const mode = overrides.mode || args.mode || "streaming";
493
+ const stateDir = overrides.stateDir || args.stateDir || "output/live-state";
494
+ const once = toBoolean(overrides.once ?? args.once, mode === "polling");
495
+ const watch = toBoolean(overrides.watch ?? args.watch, false);
496
+ const storage = new JsonFileStorage({ baseDir: stateDir });
497
+
498
+ if (configPath) {
499
+ const fileConfig = loadJsonFile(configPath);
500
+ const broker = createBrokerAdapter(args, {
501
+ ...overrides,
502
+ equity: fileConfig.equity ?? overrides.equity,
503
+ });
504
+ const brokerConfig = brokerConfigFromArgs(args, overrides);
505
+ const systems = await Promise.all(
506
+ (fileConfig.systems || []).map((system) => createLiveSystemFromConfig(system, args))
507
+ );
508
+ const orchestrator = new LiveOrchestrator({
509
+ systems,
510
+ broker,
511
+ storage,
512
+ brokerConfig,
513
+ allocation: fileConfig.allocation || args.allocation || "equal",
514
+ maxDailyLossPct: toNumber(fileConfig.maxDailyLossPct ?? args.maxDailyLossPct, 0),
515
+ equity: toNumber(fileConfig.equity ?? args.equity, 10_000),
516
+ });
517
+ await broker.connect(brokerConfig);
518
+ await orchestrator.start();
519
+
520
+ if (once && orchestrator.engines?.length) {
521
+ await Promise.all(orchestrator.engines.map((engine) => engine.pollOnce()));
522
+ }
523
+
524
+ const status = orchestrator.getStatus();
525
+ console.log(JSON.stringify(status, null, 2));
526
+
527
+ if (!watch) {
528
+ await orchestrator.stop();
529
+ }
530
+ return;
531
+ }
532
+
533
+ const broker = createBrokerAdapter(args, overrides);
534
+ const brokerConfig = brokerConfigFromArgs(args, overrides);
535
+ const signal = await loadStrategy(overrides.strategy || args.strategy, args);
536
+ const engine = new LiveEngine({
537
+ id: overrides.id || args.id,
538
+ signal,
539
+ symbol: overrides.symbol || args.symbol,
540
+ interval: overrides.interval || args.interval || "1m",
541
+ mode,
542
+ pollIntervalMs: toNumber(overrides.pollIntervalMs ?? args.pollIntervalMs, 60_000),
543
+ warmupBars: toNumber(overrides.warmupBars ?? args.warmupBars, 200),
544
+ equity: toNumber(overrides.equity ?? args.equity, 10_000),
545
+ riskPct: toNumber(overrides.riskPct ?? args.riskPct, 1),
546
+ costs: parseJsonValue(overrides.costs ?? args.costs, null),
547
+ flattenAtClose: toBoolean(overrides.flattenAtClose ?? args.flattenAtClose, false),
548
+ maxDailyLossPct: toNumber(overrides.maxDailyLossPct ?? args.maxDailyLossPct, 0),
549
+ dailyMaxTrades: toNumber(overrides.dailyMaxTrades ?? args.dailyMaxTrades, 0),
550
+ broker,
551
+ storage,
552
+ brokerConfig,
553
+ });
554
+
555
+ await engine.start();
556
+
557
+ if (once) {
558
+ await engine.pollOnce();
559
+ }
560
+
561
+ const status = engine.getStatus();
562
+ console.log(JSON.stringify(status, null, 2));
563
+
564
+ if (!watch) {
565
+ await engine.stop();
566
+ return;
567
+ }
568
+
569
+ const shutdown = async () => {
570
+ await engine.stop();
571
+ process.exit(0);
572
+ };
573
+ process.once("SIGINT", shutdown);
574
+ process.once("SIGTERM", shutdown);
575
+ }
576
+
577
+ async function commandPaper(args) {
578
+ return commandLive(args, { paper: true, broker: "paper" });
579
+ }
580
+
581
+ async function commandStatus(args) {
582
+ const stateDir = args.dir || args.stateDir || "output/live-state";
583
+ const storage = new JsonFileStorage({ baseDir: stateDir });
584
+ const namespace = args.namespace || args.id;
585
+
586
+ if (namespace) {
587
+ const state = await storage.load(namespace);
588
+ const trades = await storage.loadTrades(namespace);
589
+ const equity = await storage.loadEquityCurve(namespace);
590
+ console.log(
591
+ JSON.stringify(
592
+ {
593
+ namespace,
594
+ state,
595
+ trades: trades.length,
596
+ equityPoints: equity.length,
597
+ },
598
+ null,
599
+ 2
600
+ )
601
+ );
602
+ return;
603
+ }
604
+
605
+ if (!fs.existsSync(stateDir)) {
606
+ console.log(JSON.stringify({ dir: stateDir, namespaces: [] }, null, 2));
607
+ return;
608
+ }
609
+
610
+ const namespaces = fs
611
+ .readdirSync(stateDir, { withFileTypes: true })
612
+ .filter((entry) => entry.isDirectory())
613
+ .map((entry) => entry.name);
614
+ const summaries = [];
615
+ for (const name of namespaces) {
616
+ const state = await storage.load(name);
617
+ const trades = await storage.loadTrades(name);
618
+ summaries.push({
619
+ namespace: name,
620
+ savedAt: state?.savedAt ?? null,
621
+ equity: state?.equity ?? null,
622
+ openPosition: Boolean(state?.openPosition),
623
+ trades: trades.length,
624
+ });
625
+ }
626
+ console.log(
627
+ JSON.stringify(
628
+ {
629
+ dir: stateDir,
630
+ namespaces: summaries,
631
+ },
632
+ null,
633
+ 2
634
+ )
635
+ );
636
+ }
637
+
356
638
  const commands = {
357
639
  backtest: commandBacktest,
358
640
  portfolio: commandPortfolio,
359
641
  "walk-forward": commandWalkForward,
642
+ live: commandLive,
643
+ paper: commandPaper,
644
+ status: commandStatus,
360
645
  prefetch: commandPrefetch,
361
646
  "import-csv": commandImportCsv,
362
647
  };
@@ -365,6 +650,12 @@ async function main() {
365
650
  const args = parseArgs(process.argv.slice(2));
366
651
  const command = args._[0];
367
652
 
653
+ if (args.version || args.v) {
654
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
655
+ console.log(pkg.version);
656
+ return;
657
+ }
658
+
368
659
  if (!command || command === "help" || args.help) {
369
660
  printHelp();
370
661
  return;