tradelab 1.2.0 → 1.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/CHANGELOG.md CHANGED
@@ -5,6 +5,59 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.0] - 2026-06-27
9
+
10
+ ### Added
11
+
12
+ - **Multi-symbol portfolio sessions**
13
+ - `SessionManager.create({ symbols: ["BTC", "ETH"] })` creates a single session that tracks multiple instruments against a shared broker.
14
+ - `pushBar(bar, symbol)` and `placeOrder({ symbol })` target a specific instrument; bracket OCO is managed per-symbol.
15
+ - `closePosition(symbol)` cancels the resting bracket for that symbol before submitting the flattening order.
16
+ - Per-symbol read accessors: `lastPriceFor(sym)` and `candleBufferFor(sym)`.
17
+ - `getStatus()` now includes a `symbols` array alongside the primary `symbol`. Single-symbol usage (`symbol: "AAPL"`) is unchanged.
18
+
19
+ - **Portfolio exposure caps**
20
+ - `maxGrossExposurePct` and `maxNetExposurePct` options on `TradingSession` and `SessionManager.create()`. Both default to `0` (disabled).
21
+ - Enforced in `placeOrder()` before any broker call; throws `risk rejected: <reason>` when the cap is breached.
22
+ - The `RiskManager.checkExposure()` method is the shared gate used by both `canOpenPosition()` and `placeOrder()`.
23
+
24
+ - **Trade attribution on order events**
25
+ - `order:submitted` and `order:filled` events now include a `sizing` block: `{ entry, stop, target, rr, riskFraction, riskAmount, qty, notional }`.
26
+ - Pass `rationale` to `placeOrder()` to attach a free-text note that propagates to all fill events for that order.
27
+ - Bracket legs carry `parentEntryId` (the entry's client order id) and a `leg` field (`"stop"` or `"target"`).
28
+
29
+ - **Agent research loop**
30
+ - `createResearchStore({ dir? })` from `tradelab` returns `{ open, log, recall, close }` for file-backed hypothesis tracking.
31
+ - MCP tools `research_open`, `research_log`, `research_recall`, `research_close` expose the store over stdio.
32
+ - `run_backtest` accepts `researchId` and auto-logs the backtest result plus a Deflated Sharpe verdict (`{ deflatedSharpe, overfit, note }`) without a separate `research_log` call.
33
+
34
+ - **Multi-symbol support in MCP live tools**
35
+ - `create_session` accepts a `symbols` array.
36
+ - `feed_price`, `place_order`, `close_position`, and `attach_strategy` accept an optional `symbol` argument to route operations to a specific instrument.
37
+
38
+ - **`summarize(metrics)`**
39
+ - Exported from `tradelab`. Renders a `buildMetrics` output object as one plain-English paragraph. Accepts an optional `verdict` object to append an overfit caution sentence.
40
+
41
+ - **`tradelab run <preset>` CLI subcommand**
42
+ - Runs a named built-in strategy on Yahoo or CSV data and prints the `summarize()` paragraph to stdout.
43
+ - Accepts `--params '{"fast":5}'` to override strategy defaults.
44
+
45
+ - **`attachNotifier(session, opts)`**
46
+ - Exported from `tradelab/live`. Subscribes a callback and/or webhook URL to a session's event bus.
47
+ - Options: `events` (which events to forward), `onEvent` (async callback), `webhookUrl` (HTTP POST endpoint), `drawdownPct` (fires `drawdown:breach` when equity falls this far from peak).
48
+ - Returns an unsubscribe function.
49
+
50
+ ## [1.2.1] - 2026-06-27
51
+
52
+ ### Fixed
53
+
54
+ - `backtestPortfolio()` now reports aggregate `metrics.finalEquity` correctly when `collectEqSeries: false`.
55
+ - `PaperEngine` now rejects market orders that have no price reference instead of filling them at zero.
56
+ - `TradingSession` now clears staged brackets when an async entry is rejected or canceled, and staged brackets are matched by order id/client order id instead of a loose string check.
57
+ - MCP `attach_strategy` now evaluates built-in strategies with the normal backtest signal context and auto-places returned order intents from `feed_price`.
58
+ - `research.monteCarlo()` now rejects non-positive iteration counts instead of returning empty bands and `NaN` probability fields.
59
+ - Public live types now include `TradingSessionOptions.confirmLive` and optional dashboard `source.refresh()`.
60
+
8
61
  ## [1.2.0] - 2026-06-26
9
62
 
10
63
  ### Added
package/README.md CHANGED
@@ -1,4 +1,17 @@
1
- # tradelab
1
+ <div align="center">
2
+ <img src="https://i.imgur.com/HGvvQbq.png" width="420" alt="tradelab logo" />
3
+
4
+ <p><strong>A Node.js backtesting toolkit for serious trading strategy research.</strong></p>
5
+
6
+ [![npm version](https://img.shields.io/npm/v/tradelab?color=0f172a&label=npm&logo=npm)](https://www.npmjs.com/package/tradelab)
7
+ [![GitHub](https://img.shields.io/badge/github-ishsharm0/tradelab-0f172a?logo=github)](https://github.com/ishsharm0/tradelab)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-0f172a)](https://github.com/ishsharm0/tradelab/blob/main/LICENSE)
9
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-0f172a?logo=node.js)](https://nodejs.org)
10
+ [![TypeScript](https://img.shields.io/badge/TypeScript-ready-0f172a?logo=typescript)](https://github.com/ishsharm0/tradelab/blob/main/types/index.d.ts)
11
+
12
+ </div>
13
+
14
+ ---
2
15
 
3
16
  A Node.js toolkit for testing, validating, and operating trading strategies.
4
17
 
@@ -74,11 +87,16 @@ Start with `result.metrics` for the summary and `result.positions` for completed
74
87
  | Run a parallel parameter sweep | `optimize({ signalModulePath, parameterSets })` |
75
88
  | Use indicators | `import { rsi, macd, vwap } from "tradelab/ta"` |
76
89
  | Check overfitting risk | `research.monteCarlo`, `research.deflatedSharpe` |
77
- | Run in paper or live mode | `LiveEngine`, `LiveOrchestrator`, `tradelab paper` |
78
- | Watch a live run locally | `createDashboardServer({ source })` equity curve, KPI strip, controls |
79
- | Let MCP clients run research tools | `tradelab-mcp` `run_backtest`, `walk_forward`, `analyze_robustness`, `optimize_strategy`, `compare_strategies`, `candle_stats` |
80
- | Let MCP agents trade (paper/live) | `tradelab-mcp` `create_session`, `feed_price`, `place_order`, bracket orders, `halt_all` kill-switch (see [docs/mcp.md](docs/mcp.md)) |
81
- | Export reports and machine data | `exportBacktestArtifacts`, `exportMetricsJSON` |
90
+ | Run in paper or live mode | `LiveEngine`, `LiveOrchestrator`, `tradelab paper` |
91
+ | Trade multiple symbols in one session | `SessionManager.create({ symbols: ["BTC","ETH"] })` with per-symbol `pushBar` and `placeOrder` |
92
+ | Watch a live run locally | `createDashboardServer({ source })` with equity curve, KPI strip, controls |
93
+ | Get notified on fills or risk halts | `attachNotifier(session, { onEvent, webhookUrl })` from `tradelab/live` |
94
+ | Let MCP clients run research tools | `tradelab-mcp` with `run_backtest`, `walk_forward`, `analyze_robustness`, `optimize_strategy`, `compare_strategies`, `candle_stats` |
95
+ | Let MCP agents trade (paper/live) | `tradelab-mcp` with `create_session`, `feed_price`, `place_order`, bracket orders, `halt_all` kill-switch (see [docs/mcp.md](docs/mcp.md)) |
96
+ | Track strategy research across runs | `tradelab-mcp` with `research_open`, `research_log`, `research_recall`, `research_close` (see [docs/mcp.md](docs/mcp.md)) |
97
+ | Summarize metrics in plain English | `summarize(metrics)` returns one plain-English paragraph |
98
+ | Run a built-in preset from the CLI | `tradelab run ema-cross --source yahoo --symbol SPY --period 1y` |
99
+ | Export reports and machine data | `exportBacktestArtifacts`, `exportMetricsJSON` |
82
100
 
83
101
  ## The Signal Contract
84
102
 
@@ -256,8 +274,12 @@ Add `--dashboard --dashboardPort 4317` to open a local Server-Sent Events dashbo
256
274
 
257
275
  **Research tools:** `list_strategies`, `fetch_candles`, `run_backtest`, `walk_forward`, `analyze_robustness`, `optimize_strategy`, `compare_strategies`, `candle_stats`
258
276
 
277
+ **Research loop tools:** `research_open`, `research_log`, `research_recall`, `research_close` for persistent file-backed hypothesis tracking. `run_backtest` auto-logs when `researchId` is passed.
278
+
259
279
  **Agent trading tools (paper by default; live gated):** `create_session`, `list_sessions`, `session_status`, `feed_price`, `place_order`, `close_position`, `flatten`, `cancel_order`, `account`, `positions`, `recent_events`, `attach_strategy`, `halt_all`
260
280
 
281
+ `create_session` accepts a `symbols` array for multi-symbol portfolio sessions. Pass `symbol` to `feed_price` and `place_order` to direct bars and orders to a specific instrument.
282
+
261
283
  Paper trading needs no credentials. Live trading requires `TRADELAB_ALLOW_LIVE=true` and `confirmLive: true` plus a credentialed broker. `halt_all` is an emergency kill-switch that flattens all positions and stops every session.
262
284
 
263
285
  Use it from any MCP client that can launch a stdio server:
@@ -279,9 +301,12 @@ Use it from any MCP client that can launch a stdio server:
279
301
  tradelab backtest --source yahoo --symbol SPY --interval 1d --period 1y
280
302
  tradelab portfolio --csvPaths ./spy.csv,./qqq.csv --symbols SPY,QQQ
281
303
  tradelab walk-forward --source yahoo --symbol QQQ --interval 1d --period 2y
304
+ tradelab run ema-cross --source yahoo --symbol SPY --period 1y
282
305
  tradelab status --dir ./output/live-state
283
306
  ```
284
307
 
308
+ `tradelab run <preset>` runs a named built-in strategy on Yahoo or CSV data and prints a plain-English summary. Pass `--params '{"fast":5,"slow":20}'` to override defaults.
309
+
285
310
  ## Documentation
286
311
 
287
312
  - [Docs home](docs/README.md)
package/bin/tradelab.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  saveCandlesToCache,
15
15
  walkForwardOptimize,
16
16
  } from "../src/index.js";
17
+ import { runPreset } from "../src/cli/runPreset.js";
17
18
  import {
18
19
  AlpacaBroker,
19
20
  BinanceBroker,
@@ -278,6 +279,7 @@ Commands:
278
279
  backtest Run a one-off backtest from Yahoo or CSV data
279
280
  portfolio Run multiple CSV datasets as an equal-weight portfolio
280
281
  walk-forward Run rolling or anchored train/test optimization
282
+ run Run a named built-in preset and print a plain-English summary
281
283
  live Run live trading engine (streaming or polling)
282
284
  paper Run live engine in paper broker mode
283
285
  status Read persisted live state
@@ -288,6 +290,8 @@ Examples:
288
290
  tradelab backtest --source yahoo --symbol SPY --interval 1d --period 1y
289
291
  tradelab backtest --source csv --csvPath ./data/btc.csv --strategy buy-hold --holdBars 3
290
292
  tradelab walk-forward --source csv --csvPath ./data/spy.csv --trainBars 120 --testBars 40
293
+ tradelab run ema-cross --source yahoo --symbol SPY --period 1y
294
+ tradelab run ema-cross --source csv --csvPath ./data/spy.csv --params '{"fast":5,"slow":15}'
291
295
  tradelab live --strategy ./mySignal.js --symbol AAPL --interval 5m --broker alpaca --paper
292
296
 
293
297
  Options:
@@ -473,6 +477,37 @@ async function commandPrefetch(args) {
473
477
  console.log(`Saved ${candles.length} candles to ${outputPath}`);
474
478
  }
475
479
 
480
+ async function commandRun(args) {
481
+ const preset = args._[1];
482
+ if (!preset) {
483
+ throw new Error("run requires a preset name (e.g. tradelab run ema-cross)");
484
+ }
485
+ const symbol = args.symbol || "PRESET";
486
+ const interval = args.interval || "1d";
487
+ const params = parseJsonValue(args.params, {});
488
+
489
+ const source = args.source || (args.csvPath ? "csv" : null);
490
+ let candles;
491
+ if (source === "csv" || args.csvPath) {
492
+ candles = loadCandlesFromCSV(args.csvPath);
493
+ } else if (source === "yahoo" || args.symbol) {
494
+ candles = await getHistoricalCandles({
495
+ source: "yahoo",
496
+ symbol: args.symbol,
497
+ interval,
498
+ period: args.period || "1y",
499
+ cache: args.cache !== "false",
500
+ });
501
+ } else {
502
+ throw new Error(
503
+ "run requires candle data: provide --source yahoo --symbol TICKER or --source csv --csvPath PATH"
504
+ );
505
+ }
506
+
507
+ const out = runPreset({ preset, candles, params, symbol, interval });
508
+ console.log(out.summary);
509
+ }
510
+
476
511
  async function commandImportCsv(args) {
477
512
  const csvPath = args.csvPath || args._[1];
478
513
  if (!csvPath) {
@@ -668,6 +703,7 @@ const commands = {
668
703
  backtest: commandBacktest,
669
704
  portfolio: commandPortfolio,
670
705
  "walk-forward": commandWalkForward,
706
+ run: commandRun,
671
707
  live: commandLive,
672
708
  paper: commandPaper,
673
709
  status: commandStatus,
@@ -45,6 +45,7 @@ __export(index_exports, {
45
45
  calculatePositionSize: () => calculatePositionSize,
46
46
  candleStats: () => candleStats,
47
47
  clampFinite: () => clampFinite,
48
+ createResearchStore: () => createResearchStore,
48
49
  detectFVG: () => detectFVG,
49
50
  ema: () => ema,
50
51
  exportBacktestArtifacts: () => exportBacktestArtifacts,
@@ -75,6 +76,7 @@ __export(index_exports, {
75
76
  research: () => research_exports,
76
77
  saveCandlesToCache: () => saveCandlesToCache,
77
78
  structureState: () => structureState,
79
+ summarize: () => summarize,
78
80
  swingHigh: () => swingHigh,
79
81
  swingLow: () => swingLow,
80
82
  walkForwardOptimize: () => walkForwardOptimize
@@ -3329,10 +3331,11 @@ function backtestPortfolio({
3329
3331
  const allCandles = systems.flatMap((system) => system.candles || []);
3330
3332
  const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
3331
3333
  const metricsInterval = interval ?? systems[0]?.interval;
3334
+ const finalState = portfolioState(runners, equity);
3332
3335
  const metrics = buildMetrics({
3333
3336
  closed: trades,
3334
3337
  equityStart: equity,
3335
- equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : equity,
3338
+ equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : finalState.markedEquity,
3336
3339
  candles: orderedCandles,
3337
3340
  estBarMs: estimateBarMs(orderedCandles),
3338
3341
  eqSeries,
@@ -3967,13 +3970,17 @@ function monteCarlo({
3967
3970
  if (!Array.isArray(tradePnls) || tradePnls.length === 0) {
3968
3971
  throw new Error("monteCarlo() requires a non-empty tradePnls array");
3969
3972
  }
3973
+ const runCount = Math.floor(Number(iterations));
3974
+ if (!Number.isFinite(runCount) || runCount < 1) {
3975
+ throw new Error("monteCarlo() requires positive iterations");
3976
+ }
3970
3977
  const rng = makeRng(seed);
3971
3978
  const n = tradePnls.length;
3972
3979
  const block = Math.max(1, Math.floor(blockSize));
3973
3980
  const finals = [];
3974
3981
  const drawdowns = [];
3975
3982
  const pathSamples = Array.from({ length: n + 1 }, () => []);
3976
- for (let it = 0; it < iterations; it += 1) {
3983
+ for (let it = 0; it < runCount; it += 1) {
3977
3984
  const path7 = [equityStart];
3978
3985
  let equity = equityStart;
3979
3986
  let filled = 0;
@@ -4005,7 +4012,7 @@ function monteCarlo({
4005
4012
  p95: percentile2(sorted, 0.95)
4006
4013
  });
4007
4014
  return {
4008
- iterations,
4015
+ iterations: runCount,
4009
4016
  blockSize: block,
4010
4017
  finalEquity: bands(sortedFinals),
4011
4018
  maxDrawdown: bands(sortedDd),
@@ -4514,6 +4521,66 @@ async function backtestHistorical({ backtestOptions = {}, data, ...legacy } = {}
4514
4521
  });
4515
4522
  }
4516
4523
 
4524
+ // src/research/store.js
4525
+ var import_promises = require("node:fs/promises");
4526
+ var import_node_path2 = require("node:path");
4527
+ var DEFAULT_DIR = ".tradelab/research";
4528
+ function fileFor(dir, id) {
4529
+ if (!/^[\w.-]+$/.test(String(id))) throw new Error(`invalid research id: ${id}`);
4530
+ return (0, import_node_path2.join)(dir, `${id}.json`);
4531
+ }
4532
+ async function load(dir, id) {
4533
+ try {
4534
+ const raw = await (0, import_promises.readFile)(fileFor(dir, id), "utf8");
4535
+ return JSON.parse(raw);
4536
+ } catch {
4537
+ return null;
4538
+ }
4539
+ }
4540
+ async function save(dir, record) {
4541
+ await (0, import_promises.mkdir)(dir, { recursive: true });
4542
+ await (0, import_promises.writeFile)(fileFor(dir, record.id), JSON.stringify(record, null, 2));
4543
+ return record;
4544
+ }
4545
+ function bestSharpe(entries) {
4546
+ let best = null;
4547
+ for (const e of entries) {
4548
+ const s = e.metrics?.sharpe;
4549
+ if (Number.isFinite(s) && (best === null || s > best.sharpe)) best = { sharpe: s, params: e.params };
4550
+ }
4551
+ return best;
4552
+ }
4553
+ function createResearchStore({ dir = DEFAULT_DIR } = {}) {
4554
+ return {
4555
+ async open(id, goal = "") {
4556
+ const existing = await load(dir, id);
4557
+ if (existing) return existing;
4558
+ const record = { id, goal, createdAt: (/* @__PURE__ */ new Date()).toISOString(), closedAt: null, entries: [] };
4559
+ return save(dir, record);
4560
+ },
4561
+ async log(id, { hypothesis = "", params = {}, metrics = {}, verdict = null } = {}) {
4562
+ const record = await load(dir, id) || { id, goal: "", createdAt: (/* @__PURE__ */ new Date()).toISOString(), closedAt: null, entries: [] };
4563
+ const entry = { at: (/* @__PURE__ */ new Date()).toISOString(), hypothesis, params, metrics, verdict };
4564
+ record.entries.push(entry);
4565
+ await save(dir, record);
4566
+ return entry;
4567
+ },
4568
+ async recall(id, limit = 10) {
4569
+ const record = await load(dir, id) || { goal: "", entries: [] };
4570
+ const entries = record.entries.slice(-limit);
4571
+ const best = bestSharpe(record.entries);
4572
+ const flagged = record.entries.filter((e) => e.verdict?.overfit).length;
4573
+ const summary = record.entries.length ? `Best Sharpe so far: ${best ? best.sharpe.toFixed(2) : "n/a"}${best ? ` via ${JSON.stringify(best.params)}` : ""}. ${flagged} of ${record.entries.length} flagged overfit.` : "No entries logged yet.";
4574
+ return { goal: record.goal, entries, summary };
4575
+ },
4576
+ async close(id) {
4577
+ const record = await load(dir, id) || { id, goal: "", createdAt: (/* @__PURE__ */ new Date()).toISOString(), entries: [] };
4578
+ record.closedAt = (/* @__PURE__ */ new Date()).toISOString();
4579
+ return save(dir, record);
4580
+ }
4581
+ };
4582
+ }
4583
+
4517
4584
  // src/reporting/renderHtmlReport.js
4518
4585
  var import_fs2 = __toESM(require("fs"), 1);
4519
4586
  var import_path3 = __toESM(require("path"), 1);
@@ -4923,6 +4990,32 @@ function exportBacktestArtifacts({
4923
4990
  }
4924
4991
  return outputs;
4925
4992
  }
4993
+
4994
+ // src/reporting/summarize.js
4995
+ function pct2(value, digits = 1) {
4996
+ return Number.isFinite(value) ? `${value.toFixed(digits)}%` : "n/a";
4997
+ }
4998
+ function summarize(metrics = {}, { verdict } = {}) {
4999
+ const trades = Number.isFinite(metrics.trades) ? metrics.trades : 0;
5000
+ const win = Number.isFinite(metrics.winRate) ? Math.round(metrics.winRate * 100) : null;
5001
+ const dd = Number.isFinite(metrics.maxDrawdownPct) ? metrics.maxDrawdownPct : Number.isFinite(metrics.maxDrawdown) ? metrics.maxDrawdown * 100 : null;
5002
+ const ret = Number.isFinite(metrics.totalReturnPct) ? metrics.totalReturnPct : null;
5003
+ const sharpe = Number.isFinite(metrics.sharpe) ? metrics.sharpe : null;
5004
+ if (trades === 0) return "Ran with 0 trades, so there is nothing to evaluate yet.";
5005
+ const parts = [`Made ${trades} trades`];
5006
+ if (win !== null) parts.push(`won ${win}% of them`);
5007
+ if (ret !== null) parts.push(`for a ${pct2(ret)} total return`);
5008
+ if (dd !== null) parts.push(`with a worst drawdown of ${pct2(dd)}`);
5009
+ let text = parts.join(", ");
5010
+ if (sharpe !== null) {
5011
+ text += ` (Sharpe ${sharpe.toFixed(2)})`;
5012
+ }
5013
+ text += ".";
5014
+ if (verdict && verdict.overfit) {
5015
+ text += ` Caution: robustness checks flag this result as likely overfit${verdict.note ? ` (${verdict.note})` : ""}.`;
5016
+ }
5017
+ return text;
5018
+ }
4926
5019
  // Annotate the CommonJS export names for ESM import in node:
4927
5020
  0 && (module.exports = {
4928
5021
  BIG_NUMBER,
@@ -4940,6 +5033,7 @@ function exportBacktestArtifacts({
4940
5033
  calculatePositionSize,
4941
5034
  candleStats,
4942
5035
  clampFinite,
5036
+ createResearchStore,
4943
5037
  detectFVG,
4944
5038
  ema,
4945
5039
  exportBacktestArtifacts,
@@ -4970,6 +5064,7 @@ function exportBacktestArtifacts({
4970
5064
  research,
4971
5065
  saveCandlesToCache,
4972
5066
  structureState,
5067
+ summarize,
4973
5068
  swingHigh,
4974
5069
  swingLow,
4975
5070
  walkForwardOptimize