tradelab 1.0.1 → 1.2.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +188 -328
  3. package/bin/tradelab-mcp.js +7 -0
  4. package/bin/tradelab.js +29 -0
  5. package/dist/cjs/data.cjs +149 -26
  6. package/dist/cjs/index.cjs +1917 -1005
  7. package/dist/cjs/live.cjs +536 -25
  8. package/dist/cjs/ta.cjs +339 -0
  9. package/docs/README.md +32 -66
  10. package/docs/api-reference.md +283 -112
  11. package/docs/backtest-engine.md +210 -252
  12. package/docs/data-reporting-cli.md +114 -156
  13. package/docs/examples.md +6 -6
  14. package/docs/live-trading.md +263 -92
  15. package/docs/mcp.md +285 -0
  16. package/docs/research.md +157 -0
  17. package/examples/liveDashboard.js +33 -0
  18. package/examples/llmSignal.js +33 -0
  19. package/examples/mcpLiveTrading.js +77 -0
  20. package/examples/optimize.js +25 -0
  21. package/package.json +26 -4
  22. package/src/engine/asyncSignal.js +28 -0
  23. package/src/engine/backtest.js +13 -1
  24. package/src/engine/backtestAsync.js +27 -0
  25. package/src/engine/backtestTicks.js +13 -2
  26. package/src/engine/barSystemRunner.js +96 -41
  27. package/src/engine/execution.js +39 -0
  28. package/src/engine/grid.js +15 -0
  29. package/src/engine/llmSignal.js +84 -0
  30. package/src/engine/optimize.js +110 -0
  31. package/src/engine/optimizeWorker.js +67 -0
  32. package/src/engine/portfolio.js +4 -1
  33. package/src/engine/walkForward.js +1 -0
  34. package/src/index.js +9 -0
  35. package/src/live/dashboard/server.js +179 -0
  36. package/src/live/engine/liveEngine.js +2 -2
  37. package/src/live/engine/paperEngine.js +5 -0
  38. package/src/live/index.js +3 -0
  39. package/src/live/session.js +402 -0
  40. package/src/mcp/liveTools.js +179 -0
  41. package/src/mcp/schemas.js +167 -0
  42. package/src/mcp/server.js +35 -0
  43. package/src/mcp/tools.js +265 -0
  44. package/src/metrics/annualize.js +32 -0
  45. package/src/metrics/benchmark.js +55 -0
  46. package/src/metrics/buildMetrics.js +34 -13
  47. package/src/metrics/finite.js +17 -0
  48. package/src/research/combinations.js +18 -0
  49. package/src/research/cpcv.js +47 -0
  50. package/src/research/deflatedSharpe.js +35 -0
  51. package/src/research/index.js +6 -0
  52. package/src/research/monteCarlo.js +88 -0
  53. package/src/research/pbo.js +69 -0
  54. package/src/research/stats.js +78 -0
  55. package/src/strategies/builtins.js +96 -0
  56. package/src/strategies/index.js +30 -0
  57. package/src/ta/channels.js +67 -0
  58. package/src/ta/index.js +16 -0
  59. package/src/ta/oscillators.js +70 -0
  60. package/src/ta/trend.js +78 -0
  61. package/src/utils/random.js +33 -0
  62. package/templates/dashboard.html +661 -0
  63. package/types/index.d.ts +179 -0
  64. package/types/live.d.ts +114 -0
  65. package/types/mcp.d.ts +17 -0
  66. package/types/ta.d.ts +45 -0
@@ -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,6 +308,7 @@ 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;
310
312
  const metrics = buildMetrics({
311
313
  closed: trades,
312
314
  equityStart: equity,
@@ -314,11 +316,12 @@ export function backtestPortfolio({
314
316
  candles: orderedCandles,
315
317
  estBarMs: estimateBarMs(orderedCandles),
316
318
  eqSeries,
319
+ interval: metricsInterval,
317
320
  });
318
321
 
319
322
  return {
320
323
  symbol: "PORTFOLIO",
321
- interval: undefined,
324
+ interval: metricsInterval,
322
325
  range: undefined,
323
326
  trades,
324
327
  positions,
@@ -225,6 +225,7 @@ export function walkForwardOptimize({
225
225
  candles,
226
226
  estBarMs: estimateBarMs(candles),
227
227
  eqSeries,
228
+ interval: backtestOptions.interval,
228
229
  });
229
230
  const bestParamsSummary = summarizeBestParams(windows);
230
231
 
package/src/index.js CHANGED
@@ -1,9 +1,18 @@
1
1
  export { backtest } from "./engine/backtest.js";
2
+ export { backtestAsync } from "./engine/backtestAsync.js";
2
3
  export { backtestTicks } from "./engine/backtestTicks.js";
3
4
  export { backtestPortfolio } from "./engine/portfolio.js";
5
+ export { LlmSignal } from "./engine/llmSignal.js";
4
6
  export { walkForwardOptimize } from "./engine/walkForward.js";
7
+ export { optimize } from "./engine/optimize.js";
8
+ export { grid } from "./engine/grid.js";
9
+ export { listStrategies, getStrategy, registerStrategy } from "./strategies/index.js";
10
+ export * as research from "./research/index.js";
5
11
 
6
12
  export { buildMetrics } from "./metrics/buildMetrics.js";
13
+ export { benchmarkStats } from "./metrics/benchmark.js";
14
+ export { clampFinite, BIG_NUMBER } from "./metrics/finite.js";
15
+ export { periodsPerYear } from "./metrics/annualize.js";
7
16
  export {
8
17
  backtestHistorical,
9
18
  cachedCandlesPath,
@@ -0,0 +1,179 @@
1
+ import http from "node:http";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const FALLBACK_HTML = `<!doctype html>
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="utf-8" />
10
+ <title>tradelab live</title>
11
+ </head>
12
+ <body>
13
+ <h1>tradelab live</h1>
14
+ <pre id="state"></pre>
15
+ <script>
16
+ fetch("/state")
17
+ .then((res) => res.json())
18
+ .then((state) => {
19
+ document.getElementById("state").textContent = JSON.stringify(state, null, 2);
20
+ });
21
+ </script>
22
+ </body>
23
+ </html>`;
24
+
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
+ }
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");
45
+ try {
46
+ return readFileSync(path.join(process.cwd(), "templates", "dashboard.html"), "utf8");
47
+ } catch {
48
+ return FALLBACK_HTML;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Local realtime dashboard for a LiveEngine or LiveOrchestrator.
54
+ *
55
+ * @param {object} opts
56
+ * @param {{ eventBus: import("../events.js").EventBus, getStatus: Function }} opts.source
57
+ * @param {number} [opts.port=4317] 0 picks an ephemeral port for tests
58
+ * @param {number} [opts.maxBuffer=200] recent events replayed to new clients
59
+ * @returns {{ start: () => Promise<string>, close: () => Promise<void>, server: http.Server }}
60
+ */
61
+ export function createDashboardServer({ source, port = 4317, maxBuffer = 200 }) {
62
+ if (!source?.eventBus || typeof source.eventBus.onAny !== "function") {
63
+ throw new Error("dashboard source must expose an eventBus with onAny()");
64
+ }
65
+
66
+ const recent = [];
67
+ const clients = new Set();
68
+
69
+ const unsubscribe = source.eventBus.onAny(({ event, payload }) => {
70
+ const msg = { event, payload, t: Date.now() };
71
+ recent.push(msg);
72
+ if (recent.length > maxBuffer) recent.shift();
73
+ const frame = `data: ${JSON.stringify(msg)}\n\n`;
74
+ for (const res of clients) res.write(frame);
75
+ });
76
+
77
+ const server = http.createServer(async (req, res) => {
78
+ const url = (req.url || "/").split("?")[0];
79
+
80
+ if (url === "/") {
81
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
82
+ res.end(readDashboardHtml());
83
+ return;
84
+ }
85
+
86
+ if (url === "/state") {
87
+ if (typeof source.refresh === "function") await source.refresh().catch(() => {});
88
+ const status = typeof source.getStatus === "function" ? source.getStatus() : {};
89
+ res.writeHead(200, { "Content-Type": "application/json" });
90
+ res.end(JSON.stringify(status));
91
+ return;
92
+ }
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
+
139
+ if (url === "/events") {
140
+ res.writeHead(200, {
141
+ "Content-Type": "text/event-stream",
142
+ "Cache-Control": "no-cache",
143
+ Connection: "keep-alive",
144
+ });
145
+ res.flushHeaders();
146
+ for (const msg of recent) res.write(`data: ${JSON.stringify(msg)}\n\n`);
147
+ clients.add(res);
148
+ req.on("close", () => clients.delete(res));
149
+ return;
150
+ }
151
+
152
+ res.writeHead(404, { "Content-Type": "text/plain" });
153
+ res.end("not found");
154
+ });
155
+
156
+ return {
157
+ start() {
158
+ return new Promise((resolve) => {
159
+ server.listen(port, () => {
160
+ const address = server.address();
161
+ const actualPort = typeof address === "object" && address ? address.port : port;
162
+ resolve(`http://localhost:${actualPort}`);
163
+ });
164
+ });
165
+ },
166
+ close() {
167
+ unsubscribe();
168
+ for (const res of clients) res.end();
169
+ clients.clear();
170
+ return new Promise((resolve, reject) => {
171
+ server.close((error) => {
172
+ if (error) reject(error);
173
+ else resolve();
174
+ });
175
+ });
176
+ },
177
+ server,
178
+ };
179
+ }
@@ -2,7 +2,7 @@ import { calculatePositionSize } from "../../utils/positionSizing.js";
2
2
  import { normalizeCandles } from "../../data/csv.js";
3
3
  import { isEODBar, ocoExitCheck } from "../../engine/execution.js";
4
4
  import {
5
- callSignalWithContext,
5
+ callSignalWithContextAsync,
6
6
  normalizeSignal,
7
7
  snapshotOpenPosition,
8
8
  } from "../../engine/barSystemRunner.js";
@@ -513,7 +513,7 @@ export class LiveEngine {
513
513
 
514
514
  if (!this.openPosition && !this.pendingOrder) {
515
515
  const context = this._signalContext(bar);
516
- const rawSignal = callSignalWithContext({
516
+ const rawSignal = await callSignalWithContextAsync({
517
517
  signal: this.options.signal,
518
518
  context,
519
519
  index: context.index,
@@ -424,6 +424,11 @@ export class PaperEngine extends BrokerAdapter {
424
424
 
425
425
  const orders = [...this.openOrders.values()].filter((order) => order.symbol === symbol);
426
426
  for (const order of orders) {
427
+ // Skip orders already consumed this pass (e.g. an OCO sibling about to be
428
+ // canceled, or any order removed by a prior fill). _fillOrder deletes from
429
+ // openOrders before emitting, so this guard prevents bracket double-fills
430
+ // when one bar straddles both stop and target.
431
+ if (!this.openOrders.has(order.orderId)) continue;
427
432
  if (order.type === "limit") {
428
433
  if (this._touchesLimit(order, normalizedBar)) {
429
434
  this._fillOrder(order, order.limitPrice, "limit", normalizedBar.time);
package/src/live/index.js CHANGED
@@ -25,3 +25,6 @@ export { PaperEngine, createPaperEngine } from "./engine/paperEngine.js";
25
25
  export { LiveEngine, createLiveEngine } from "./engine/liveEngine.js";
26
26
 
27
27
  export { LiveOrchestrator, createLiveOrchestrator } from "./orchestrator.js";
28
+ export { createDashboardServer } from "./dashboard/server.js";
29
+
30
+ export { TradingSession, SessionManager, createSessionManager } from "./session.js";
@@ -0,0 +1,402 @@
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
+ export class TradingSession {
16
+ constructor({
17
+ id,
18
+ symbol,
19
+ interval = "1m",
20
+ broker,
21
+ mode = "paper",
22
+ equity = 10_000,
23
+ riskPct = 1,
24
+ maxDailyLossPct = 0,
25
+ maxPositionPct = 1,
26
+ qtyStep = 0.001,
27
+ minQty = 0.001,
28
+ maxLeverage = 2,
29
+ confirmLive = false,
30
+ eventBus,
31
+ } = {}) {
32
+ if (mode === "live" && (!TradingSession.liveAllowed() || !confirmLive)) {
33
+ throw new Error(
34
+ "live trading is gated: set TRADELAB_ALLOW_LIVE=true and pass confirmLive:true with a credentialed broker"
35
+ );
36
+ }
37
+ if (!broker) throw new Error("TradingSession requires a broker (PaperEngine by default)");
38
+ if (!symbol) throw new Error("TradingSession requires a symbol");
39
+
40
+ this.id = id || `${symbol}-${interval}`;
41
+ this.symbol = symbol;
42
+ this.interval = interval;
43
+ this.broker = broker;
44
+ this.mode = mode;
45
+ this.equity = equity;
46
+ this._startEquity = equity;
47
+ this.riskPct = riskPct;
48
+ this.maxPositionPct = maxPositionPct;
49
+ this.qtyStep = qtyStep;
50
+ this.minQty = minQty;
51
+ this.maxLeverage = maxLeverage;
52
+ this.eventBus = eventBus || new EventBus();
53
+ this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0 });
54
+ this.lastPrice = null;
55
+ this.running = false;
56
+ this.events = [];
57
+ this.brackets = new Map(); // symbol -> { stopId, targetId }
58
+ this._pendingBracket = null;
59
+ this._cachedPositions = [];
60
+ this._cachedOpenOrders = [];
61
+ this.candleBuffer = [];
62
+ this._strategy = null;
63
+
64
+ this._wireBrokerEvents();
65
+ }
66
+
67
+ static liveAllowed() {
68
+ return process.env.TRADELAB_ALLOW_LIVE === "true";
69
+ }
70
+
71
+ _record(event, payload) {
72
+ const msg = { event, payload, t: Date.now() };
73
+ this.events.push(msg);
74
+ if (this.events.length > 500) this.events.shift();
75
+ this.eventBus.emitEvent(event, { sessionId: this.id, symbol: this.symbol, ...payload });
76
+ }
77
+
78
+ _wireBrokerEvents() {
79
+ // Forward broker fills/cancels onto the session bus, and run OCO bracket logic.
80
+ 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));
83
+ this.broker.on?.("equity:update", (acct) => this._record("equity:update", acct));
84
+ }
85
+
86
+ // Sync event handler — fire-and-forget async OCO work via a stored promise
87
+ _onBrokerFillSync(order) {
88
+ this._record("order:filled", order);
89
+
90
+ // 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;
99
+ }
100
+
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
+ })();
112
+ }
113
+ }
114
+
115
+ async start() {
116
+ if (!this.broker.isConnected?.()) await this.broker.connect?.({});
117
+ const acct = await this.broker.getAccount?.().catch(() => null);
118
+ if (Number.isFinite(acct?.equity)) {
119
+ this.equity = acct.equity;
120
+ this._startEquity = acct.equity;
121
+ }
122
+ this.riskManager.initialize(this.equity, Date.now());
123
+ this.running = true;
124
+ this._record("connected", { mode: this.mode });
125
+ }
126
+
127
+ async stop({ flatten = false } = {}) {
128
+ if (flatten) await this.flatten();
129
+ this.running = false;
130
+ this._record("shutdown", {});
131
+ }
132
+
133
+ async pushBar(b) {
134
+ this.lastPrice = b.close;
135
+ if (typeof this.broker.simulateBar === "function") {
136
+ await this.broker.simulateBar(this.symbol, this.interval, b);
137
+ }
138
+ // Wait for any pending OCO cancel triggered by simulateBar fills
139
+ if (this._pendingCancelPromise) {
140
+ await this._pendingCancelPromise;
141
+ this._pendingCancelPromise = null;
142
+ }
143
+ this.candleBuffer.push(b);
144
+ if (this.candleBuffer.length > 200) this.candleBuffer.shift();
145
+ this._record("bar", { close: b.close, time: b.time });
146
+ await this._syncEquityAndRisk();
147
+ await this.refresh();
148
+ }
149
+
150
+ _riskHalted() {
151
+ const state = this.riskManager.getState?.() || {};
152
+ return Boolean(state.halted);
153
+ }
154
+
155
+ async placeOrder({ side, type = "market", qty, riskPct, stop, target, rr, limitPrice } = {}) {
156
+ if (!this.running) throw new Error("session not started");
157
+ if (this._riskHalted()) throw new Error("session is risk-halted for the day");
158
+ const entryRef = type === "limit" ? limitPrice : this.lastPrice;
159
+ if (!Number.isFinite(entryRef)) throw new Error("no price available; pushBar() a price first");
160
+
161
+ let size = qty;
162
+ if (!Number.isFinite(size)) {
163
+ const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
164
+ if (!Number.isFinite(stop)) throw new Error("risk-based sizing requires a stop");
165
+ size = calculatePositionSize({
166
+ equity: this.equity,
167
+ entry: entryRef,
168
+ stop,
169
+ riskFraction: fraction,
170
+ qtyStep: this.qtyStep,
171
+ minQty: this.minQty,
172
+ maxLeverage: this.maxLeverage,
173
+ });
174
+ }
175
+ size = roundStep(size, this.qtyStep);
176
+ if (!(size >= this.minQty)) throw new Error(`sized below minQty (${size})`);
177
+
178
+ const receipt = await this.broker.submitOrder({
179
+ symbol: this.symbol,
180
+ side: toBrokerSide(side),
181
+ type,
182
+ qty: size,
183
+ limitPrice: type === "limit" ? limitPrice : undefined,
184
+ clientOrderId: `${this.id}-entry-${Date.now()}`,
185
+ });
186
+
187
+ // Stage bracket if needed — market orders fill synchronously in PaperEngine
188
+ if (Number.isFinite(stop) || Number.isFinite(target) || Number.isFinite(rr)) {
189
+ if (receipt.status === "filled") {
190
+ await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt });
191
+ } else {
192
+ this._pendingBracket = { side, size, stop, target, rr, entryRef };
193
+ }
194
+ }
195
+
196
+ await this.refresh();
197
+ return receipt;
198
+ }
199
+
200
+ async _attachBracket({ side, size, stop, target, rr, entryRef, receipt }) {
201
+ const entryFill = receipt?.avgFillPrice ?? entryRef;
202
+ const risk = Number.isFinite(stop) ? Math.abs(entryFill - stop) : null;
203
+ const targetPrice = Number.isFinite(target)
204
+ ? target
205
+ : Number.isFinite(rr) && risk
206
+ ? side === "long" || side === "buy"
207
+ ? entryFill + rr * risk
208
+ : entryFill - rr * risk
209
+ : null;
210
+ const exitSide = oppositeSide(side);
211
+ const bracket = {};
212
+
213
+ if (Number.isFinite(stop)) {
214
+ const stopOrder = await this.broker.submitOrder({
215
+ symbol: this.symbol,
216
+ side: exitSide,
217
+ type: "stop",
218
+ qty: size,
219
+ stopPrice: stop,
220
+ clientOrderId: `${this.id}-stop-${Date.now()}`,
221
+ });
222
+ bracket.stopId = stopOrder.orderId;
223
+ }
224
+ if (Number.isFinite(targetPrice)) {
225
+ const tgtOrder = await this.broker.submitOrder({
226
+ symbol: this.symbol,
227
+ side: exitSide,
228
+ type: "limit",
229
+ qty: size,
230
+ limitPrice: targetPrice,
231
+ clientOrderId: `${this.id}-target-${Date.now()}`,
232
+ });
233
+ bracket.targetId = tgtOrder.orderId;
234
+ }
235
+ this.brackets.set(this.symbol, bracket);
236
+ }
237
+
238
+ async _syncEquityAndRisk() {
239
+ const acct = await this.broker.getAccount?.().catch(() => null);
240
+ if (!Number.isFinite(acct?.equity)) return;
241
+ const prevEquity = this.equity;
242
+ this.equity = acct.equity;
243
+ const pnlDelta = this.equity - prevEquity;
244
+ // Record the trade pnl change so RiskManager can check daily loss
245
+ if (pnlDelta !== 0) {
246
+ this.riskManager.recordTrade({ pnl: pnlDelta, timeMs: Date.now(), equity: this.equity });
247
+ } else {
248
+ this.riskManager.update({ timeMs: Date.now(), equity: this.equity });
249
+ }
250
+ }
251
+
252
+ async closePosition(symbol = this.symbol) {
253
+ const positions = await this.broker.getPositions();
254
+ const pos = positions.find((p) => p.symbol === symbol);
255
+ if (!pos) return null;
256
+
257
+ // cancel any resting bracket first
258
+ const bracket = this.brackets.get(symbol);
259
+ if (bracket) {
260
+ for (const id of [bracket.stopId, bracket.targetId]) {
261
+ if (id) await this.broker.cancelOrder(id).catch(() => {});
262
+ }
263
+ this.brackets.delete(symbol);
264
+ }
265
+
266
+ const receipt = await this.broker.submitOrder({
267
+ symbol,
268
+ side: oppositeSide(pos.side),
269
+ type: "market",
270
+ qty: pos.qty,
271
+ clientOrderId: `${this.id}-close-${Date.now()}`,
272
+ });
273
+ await this._syncEquityAndRisk();
274
+ await this.refresh();
275
+ return receipt;
276
+ }
277
+
278
+ async flatten() {
279
+ const positions = await this.broker.getPositions();
280
+ for (const p of positions) await this.closePosition(p.symbol);
281
+ const open = (await this.broker.getOpenOrders?.().catch(() => [])) ?? [];
282
+ for (const o of open) await this.broker.cancelOrder(o.orderId).catch(() => {});
283
+ await this.refresh();
284
+ }
285
+
286
+ async cancelOrder(orderId) {
287
+ await this.broker.cancelOrder(orderId);
288
+ await this.refresh();
289
+ }
290
+
291
+ async getAccount() {
292
+ return this.broker.getAccount();
293
+ }
294
+
295
+ async getPositions() {
296
+ return this.broker.getPositions();
297
+ }
298
+
299
+ recentEvents(limit = 50) {
300
+ return this.events.slice(-limit);
301
+ }
302
+
303
+ getStatus() {
304
+ const risk = this.riskManager.getState?.() || {};
305
+ return {
306
+ id: this.id,
307
+ symbol: this.symbol,
308
+ interval: this.interval,
309
+ mode: this.mode,
310
+ running: this.running,
311
+ equity: this.equity,
312
+ dayPnl: risk.dayPnl ?? 0,
313
+ lastPrice: this.lastPrice,
314
+ positions: this._cachedPositions ?? [],
315
+ openOrders: this._cachedOpenOrders ?? [],
316
+ risk: { halted: Boolean(risk.halted), ...risk },
317
+ };
318
+ }
319
+
320
+ /** Refresh sync caches used by getStatus() */
321
+ async refresh() {
322
+ // Wait for any pending OCO cancel before refreshing state
323
+ if (this._pendingCancelPromise) {
324
+ await this._pendingCancelPromise;
325
+ this._pendingCancelPromise = null;
326
+ }
327
+ this._cachedPositions = await this.broker.getPositions().catch(() => []);
328
+ this._cachedOpenOrders = (await this.broker.getOpenOrders?.().catch(() => [])) ?? [];
329
+ const acct = await this.broker.getAccount?.().catch(() => null);
330
+ if (Number.isFinite(acct?.equity)) this.equity = acct.equity;
331
+ return this.getStatus();
332
+ }
333
+ }
334
+
335
+ export class SessionManager {
336
+ constructor({ brokerFactory } = {}) {
337
+ this.sessions = new Map();
338
+ this.brokerFactory = brokerFactory;
339
+ }
340
+
341
+ async create({
342
+ id,
343
+ mode = "paper",
344
+ symbol,
345
+ interval = "1m",
346
+ equity = 10_000,
347
+ confirmLive = false,
348
+ broker,
349
+ ...rest
350
+ } = {}) {
351
+ if (this.sessions.has(id)) throw new Error(`session "${id}" already exists`);
352
+ let resolvedBroker = broker;
353
+ if (mode === "live") {
354
+ if (!TradingSession.liveAllowed() || !confirmLive) {
355
+ throw new Error("live mode requires TRADELAB_ALLOW_LIVE=true and confirmLive:true");
356
+ }
357
+ if (!resolvedBroker && this.brokerFactory) {
358
+ resolvedBroker = this.brokerFactory({ symbol, ...rest });
359
+ }
360
+ if (!resolvedBroker) throw new Error("live mode requires a credentialed broker");
361
+ }
362
+ if (!resolvedBroker) resolvedBroker = new PaperEngine({ equity });
363
+ const session = new TradingSession({
364
+ id,
365
+ symbol,
366
+ interval,
367
+ broker: resolvedBroker,
368
+ mode,
369
+ equity,
370
+ confirmLive,
371
+ ...rest,
372
+ });
373
+ await session.start();
374
+ this.sessions.set(session.id, session);
375
+ return session;
376
+ }
377
+
378
+ get(id) {
379
+ return this.sessions.get(id) ?? null;
380
+ }
381
+
382
+ list() {
383
+ return [...this.sessions.values()];
384
+ }
385
+
386
+ async remove(id, { flatten = true } = {}) {
387
+ const s = this.sessions.get(id);
388
+ if (!s) return;
389
+ await s.stop({ flatten });
390
+ this.sessions.delete(id);
391
+ }
392
+
393
+ async haltAll() {
394
+ for (const s of this.sessions.values()) await s.stop({ flatten: true });
395
+ // Remove stopped sessions so list() does not retain them and re-runs don't collide.
396
+ this.sessions.clear();
397
+ }
398
+ }
399
+
400
+ export function createSessionManager(opts) {
401
+ return new SessionManager(opts);
402
+ }