tradelab 1.2.1 → 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,48 @@ 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
+
8
50
  ## [1.2.1] - 2026-06-27
9
51
 
10
52
  ### Fixed
package/README.md CHANGED
@@ -87,11 +87,16 @@ Start with `result.metrics` for the summary and `result.positions` for completed
87
87
  | Run a parallel parameter sweep | `optimize({ signalModulePath, parameterSets })` |
88
88
  | Use indicators | `import { rsi, macd, vwap } from "tradelab/ta"` |
89
89
  | 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` |
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` |
95
100
 
96
101
  ## The Signal Contract
97
102
 
@@ -269,8 +274,12 @@ Add `--dashboard --dashboardPort 4317` to open a local Server-Sent Events dashbo
269
274
 
270
275
  **Research tools:** `list_strategies`, `fetch_candles`, `run_backtest`, `walk_forward`, `analyze_robustness`, `optimize_strategy`, `compare_strategies`, `candle_stats`
271
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
+
272
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`
273
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
+
274
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.
275
284
 
276
285
  Use it from any MCP client that can launch a stdio server:
@@ -292,9 +301,12 @@ Use it from any MCP client that can launch a stdio server:
292
301
  tradelab backtest --source yahoo --symbol SPY --interval 1d --period 1y
293
302
  tradelab portfolio --csvPaths ./spy.csv,./qqq.csv --symbols SPY,QQQ
294
303
  tradelab walk-forward --source yahoo --symbol QQQ --interval 1d --period 2y
304
+ tradelab run ema-cross --source yahoo --symbol SPY --period 1y
295
305
  tradelab status --dir ./output/live-state
296
306
  ```
297
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
+
298
310
  ## Documentation
299
311
 
300
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
@@ -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