tradelab 1.2.0 → 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.
@@ -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
+ }
@@ -12,10 +12,24 @@ function toBrokerSide(side) {
12
12
  return side === "long" || side === "buy" ? "buy" : "sell";
13
13
  }
14
14
 
15
+ function matchesOrderRef(reference, order) {
16
+ if (!reference || !order) return false;
17
+ if (reference.orderId && order.orderId && reference.orderId === order.orderId) return true;
18
+ if (
19
+ reference.clientOrderId &&
20
+ order.clientOrderId &&
21
+ reference.clientOrderId === order.clientOrderId
22
+ ) {
23
+ return true;
24
+ }
25
+ return false;
26
+ }
27
+
15
28
  export class TradingSession {
16
29
  constructor({
17
30
  id,
18
31
  symbol,
32
+ symbols,
19
33
  interval = "1m",
20
34
  broker,
21
35
  mode = "paper",
@@ -23,6 +37,8 @@ export class TradingSession {
23
37
  riskPct = 1,
24
38
  maxDailyLossPct = 0,
25
39
  maxPositionPct = 1,
40
+ maxGrossExposurePct = 0,
41
+ maxNetExposurePct = 0,
26
42
  qtyStep = 0.001,
27
43
  minQty = 0.001,
28
44
  maxLeverage = 2,
@@ -35,10 +51,14 @@ export class TradingSession {
35
51
  );
36
52
  }
37
53
  if (!broker) throw new Error("TradingSession requires a broker (PaperEngine by default)");
38
- if (!symbol) throw new Error("TradingSession requires a symbol");
39
54
 
40
- this.id = id || `${symbol}-${interval}`;
41
- 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}`;
42
62
  this.interval = interval;
43
63
  this.broker = broker;
44
64
  this.mode = mode;
@@ -50,20 +70,50 @@ export class TradingSession {
50
70
  this.minQty = minQty;
51
71
  this.maxLeverage = maxLeverage;
52
72
  this.eventBus = eventBus || new EventBus();
53
- this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0 });
54
- this.lastPrice = null;
73
+ this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0, maxGrossExposurePct, maxNetExposurePct });
55
74
  this.running = false;
56
75
  this.events = [];
57
76
  this.brackets = new Map(); // symbol -> { stopId, targetId }
58
- 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 }
59
80
  this._cachedPositions = [];
60
81
  this._cachedOpenOrders = [];
61
- this.candleBuffer = [];
62
- 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;
63
89
 
64
90
  this._wireBrokerEvents();
65
91
  }
66
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
+
67
117
  static liveAllowed() {
68
118
  return process.env.TRADELAB_ALLOW_LIVE === "true";
69
119
  }
@@ -78,37 +128,70 @@ export class TradingSession {
78
128
  _wireBrokerEvents() {
79
129
  // Forward broker fills/cancels onto the session bus, and run OCO bracket logic.
80
130
  this.broker.on?.("order:filled", (order) => this._onBrokerFillSync(order));
81
- this.broker.on?.("order:submitted", (order) => this._record("order:submitted", order));
82
- this.broker.on?.("order:canceled", (order) => this._record("order:canceled", order));
131
+ this.broker.on?.("order:submitted", (order) => this._record("order:submitted", this._withMeta(order)));
132
+ this.broker.on?.("order:canceled", (order) =>
133
+ this._onBrokerTerminalOrderSync("order:canceled", order)
134
+ );
135
+ this.broker.on?.("order:rejected", (order) =>
136
+ this._onBrokerTerminalOrderSync("order:rejected", order)
137
+ );
83
138
  this.broker.on?.("equity:update", (acct) => this._record("equity:update", acct));
84
139
  }
85
140
 
141
+ _onBrokerTerminalOrderSync(event, order) {
142
+ this._record(event, order);
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
+ }
149
+ }
150
+ }
151
+
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
+
86
164
  // Sync event handler — fire-and-forget async OCO work via a stored promise
87
165
  _onBrokerFillSync(order) {
88
- this._record("order:filled", order);
166
+ this._record("order:filled", this._withMeta(order));
89
167
 
90
168
  // Resting entry order (e.g. a limit) just filled — attach its staged bracket.
91
- if (this._pendingBracket && String(order.clientOrderId || "").includes("-entry-")) {
92
- const staged = this._pendingBracket;
93
- this._pendingBracket = null;
94
- // simulateBar may still be iterating orders, so schedule attach without awaiting.
95
- this._pendingCancelPromise = Promise.resolve(
96
- this._attachBracket({ ...staged, receipt: order })
97
- );
98
- return;
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
+ }
99
180
  }
100
181
 
101
- // Track bracket leg fills for OCO
102
- const bracket = this.brackets.get(this.symbol);
103
- if (bracket && (order.orderId === bracket.stopId || order.orderId === bracket.targetId)) {
104
- const siblingId = order.orderId === bracket.stopId ? bracket.targetId : bracket.stopId;
105
- // Schedule the cancel — simulateBar is still iterating orders, so we must not await here.
106
- // We keep a pending cancel promise that refresh() awaits.
107
- this._pendingCancelPromise = (async () => {
108
- if (siblingId) await this.broker.cancelOrder(siblingId).catch(() => {});
109
- this.brackets.delete(this.symbol);
110
- this._record("position:closed", { reason: order.orderId === bracket.stopId ? "SL" : "TP" });
111
- })();
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
+ }
112
195
  }
113
196
  }
114
197
 
@@ -130,19 +213,22 @@ export class TradingSession {
130
213
  this._record("shutdown", {});
131
214
  }
132
215
 
133
- async pushBar(b) {
134
- this.lastPrice = b.close;
216
+ async pushBar(b, symbol) {
217
+ const sym = this._resolveSymbol(symbol);
218
+ this._lastPrice.set(sym, b.close);
135
219
  if (typeof this.broker.simulateBar === "function") {
136
- await this.broker.simulateBar(this.symbol, this.interval, b);
220
+ await this.broker.simulateBar(sym, this.interval, b);
137
221
  }
138
222
  // Wait for any pending OCO cancel triggered by simulateBar fills
139
223
  if (this._pendingCancelPromise) {
140
224
  await this._pendingCancelPromise;
141
225
  this._pendingCancelPromise = null;
142
226
  }
143
- this.candleBuffer.push(b);
144
- if (this.candleBuffer.length > 200) this.candleBuffer.shift();
145
- 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 });
146
232
  await this._syncEquityAndRisk();
147
233
  await this.refresh();
148
234
  }
@@ -152,15 +238,16 @@ export class TradingSession {
152
238
  return Boolean(state.halted);
153
239
  }
154
240
 
155
- 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 } = {}) {
156
242
  if (!this.running) throw new Error("session not started");
157
243
  if (this._riskHalted()) throw new Error("session is risk-halted for the day");
158
- const entryRef = type === "limit" ? limitPrice : this.lastPrice;
244
+ const sym = this._resolveSymbol(symbol);
245
+ const entryRef = type === "limit" ? limitPrice : this.lastPriceFor(sym);
159
246
  if (!Number.isFinite(entryRef)) throw new Error("no price available; pushBar() a price first");
160
247
 
248
+ const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
161
249
  let size = qty;
162
250
  if (!Number.isFinite(size)) {
163
- const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
164
251
  if (!Number.isFinite(stop)) throw new Error("risk-based sizing requires a stop");
165
252
  size = calculatePositionSize({
166
253
  equity: this.equity,
@@ -175,21 +262,73 @@ export class TradingSession {
175
262
  size = roundStep(size, this.qtyStep);
176
263
  if (!(size >= this.minQty)) throw new Error(`sized below minQty (${size})`);
177
264
 
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
+
178
304
  const receipt = await this.broker.submitOrder({
179
- symbol: this.symbol,
305
+ symbol: sym,
180
306
  side: toBrokerSide(side),
181
307
  type,
182
308
  qty: size,
183
309
  limitPrice: type === "limit" ? limitPrice : undefined,
184
- clientOrderId: `${this.id}-entry-${Date.now()}`,
310
+ clientOrderId: entryClientOrderId,
185
311
  });
186
312
 
187
313
  // Stage bracket if needed — market orders fill synchronously in PaperEngine
188
314
  if (Number.isFinite(stop) || Number.isFinite(target) || Number.isFinite(rr)) {
315
+ const parentEntryId = receipt?.clientOrderId ?? entryClientOrderId;
189
316
  if (receipt.status === "filled") {
190
- await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt });
317
+ await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt, parentEntryId, symbol: sym });
318
+ } else if (receipt.status !== "rejected") {
319
+ this._pendingBrackets.set(sym, {
320
+ side,
321
+ size,
322
+ stop,
323
+ target,
324
+ rr,
325
+ entryRef,
326
+ orderId: receipt.orderId,
327
+ clientOrderId: receipt.clientOrderId || entryClientOrderId,
328
+ parentEntryId,
329
+ });
191
330
  } else {
192
- this._pendingBracket = { side, size, stop, target, rr, entryRef };
331
+ this._pendingBrackets.delete(sym);
193
332
  }
194
333
  }
195
334
 
@@ -197,7 +336,8 @@ export class TradingSession {
197
336
  return receipt;
198
337
  }
199
338
 
200
- 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;
201
341
  const entryFill = receipt?.avgFillPrice ?? entryRef;
202
342
  const risk = Number.isFinite(stop) ? Math.abs(entryFill - stop) : null;
203
343
  const targetPrice = Number.isFinite(target)
@@ -211,28 +351,32 @@ export class TradingSession {
211
351
  const bracket = {};
212
352
 
213
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" });
214
356
  const stopOrder = await this.broker.submitOrder({
215
- symbol: this.symbol,
357
+ symbol: sym,
216
358
  side: exitSide,
217
359
  type: "stop",
218
360
  qty: size,
219
361
  stopPrice: stop,
220
- clientOrderId: `${this.id}-stop-${Date.now()}`,
362
+ clientOrderId: stopCoid,
221
363
  });
222
364
  bracket.stopId = stopOrder.orderId;
223
365
  }
224
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" });
225
369
  const tgtOrder = await this.broker.submitOrder({
226
- symbol: this.symbol,
370
+ symbol: sym,
227
371
  side: exitSide,
228
372
  type: "limit",
229
373
  qty: size,
230
374
  limitPrice: targetPrice,
231
- clientOrderId: `${this.id}-target-${Date.now()}`,
375
+ clientOrderId: tgtCoid,
232
376
  });
233
377
  bracket.targetId = tgtOrder.orderId;
234
378
  }
235
- this.brackets.set(this.symbol, bracket);
379
+ this.brackets.set(sym, bracket);
236
380
  }
237
381
 
238
382
  async _syncEquityAndRisk() {
@@ -247,6 +391,12 @@ export class TradingSession {
247
391
  } else {
248
392
  this.riskManager.update({ timeMs: Date.now(), equity: this.equity });
249
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;
250
400
  }
251
401
 
252
402
  async closePosition(symbol = this.symbol) {
@@ -305,6 +455,7 @@ export class TradingSession {
305
455
  return {
306
456
  id: this.id,
307
457
  symbol: this.symbol,
458
+ symbols: this.symbols,
308
459
  interval: this.interval,
309
460
  mode: this.mode,
310
461
  running: this.running,
@@ -10,6 +10,19 @@ function requireSession(sessionId) {
10
10
  return s;
11
11
  }
12
12
 
13
+ function signalToOrder(signal) {
14
+ return {
15
+ side: signal.side ?? signal.direction ?? signal.action,
16
+ type: signal.type ?? "market",
17
+ qty: signal.qty ?? signal.size,
18
+ riskPct: signal.riskPct,
19
+ stop: signal.stop ?? signal.stopLoss ?? signal.sl,
20
+ target: signal.target ?? signal.takeProfit ?? signal.tp,
21
+ rr: signal.rr ?? signal._rr,
22
+ limitPrice: signal.limitPrice ?? signal.limit ?? signal.entry ?? signal.price,
23
+ };
24
+ }
25
+
13
26
  export { manager as sessionManager };
14
27
 
15
28
  export const liveTools = {
@@ -19,6 +32,7 @@ export const liveTools = {
19
32
  handler: async ({
20
33
  sessionId,
21
34
  symbol,
35
+ symbols,
22
36
  mode = "paper",
23
37
  interval = "1m",
24
38
  equity = 10_000,
@@ -29,6 +43,7 @@ export const liveTools = {
29
43
  const session = await manager.create({
30
44
  id: sessionId,
31
45
  symbol,
46
+ symbols,
32
47
  mode,
33
48
  interval,
34
49
  equity,
@@ -59,21 +74,36 @@ export const liveTools = {
59
74
  feed_price: {
60
75
  description:
61
76
  "Feed a price bar (or single price) to a session, advancing paper simulations and triggering fills.",
62
- handler: async ({ sessionId, bar, price } = {}) => {
77
+ handler: async ({ sessionId, bar, price, symbol } = {}) => {
63
78
  const session = requireSession(sessionId);
64
79
  let b = bar;
65
80
  if (!b && Number.isFinite(price)) {
66
81
  b = { time: Date.now(), open: price, high: price, low: price, close: price, volume: 0 };
67
82
  }
68
83
  if (!b) throw new Error("Provide either `bar` (OHLCV) or `price` (number)");
69
- await session.pushBar(b);
70
-
71
- // If a strategy is attached and session is flat, evaluate it
72
- 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) {
73
92
  try {
74
- const signal = session._strategy(session.candleBuffer, session.getStatus());
75
- if (signal && signal.side && signal.type) {
76
- await session.placeOrder(signal).catch(() => {});
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);
105
+ if (signal && (signal.side || signal.direction || signal.action)) {
106
+ await session.placeOrder({ ...signalToOrder(signal), symbol: effectiveSym }).catch(() => {});
77
107
  }
78
108
  } catch {
79
109
  // strategy errors are non-fatal
@@ -97,9 +127,10 @@ export const liveTools = {
97
127
  target,
98
128
  rr,
99
129
  limitPrice,
130
+ symbol,
100
131
  } = {}) => {
101
132
  const session = requireSession(sessionId);
102
- return session.placeOrder({ side, type, qty, riskPct, stop, target, rr, limitPrice });
133
+ return session.placeOrder({ side, type, qty, riskPct, stop, target, rr, limitPrice, symbol });
103
134
  },
104
135
  },
105
136
 
@@ -156,15 +187,11 @@ export const liveTools = {
156
187
  attach_strategy: {
157
188
  description:
158
189
  "Attach a named built-in strategy to a session. It will auto-evaluate on each feed_price and place orders when flat.",
159
- handler: async ({ sessionId, strategy, params = {} } = {}) => {
190
+ handler: async ({ sessionId, strategy, params = {}, symbol } = {}) => {
160
191
  const session = requireSession(sessionId);
161
192
  const factory = getStrategy(strategy);
162
193
  const signal = factory(params);
163
- // Wrap: accept (candleBuffer, status) and call signal with the buffer
164
- session._strategy = (candleBuffer) => {
165
- if (!candleBuffer || candleBuffer.length === 0) return null;
166
- return signal(candleBuffer[candleBuffer.length - 1], candleBuffer);
167
- };
194
+ session._strategies.set(symbol ?? session.symbol, signal);
168
195
  return { ok: true, strategy, params };
169
196
  },
170
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
  };
package/src/mcp/tools.js CHANGED
@@ -5,6 +5,10 @@ import { getStrategy, listStrategies } from "../strategies/index.js";
5
5
  import { grid } from "../engine/grid.js";
6
6
  import { monteCarlo, deflatedSharpe } from "../research/index.js";
7
7
  import { liveTools } from "./liveTools.js";
8
+ import { researchTools as researchSessionTools } from "./researchSession.js";
9
+ import { createResearchStore } from "../research/store.js";
10
+
11
+ const researchStore = createResearchStore();
8
12
 
9
13
  function summarizeMetrics(metrics) {
10
14
  const {
@@ -93,10 +97,31 @@ export const researchTools = {
93
97
  collectReplay: false,
94
98
  ...(args.backtestOptions || {}),
95
99
  });
100
+ const metrics = summarizeMetrics(result.metrics);
101
+ if (args.researchId) {
102
+ let verdict = null;
103
+ try {
104
+ const psr = deflatedSharpe({
105
+ sharpe: result.metrics.sharpe,
106
+ sampleSize: result.metrics.trades,
107
+ numTrials: args.numTrials ?? 1,
108
+ });
109
+ verdict = {
110
+ deflatedSharpe: psr,
111
+ overfit: Number.isFinite(psr) ? psr < 0.9 : false,
112
+ note: Number.isFinite(psr) ? `PSR ${(psr * 100).toFixed(1)}%` : "insufficient data",
113
+ };
114
+ } catch {
115
+ verdict = { deflatedSharpe: null, overfit: false, note: "verdict unavailable" };
116
+ }
117
+ await researchStore.log(args.researchId, {
118
+ hypothesis: args.strategy, params: args.params || {}, metrics, verdict,
119
+ }).catch(() => {});
120
+ }
96
121
  return {
97
122
  symbol: result.symbol,
98
123
  interval: result.interval,
99
- metrics: summarizeMetrics(result.metrics),
124
+ metrics,
100
125
  tradesPreview: result.positions.slice(0, 10).map((p) => ({
101
126
  side: p.side,
102
127
  entry: p.entryFill ?? p.entry,
@@ -262,4 +287,4 @@ export const researchPlusTools = {
262
287
  },
263
288
  };
264
289
 
265
- export const mcpTools = { ...researchTools, ...researchPlusTools, ...liveTools };
290
+ export const mcpTools = { ...researchTools, ...researchPlusTools, ...liveTools, ...researchSessionTools() };