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/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
+ }
@@ -29,6 +29,7 @@ export class TradingSession {
29
29
  constructor({
30
30
  id,
31
31
  symbol,
32
+ symbols,
32
33
  interval = "1m",
33
34
  broker,
34
35
  mode = "paper",
@@ -36,6 +37,8 @@ export class TradingSession {
36
37
  riskPct = 1,
37
38
  maxDailyLossPct = 0,
38
39
  maxPositionPct = 1,
40
+ maxGrossExposurePct = 0,
41
+ maxNetExposurePct = 0,
39
42
  qtyStep = 0.001,
40
43
  minQty = 0.001,
41
44
  maxLeverage = 2,
@@ -48,10 +51,14 @@ export class TradingSession {
48
51
  );
49
52
  }
50
53
  if (!broker) throw new Error("TradingSession requires a broker (PaperEngine by default)");
51
- if (!symbol) throw new Error("TradingSession requires a symbol");
52
54
 
53
- this.id = id || `${symbol}-${interval}`;
54
- this.symbol = symbol;
55
+ const symbolList = Array.isArray(symbols) && symbols.length ? symbols : symbol ? [symbol] : null;
56
+ if (!symbolList) throw new Error("TradingSession requires a symbol or symbols");
57
+
58
+ this.symbols = symbolList;
59
+ this.symbol = symbolList[0]; // back-compat primary symbol
60
+
61
+ this.id = id || `${this.symbol}-${interval}`;
55
62
  this.interval = interval;
56
63
  this.broker = broker;
57
64
  this.mode = mode;
@@ -63,20 +70,50 @@ export class TradingSession {
63
70
  this.minQty = minQty;
64
71
  this.maxLeverage = maxLeverage;
65
72
  this.eventBus = eventBus || new EventBus();
66
- this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0 });
67
- this.lastPrice = null;
73
+ this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0, maxGrossExposurePct, maxNetExposurePct });
68
74
  this.running = false;
69
75
  this.events = [];
70
76
  this.brackets = new Map(); // symbol -> { stopId, targetId }
71
- this._pendingBracket = null;
77
+ this._pendingBrackets = new Map(); // symbol -> staged bracket
78
+ this._entryMeta = new Map(); // clientOrderId -> { sizing, rationale }
79
+ this._legMeta = new Map(); // clientOrderId -> { parentEntryId, leg }
72
80
  this._cachedPositions = [];
73
81
  this._cachedOpenOrders = [];
74
- this.candleBuffer = [];
75
- this._strategy = null;
82
+ this._lastPrice = new Map(); // symbol -> price
83
+ this._candleBuffers = new Map(); // symbol -> bar[]
84
+ this._strategies = new Map(); // symbol -> signalFn
85
+ for (const sym of this.symbols) this._candleBuffers.set(sym, []);
86
+
87
+ this._wasHalted = false;
88
+ this._coidSeq = 0;
76
89
 
77
90
  this._wireBrokerEvents();
78
91
  }
79
92
 
93
+ // Back-compat getters/setters for single-symbol usage and MCP feed_price handler
94
+ get lastPrice() { return this._lastPrice.get(this.symbol) ?? null; }
95
+ set lastPrice(v) { this._lastPrice.set(this.symbol, v); }
96
+ get candleBuffer() { return this._candleBuffers.get(this.symbol) ?? []; }
97
+ set candleBuffer(v) { this._candleBuffers.set(this.symbol, v); }
98
+ get _strategy() { return this._strategies.get(this.symbol) ?? null; }
99
+ set _strategy(fn) { this._strategies.set(this.symbol, fn); }
100
+ // Back-compat for tests that read/write _pendingBracket directly (primary symbol only)
101
+ get _pendingBracket() { return this._pendingBrackets.get(this.symbol) ?? null; }
102
+ set _pendingBracket(v) {
103
+ if (v == null) this._pendingBrackets.delete(this.symbol);
104
+ else this._pendingBrackets.set(this.symbol, v);
105
+ }
106
+
107
+ // Per-symbol accessors
108
+ lastPriceFor(sym = this.symbol) { return this._lastPrice.get(sym) ?? null; }
109
+ candleBufferFor(sym = this.symbol) { return this._candleBuffers.get(sym) ?? []; }
110
+
111
+ _resolveSymbol(symbol) {
112
+ if (symbol) return symbol;
113
+ if (this.symbols.length === 1) return this.symbol;
114
+ throw new Error("symbol is required for a multi-symbol session");
115
+ }
116
+
80
117
  static liveAllowed() {
81
118
  return process.env.TRADELAB_ALLOW_LIVE === "true";
82
119
  }
@@ -91,7 +128,7 @@ export class TradingSession {
91
128
  _wireBrokerEvents() {
92
129
  // Forward broker fills/cancels onto the session bus, and run OCO bracket logic.
93
130
  this.broker.on?.("order:filled", (order) => this._onBrokerFillSync(order));
94
- this.broker.on?.("order:submitted", (order) => this._record("order:submitted", order));
131
+ this.broker.on?.("order:submitted", (order) => this._record("order:submitted", this._withMeta(order)));
95
132
  this.broker.on?.("order:canceled", (order) =>
96
133
  this._onBrokerTerminalOrderSync("order:canceled", order)
97
134
  );
@@ -103,37 +140,58 @@ export class TradingSession {
103
140
 
104
141
  _onBrokerTerminalOrderSync(event, order) {
105
142
  this._record(event, order);
106
- if (matchesOrderRef(this._pendingBracket, order)) {
107
- this._pendingBracket = null;
143
+ // Scan all pending brackets to clear any that match this terminal order
144
+ for (const [sym, staged] of this._pendingBrackets) {
145
+ if (matchesOrderRef(staged, order)) {
146
+ this._pendingBrackets.delete(sym);
147
+ break;
148
+ }
108
149
  }
109
150
  }
110
151
 
111
- // Sync event handler — fire-and-forget async OCO work via a stored promise
152
+ _withMeta(order) {
153
+ const key = order.clientOrderId;
154
+ if (key && this._entryMeta?.has(key)) {
155
+ const m = this._entryMeta.get(key);
156
+ return { ...order, sizing: m.sizing, ...(m.rationale ? { rationale: m.rationale } : {}) };
157
+ }
158
+ if (key && this._legMeta?.has(key)) {
159
+ return { ...order, ...this._legMeta.get(key) };
160
+ }
161
+ return order;
162
+ }
163
+
164
+ // Sync event handler; fire-and-forget async OCO work via a stored promise
112
165
  _onBrokerFillSync(order) {
113
- this._record("order:filled", order);
114
-
115
- // Resting entry order (e.g. a limit) just filled attach its staged bracket.
116
- if (matchesOrderRef(this._pendingBracket, order)) {
117
- const staged = this._pendingBracket;
118
- this._pendingBracket = null;
119
- // simulateBar may still be iterating orders, so schedule attach without awaiting.
120
- this._pendingCancelPromise = Promise.resolve(
121
- this._attachBracket({ ...staged, receipt: order })
122
- );
123
- return;
166
+ this._record("order:filled", this._withMeta(order));
167
+
168
+ // Resting entry order (e.g. a limit) just filled; attach its staged bracket.
169
+ // Scan _pendingBrackets for a match (works for both single- and multi-symbol sessions).
170
+ for (const [sym, staged] of this._pendingBrackets) {
171
+ if (matchesOrderRef(staged, order)) {
172
+ this._pendingBrackets.delete(sym);
173
+ const parentEntryId = staged.parentEntryId ?? order.clientOrderId;
174
+ // simulateBar may still be iterating orders, so schedule attach without awaiting.
175
+ this._pendingCancelPromise = Promise.resolve(
176
+ this._attachBracket({ ...staged, symbol: sym, receipt: order, parentEntryId })
177
+ );
178
+ return;
179
+ }
124
180
  }
125
181
 
126
- // Track bracket leg fills for OCO
127
- const bracket = this.brackets.get(this.symbol);
128
- if (bracket && (order.orderId === bracket.stopId || order.orderId === bracket.targetId)) {
129
- const siblingId = order.orderId === bracket.stopId ? bracket.targetId : bracket.stopId;
130
- // Schedule the cancel simulateBar is still iterating orders, so we must not await here.
131
- // We keep a pending cancel promise that refresh() awaits.
132
- this._pendingCancelPromise = (async () => {
133
- if (siblingId) await this.broker.cancelOrder(siblingId).catch(() => {});
134
- this.brackets.delete(this.symbol);
135
- this._record("position:closed", { reason: order.orderId === bracket.stopId ? "SL" : "TP" });
136
- })();
182
+ // Track bracket leg fills for OCO; find which symbol this fill belongs to
183
+ for (const [sym, bracket] of this.brackets) {
184
+ if (bracket && (order.orderId === bracket.stopId || order.orderId === bracket.targetId)) {
185
+ const siblingId = order.orderId === bracket.stopId ? bracket.targetId : bracket.stopId;
186
+ // Schedule the cancel; simulateBar is still iterating orders, so we must not await here.
187
+ // We keep a pending cancel promise that refresh() awaits.
188
+ this._pendingCancelPromise = (async () => {
189
+ if (siblingId) await this.broker.cancelOrder(siblingId).catch(() => {});
190
+ this.brackets.delete(sym);
191
+ this._record("position:closed", { symbol: sym, reason: order.orderId === bracket.stopId ? "SL" : "TP" });
192
+ })();
193
+ return;
194
+ }
137
195
  }
138
196
  }
139
197
 
@@ -155,19 +213,22 @@ export class TradingSession {
155
213
  this._record("shutdown", {});
156
214
  }
157
215
 
158
- async pushBar(b) {
159
- this.lastPrice = b.close;
216
+ async pushBar(b, symbol) {
217
+ const sym = this._resolveSymbol(symbol);
218
+ this._lastPrice.set(sym, b.close);
160
219
  if (typeof this.broker.simulateBar === "function") {
161
- await this.broker.simulateBar(this.symbol, this.interval, b);
220
+ await this.broker.simulateBar(sym, this.interval, b);
162
221
  }
163
222
  // Wait for any pending OCO cancel triggered by simulateBar fills
164
223
  if (this._pendingCancelPromise) {
165
224
  await this._pendingCancelPromise;
166
225
  this._pendingCancelPromise = null;
167
226
  }
168
- this.candleBuffer.push(b);
169
- if (this.candleBuffer.length > 200) this.candleBuffer.shift();
170
- this._record("bar", { close: b.close, time: b.time });
227
+ const buf = this._candleBuffers.get(sym) ?? [];
228
+ buf.push(b);
229
+ if (buf.length > 200) buf.shift();
230
+ this._candleBuffers.set(sym, buf);
231
+ this._record("bar", { symbol: sym, close: b.close, time: b.time });
171
232
  await this._syncEquityAndRisk();
172
233
  await this.refresh();
173
234
  }
@@ -177,15 +238,16 @@ export class TradingSession {
177
238
  return Boolean(state.halted);
178
239
  }
179
240
 
180
- async placeOrder({ side, type = "market", qty, riskPct, stop, target, rr, limitPrice } = {}) {
241
+ async placeOrder({ side, type = "market", qty, riskPct, stop, target, rr, limitPrice, rationale, symbol } = {}) {
181
242
  if (!this.running) throw new Error("session not started");
182
243
  if (this._riskHalted()) throw new Error("session is risk-halted for the day");
183
- const entryRef = type === "limit" ? limitPrice : this.lastPrice;
244
+ const sym = this._resolveSymbol(symbol);
245
+ const entryRef = type === "limit" ? limitPrice : this.lastPriceFor(sym);
184
246
  if (!Number.isFinite(entryRef)) throw new Error("no price available; pushBar() a price first");
185
247
 
248
+ const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
186
249
  let size = qty;
187
250
  if (!Number.isFinite(size)) {
188
- const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
189
251
  if (!Number.isFinite(stop)) throw new Error("risk-based sizing requires a stop");
190
252
  size = calculatePositionSize({
191
253
  equity: this.equity,
@@ -200,9 +262,47 @@ export class TradingSession {
200
262
  size = roundStep(size, this.qtyStep);
201
263
  if (!(size >= this.minQty)) throw new Error(`sized below minQty (${size})`);
202
264
 
203
- const entryClientOrderId = `${this.id}-entry-${Date.now()}`;
265
+ const targetPx = Number.isFinite(target)
266
+ ? target
267
+ : Number.isFinite(rr) && Number.isFinite(stop)
268
+ ? (side === "long" || side === "buy"
269
+ ? entryRef + rr * Math.abs(entryRef - stop)
270
+ : entryRef - rr * Math.abs(entryRef - stop))
271
+ : null;
272
+ const sizing = {
273
+ entry: entryRef,
274
+ stop: Number.isFinite(stop) ? stop : null,
275
+ target: targetPx,
276
+ rr: Number.isFinite(rr) ? rr : null,
277
+ riskFraction: fraction,
278
+ riskAmount: this.equity * fraction,
279
+ qty: size,
280
+ notional: size * entryRef,
281
+ };
282
+
283
+ const positions = this._cachedPositions ?? [];
284
+ const newNotional = sizing.notional * ((side === "long" || side === "buy") ? 1 : -1);
285
+ let gross = Math.abs(sizing.notional);
286
+ let net = newNotional;
287
+ for (const p of positions) {
288
+ const px = p.avgEntry ?? p.avgPrice ?? p.entryPrice ?? entryRef;
289
+ const pv = Number.isFinite(p.marketValue) ? p.marketValue : (p.qty ?? 0) * px;
290
+ const signed = (p.side === "long" || p.side === "buy") ? pv : -pv;
291
+ gross += Math.abs(pv);
292
+ net += signed;
293
+ }
294
+ const gate = this.riskManager.checkExposure({
295
+ grossExposure: gross,
296
+ netExposure: net,
297
+ equity: this.equity,
298
+ });
299
+ if (!gate.ok) throw new Error(`risk rejected: ${gate.reason}`);
300
+
301
+ const entryClientOrderId = `${this.id}-entry-${Date.now()}-${++this._coidSeq}`;
302
+ this._entryMeta.set(entryClientOrderId, { sizing, rationale });
303
+
204
304
  const receipt = await this.broker.submitOrder({
205
- symbol: this.symbol,
305
+ symbol: sym,
206
306
  side: toBrokerSide(side),
207
307
  type,
208
308
  qty: size,
@@ -210,12 +310,13 @@ export class TradingSession {
210
310
  clientOrderId: entryClientOrderId,
211
311
  });
212
312
 
213
- // Stage bracket if needed market orders fill synchronously in PaperEngine
313
+ // Stage bracket if needed; market orders fill synchronously in PaperEngine
214
314
  if (Number.isFinite(stop) || Number.isFinite(target) || Number.isFinite(rr)) {
315
+ const parentEntryId = receipt?.clientOrderId ?? entryClientOrderId;
215
316
  if (receipt.status === "filled") {
216
- await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt });
317
+ await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt, parentEntryId, symbol: sym });
217
318
  } else if (receipt.status !== "rejected") {
218
- this._pendingBracket = {
319
+ this._pendingBrackets.set(sym, {
219
320
  side,
220
321
  size,
221
322
  stop,
@@ -224,9 +325,10 @@ export class TradingSession {
224
325
  entryRef,
225
326
  orderId: receipt.orderId,
226
327
  clientOrderId: receipt.clientOrderId || entryClientOrderId,
227
- };
328
+ parentEntryId,
329
+ });
228
330
  } else {
229
- this._pendingBracket = null;
331
+ this._pendingBrackets.delete(sym);
230
332
  }
231
333
  }
232
334
 
@@ -234,7 +336,8 @@ export class TradingSession {
234
336
  return receipt;
235
337
  }
236
338
 
237
- async _attachBracket({ side, size, stop, target, rr, entryRef, receipt }) {
339
+ async _attachBracket({ side, size, stop, target, rr, entryRef, receipt, parentEntryId, symbol }) {
340
+ const sym = symbol ?? this.symbol;
238
341
  const entryFill = receipt?.avgFillPrice ?? entryRef;
239
342
  const risk = Number.isFinite(stop) ? Math.abs(entryFill - stop) : null;
240
343
  const targetPrice = Number.isFinite(target)
@@ -248,28 +351,32 @@ export class TradingSession {
248
351
  const bracket = {};
249
352
 
250
353
  if (Number.isFinite(stop)) {
354
+ const stopCoid = `${this.id}-stop-${Date.now()}-${++this._coidSeq}`;
355
+ if (parentEntryId) this._legMeta.set(stopCoid, { parentEntryId, leg: "stop" });
251
356
  const stopOrder = await this.broker.submitOrder({
252
- symbol: this.symbol,
357
+ symbol: sym,
253
358
  side: exitSide,
254
359
  type: "stop",
255
360
  qty: size,
256
361
  stopPrice: stop,
257
- clientOrderId: `${this.id}-stop-${Date.now()}`,
362
+ clientOrderId: stopCoid,
258
363
  });
259
364
  bracket.stopId = stopOrder.orderId;
260
365
  }
261
366
  if (Number.isFinite(targetPrice)) {
367
+ const tgtCoid = `${this.id}-target-${Date.now()}-${++this._coidSeq}`;
368
+ if (parentEntryId) this._legMeta.set(tgtCoid, { parentEntryId, leg: "target" });
262
369
  const tgtOrder = await this.broker.submitOrder({
263
- symbol: this.symbol,
370
+ symbol: sym,
264
371
  side: exitSide,
265
372
  type: "limit",
266
373
  qty: size,
267
374
  limitPrice: targetPrice,
268
- clientOrderId: `${this.id}-target-${Date.now()}`,
375
+ clientOrderId: tgtCoid,
269
376
  });
270
377
  bracket.targetId = tgtOrder.orderId;
271
378
  }
272
- this.brackets.set(this.symbol, bracket);
379
+ this.brackets.set(sym, bracket);
273
380
  }
274
381
 
275
382
  async _syncEquityAndRisk() {
@@ -284,6 +391,12 @@ export class TradingSession {
284
391
  } else {
285
392
  this.riskManager.update({ timeMs: Date.now(), equity: this.equity });
286
393
  }
394
+ // Emit risk:halt once per halt transition
395
+ const nowHalted = Boolean(this.riskManager.getState?.().halted);
396
+ if (nowHalted && !this._wasHalted) {
397
+ this._record("risk:halt", { reason: this.riskManager.haltReason ?? this.riskManager.getState?.().haltReason ?? "risk halt" });
398
+ }
399
+ this._wasHalted = nowHalted;
287
400
  }
288
401
 
289
402
  async closePosition(symbol = this.symbol) {
@@ -342,6 +455,7 @@ export class TradingSession {
342
455
  return {
343
456
  id: this.id,
344
457
  symbol: this.symbol,
458
+ symbols: this.symbols,
345
459
  interval: this.interval,
346
460
  mode: this.mode,
347
461
  running: this.running,
@@ -10,20 +10,6 @@ function requireSession(sessionId) {
10
10
  return s;
11
11
  }
12
12
 
13
- function strategyContext(session) {
14
- const candles = session.candleBuffer;
15
- const bar = candles[candles.length - 1] ?? null;
16
- const status = session.getStatus();
17
- return {
18
- candles,
19
- index: candles.length - 1,
20
- bar,
21
- equity: status.equity,
22
- openPosition: status.positions[0] ?? null,
23
- pendingOrder: null,
24
- };
25
- }
26
-
27
13
  function signalToOrder(signal) {
28
14
  return {
29
15
  side: signal.side ?? signal.direction ?? signal.action,
@@ -46,6 +32,7 @@ export const liveTools = {
46
32
  handler: async ({
47
33
  sessionId,
48
34
  symbol,
35
+ symbols,
49
36
  mode = "paper",
50
37
  interval = "1m",
51
38
  equity = 10_000,
@@ -56,6 +43,7 @@ export const liveTools = {
56
43
  const session = await manager.create({
57
44
  id: sessionId,
58
45
  symbol,
46
+ symbols,
59
47
  mode,
60
48
  interval,
61
49
  equity,
@@ -86,21 +74,36 @@ export const liveTools = {
86
74
  feed_price: {
87
75
  description:
88
76
  "Feed a price bar (or single price) to a session, advancing paper simulations and triggering fills.",
89
- handler: async ({ sessionId, bar, price } = {}) => {
77
+ handler: async ({ sessionId, bar, price, symbol } = {}) => {
90
78
  const session = requireSession(sessionId);
91
79
  let b = bar;
92
80
  if (!b && Number.isFinite(price)) {
93
81
  b = { time: Date.now(), open: price, high: price, low: price, close: price, volume: 0 };
94
82
  }
95
83
  if (!b) throw new Error("Provide either `bar` (OHLCV) or `price` (number)");
96
- await session.pushBar(b);
97
-
98
- // If a strategy is attached and session is flat, evaluate it
99
- if (session._strategy && session.getStatus().positions.length === 0) {
84
+ await session.pushBar(b, symbol);
85
+
86
+ // If a strategy is attached for this symbol and the session is flat for it, evaluate it
87
+ const effectiveSym = symbol ?? session.symbol;
88
+ const strategyFn = session._strategies.get(effectiveSym);
89
+ const positions = session.getStatus().positions;
90
+ const symPositions = positions.filter((p) => !p.symbol || p.symbol === effectiveSym);
91
+ if (strategyFn && symPositions.length === 0) {
100
92
  try {
101
- const signal = session._strategy(strategyContext(session));
93
+ const candles = session.candleBufferFor(effectiveSym);
94
+ const bar = candles[candles.length - 1] ?? null;
95
+ const status = session.getStatus();
96
+ const ctx = {
97
+ candles,
98
+ index: candles.length - 1,
99
+ bar,
100
+ equity: status.equity,
101
+ openPosition: positions.find((p) => !p.symbol || p.symbol === effectiveSym) ?? null,
102
+ pendingOrder: null,
103
+ };
104
+ const signal = strategyFn(ctx);
102
105
  if (signal && (signal.side || signal.direction || signal.action)) {
103
- await session.placeOrder(signalToOrder(signal)).catch(() => {});
106
+ await session.placeOrder({ ...signalToOrder(signal), symbol: effectiveSym }).catch(() => {});
104
107
  }
105
108
  } catch {
106
109
  // strategy errors are non-fatal
@@ -124,9 +127,10 @@ export const liveTools = {
124
127
  target,
125
128
  rr,
126
129
  limitPrice,
130
+ symbol,
127
131
  } = {}) => {
128
132
  const session = requireSession(sessionId);
129
- return session.placeOrder({ side, type, qty, riskPct, stop, target, rr, limitPrice });
133
+ return session.placeOrder({ side, type, qty, riskPct, stop, target, rr, limitPrice, symbol });
130
134
  },
131
135
  },
132
136
 
@@ -183,11 +187,11 @@ export const liveTools = {
183
187
  attach_strategy: {
184
188
  description:
185
189
  "Attach a named built-in strategy to a session. It will auto-evaluate on each feed_price and place orders when flat.",
186
- handler: async ({ sessionId, strategy, params = {} } = {}) => {
190
+ handler: async ({ sessionId, strategy, params = {}, symbol } = {}) => {
187
191
  const session = requireSession(sessionId);
188
192
  const factory = getStrategy(strategy);
189
193
  const signal = factory(params);
190
- session._strategy = signal;
194
+ session._strategies.set(symbol ?? session.symbol, signal);
191
195
  return { ok: true, strategy, params };
192
196
  },
193
197
  },
@@ -0,0 +1,24 @@
1
+ import { createResearchStore } from "../research/store.js";
2
+
3
+ export function researchTools({ dir } = {}) {
4
+ const store = createResearchStore(dir ? { dir } : {});
5
+ return {
6
+ research_open: {
7
+ description: "Open or resume a persistent research session for iterating on strategy hypotheses.",
8
+ handler: async ({ id, goal } = {}) => store.open(id, goal),
9
+ },
10
+ research_log: {
11
+ description: "Append a tested hypothesis (params, metrics, optional overfitting verdict) to a research session.",
12
+ handler: async ({ id, hypothesis, params, metrics, verdict } = {}) =>
13
+ store.log(id, { hypothesis, params, metrics, verdict }),
14
+ },
15
+ research_recall: {
16
+ description: "Recall recent research entries plus a synthesized summary (best Sharpe, overfit count).",
17
+ handler: async ({ id, limit } = {}) => store.recall(id, limit),
18
+ },
19
+ research_close: {
20
+ description: "Mark a research session complete and return its final record.",
21
+ handler: async ({ id } = {}) => store.close(id),
22
+ },
23
+ };
24
+ }
@@ -109,7 +109,8 @@ export const schemas = {
109
109
  // Live trading tools
110
110
  create_session: {
111
111
  sessionId: z.string(),
112
- symbol: z.string(),
112
+ symbol: z.string().optional(),
113
+ symbols: z.array(z.string()).optional(),
113
114
  mode: sessionMode,
114
115
  interval: z.string().optional(),
115
116
  equity: z.number().optional(),
@@ -125,6 +126,7 @@ export const schemas = {
125
126
  sessionId: z.string(),
126
127
  bar: barShape.optional(),
127
128
  price: z.number().optional(),
129
+ symbol: z.string().optional(),
128
130
  },
129
131
  place_order: {
130
132
  sessionId: z.string(),
@@ -136,6 +138,7 @@ export const schemas = {
136
138
  target: z.number().optional(),
137
139
  rr: z.number().optional(),
138
140
  limitPrice: z.number().optional(),
141
+ symbol: z.string().optional(),
139
142
  },
140
143
  close_position: {
141
144
  sessionId: z.string(),
@@ -162,6 +165,7 @@ export const schemas = {
162
165
  sessionId: z.string(),
163
166
  strategy: z.string(),
164
167
  params: z.record(z.string(), z.any()).optional(),
168
+ symbol: z.string().optional(),
165
169
  },
166
170
  halt_all: {},
167
171
  };