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.
@@ -151,6 +151,136 @@ tradelab paper \
151
151
  --once true
152
152
  ```
153
153
 
154
+ ## Multi-Symbol Portfolio Sessions
155
+
156
+ `TradingSession` accepts a `symbols` array so one session can trade multiple instruments independently against a shared broker.
157
+
158
+ ```js
159
+ import { SessionManager, PaperEngine } from "tradelab/live";
160
+
161
+ const manager = new SessionManager();
162
+ const session = await manager.create({
163
+ id: "crypto-portfolio",
164
+ symbols: ["BTC", "ETH"],
165
+ interval: "1h",
166
+ equity: 20_000,
167
+ riskPct: 1,
168
+ maxGrossExposurePct: 150, // cap total gross notional at 150% of equity
169
+ broker: new PaperEngine({ equity: 20_000 }),
170
+ });
171
+ ```
172
+
173
+ Feed bars and place orders per symbol:
174
+
175
+ ```js
176
+ await session.pushBar({ time, open, high, low, close, volume }, "BTC");
177
+ await session.pushBar({ time, open, high, low, close, volume }, "ETH");
178
+
179
+ await session.placeOrder({ symbol: "BTC", side: "long", riskPct: 1, stop: 29_500, rr: 3 });
180
+ await session.placeOrder({ symbol: "ETH", side: "long", riskPct: 1, stop: 1_900, rr: 2 });
181
+ ```
182
+
183
+ Close a single position without touching the other:
184
+
185
+ ```js
186
+ await session.closePosition("ETH");
187
+ ```
188
+
189
+ Per-symbol accessors:
190
+
191
+ ```js
192
+ session.lastPriceFor("BTC"); // last close fed via pushBar
193
+ session.candleBufferFor("ETH"); // up to 200 candles
194
+ ```
195
+
196
+ `getStatus()` now includes a `symbols` array alongside the primary `symbol`:
197
+
198
+ ```js
199
+ const status = session.getStatus();
200
+ // { id, symbol: "BTC", symbols: ["BTC", "ETH"], positions, openOrders, equity, ... }
201
+ ```
202
+
203
+ Single-symbol usage (`symbol: "AAPL"`) is unchanged. `session.symbol`, `session.lastPrice`, and `session.candleBuffer` all still work as before.
204
+
205
+ ## Exposure Caps
206
+
207
+ Pass `maxGrossExposurePct` or `maxNetExposurePct` to `SessionManager.create()` (or directly to `TradingSession`) to cap portfolio exposure. Both default to `0` (off).
208
+
209
+ | Option | Meaning |
210
+ | --------------------- | --------------------------------------------------------------------------- |
211
+ | `maxGrossExposurePct` | Maximum sum of absolute position notional as a percent of equity |
212
+ | `maxNetExposurePct` | Maximum absolute net long/short notional imbalance as a percent of equity |
213
+
214
+ When a `placeOrder()` call would push exposure past a cap, it throws:
215
+
216
+ ```
217
+ Error: risk rejected: max gross exposure exceeded
218
+ ```
219
+
220
+ The check includes the pending order size, so the cap is evaluated before a fill, not after.
221
+
222
+ ## Trade Attribution
223
+
224
+ Every `order:submitted` and `order:filled` event now carries a `sizing` block that records how the position was sized:
225
+
226
+ ```js
227
+ session.eventBus.onAny(({ event, payload }) => {
228
+ if (event === "order:filled") {
229
+ console.log(payload.sizing);
230
+ // {
231
+ // entry: 100, stop: 98, target: 104, rr: 2,
232
+ // riskFraction: 0.01, riskAmount: 100,
233
+ // qty: 50, notional: 5000
234
+ // }
235
+ if (payload.rationale) console.log(payload.rationale);
236
+ }
237
+ });
238
+ ```
239
+
240
+ Pass `rationale` to `placeOrder()` to attach a free-text note that propagates to all fill events for that order:
241
+
242
+ ```js
243
+ await session.placeOrder({
244
+ symbol: "AAPL",
245
+ side: "long",
246
+ riskPct: 1,
247
+ stop: 148,
248
+ rr: 2,
249
+ rationale: "EMA cross on hourly, trend continuation",
250
+ });
251
+ ```
252
+
253
+ Bracket legs carry `parentEntryId` (the client order id of the entry) and a `leg` field (`"stop"` or `"target"`), making it straightforward to correlate fills across entry and exit legs.
254
+
255
+ ## Event Notifier
256
+
257
+ `attachNotifier()` wires a callback and/or a webhook URL to a session's event bus.
258
+
259
+ ```js
260
+ import { attachNotifier } from "tradelab/live";
261
+
262
+ const unsubscribe = attachNotifier(session, {
263
+ events: ["order:filled", "risk:halt"],
264
+ onEvent({ event, payload }) {
265
+ console.log(event, payload);
266
+ },
267
+ webhookUrl: "https://hooks.example.com/tradelab",
268
+ drawdownPct: 5, // also fires "drawdown:breach" when equity drops 5% from peak
269
+ });
270
+
271
+ // When done:
272
+ unsubscribe();
273
+ ```
274
+
275
+ `attachNotifier` options:
276
+
277
+ | Option | Default | Meaning |
278
+ | ------------- | -------------------------------- | -------------------------------------------------------- |
279
+ | `events` | `["order:filled","risk:halt"]` | Events to forward |
280
+ | `onEvent` | `undefined` | Async callback `({ event, payload }) => void` |
281
+ | `webhookUrl` | `undefined` | HTTP endpoint; receives `POST` with JSON body |
282
+ | `drawdownPct` | `0` | Also fires `drawdown:breach` when equity falls this far |
283
+
154
284
  ## Run Multiple Systems
155
285
 
156
286
  Use `LiveOrchestrator` when several systems share one account and broker.
package/docs/mcp.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## Safety
8
8
 
9
- **Paper is the default and always safe.** Every session is paper unless you explicitly request live mode. Live mode requires all three gates simultaneously if any is missing the call throws and nothing is created:
9
+ **Paper is the default and always safe.** Every session is paper unless you explicitly request live mode. Live mode requires all three gates simultaneously. If any is missing the call throws and nothing is created:
10
10
 
11
11
  1. Environment variable `TRADELAB_ALLOW_LIVE=true` must be set in the server process.
12
12
  2. The `create_session` call must include `confirmLive: true`.
@@ -14,8 +14,8 @@
14
14
 
15
15
  Every session also enforces:
16
16
 
17
- - `maxDailyLossPct` if realized day PnL drops below this percentage of starting equity, all new `place_order` calls are rejected for the remainder of the day.
18
- - `halt_all` an emergency kill-switch tool that flattens all positions and stops all sessions in the server process.
17
+ - `maxDailyLossPct`: if realized day PnL drops below this percentage of starting equity, all new `place_order` calls are rejected for the remainder of the day.
18
+ - `halt_all`: an emergency kill-switch tool that flattens all positions and stops all sessions in the server process.
19
19
 
20
20
  Brackets (stop + target) are true OCO: when one leg fills, the sibling is canceled automatically.
21
21
 
@@ -94,41 +94,78 @@ Local checkout example:
94
94
  | `fetch_candles` | Load Yahoo or CSV candles and return first/last bars |
95
95
  | `run_backtest` | Run one named strategy and return compact metrics |
96
96
  | `walk_forward` | Run a parameter grid through walk-forward validation |
97
- | `analyze_robustness` | Backtest + Monte Carlo + Deflated Sharpe validate before you trade |
97
+ | `analyze_robustness` | Backtest + Monte Carlo + Deflated Sharpe; validate before you trade |
98
98
  | `optimize_strategy` | In-process grid sweep; returns a leaderboard sorted by chosen metric |
99
99
  | `compare_strategies` | Run several named strategies on the same dataset, ranked head-to-head |
100
100
  | `candle_stats` | Sanity-check candle data: count, date range, price range, interval |
101
101
 
102
+ ### Research loop tools
103
+
104
+ | Tool | Args (required) | Returns |
105
+ | ------------------ | --------------------- | ---------------------------------------------------- |
106
+ | `research_open` | `id`, `goal?` | Record with `id`, `goal`, `entries`, `createdAt` |
107
+ | `research_log` | `id`, `hypothesis?`, `params?`, `metrics?`, `verdict?` | Appended entry |
108
+ | `research_recall` | `id`, `limit?` | Recent entries plus a synthesized `summary` string |
109
+ | `research_close` | `id` | Final record with `closedAt` timestamp |
110
+
111
+ Research sessions are file-backed in `.tradelab/research/` (one JSON file per `id`). They persist across MCP server restarts so agents can resume a session after a context reset.
112
+
113
+ `run_backtest` also accepts a `researchId` argument. When provided, it auto-logs the backtest result and a Deflated Sharpe verdict to the session without requiring a separate `research_log` call:
114
+
115
+ ```json
116
+ {
117
+ "data": { "source": "yahoo", "symbol": "SPY", "interval": "1d", "period": "2y" },
118
+ "strategy": "ema-cross",
119
+ "params": { "fast": 10, "slow": 30 },
120
+ "researchId": "spy-ema-study",
121
+ "numTrials": 3
122
+ }
123
+ ```
124
+
125
+ The auto-logged verdict contains:
126
+
127
+ ```json
128
+ {
129
+ "deflatedSharpe": 0.87,
130
+ "overfit": true,
131
+ "note": "PSR 87.0%"
132
+ }
133
+ ```
134
+
135
+ `overfit: true` means the Probabilistic Sharpe Ratio fell below the 0.9 threshold given the number of trials.
136
+
102
137
  Tool responses are intentionally compact. They are meant for planning and comparison, not for replacing full HTML/CSV/JSON reports from the CLI.
103
138
 
104
139
  ### Live trading tools
105
140
 
106
- | Tool | Args (required) | Returns |
107
- | ----------------- | -------------------------------------------------------- | ------------------------------------ |
108
- | `create_session` | `sessionId`, `symbol` | session status snapshot |
109
- | `list_sessions` | | array of session statuses |
110
- | `session_status` | `sessionId` | full refresh (positions/orders/risk) |
111
- | `feed_price` | `sessionId`, `bar` OR `price` | status after fills |
112
- | `place_order` | `sessionId`, `side`, `type?`, `qty?` OR `riskPct`+`stop` | order receipt |
113
- | `close_position` | `sessionId`, `symbol?` | order receipt |
114
- | `flatten` | `sessionId` | `{ ok: true }` |
115
- | `cancel_order` | `sessionId`, `orderId` | `{ ok: true }` |
116
- | `account` | `sessionId` | broker account info |
117
- | `positions` | `sessionId` | open positions |
118
- | `recent_events` | `sessionId`, `limit?` | event log |
119
- | `attach_strategy` | `sessionId`, `strategy`, `params?` | `{ ok: true }` |
120
- | `halt_all` | | `{ ok: true, sessionsHalted: N }` |
141
+ | Tool | Args (required) | Returns |
142
+ | ----------------- | ------------------------------------------------------------- | ------------------------------------ |
143
+ | `create_session` | `sessionId`, `symbol` OR `symbols` | session status snapshot |
144
+ | `list_sessions` | `(none)` | array of session statuses |
145
+ | `session_status` | `sessionId` | full refresh (positions/orders/risk) |
146
+ | `feed_price` | `sessionId`, `bar` OR `price`, `symbol?` | status after fills |
147
+ | `place_order` | `sessionId`, `side`, `type?`, `qty?` OR `riskPct`+`stop`, `symbol?` | order receipt |
148
+ | `close_position` | `sessionId`, `symbol?` | order receipt |
149
+ | `flatten` | `sessionId` | `{ ok: true }` |
150
+ | `cancel_order` | `sessionId`, `orderId` | `{ ok: true }` |
151
+ | `account` | `sessionId` | broker account info |
152
+ | `positions` | `sessionId` | open positions |
153
+ | `recent_events` | `sessionId`, `limit?` | event log |
154
+ | `attach_strategy` | `sessionId`, `strategy`, `params?`, `symbol?` | `{ ok: true }` |
155
+ | `halt_all` | `(none)` | `{ ok: true, sessionsHalted: N }` |
121
156
 
122
157
  ## Agent trading loop
123
158
 
124
159
  A typical autonomous paper-trading loop:
125
160
 
126
- 1. Call `create_session` with `sessionId`, `symbol`, and `equity` (paper by default).
127
- 2. Call `feed_price` with each new bar as it arrives fills resting bracket orders automatically.
128
- 3. Call `place_order` with `riskPct` + `stop` to size automatically; add `target` or `rr` for a bracket.
129
- 4. Call `session_status` any time for a snapshot of positions, orders, equity, and risk state.
161
+ 1. Call `create_session` with `sessionId`, `symbol` (or `symbols` for a multi-symbol session), and `equity` (paper by default).
162
+ 2. Call `feed_price` with each new bar as it arrives, passing `symbol` when tracking more than one instrument. Fills resting bracket orders automatically.
163
+ 3. Call `place_order` with `riskPct` + `stop` to size automatically; add `target` or `rr` for a bracket. Pass `symbol` for multi-symbol sessions.
164
+ 4. Call `session_status` any time for a snapshot of positions, orders, equity, and risk state. The snapshot includes a `symbols` array.
130
165
  5. Call `flatten` or `halt_all` to emergency-close everything.
131
166
 
167
+ For multi-symbol sessions you can also pass `maxGrossExposurePct` or `maxNetExposurePct` to `create_session` to cap portfolio-level exposure. Orders that would breach the cap are rejected before they reach the broker.
168
+
132
169
  If you attach a strategy with `attach_strategy`, `feed_price` will auto-evaluate it each bar and place orders when the session is flat. Attached strategies receive the same `{ candles, index, bar, equity, openPosition, pendingOrder }` context as `backtest()`, and returned order intents default to a market order unless `type` is set.
133
170
 
134
171
  ## Typical Research Flow
@@ -140,6 +177,38 @@ If you attach a strategy with `attach_strategy`, `feed_price` will auto-evaluate
140
177
  5. Inspect trade count, profit factor, drawdown, return, and Sharpe fields.
141
178
  6. Call `walk_forward` with a grid to see whether parameters hold up out of sample.
142
179
 
180
+ ## Agent Research Loop
181
+
182
+ The research loop tools let an agent track hypothesis iteration across many `run_backtest` calls without losing context:
183
+
184
+ 1. Call `research_open` with an `id` and a plain-text `goal`.
185
+ 2. For each parameter set you want to test: call `run_backtest` with `researchId` set to that `id`. The result is auto-logged with a Deflated Sharpe verdict.
186
+ 3. Alternatively, call `research_log` directly to record results from external tools or your own computations.
187
+ 4. Call `research_recall` at any time to get the last N entries plus a synthesized one-liner: best Sharpe, how many runs flagged as overfit.
188
+ 5. Call `research_close` when the study is complete.
189
+
190
+ ```json
191
+ // Step 1: open
192
+ { "id": "spy-cross-study", "goal": "Find the best EMA pair for SPY daily" }
193
+
194
+ // Step 2: run with auto-logging
195
+ {
196
+ "data": { "source": "yahoo", "symbol": "SPY", "interval": "1d", "period": "3y" },
197
+ "strategy": "ema-cross",
198
+ "params": { "fast": 10, "slow": 30 },
199
+ "researchId": "spy-cross-study"
200
+ }
201
+
202
+ // Step 4: recall
203
+ { "id": "spy-cross-study", "limit": 10 }
204
+ // returns: { goal, entries: [...], summary: "Best Sharpe so far: 1.42 via {fast:10,slow:30}. 1 of 4 flagged overfit." }
205
+
206
+ // Step 5: close
207
+ { "id": "spy-cross-study" }
208
+ ```
209
+
210
+ Research files are stored in `.tradelab/research/` in the directory where the MCP server was launched. They persist across server restarts.
211
+
143
212
  ## Example Calls
144
213
 
145
214
  Fetch candles:
package/docs/research.md CHANGED
@@ -154,4 +154,19 @@ For a strategy you might run live, combine several checks:
154
154
  5. Penalize multiple trials with deflated Sharpe or sweep haircut.
155
155
  6. Re-run on a later untouched data period before using live credentials.
156
156
 
157
+ ## Agent Research Loop
158
+
159
+ `createResearchStore({ dir })` persists a research session so an agent (or you) can iterate across many runs without losing the thread:
160
+
161
+ ```js
162
+ import { createResearchStore } from "tradelab";
163
+
164
+ const store = createResearchStore({ dir: ".tradelab/research" });
165
+ await store.open("btc-trend", "find a robust BTC trend strategy");
166
+ await store.log("btc-trend", { hypothesis: "ema 20/50", params: { fast: 20, slow: 50 }, metrics, verdict });
167
+ const { entries, summary } = await store.recall("btc-trend");
168
+ ```
169
+
170
+ `recall` returns a synthesized summary naming the best Sharpe so far and how many runs were flagged as likely overfit. Over MCP, the same store backs the `research_open`, `research_log`, `research_recall`, and `research_close` tools, and `run_backtest` auto-logs an overfitting verdict when called with a `researchId`. See [the MCP guide](mcp.md).
171
+
157
172
  <small>[Back to docs](README.md)</small>
@@ -0,0 +1,188 @@
1
+ /**
2
+ * agentResearchLoop.js: Open a research session, run two backtests, log each
3
+ * result with an overfit verdict, then recall entries and print the synthesis
4
+ * plus a plain-English summary via summarize().
5
+ *
6
+ * Shows:
7
+ * - createResearchStore() with open / log / recall / close
8
+ * - Logging a backtest result manually (mirrors what run_backtest does when
9
+ * researchId is passed to the MCP tool)
10
+ * - research_recall synthesized summary (best Sharpe, overfit count)
11
+ * - summarize(metrics) for a one-paragraph plain-English output
12
+ *
13
+ * node examples/agentResearchLoop.js
14
+ */
15
+
16
+ import { backtest, ema } from "../src/index.js";
17
+ import { createResearchStore } from "../src/research/store.js";
18
+ import { deflatedSharpe } from "../src/research/index.js";
19
+ import { summarize } from "../src/reporting/summarize.js";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Seeded candle generator; deterministic so the example output is stable.
23
+ // ---------------------------------------------------------------------------
24
+ function makeRng(seed) {
25
+ let s = seed >>> 0;
26
+ return () => {
27
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
28
+ return s / 0x100000000;
29
+ };
30
+ }
31
+
32
+ function syntheticCandles(count = 500) {
33
+ const rng = makeRng(99999);
34
+ const candles = [];
35
+ let price = 100;
36
+ const base = Date.UTC(2022, 0, 3);
37
+ for (let i = 0; i < count; i++) {
38
+ const noise = (rng() - 0.5) * 5;
39
+ price = Math.max(price + noise + 0.1, 5);
40
+ const range = Math.abs(noise) + 1;
41
+ const open = price + (rng() - 0.5) * range * 0.4;
42
+ const close = price;
43
+ const high = Math.max(open, close) + rng() * range;
44
+ const low = Math.min(open, close) - rng() * range;
45
+ candles.push({ time: base + i * 86_400_000, open, high, low, close, volume: 10_000 });
46
+ }
47
+ return candles;
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Simple bidirectional EMA-cross signal.
52
+ // ---------------------------------------------------------------------------
53
+ function makeEmaSignal({ fast = 10, slow = 30, rr = 2 } = {}) {
54
+ return ({ candles: history, bar }) => {
55
+ if (history.length < slow + 2) return null;
56
+ const closes = history.map((c) => c.close);
57
+ const f = ema(closes, fast);
58
+ const s = ema(closes, slow);
59
+ const last = closes.length - 1;
60
+ if (f[last - 1] <= s[last - 1] && f[last] > s[last]) {
61
+ const stop = Math.min(...history.slice(-15).map((c) => c.low));
62
+ if (stop >= bar.close) return null;
63
+ return { side: "long", entry: bar.close, stop, rr };
64
+ }
65
+ if (f[last - 1] >= s[last - 1] && f[last] < s[last]) {
66
+ const stop = Math.max(...history.slice(-15).map((c) => c.high));
67
+ if (stop <= bar.close) return null;
68
+ return { side: "short", entry: bar.close, stop, rr };
69
+ }
70
+ return null;
71
+ };
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Helper: run a backtest, compute a DSR overfit verdict, log to the store.
76
+ // This mirrors what the MCP run_backtest tool does when researchId is passed.
77
+ // ---------------------------------------------------------------------------
78
+ async function runAndLog(store, researchId, { label, params, candles, symbol }) {
79
+ const signal = makeEmaSignal(params);
80
+ const result = backtest({ candles, symbol, interval: "1d", signal, collectReplay: false });
81
+ const m = result.metrics;
82
+
83
+ let verdict = null;
84
+ try {
85
+ const psr = deflatedSharpe({
86
+ sharpe: m.sharpe,
87
+ sampleSize: m.trades,
88
+ numTrials: 2, // two parameter sets tried in this session
89
+ });
90
+ verdict = {
91
+ deflatedSharpe: psr,
92
+ overfit: Number.isFinite(psr) ? psr < 0.9 : false,
93
+ note: Number.isFinite(psr) ? `PSR ${(psr * 100).toFixed(1)}%` : "insufficient data",
94
+ };
95
+ } catch {
96
+ verdict = { deflatedSharpe: null, overfit: false, note: "verdict unavailable" };
97
+ }
98
+
99
+ await store.log(researchId, {
100
+ hypothesis: label,
101
+ params,
102
+ metrics: {
103
+ trades: m.trades,
104
+ winRate: m.winRate,
105
+ profitFactor: m.profitFactor,
106
+ sharpe: m.sharpe,
107
+ maxDrawdown: m.maxDrawdown,
108
+ returnPct: m.returnPct,
109
+ },
110
+ verdict,
111
+ });
112
+
113
+ return { metrics: m, verdict };
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Main
118
+ // ---------------------------------------------------------------------------
119
+ async function main() {
120
+ const candles = syntheticCandles(500);
121
+ // Use a unique subdirectory so repeated runs start fresh.
122
+ const store = createResearchStore({ dir: ".tradelab/research-example" });
123
+ const researchId = "ema-cross-study";
124
+
125
+ // 1. Open (or resume) a named research session.
126
+ const session = await store.open(researchId, "Compare fast vs slow EMA-cross on synthetic data");
127
+ console.log("Research session:", session.id);
128
+ console.log("Goal:", session.goal);
129
+ console.log("");
130
+
131
+ // 2. First backtest: tighter EMA pair, moderate R:R.
132
+ const run1 = await runAndLog(store, researchId, {
133
+ label: "EMA 5/20 rr=2",
134
+ params: { fast: 5, slow: 20, rr: 2 },
135
+ candles,
136
+ symbol: "SYNTHETIC",
137
+ });
138
+ console.log("Run 1 (fast=5, slow=20, rr=2):");
139
+ console.log(" trades:", run1.metrics.trades);
140
+ console.log(" profitFactor:", run1.metrics.profitFactor?.toFixed(2));
141
+ console.log(" sharpe:", run1.metrics.sharpe?.toFixed(2));
142
+ console.log(" verdict:", run1.verdict);
143
+
144
+ // 3. Second backtest: wider EMA pair, higher R:R.
145
+ const run2 = await runAndLog(store, researchId, {
146
+ label: "EMA 10/30 rr=3",
147
+ params: { fast: 10, slow: 30, rr: 3 },
148
+ candles,
149
+ symbol: "SYNTHETIC",
150
+ });
151
+ console.log("\nRun 2 (fast=10, slow=30, rr=3):");
152
+ console.log(" trades:", run2.metrics.trades);
153
+ console.log(" profitFactor:", run2.metrics.profitFactor?.toFixed(2));
154
+ console.log(" sharpe:", run2.metrics.sharpe?.toFixed(2));
155
+ console.log(" verdict:", run2.verdict);
156
+
157
+ // 4. Recall entries: the store returns recent entries plus a plain-text synthesis.
158
+ const recall = await store.recall(researchId);
159
+ console.log("\nResearch recall summary:");
160
+ console.log(" ", recall.summary);
161
+ console.log(" entries logged:", recall.entries.length);
162
+
163
+ // 5. Pick the run with the better profitFactor and produce a human-readable summary.
164
+ const bestMetrics =
165
+ (run1.metrics.profitFactor ?? 0) >= (run2.metrics.profitFactor ?? 0)
166
+ ? run1.metrics
167
+ : run2.metrics;
168
+
169
+ // summarize() expects percent-valued drawdown/return; backtest returns fractions.
170
+ const normalized = {
171
+ trades: bestMetrics.trades,
172
+ winRate: bestMetrics.winRate,
173
+ totalReturnPct: (bestMetrics.returnPct ?? 0) * 100,
174
+ maxDrawdownPct: (bestMetrics.maxDrawdown ?? 0) * 100,
175
+ sharpe: bestMetrics.sharpe,
176
+ };
177
+ console.log("\nBest run plain-English summary:");
178
+ console.log(" ", summarize(normalized));
179
+
180
+ // 6. Close the research session.
181
+ const closed = await store.close(researchId);
182
+ console.log("\nResearch session closed:", closed.closedAt);
183
+ }
184
+
185
+ main().catch((err) => {
186
+ console.error(err);
187
+ process.exit(1);
188
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * multiSymbolPortfolio.js: Paper session trading two symbols with independent
3
+ * bracket orders and a portfolio exposure cap.
4
+ *
5
+ * Shows:
6
+ * - SessionManager.create() with a symbols array
7
+ * - Per-symbol pushBar() and placeOrder()
8
+ * - maxGrossExposurePct exposure cap blocking over-sized orders
9
+ * - getStatus() reporting positions, equity, and the symbols list
10
+ *
11
+ * node examples/multiSymbolPortfolio.js
12
+ */
13
+
14
+ import { SessionManager, PaperEngine } from "../src/live/index.js";
15
+
16
+ function bar(time, price, { high = price, low = price } = {}) {
17
+ return { time, open: price, high, low, close: price, volume: 1000 };
18
+ }
19
+
20
+ async function main() {
21
+ // 1. One shared broker for two symbols.
22
+ const broker = new PaperEngine({ equity: 20_000 });
23
+
24
+ const manager = new SessionManager();
25
+ const session = await manager.create({
26
+ id: "btc-eth-portfolio",
27
+ symbols: ["BTC", "ETH"],
28
+ interval: "1h",
29
+ equity: 20_000,
30
+ riskPct: 1,
31
+ // Cap total gross notional to 150% of equity.
32
+ // A second order that would push exposure past that limit is rejected.
33
+ maxGrossExposurePct: 150,
34
+ broker,
35
+ });
36
+
37
+ console.log("Session started:", session.id);
38
+ console.log("Tracked symbols:", session.symbols);
39
+
40
+ // 2. Feed opening bars for each symbol.
41
+ await session.pushBar(bar(1, 30_000), "BTC");
42
+ await session.pushBar(bar(1, 2_000), "ETH");
43
+
44
+ // 3. Place a risk-sized long bracket on BTC.
45
+ // 1% of $20k = $200 risk, $500/BTC stop distance -> 0.4 BTC.
46
+ const btcReceipt = await session.placeOrder({
47
+ symbol: "BTC",
48
+ side: "long",
49
+ riskPct: 1,
50
+ stop: 29_500,
51
+ rr: 3,
52
+ });
53
+ console.log(
54
+ "\nBTC entry filled:",
55
+ btcReceipt.status,
56
+ "qty:", btcReceipt.filledQty,
57
+ "@", btcReceipt.avgFillPrice
58
+ );
59
+
60
+ // 4. Place a risk-sized long bracket on ETH.
61
+ // 1% of $20k = $200 risk, $100/ETH stop distance -> 2 ETH.
62
+ const ethReceipt = await session.placeOrder({
63
+ symbol: "ETH",
64
+ side: "long",
65
+ riskPct: 1,
66
+ stop: 1_900,
67
+ rr: 2,
68
+ });
69
+ console.log(
70
+ "ETH entry filled:",
71
+ ethReceipt.status,
72
+ "qty:", ethReceipt.filledQty,
73
+ "@", ethReceipt.avgFillPrice
74
+ );
75
+
76
+ let status = session.getStatus();
77
+ console.log("\nAfter both entries:");
78
+ console.log(" positions:", status.positions.length, "(BTC + ETH)");
79
+ console.log(" open orders:", status.openOrders.length, "(4 bracket legs)");
80
+ console.log(" equity:", status.equity.toFixed(2));
81
+ console.log(" symbols:", status.symbols);
82
+
83
+ // 5. Demonstrate the exposure cap: trying to open a large third position is rejected.
84
+ try {
85
+ await session.placeOrder({
86
+ symbol: "BTC",
87
+ side: "long",
88
+ qty: 1, // large fixed size that would exceed the 150% cap
89
+ stop: 28_000,
90
+ });
91
+ console.log("\nLarge order unexpectedly succeeded.");
92
+ } catch (err) {
93
+ console.log("\nExposure cap enforced:", err.message);
94
+ }
95
+
96
+ // 6. Simulate BTC hitting its target (high = 31_500).
97
+ await session.pushBar(bar(2, 31_500, { high: 31_500 }), "BTC");
98
+ await session.pushBar(bar(2, 2_000), "ETH");
99
+
100
+ // 7. Close the remaining ETH position manually.
101
+ await session.closePosition("ETH");
102
+
103
+ status = session.getStatus();
104
+ console.log("\nAfter BTC target + ETH close:");
105
+ console.log(" positions:", status.positions.length, "(should be 0)");
106
+ console.log(" open orders:", status.openOrders.length, "(should be 0)");
107
+ console.log(" final equity:", status.equity.toFixed(2));
108
+ console.log(" dayPnl:", status.dayPnl.toFixed(2));
109
+ console.log(" risk.halted:", status.risk.halted);
110
+
111
+ // 8. Per-symbol price and candle buffer accessors.
112
+ console.log("\nlastPriceFor BTC:", session.lastPriceFor("BTC"));
113
+ console.log("candleBufferFor ETH length:", session.candleBufferFor("ETH").length);
114
+
115
+ await session.stop();
116
+ console.log("\nDone.");
117
+ }
118
+
119
+ main().catch((err) => {
120
+ console.error(err);
121
+ process.exit(1);
122
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tradelab",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "Backtesting toolkit for Node.js with strategy simulation, historical data loading, and report generation",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.cjs",
@@ -0,0 +1,42 @@
1
+ // src/cli/runPreset.js
2
+ import { getStrategy, listStrategies } from "../strategies/index.js";
3
+ import { backtest } from "../engine/backtest.js";
4
+ import { summarize } from "../reporting/summarize.js";
5
+
6
+ /**
7
+ * Run a named built-in strategy over provided candles and return metrics + summary.
8
+ *
9
+ * @param {{ preset: string, candles: object[], params?: object, symbol?: string, interval?: string }} options
10
+ * @returns {{ metrics: object, summary: string }}
11
+ */
12
+ export function runPreset({ preset, candles, params = {}, symbol = "PRESET", interval = "1d" } = {}) {
13
+ let factory;
14
+ try {
15
+ factory = getStrategy(preset);
16
+ } catch {
17
+ factory = null;
18
+ }
19
+
20
+ if (!factory) {
21
+ const names = listStrategies()
22
+ .map((s) => s.name)
23
+ .join(", ");
24
+ throw new Error(`unknown preset "${preset}". Available: ${names}`);
25
+ }
26
+
27
+ const signal = factory(params);
28
+ const result = backtest({ candles, symbol, interval, signal, warmupBars: 0 });
29
+ const m = result.metrics;
30
+
31
+ // Normalize units: backtest metrics use fractions; summarize() expects percent-valued fields.
32
+ const normalized = {
33
+ trades: m.trades,
34
+ winRate: m.winRate,
35
+ totalReturnPct: m.returnPct * 100,
36
+ maxDrawdownPct: m.maxDrawdown * 100,
37
+ sharpe: m.sharpe,
38
+ };
39
+
40
+ const summary = summarize(normalized);
41
+ return { metrics: result.metrics, summary };
42
+ }