tradelab 1.2.1 → 1.3.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,54 @@ 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.1] - 2026-06-28
9
+
10
+ ### Changed
11
+
12
+ - Documentation refresh: the README now leads with the agent-native MCP story, and the reference docs cover `summarize`, `createResearchStore` and the agent research loop, `attachNotifier`, multi-symbol sessions, exposure caps, and `tradelab run`.
13
+
14
+ ## [1.3.0] - 2026-06-27
15
+
16
+ ### Added
17
+
18
+ - **Multi-symbol portfolio sessions**
19
+ - `SessionManager.create({ symbols: ["BTC", "ETH"] })` creates a single session that tracks multiple instruments against a shared broker.
20
+ - `pushBar(bar, symbol)` and `placeOrder({ symbol })` target a specific instrument; bracket OCO is managed per-symbol.
21
+ - `closePosition(symbol)` cancels the resting bracket for that symbol before submitting the flattening order.
22
+ - Per-symbol read accessors: `lastPriceFor(sym)` and `candleBufferFor(sym)`.
23
+ - `getStatus()` now includes a `symbols` array alongside the primary `symbol`. Single-symbol usage (`symbol: "AAPL"`) is unchanged.
24
+
25
+ - **Portfolio exposure caps**
26
+ - `maxGrossExposurePct` and `maxNetExposurePct` options on `TradingSession` and `SessionManager.create()`. Both default to `0` (disabled).
27
+ - Enforced in `placeOrder()` before any broker call; throws `risk rejected: <reason>` when the cap is breached.
28
+ - The `RiskManager.checkExposure()` method is the shared gate used by both `canOpenPosition()` and `placeOrder()`.
29
+
30
+ - **Trade attribution on order events**
31
+ - `order:submitted` and `order:filled` events now include a `sizing` block: `{ entry, stop, target, rr, riskFraction, riskAmount, qty, notional }`.
32
+ - Pass `rationale` to `placeOrder()` to attach a free-text note that propagates to all fill events for that order.
33
+ - Bracket legs carry `parentEntryId` (the entry's client order id) and a `leg` field (`"stop"` or `"target"`).
34
+
35
+ - **Agent research loop**
36
+ - `createResearchStore({ dir? })` from `tradelab` returns `{ open, log, recall, close }` for file-backed hypothesis tracking.
37
+ - MCP tools `research_open`, `research_log`, `research_recall`, `research_close` expose the store over stdio.
38
+ - `run_backtest` accepts `researchId` and auto-logs the backtest result plus a Deflated Sharpe verdict (`{ deflatedSharpe, overfit, note }`) without a separate `research_log` call.
39
+
40
+ - **Multi-symbol support in MCP live tools**
41
+ - `create_session` accepts a `symbols` array.
42
+ - `feed_price`, `place_order`, `close_position`, and `attach_strategy` accept an optional `symbol` argument to route operations to a specific instrument.
43
+
44
+ - **`summarize(metrics)`**
45
+ - Exported from `tradelab`. Renders a `buildMetrics` output object as one plain-English paragraph. Accepts an optional `verdict` object to append an overfit caution sentence.
46
+
47
+ - **`tradelab run <preset>` CLI subcommand**
48
+ - Runs a named built-in strategy on Yahoo or CSV data and prints the `summarize()` paragraph to stdout.
49
+ - Accepts `--params '{"fast":5}'` to override strategy defaults.
50
+
51
+ - **`attachNotifier(session, opts)`**
52
+ - Exported from `tradelab/live`. Subscribes a callback and/or webhook URL to a session's event bus.
53
+ - Options: `events` (which events to forward), `onEvent` (async callback), `webhookUrl` (HTTP POST endpoint), `drawdownPct` (fires `drawdown:breach` when equity falls this far from peak).
54
+ - Returns an unsubscribe function.
55
+
8
56
  ## [1.2.1] - 2026-06-27
9
57
 
10
58
  ### Fixed
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  <div align="center">
2
2
  <img src="https://i.imgur.com/HGvvQbq.png" width="420" alt="tradelab logo" />
3
3
 
4
- <p><strong>A Node.js backtesting toolkit for serious trading strategy research.</strong></p>
4
+ <p><strong>An agent-native Node.js trading engine: research, backtest, and trade live through one signal contract.</strong></p>
5
5
 
6
6
  [![npm version](https://img.shields.io/npm/v/tradelab?color=0f172a&label=npm&logo=npm)](https://www.npmjs.com/package/tradelab)
7
7
  [![GitHub](https://img.shields.io/badge/github-ishsharm0/tradelab-0f172a?logo=github)](https://github.com/ishsharm0/tradelab)
@@ -13,17 +13,18 @@
13
13
 
14
14
  ---
15
15
 
16
- A Node.js toolkit for testing, validating, and operating trading strategies.
16
+ A Node.js toolkit for testing, validating, and operating trading strategies, built so humans and AI agents work from the same primitives.
17
17
 
18
- tradelab gives you one `signal()` contract across research and execution:
18
+ One `signal()` contract runs across research and execution:
19
19
 
20
20
  - run candle or tick backtests
21
21
  - model slippage, commissions, borrow, carry, and funding
22
22
  - validate parameters with walk-forward tests and research statistics
23
23
  - combine multiple systems into a shared-capital portfolio
24
- - move the same strategy into paper or live execution
24
+ - move the same strategy into paper or live execution, single or multi-symbol
25
25
  - export reports, metrics, and trade ledgers
26
- - expose research tools through an MCP server
26
+
27
+ **Agent-native.** The `tradelab-mcp` server exposes 25 tools over stdio, so an AI agent can run the whole loop itself: pull data, run and score backtests, track hypotheses across runs with built-in overfitting guards, then open a paper or live session and place risk-sized bracket orders behind a kill-switch. Agents get the same depth a quant does, not a thin read-only wrapper. See [docs/mcp.md](docs/mcp.md).
27
28
 
28
29
  ```bash
29
30
  npm install tradelab
@@ -87,11 +88,16 @@ Start with `result.metrics` for the summary and `result.positions` for completed
87
88
  | Run a parallel parameter sweep | `optimize({ signalModulePath, parameterSets })` |
88
89
  | Use indicators | `import { rsi, macd, vwap } from "tradelab/ta"` |
89
90
  | Check overfitting risk | `research.monteCarlo`, `research.deflatedSharpe` |
90
- | Run in paper or live mode | `LiveEngine`, `LiveOrchestrator`, `tradelab paper` |
91
- | Watch a live run locally | `createDashboardServer({ source })` equity curve, KPI strip, controls |
92
- | Let MCP clients run research tools | `tradelab-mcp` `run_backtest`, `walk_forward`, `analyze_robustness`, `optimize_strategy`, `compare_strategies`, `candle_stats` |
93
- | 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)) |
94
- | Export reports and machine data | `exportBacktestArtifacts`, `exportMetricsJSON` |
91
+ | Run in paper or live mode | `LiveEngine`, `LiveOrchestrator`, `tradelab paper` |
92
+ | Trade multiple symbols in one session | `SessionManager.create({ symbols: ["BTC","ETH"] })` with per-symbol `pushBar` and `placeOrder` |
93
+ | Watch a live run locally | `createDashboardServer({ source })` with equity curve, KPI strip, controls |
94
+ | Get notified on fills or risk halts | `attachNotifier(session, { onEvent, webhookUrl })` from `tradelab/live` |
95
+ | Let MCP clients run research tools | `tradelab-mcp` with `run_backtest`, `walk_forward`, `analyze_robustness`, `optimize_strategy`, `compare_strategies`, `candle_stats` |
96
+ | 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)) |
97
+ | Track strategy research across runs | `tradelab-mcp` with `research_open`, `research_log`, `research_recall`, `research_close` (see [docs/mcp.md](docs/mcp.md)) |
98
+ | Summarize metrics in plain English | `summarize(metrics)` returns one plain-English paragraph |
99
+ | Run a built-in preset from the CLI | `tradelab run ema-cross --source yahoo --symbol SPY --period 1y` |
100
+ | Export reports and machine data | `exportBacktestArtifacts`, `exportMetricsJSON` |
95
101
 
96
102
  ## The Signal Contract
97
103
 
@@ -265,12 +271,16 @@ Add `--dashboard --dashboardPort 4317` to open a local Server-Sent Events dashbo
265
271
 
266
272
  ## MCP Server
267
273
 
268
- `tradelab-mcp` exposes research and live-trading tools over stdio to any MCP-capable agent (Claude Desktop, Cursor, etc.). See [docs/mcp.md](docs/mcp.md) for the full tool reference and agent trading guide.
274
+ `tradelab-mcp` exposes 25 tools over stdio to any MCP-capable agent (Claude Desktop, Cursor, and similar). They cover the full loop an agent needs to work a strategy end to end: research it, validate it, track the search, then trade it. See [docs/mcp.md](docs/mcp.md) for the full tool reference and agent trading guide.
269
275
 
270
276
  **Research tools:** `list_strategies`, `fetch_candles`, `run_backtest`, `walk_forward`, `analyze_robustness`, `optimize_strategy`, `compare_strategies`, `candle_stats`
271
277
 
278
+ **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.
279
+
272
280
  **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`
273
281
 
282
+ `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.
283
+
274
284
  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.
275
285
 
276
286
  Use it from any MCP client that can launch a stdio server:
@@ -292,9 +302,12 @@ Use it from any MCP client that can launch a stdio server:
292
302
  tradelab backtest --source yahoo --symbol SPY --interval 1d --period 1y
293
303
  tradelab portfolio --csvPaths ./spy.csv,./qqq.csv --symbols SPY,QQQ
294
304
  tradelab walk-forward --source yahoo --symbol QQQ --interval 1d --period 2y
305
+ tradelab run ema-cross --source yahoo --symbol SPY --period 1y
295
306
  tradelab status --dir ./output/live-state
296
307
  ```
297
308
 
309
+ `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.
310
+
298
311
  ## Documentation
299
312
 
300
313
  - [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,
@@ -270,7 +271,7 @@ async function loadWalkForwardStrategy(strategyArg, args) {
270
271
  }
271
272
 
272
273
  function printHelp() {
273
- console.log(`tradelab backtesting toolkit for Node.js
274
+ console.log(`tradelab: agent-native trading engine for Node.js
274
275
 
275
276
  Usage: tradelab <command> [options]
276
277
 
@@ -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
@@ -4519,6 +4521,66 @@ async function backtestHistorical({ backtestOptions = {}, data, ...legacy } = {}
4519
4521
  });
4520
4522
  }
4521
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
+
4522
4584
  // src/reporting/renderHtmlReport.js
4523
4585
  var import_fs2 = __toESM(require("fs"), 1);
4524
4586
  var import_path3 = __toESM(require("path"), 1);
@@ -4928,6 +4990,32 @@ function exportBacktestArtifacts({
4928
4990
  }
4929
4991
  return outputs;
4930
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
+ }
4931
5019
  // Annotate the CommonJS export names for ESM import in node:
4932
5020
  0 && (module.exports = {
4933
5021
  BIG_NUMBER,
@@ -4945,6 +5033,7 @@ function exportBacktestArtifacts({
4945
5033
  calculatePositionSize,
4946
5034
  candleStats,
4947
5035
  clampFinite,
5036
+ createResearchStore,
4948
5037
  detectFVG,
4949
5038
  ema,
4950
5039
  exportBacktestArtifacts,
@@ -4975,6 +5064,7 @@ function exportBacktestArtifacts({
4975
5064
  research,
4976
5065
  saveCandlesToCache,
4977
5066
  structureState,
5067
+ summarize,
4978
5068
  swingHigh,
4979
5069
  swingLow,
4980
5070
  walkForwardOptimize