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/docs/mcp.md CHANGED
@@ -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:
@@ -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.0",
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
+ }
package/src/index.js CHANGED
@@ -27,10 +27,12 @@ export {
27
27
  saveCandlesToCache,
28
28
  } from "./data/index.js";
29
29
 
30
+ export { createResearchStore } from "./research/store.js";
30
31
  export { renderHtmlReport, exportHtmlReport } from "./reporting/renderHtmlReport.js";
31
32
  export { exportTradesCsv } from "./reporting/exportTradesCsv.js";
32
33
  export { exportMetricsJSON } from "./reporting/exportMetricsJson.js";
33
34
  export { exportBacktestArtifacts } from "./reporting/exportBacktestArtifacts.js";
35
+ export { summarize } from "./reporting/summarize.js";
34
36
 
35
37
  export {
36
38
  ema,
@@ -21,6 +21,8 @@ export class RiskManager {
21
21
  cooldownAfterLossMs: 0,
22
22
  allowedSessions: "AUTO",
23
23
  allowedWindows: null,
24
+ maxGrossExposurePct: 0,
25
+ maxNetExposurePct: 0,
24
26
  ...options,
25
27
  };
26
28
  this.allowedWindows = parseWindowsCSV(this.options.allowedWindows);
@@ -114,6 +116,8 @@ export class RiskManager {
114
116
  positionCount = 0,
115
117
  positionValue = 0,
116
118
  equity = null,
119
+ grossExposure = undefined,
120
+ netExposure = undefined,
117
121
  } = {}) {
118
122
  const base = this.canTrade({ timeMs });
119
123
  if (!base.ok) return base;
@@ -135,6 +139,40 @@ export class RiskManager {
135
139
  }
136
140
  }
137
141
 
142
+ return this._checkExposureCaps({ grossExposure, netExposure, equity: eq });
143
+ }
144
+
145
+ /**
146
+ * Check only the portfolio exposure caps (no session/halt/trade-count checks).
147
+ * Called from placeOrder after the halt check has already run.
148
+ */
149
+ checkExposure({ grossExposure = undefined, netExposure = undefined, equity = null } = {}) {
150
+ const eq = Number.isFinite(equity) ? equity : this.currentEquity;
151
+ return this._checkExposureCaps({ grossExposure, netExposure, equity: eq });
152
+ }
153
+
154
+ /**
155
+ * Shared gross/net exposure cap logic used by canOpenPosition and checkExposure.
156
+ * Expects a resolved equity value (NaN/null fallback already applied by caller).
157
+ * Returns { ok: true, reason: null } when within caps or caps are disabled.
158
+ */
159
+ _checkExposureCaps({ grossExposure = undefined, netExposure = undefined, equity } = {}) {
160
+ const eq = equity;
161
+
162
+ const grossCap = pctToFraction(this.options.maxGrossExposurePct, 0);
163
+ if (grossCap > 0 && Number.isFinite(eq) && eq > 0 && Number.isFinite(grossExposure)) {
164
+ if (Math.abs(grossExposure) / eq > grossCap) {
165
+ return { ok: false, reason: "max gross exposure exceeded" };
166
+ }
167
+ }
168
+
169
+ const netCap = pctToFraction(this.options.maxNetExposurePct, 0);
170
+ if (netCap > 0 && Number.isFinite(eq) && eq > 0 && Number.isFinite(netExposure)) {
171
+ if (Math.abs(netExposure) / eq > netCap) {
172
+ return { ok: false, reason: "max net exposure exceeded" };
173
+ }
174
+ }
175
+
138
176
  return { ok: true, reason: null };
139
177
  }
140
178
 
package/src/live/index.js CHANGED
@@ -28,3 +28,4 @@ export { LiveOrchestrator, createLiveOrchestrator } from "./orchestrator.js";
28
28
  export { createDashboardServer } from "./dashboard/server.js";
29
29
 
30
30
  export { TradingSession, SessionManager, createSessionManager } from "./session.js";
31
+ export { attachNotifier } from "./notify.js";
@@ -0,0 +1,42 @@
1
+ // src/live/notify.js
2
+ const DEFAULT_EVENTS = ["order:filled", "risk:halt"];
3
+
4
+ /**
5
+ * Subscribe a notifier to a trading session's event bus. Returns an unsubscribe
6
+ * function. Fires onEvent and/or POSTs to webhookUrl for the configured events,
7
+ * plus a drawdown breach on equity updates.
8
+ */
9
+ export function attachNotifier(session, { onEvent, webhookUrl, events = DEFAULT_EVENTS, drawdownPct = 0 } = {}) {
10
+ const wanted = new Set(events);
11
+ let peak = null;
12
+
13
+ const deliver = async (event, payload) => {
14
+ if (typeof onEvent === "function") {
15
+ try { await onEvent({ event, payload }); } catch { /* non-fatal */ }
16
+ }
17
+ if (webhookUrl && typeof fetch === "function") {
18
+ try {
19
+ await fetch(webhookUrl, {
20
+ method: "POST",
21
+ headers: { "content-type": "application/json" },
22
+ body: JSON.stringify({ event, payload }),
23
+ });
24
+ } catch { /* non-fatal */ }
25
+ }
26
+ };
27
+
28
+ const handler = ({ event, payload }) => {
29
+ if (wanted.has(event)) { deliver(event, payload).catch(() => {}); return; }
30
+ if (drawdownPct > 0 && event === "equity:update") {
31
+ const eq = payload?.equity;
32
+ if (Number.isFinite(eq)) {
33
+ if (peak === null || eq > peak) peak = eq;
34
+ if (peak > 0 && ((peak - eq) / peak) * 100 >= drawdownPct) {
35
+ deliver("drawdown:breach", { equity: eq, peak, drawdownPct: ((peak - eq) / peak) * 100 }).catch(() => {});
36
+ }
37
+ }
38
+ }
39
+ };
40
+
41
+ return session.eventBus.onAny(handler);
42
+ }