tradelab 1.1.0 → 1.2.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +183 -373
  3. package/dist/cjs/index.cjs +39 -12
  4. package/dist/cjs/live.cjs +457 -18
  5. package/docs/README.md +32 -66
  6. package/docs/api-reference.md +269 -144
  7. package/docs/backtest-engine.md +167 -321
  8. package/docs/data-reporting-cli.md +114 -156
  9. package/docs/examples.md +6 -6
  10. package/docs/live-trading.md +254 -134
  11. package/docs/mcp.md +244 -23
  12. package/docs/research.md +99 -45
  13. package/examples/mcpLiveTrading.js +77 -0
  14. package/package.json +11 -3
  15. package/src/engine/optimize.js +25 -1
  16. package/src/engine/portfolio.js +6 -2
  17. package/src/live/dashboard/server.js +67 -8
  18. package/src/live/engine/paperEngine.js +21 -11
  19. package/src/live/index.js +2 -0
  20. package/src/live/session.js +439 -0
  21. package/src/mcp/liveTools.js +202 -0
  22. package/src/mcp/schemas.js +119 -0
  23. package/src/mcp/server.js +5 -1
  24. package/src/mcp/tools.js +125 -2
  25. package/src/research/monteCarlo.js +6 -2
  26. package/templates/dashboard.html +595 -108
  27. package/types/index.d.ts +25 -0
  28. package/types/live.d.ts +102 -1
  29. package/types/mcp.d.ts +17 -0
  30. package/docs/superpowers/plans/2026-00-overview.md +0 -101
  31. package/docs/superpowers/plans/2026-01-metrics-correctness.md +0 -873
  32. package/docs/superpowers/plans/2026-02-indicator-library.md +0 -677
  33. package/docs/superpowers/plans/2026-03-overfitting-toolkit.md +0 -882
  34. package/docs/superpowers/plans/2026-04-async-signals-seeding.md +0 -981
  35. package/docs/superpowers/plans/2026-05-mcp-server.md +0 -758
  36. package/docs/superpowers/plans/2026-06-parallel-param-sweep.md +0 -508
  37. package/docs/superpowers/plans/2026-07-funding-carry-costs.md +0 -535
  38. package/docs/superpowers/plans/2026-08-live-dashboard.md +0 -547
  39. package/docs/superpowers/plans/HANDOFF.md +0 -88
@@ -148,6 +148,7 @@ function forceExitAll(runners, time) {
148
148
  export function backtestPortfolio({
149
149
  systems = [],
150
150
  equity = 10_000,
151
+ interval,
151
152
  allocation = "equal",
152
153
  collectEqSeries = true,
153
154
  collectReplay = false,
@@ -307,18 +308,21 @@ export function backtestPortfolio({
307
308
  const replay = combineReplay(systemResults, eqSeries, collectReplay);
308
309
  const allCandles = systems.flatMap((system) => system.candles || []);
309
310
  const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
311
+ const metricsInterval = interval ?? systems[0]?.interval;
312
+ const finalState = portfolioState(runners, equity);
310
313
  const metrics = buildMetrics({
311
314
  closed: trades,
312
315
  equityStart: equity,
313
- equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : equity,
316
+ equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : finalState.markedEquity,
314
317
  candles: orderedCandles,
315
318
  estBarMs: estimateBarMs(orderedCandles),
316
319
  eqSeries,
320
+ interval: metricsInterval,
317
321
  });
318
322
 
319
323
  return {
320
324
  symbol: "PORTFOLIO",
321
- interval: undefined,
325
+ interval: metricsInterval,
322
326
  range: undefined,
323
327
  trades,
324
328
  positions,
@@ -1,5 +1,5 @@
1
1
  import http from "node:http";
2
- import { readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
@@ -22,13 +22,26 @@ const FALLBACK_HTML = `<!doctype html>
22
22
  </body>
23
23
  </html>`;
24
24
 
25
- function readDashboardHtml() {
26
- if (import.meta.url) {
27
- const here = path.dirname(fileURLToPath(import.meta.url));
28
- const htmlPath = path.join(here, "..", "..", "..", "templates", "dashboard.html");
29
- return readFileSync(htmlPath, "utf8");
30
- }
25
+ function callerModuleDir() {
26
+ const stack = new Error().stack || "";
27
+ const lines = stack.split("\n").slice(1);
28
+ const match = lines
29
+ .map((line) => line.match(/(?:\()?(file:\/\/\/[^\s)]+|\/[^\s)]+):\d+:\d+/))
30
+ .find(Boolean);
31
+ if (!match) return process.cwd();
32
+ const filePath = match[1].startsWith("file://") ? fileURLToPath(match[1]) : match[1];
33
+ return path.dirname(filePath);
34
+ }
31
35
 
36
+ function readDashboardHtml() {
37
+ const here = callerModuleDir();
38
+ const candidates = [
39
+ path.join(here, "..", "..", "..", "templates", "dashboard.html"),
40
+ path.join(here, "..", "..", "templates", "dashboard.html"),
41
+ path.join(process.cwd(), "templates", "dashboard.html"),
42
+ ];
43
+ const htmlPath = candidates.find((candidate) => existsSync(candidate));
44
+ if (htmlPath) return readFileSync(htmlPath, "utf8");
32
45
  try {
33
46
  return readFileSync(path.join(process.cwd(), "templates", "dashboard.html"), "utf8");
34
47
  } catch {
@@ -61,7 +74,7 @@ export function createDashboardServer({ source, port = 4317, maxBuffer = 200 })
61
74
  for (const res of clients) res.write(frame);
62
75
  });
63
76
 
64
- const server = http.createServer((req, res) => {
77
+ const server = http.createServer(async (req, res) => {
65
78
  const url = (req.url || "/").split("?")[0];
66
79
 
67
80
  if (url === "/") {
@@ -71,12 +84,58 @@ export function createDashboardServer({ source, port = 4317, maxBuffer = 200 })
71
84
  }
72
85
 
73
86
  if (url === "/state") {
87
+ if (typeof source.refresh === "function") await source.refresh().catch(() => {});
74
88
  const status = typeof source.getStatus === "function" ? source.getStatus() : {};
75
89
  res.writeHead(200, { "Content-Type": "application/json" });
76
90
  res.end(JSON.stringify(status));
77
91
  return;
78
92
  }
79
93
 
94
+ if (url === "/command" && req.method === "POST") {
95
+ const WHITELIST = {
96
+ flatten: "flatten",
97
+ stop: "stop",
98
+ closePosition: "closePosition",
99
+ cancelOrder: "cancelOrder",
100
+ };
101
+ let body = "";
102
+ req.on("data", (c) => (body += c));
103
+ req.on("end", async () => {
104
+ let cmd;
105
+ try {
106
+ cmd = JSON.parse(body || "{}");
107
+ } catch {
108
+ cmd = {};
109
+ }
110
+ const method = WHITELIST[cmd.type];
111
+ if (!method || typeof source[method] !== "function") {
112
+ res.writeHead(400, { "Content-Type": "application/json" });
113
+ res.end(JSON.stringify({ ok: false, error: `unsupported command "${cmd.type}"` }));
114
+ return;
115
+ }
116
+ try {
117
+ const arg =
118
+ cmd.type === "closePosition"
119
+ ? cmd.symbol
120
+ : cmd.type === "cancelOrder"
121
+ ? cmd.orderId
122
+ : undefined;
123
+ await source[method](arg);
124
+ res.writeHead(200, { "Content-Type": "application/json" });
125
+ res.end(JSON.stringify({ ok: true }));
126
+ } catch (error) {
127
+ res.writeHead(500, { "Content-Type": "application/json" });
128
+ res.end(
129
+ JSON.stringify({
130
+ ok: false,
131
+ error: error instanceof Error ? error.message : String(error),
132
+ })
133
+ );
134
+ }
135
+ });
136
+ return;
137
+ }
138
+
80
139
  if (url === "/events") {
81
140
  res.writeHead(200, {
82
141
  "Content-Type": "text/event-stream",
@@ -216,15 +216,21 @@ export class PaperEngine extends BrokerAdapter {
216
216
  this.orderHistory.set(order.orderId, { ...order });
217
217
  }
218
218
 
219
+ _rejectOrder(order, reason) {
220
+ order.status = "rejected";
221
+ order.rejectReason = reason;
222
+ this._recordOrder(order);
223
+ this.openOrders.delete(order.orderId);
224
+ const receipt = cloneOrder(order);
225
+ this.emit("order:rejected", receipt);
226
+ return receipt;
227
+ }
228
+
219
229
  _fillOrder(order, fillPrice, kind = "market", fillTime = Date.now()) {
220
230
  const side = normalizeOrderSide(order.side);
221
231
  const qty = Math.max(0, asNumber(order.qty, 0));
222
232
  if (!(qty > 0)) {
223
- order.status = "rejected";
224
- order.rejectReason = "invalid quantity";
225
- this._recordOrder(order);
226
- this.emit("order:rejected", cloneOrder(order));
227
- return cloneOrder(order);
233
+ return this._rejectOrder(order, "invalid quantity");
228
234
  }
229
235
 
230
236
  const sideForFill = side === "buy" ? "long" : "short";
@@ -346,11 +352,7 @@ export class PaperEngine extends BrokerAdapter {
346
352
  };
347
353
 
348
354
  if (!(normalized.qty > 0)) {
349
- normalized.status = "rejected";
350
- normalized.rejectReason = "invalid quantity";
351
- this._recordOrder(normalized);
352
- this.emit("order:rejected", cloneOrder(normalized));
353
- return cloneOrder(normalized);
355
+ return this._rejectOrder(normalized, "invalid quantity");
354
356
  }
355
357
 
356
358
  this._recordOrder(normalized);
@@ -358,7 +360,10 @@ export class PaperEngine extends BrokerAdapter {
358
360
 
359
361
  if (normalized.type === "market") {
360
362
  const mark = this.lastPrices.get(normalized.symbol);
361
- const fillPrice = mark ?? normalized.limitPrice ?? normalized.stopPrice ?? 0;
363
+ const fillPrice = mark ?? normalized.limitPrice ?? normalized.stopPrice;
364
+ if (!Number.isFinite(fillPrice)) {
365
+ return this._rejectOrder(normalized, "no price available for market order");
366
+ }
362
367
  return this._fillOrder(normalized, fillPrice, "market");
363
368
  }
364
369
 
@@ -424,6 +429,11 @@ export class PaperEngine extends BrokerAdapter {
424
429
 
425
430
  const orders = [...this.openOrders.values()].filter((order) => order.symbol === symbol);
426
431
  for (const order of orders) {
432
+ // Skip orders already consumed this pass (e.g. an OCO sibling about to be
433
+ // canceled, or any order removed by a prior fill). _fillOrder deletes from
434
+ // openOrders before emitting, so this guard prevents bracket double-fills
435
+ // when one bar straddles both stop and target.
436
+ if (!this.openOrders.has(order.orderId)) continue;
427
437
  if (order.type === "limit") {
428
438
  if (this._touchesLimit(order, normalizedBar)) {
429
439
  this._fillOrder(order, order.limitPrice, "limit", normalizedBar.time);
package/src/live/index.js CHANGED
@@ -26,3 +26,5 @@ export { LiveEngine, createLiveEngine } from "./engine/liveEngine.js";
26
26
 
27
27
  export { LiveOrchestrator, createLiveOrchestrator } from "./orchestrator.js";
28
28
  export { createDashboardServer } from "./dashboard/server.js";
29
+
30
+ export { TradingSession, SessionManager, createSessionManager } from "./session.js";
@@ -0,0 +1,439 @@
1
+ import { EventBus } from "./events.js";
2
+ import { RiskManager } from "./engine/riskManager.js";
3
+ import { calculatePositionSize } from "../utils/positionSizing.js";
4
+ import { roundStep } from "../engine/execution.js";
5
+ import { PaperEngine } from "./engine/paperEngine.js";
6
+
7
+ function oppositeSide(side) {
8
+ return side === "long" || side === "buy" ? "sell" : "buy";
9
+ }
10
+
11
+ function toBrokerSide(side) {
12
+ return side === "long" || side === "buy" ? "buy" : "sell";
13
+ }
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
+
28
+ export class TradingSession {
29
+ constructor({
30
+ id,
31
+ symbol,
32
+ interval = "1m",
33
+ broker,
34
+ mode = "paper",
35
+ equity = 10_000,
36
+ riskPct = 1,
37
+ maxDailyLossPct = 0,
38
+ maxPositionPct = 1,
39
+ qtyStep = 0.001,
40
+ minQty = 0.001,
41
+ maxLeverage = 2,
42
+ confirmLive = false,
43
+ eventBus,
44
+ } = {}) {
45
+ if (mode === "live" && (!TradingSession.liveAllowed() || !confirmLive)) {
46
+ throw new Error(
47
+ "live trading is gated: set TRADELAB_ALLOW_LIVE=true and pass confirmLive:true with a credentialed broker"
48
+ );
49
+ }
50
+ if (!broker) throw new Error("TradingSession requires a broker (PaperEngine by default)");
51
+ if (!symbol) throw new Error("TradingSession requires a symbol");
52
+
53
+ this.id = id || `${symbol}-${interval}`;
54
+ this.symbol = symbol;
55
+ this.interval = interval;
56
+ this.broker = broker;
57
+ this.mode = mode;
58
+ this.equity = equity;
59
+ this._startEquity = equity;
60
+ this.riskPct = riskPct;
61
+ this.maxPositionPct = maxPositionPct;
62
+ this.qtyStep = qtyStep;
63
+ this.minQty = minQty;
64
+ this.maxLeverage = maxLeverage;
65
+ this.eventBus = eventBus || new EventBus();
66
+ this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0 });
67
+ this.lastPrice = null;
68
+ this.running = false;
69
+ this.events = [];
70
+ this.brackets = new Map(); // symbol -> { stopId, targetId }
71
+ this._pendingBracket = null;
72
+ this._cachedPositions = [];
73
+ this._cachedOpenOrders = [];
74
+ this.candleBuffer = [];
75
+ this._strategy = null;
76
+
77
+ this._wireBrokerEvents();
78
+ }
79
+
80
+ static liveAllowed() {
81
+ return process.env.TRADELAB_ALLOW_LIVE === "true";
82
+ }
83
+
84
+ _record(event, payload) {
85
+ const msg = { event, payload, t: Date.now() };
86
+ this.events.push(msg);
87
+ if (this.events.length > 500) this.events.shift();
88
+ this.eventBus.emitEvent(event, { sessionId: this.id, symbol: this.symbol, ...payload });
89
+ }
90
+
91
+ _wireBrokerEvents() {
92
+ // Forward broker fills/cancels onto the session bus, and run OCO bracket logic.
93
+ this.broker.on?.("order:filled", (order) => this._onBrokerFillSync(order));
94
+ this.broker.on?.("order:submitted", (order) => this._record("order:submitted", order));
95
+ this.broker.on?.("order:canceled", (order) =>
96
+ this._onBrokerTerminalOrderSync("order:canceled", order)
97
+ );
98
+ this.broker.on?.("order:rejected", (order) =>
99
+ this._onBrokerTerminalOrderSync("order:rejected", order)
100
+ );
101
+ this.broker.on?.("equity:update", (acct) => this._record("equity:update", acct));
102
+ }
103
+
104
+ _onBrokerTerminalOrderSync(event, order) {
105
+ this._record(event, order);
106
+ if (matchesOrderRef(this._pendingBracket, order)) {
107
+ this._pendingBracket = null;
108
+ }
109
+ }
110
+
111
+ // Sync event handler — fire-and-forget async OCO work via a stored promise
112
+ _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;
124
+ }
125
+
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
+ })();
137
+ }
138
+ }
139
+
140
+ async start() {
141
+ if (!this.broker.isConnected?.()) await this.broker.connect?.({});
142
+ const acct = await this.broker.getAccount?.().catch(() => null);
143
+ if (Number.isFinite(acct?.equity)) {
144
+ this.equity = acct.equity;
145
+ this._startEquity = acct.equity;
146
+ }
147
+ this.riskManager.initialize(this.equity, Date.now());
148
+ this.running = true;
149
+ this._record("connected", { mode: this.mode });
150
+ }
151
+
152
+ async stop({ flatten = false } = {}) {
153
+ if (flatten) await this.flatten();
154
+ this.running = false;
155
+ this._record("shutdown", {});
156
+ }
157
+
158
+ async pushBar(b) {
159
+ this.lastPrice = b.close;
160
+ if (typeof this.broker.simulateBar === "function") {
161
+ await this.broker.simulateBar(this.symbol, this.interval, b);
162
+ }
163
+ // Wait for any pending OCO cancel triggered by simulateBar fills
164
+ if (this._pendingCancelPromise) {
165
+ await this._pendingCancelPromise;
166
+ this._pendingCancelPromise = null;
167
+ }
168
+ this.candleBuffer.push(b);
169
+ if (this.candleBuffer.length > 200) this.candleBuffer.shift();
170
+ this._record("bar", { close: b.close, time: b.time });
171
+ await this._syncEquityAndRisk();
172
+ await this.refresh();
173
+ }
174
+
175
+ _riskHalted() {
176
+ const state = this.riskManager.getState?.() || {};
177
+ return Boolean(state.halted);
178
+ }
179
+
180
+ async placeOrder({ side, type = "market", qty, riskPct, stop, target, rr, limitPrice } = {}) {
181
+ if (!this.running) throw new Error("session not started");
182
+ if (this._riskHalted()) throw new Error("session is risk-halted for the day");
183
+ const entryRef = type === "limit" ? limitPrice : this.lastPrice;
184
+ if (!Number.isFinite(entryRef)) throw new Error("no price available; pushBar() a price first");
185
+
186
+ let size = qty;
187
+ if (!Number.isFinite(size)) {
188
+ const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
189
+ if (!Number.isFinite(stop)) throw new Error("risk-based sizing requires a stop");
190
+ size = calculatePositionSize({
191
+ equity: this.equity,
192
+ entry: entryRef,
193
+ stop,
194
+ riskFraction: fraction,
195
+ qtyStep: this.qtyStep,
196
+ minQty: this.minQty,
197
+ maxLeverage: this.maxLeverage,
198
+ });
199
+ }
200
+ size = roundStep(size, this.qtyStep);
201
+ if (!(size >= this.minQty)) throw new Error(`sized below minQty (${size})`);
202
+
203
+ const entryClientOrderId = `${this.id}-entry-${Date.now()}`;
204
+ const receipt = await this.broker.submitOrder({
205
+ symbol: this.symbol,
206
+ side: toBrokerSide(side),
207
+ type,
208
+ qty: size,
209
+ limitPrice: type === "limit" ? limitPrice : undefined,
210
+ clientOrderId: entryClientOrderId,
211
+ });
212
+
213
+ // Stage bracket if needed — market orders fill synchronously in PaperEngine
214
+ if (Number.isFinite(stop) || Number.isFinite(target) || Number.isFinite(rr)) {
215
+ if (receipt.status === "filled") {
216
+ await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt });
217
+ } else if (receipt.status !== "rejected") {
218
+ this._pendingBracket = {
219
+ side,
220
+ size,
221
+ stop,
222
+ target,
223
+ rr,
224
+ entryRef,
225
+ orderId: receipt.orderId,
226
+ clientOrderId: receipt.clientOrderId || entryClientOrderId,
227
+ };
228
+ } else {
229
+ this._pendingBracket = null;
230
+ }
231
+ }
232
+
233
+ await this.refresh();
234
+ return receipt;
235
+ }
236
+
237
+ async _attachBracket({ side, size, stop, target, rr, entryRef, receipt }) {
238
+ const entryFill = receipt?.avgFillPrice ?? entryRef;
239
+ const risk = Number.isFinite(stop) ? Math.abs(entryFill - stop) : null;
240
+ const targetPrice = Number.isFinite(target)
241
+ ? target
242
+ : Number.isFinite(rr) && risk
243
+ ? side === "long" || side === "buy"
244
+ ? entryFill + rr * risk
245
+ : entryFill - rr * risk
246
+ : null;
247
+ const exitSide = oppositeSide(side);
248
+ const bracket = {};
249
+
250
+ if (Number.isFinite(stop)) {
251
+ const stopOrder = await this.broker.submitOrder({
252
+ symbol: this.symbol,
253
+ side: exitSide,
254
+ type: "stop",
255
+ qty: size,
256
+ stopPrice: stop,
257
+ clientOrderId: `${this.id}-stop-${Date.now()}`,
258
+ });
259
+ bracket.stopId = stopOrder.orderId;
260
+ }
261
+ if (Number.isFinite(targetPrice)) {
262
+ const tgtOrder = await this.broker.submitOrder({
263
+ symbol: this.symbol,
264
+ side: exitSide,
265
+ type: "limit",
266
+ qty: size,
267
+ limitPrice: targetPrice,
268
+ clientOrderId: `${this.id}-target-${Date.now()}`,
269
+ });
270
+ bracket.targetId = tgtOrder.orderId;
271
+ }
272
+ this.brackets.set(this.symbol, bracket);
273
+ }
274
+
275
+ async _syncEquityAndRisk() {
276
+ const acct = await this.broker.getAccount?.().catch(() => null);
277
+ if (!Number.isFinite(acct?.equity)) return;
278
+ const prevEquity = this.equity;
279
+ this.equity = acct.equity;
280
+ const pnlDelta = this.equity - prevEquity;
281
+ // Record the trade pnl change so RiskManager can check daily loss
282
+ if (pnlDelta !== 0) {
283
+ this.riskManager.recordTrade({ pnl: pnlDelta, timeMs: Date.now(), equity: this.equity });
284
+ } else {
285
+ this.riskManager.update({ timeMs: Date.now(), equity: this.equity });
286
+ }
287
+ }
288
+
289
+ async closePosition(symbol = this.symbol) {
290
+ const positions = await this.broker.getPositions();
291
+ const pos = positions.find((p) => p.symbol === symbol);
292
+ if (!pos) return null;
293
+
294
+ // cancel any resting bracket first
295
+ const bracket = this.brackets.get(symbol);
296
+ if (bracket) {
297
+ for (const id of [bracket.stopId, bracket.targetId]) {
298
+ if (id) await this.broker.cancelOrder(id).catch(() => {});
299
+ }
300
+ this.brackets.delete(symbol);
301
+ }
302
+
303
+ const receipt = await this.broker.submitOrder({
304
+ symbol,
305
+ side: oppositeSide(pos.side),
306
+ type: "market",
307
+ qty: pos.qty,
308
+ clientOrderId: `${this.id}-close-${Date.now()}`,
309
+ });
310
+ await this._syncEquityAndRisk();
311
+ await this.refresh();
312
+ return receipt;
313
+ }
314
+
315
+ async flatten() {
316
+ const positions = await this.broker.getPositions();
317
+ for (const p of positions) await this.closePosition(p.symbol);
318
+ const open = (await this.broker.getOpenOrders?.().catch(() => [])) ?? [];
319
+ for (const o of open) await this.broker.cancelOrder(o.orderId).catch(() => {});
320
+ await this.refresh();
321
+ }
322
+
323
+ async cancelOrder(orderId) {
324
+ await this.broker.cancelOrder(orderId);
325
+ await this.refresh();
326
+ }
327
+
328
+ async getAccount() {
329
+ return this.broker.getAccount();
330
+ }
331
+
332
+ async getPositions() {
333
+ return this.broker.getPositions();
334
+ }
335
+
336
+ recentEvents(limit = 50) {
337
+ return this.events.slice(-limit);
338
+ }
339
+
340
+ getStatus() {
341
+ const risk = this.riskManager.getState?.() || {};
342
+ return {
343
+ id: this.id,
344
+ symbol: this.symbol,
345
+ interval: this.interval,
346
+ mode: this.mode,
347
+ running: this.running,
348
+ equity: this.equity,
349
+ dayPnl: risk.dayPnl ?? 0,
350
+ lastPrice: this.lastPrice,
351
+ positions: this._cachedPositions ?? [],
352
+ openOrders: this._cachedOpenOrders ?? [],
353
+ risk: { halted: Boolean(risk.halted), ...risk },
354
+ };
355
+ }
356
+
357
+ /** Refresh sync caches used by getStatus() */
358
+ async refresh() {
359
+ // Wait for any pending OCO cancel before refreshing state
360
+ if (this._pendingCancelPromise) {
361
+ await this._pendingCancelPromise;
362
+ this._pendingCancelPromise = null;
363
+ }
364
+ this._cachedPositions = await this.broker.getPositions().catch(() => []);
365
+ this._cachedOpenOrders = (await this.broker.getOpenOrders?.().catch(() => [])) ?? [];
366
+ const acct = await this.broker.getAccount?.().catch(() => null);
367
+ if (Number.isFinite(acct?.equity)) this.equity = acct.equity;
368
+ return this.getStatus();
369
+ }
370
+ }
371
+
372
+ export class SessionManager {
373
+ constructor({ brokerFactory } = {}) {
374
+ this.sessions = new Map();
375
+ this.brokerFactory = brokerFactory;
376
+ }
377
+
378
+ async create({
379
+ id,
380
+ mode = "paper",
381
+ symbol,
382
+ interval = "1m",
383
+ equity = 10_000,
384
+ confirmLive = false,
385
+ broker,
386
+ ...rest
387
+ } = {}) {
388
+ if (this.sessions.has(id)) throw new Error(`session "${id}" already exists`);
389
+ let resolvedBroker = broker;
390
+ if (mode === "live") {
391
+ if (!TradingSession.liveAllowed() || !confirmLive) {
392
+ throw new Error("live mode requires TRADELAB_ALLOW_LIVE=true and confirmLive:true");
393
+ }
394
+ if (!resolvedBroker && this.brokerFactory) {
395
+ resolvedBroker = this.brokerFactory({ symbol, ...rest });
396
+ }
397
+ if (!resolvedBroker) throw new Error("live mode requires a credentialed broker");
398
+ }
399
+ if (!resolvedBroker) resolvedBroker = new PaperEngine({ equity });
400
+ const session = new TradingSession({
401
+ id,
402
+ symbol,
403
+ interval,
404
+ broker: resolvedBroker,
405
+ mode,
406
+ equity,
407
+ confirmLive,
408
+ ...rest,
409
+ });
410
+ await session.start();
411
+ this.sessions.set(session.id, session);
412
+ return session;
413
+ }
414
+
415
+ get(id) {
416
+ return this.sessions.get(id) ?? null;
417
+ }
418
+
419
+ list() {
420
+ return [...this.sessions.values()];
421
+ }
422
+
423
+ async remove(id, { flatten = true } = {}) {
424
+ const s = this.sessions.get(id);
425
+ if (!s) return;
426
+ await s.stop({ flatten });
427
+ this.sessions.delete(id);
428
+ }
429
+
430
+ async haltAll() {
431
+ for (const s of this.sessions.values()) await s.stop({ flatten: true });
432
+ // Remove stopped sessions so list() does not retain them and re-runs don't collide.
433
+ this.sessions.clear();
434
+ }
435
+ }
436
+
437
+ export function createSessionManager(opts) {
438
+ return new SessionManager(opts);
439
+ }