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.
package/dist/cjs/live.cjs CHANGED
@@ -52,6 +52,7 @@ __export(index_exports, {
52
52
  StateManager: () => StateManager,
53
53
  StorageProvider: () => StorageProvider,
54
54
  TradingSession: () => TradingSession,
55
+ attachNotifier: () => attachNotifier,
55
56
  createAlpacaBroker: () => createAlpacaBroker,
56
57
  createBinanceBroker: () => createBinanceBroker,
57
58
  createBrokerFeed: () => createBrokerFeed,
@@ -1881,6 +1882,8 @@ var RiskManager = class {
1881
1882
  cooldownAfterLossMs: 0,
1882
1883
  allowedSessions: "AUTO",
1883
1884
  allowedWindows: null,
1885
+ maxGrossExposurePct: 0,
1886
+ maxNetExposurePct: 0,
1884
1887
  ...options
1885
1888
  };
1886
1889
  this.allowedWindows = parseWindowsCSV(this.options.allowedWindows);
@@ -1957,7 +1960,9 @@ var RiskManager = class {
1957
1960
  timeMs = Date.now(),
1958
1961
  positionCount = 0,
1959
1962
  positionValue = 0,
1960
- equity = null
1963
+ equity = null,
1964
+ grossExposure = void 0,
1965
+ netExposure = void 0
1961
1966
  } = {}) {
1962
1967
  const base = this.canTrade({ timeMs });
1963
1968
  if (!base.ok) return base;
@@ -1975,6 +1980,35 @@ var RiskManager = class {
1975
1980
  return { ok: false, reason: "max position size exceeded" };
1976
1981
  }
1977
1982
  }
1983
+ return this._checkExposureCaps({ grossExposure, netExposure, equity: eq });
1984
+ }
1985
+ /**
1986
+ * Check only the portfolio exposure caps (no session/halt/trade-count checks).
1987
+ * Called from placeOrder after the halt check has already run.
1988
+ */
1989
+ checkExposure({ grossExposure = void 0, netExposure = void 0, equity = null } = {}) {
1990
+ const eq = Number.isFinite(equity) ? equity : this.currentEquity;
1991
+ return this._checkExposureCaps({ grossExposure, netExposure, equity: eq });
1992
+ }
1993
+ /**
1994
+ * Shared gross/net exposure cap logic used by canOpenPosition and checkExposure.
1995
+ * Expects a resolved equity value (NaN/null fallback already applied by caller).
1996
+ * Returns { ok: true, reason: null } when within caps or caps are disabled.
1997
+ */
1998
+ _checkExposureCaps({ grossExposure = void 0, netExposure = void 0, equity } = {}) {
1999
+ const eq = equity;
2000
+ const grossCap = pctToFraction(this.options.maxGrossExposurePct, 0);
2001
+ if (grossCap > 0 && Number.isFinite(eq) && eq > 0 && Number.isFinite(grossExposure)) {
2002
+ if (Math.abs(grossExposure) / eq > grossCap) {
2003
+ return { ok: false, reason: "max gross exposure exceeded" };
2004
+ }
2005
+ }
2006
+ const netCap = pctToFraction(this.options.maxNetExposurePct, 0);
2007
+ if (netCap > 0 && Number.isFinite(eq) && eq > 0 && Number.isFinite(netExposure)) {
2008
+ if (Math.abs(netExposure) / eq > netCap) {
2009
+ return { ok: false, reason: "max net exposure exceeded" };
2010
+ }
2011
+ }
1978
2012
  return { ok: true, reason: null };
1979
2013
  }
1980
2014
  recordTrade({ pnl = 0, timeMs = Date.now(), equity = null } = {}) {
@@ -2301,15 +2335,20 @@ var PaperEngine = class extends BrokerAdapter {
2301
2335
  _recordOrder(order) {
2302
2336
  this.orderHistory.set(order.orderId, { ...order });
2303
2337
  }
2338
+ _rejectOrder(order, reason) {
2339
+ order.status = "rejected";
2340
+ order.rejectReason = reason;
2341
+ this._recordOrder(order);
2342
+ this.openOrders.delete(order.orderId);
2343
+ const receipt = cloneOrder(order);
2344
+ this.emit("order:rejected", receipt);
2345
+ return receipt;
2346
+ }
2304
2347
  _fillOrder(order, fillPrice, kind = "market", fillTime = Date.now()) {
2305
2348
  const side = normalizeOrderSide(order.side);
2306
2349
  const qty = Math.max(0, asNumber(order.qty, 0));
2307
2350
  if (!(qty > 0)) {
2308
- order.status = "rejected";
2309
- order.rejectReason = "invalid quantity";
2310
- this._recordOrder(order);
2311
- this.emit("order:rejected", cloneOrder(order));
2312
- return cloneOrder(order);
2351
+ return this._rejectOrder(order, "invalid quantity");
2313
2352
  }
2314
2353
  const sideForFill = side === "buy" ? "long" : "short";
2315
2354
  const filled = applyFill(fillPrice, sideForFill, {
@@ -2417,17 +2456,16 @@ var PaperEngine = class extends BrokerAdapter {
2417
2456
  rejectReason: void 0
2418
2457
  };
2419
2458
  if (!(normalized.qty > 0)) {
2420
- normalized.status = "rejected";
2421
- normalized.rejectReason = "invalid quantity";
2422
- this._recordOrder(normalized);
2423
- this.emit("order:rejected", cloneOrder(normalized));
2424
- return cloneOrder(normalized);
2459
+ return this._rejectOrder(normalized, "invalid quantity");
2425
2460
  }
2426
2461
  this._recordOrder(normalized);
2427
2462
  this.emit("order:submitted", cloneOrder(normalized));
2428
2463
  if (normalized.type === "market") {
2429
2464
  const mark = this.lastPrices.get(normalized.symbol);
2430
- const fillPrice = mark ?? normalized.limitPrice ?? normalized.stopPrice ?? 0;
2465
+ const fillPrice = mark ?? normalized.limitPrice ?? normalized.stopPrice;
2466
+ if (!Number.isFinite(fillPrice)) {
2467
+ return this._rejectOrder(normalized, "no price available for market order");
2468
+ }
2431
2469
  return this._fillOrder(normalized, fillPrice, "market");
2432
2470
  }
2433
2471
  this.openOrders.set(normalized.orderId, normalized);
@@ -3480,10 +3518,19 @@ function oppositeSide2(side) {
3480
3518
  function toBrokerSide(side) {
3481
3519
  return side === "long" || side === "buy" ? "buy" : "sell";
3482
3520
  }
3521
+ function matchesOrderRef(reference, order) {
3522
+ if (!reference || !order) return false;
3523
+ if (reference.orderId && order.orderId && reference.orderId === order.orderId) return true;
3524
+ if (reference.clientOrderId && order.clientOrderId && reference.clientOrderId === order.clientOrderId) {
3525
+ return true;
3526
+ }
3527
+ return false;
3528
+ }
3483
3529
  var TradingSession = class _TradingSession {
3484
3530
  constructor({
3485
3531
  id,
3486
3532
  symbol,
3533
+ symbols,
3487
3534
  interval = "1m",
3488
3535
  broker,
3489
3536
  mode = "paper",
@@ -3491,6 +3538,8 @@ var TradingSession = class _TradingSession {
3491
3538
  riskPct = 1,
3492
3539
  maxDailyLossPct = 0,
3493
3540
  maxPositionPct = 1,
3541
+ maxGrossExposurePct = 0,
3542
+ maxNetExposurePct = 0,
3494
3543
  qtyStep = 1e-3,
3495
3544
  minQty = 1e-3,
3496
3545
  maxLeverage = 2,
@@ -3503,9 +3552,11 @@ var TradingSession = class _TradingSession {
3503
3552
  );
3504
3553
  }
3505
3554
  if (!broker) throw new Error("TradingSession requires a broker (PaperEngine by default)");
3506
- if (!symbol) throw new Error("TradingSession requires a symbol");
3507
- this.id = id || `${symbol}-${interval}`;
3508
- this.symbol = symbol;
3555
+ const symbolList = Array.isArray(symbols) && symbols.length ? symbols : symbol ? [symbol] : null;
3556
+ if (!symbolList) throw new Error("TradingSession requires a symbol or symbols");
3557
+ this.symbols = symbolList;
3558
+ this.symbol = symbolList[0];
3559
+ this.id = id || `${this.symbol}-${interval}`;
3509
3560
  this.interval = interval;
3510
3561
  this.broker = broker;
3511
3562
  this.mode = mode;
@@ -3517,18 +3568,62 @@ var TradingSession = class _TradingSession {
3517
3568
  this.minQty = minQty;
3518
3569
  this.maxLeverage = maxLeverage;
3519
3570
  this.eventBus = eventBus || new EventBus();
3520
- this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0 });
3521
- this.lastPrice = null;
3571
+ this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0, maxGrossExposurePct, maxNetExposurePct });
3522
3572
  this.running = false;
3523
3573
  this.events = [];
3524
3574
  this.brackets = /* @__PURE__ */ new Map();
3525
- this._pendingBracket = null;
3575
+ this._pendingBrackets = /* @__PURE__ */ new Map();
3576
+ this._entryMeta = /* @__PURE__ */ new Map();
3577
+ this._legMeta = /* @__PURE__ */ new Map();
3526
3578
  this._cachedPositions = [];
3527
3579
  this._cachedOpenOrders = [];
3528
- this.candleBuffer = [];
3529
- this._strategy = null;
3580
+ this._lastPrice = /* @__PURE__ */ new Map();
3581
+ this._candleBuffers = /* @__PURE__ */ new Map();
3582
+ this._strategies = /* @__PURE__ */ new Map();
3583
+ for (const sym of this.symbols) this._candleBuffers.set(sym, []);
3584
+ this._wasHalted = false;
3585
+ this._coidSeq = 0;
3530
3586
  this._wireBrokerEvents();
3531
3587
  }
3588
+ // Back-compat getters/setters for single-symbol usage and MCP feed_price handler
3589
+ get lastPrice() {
3590
+ return this._lastPrice.get(this.symbol) ?? null;
3591
+ }
3592
+ set lastPrice(v) {
3593
+ this._lastPrice.set(this.symbol, v);
3594
+ }
3595
+ get candleBuffer() {
3596
+ return this._candleBuffers.get(this.symbol) ?? [];
3597
+ }
3598
+ set candleBuffer(v) {
3599
+ this._candleBuffers.set(this.symbol, v);
3600
+ }
3601
+ get _strategy() {
3602
+ return this._strategies.get(this.symbol) ?? null;
3603
+ }
3604
+ set _strategy(fn) {
3605
+ this._strategies.set(this.symbol, fn);
3606
+ }
3607
+ // Back-compat for tests that read/write _pendingBracket directly (primary symbol only)
3608
+ get _pendingBracket() {
3609
+ return this._pendingBrackets.get(this.symbol) ?? null;
3610
+ }
3611
+ set _pendingBracket(v) {
3612
+ if (v == null) this._pendingBrackets.delete(this.symbol);
3613
+ else this._pendingBrackets.set(this.symbol, v);
3614
+ }
3615
+ // Per-symbol accessors
3616
+ lastPriceFor(sym = this.symbol) {
3617
+ return this._lastPrice.get(sym) ?? null;
3618
+ }
3619
+ candleBufferFor(sym = this.symbol) {
3620
+ return this._candleBuffers.get(sym) ?? [];
3621
+ }
3622
+ _resolveSymbol(symbol) {
3623
+ if (symbol) return symbol;
3624
+ if (this.symbols.length === 1) return this.symbol;
3625
+ throw new Error("symbol is required for a multi-symbol session");
3626
+ }
3532
3627
  static liveAllowed() {
3533
3628
  return process.env.TRADELAB_ALLOW_LIVE === "true";
3534
3629
  }
@@ -3540,30 +3635,61 @@ var TradingSession = class _TradingSession {
3540
3635
  }
3541
3636
  _wireBrokerEvents() {
3542
3637
  this.broker.on?.("order:filled", (order) => this._onBrokerFillSync(order));
3543
- this.broker.on?.("order:submitted", (order) => this._record("order:submitted", order));
3544
- this.broker.on?.("order:canceled", (order) => this._record("order:canceled", order));
3638
+ this.broker.on?.("order:submitted", (order) => this._record("order:submitted", this._withMeta(order)));
3639
+ this.broker.on?.(
3640
+ "order:canceled",
3641
+ (order) => this._onBrokerTerminalOrderSync("order:canceled", order)
3642
+ );
3643
+ this.broker.on?.(
3644
+ "order:rejected",
3645
+ (order) => this._onBrokerTerminalOrderSync("order:rejected", order)
3646
+ );
3545
3647
  this.broker.on?.("equity:update", (acct) => this._record("equity:update", acct));
3546
3648
  }
3649
+ _onBrokerTerminalOrderSync(event, order) {
3650
+ this._record(event, order);
3651
+ for (const [sym, staged] of this._pendingBrackets) {
3652
+ if (matchesOrderRef(staged, order)) {
3653
+ this._pendingBrackets.delete(sym);
3654
+ break;
3655
+ }
3656
+ }
3657
+ }
3658
+ _withMeta(order) {
3659
+ const key = order.clientOrderId;
3660
+ if (key && this._entryMeta?.has(key)) {
3661
+ const m = this._entryMeta.get(key);
3662
+ return { ...order, sizing: m.sizing, ...m.rationale ? { rationale: m.rationale } : {} };
3663
+ }
3664
+ if (key && this._legMeta?.has(key)) {
3665
+ return { ...order, ...this._legMeta.get(key) };
3666
+ }
3667
+ return order;
3668
+ }
3547
3669
  // Sync event handler — fire-and-forget async OCO work via a stored promise
3548
3670
  _onBrokerFillSync(order) {
3549
- this._record("order:filled", order);
3550
- if (this._pendingBracket && String(order.clientOrderId || "").includes("-entry-")) {
3551
- const staged = this._pendingBracket;
3552
- this._pendingBracket = null;
3553
- this._pendingCancelPromise = Promise.resolve(
3554
- this._attachBracket({ ...staged, receipt: order })
3555
- );
3556
- return;
3671
+ this._record("order:filled", this._withMeta(order));
3672
+ for (const [sym, staged] of this._pendingBrackets) {
3673
+ if (matchesOrderRef(staged, order)) {
3674
+ this._pendingBrackets.delete(sym);
3675
+ const parentEntryId = staged.parentEntryId ?? order.clientOrderId;
3676
+ this._pendingCancelPromise = Promise.resolve(
3677
+ this._attachBracket({ ...staged, symbol: sym, receipt: order, parentEntryId })
3678
+ );
3679
+ return;
3680
+ }
3557
3681
  }
3558
- const bracket = this.brackets.get(this.symbol);
3559
- if (bracket && (order.orderId === bracket.stopId || order.orderId === bracket.targetId)) {
3560
- const siblingId = order.orderId === bracket.stopId ? bracket.targetId : bracket.stopId;
3561
- this._pendingCancelPromise = (async () => {
3562
- if (siblingId) await this.broker.cancelOrder(siblingId).catch(() => {
3563
- });
3564
- this.brackets.delete(this.symbol);
3565
- this._record("position:closed", { reason: order.orderId === bracket.stopId ? "SL" : "TP" });
3566
- })();
3682
+ for (const [sym, bracket] of this.brackets) {
3683
+ if (bracket && (order.orderId === bracket.stopId || order.orderId === bracket.targetId)) {
3684
+ const siblingId = order.orderId === bracket.stopId ? bracket.targetId : bracket.stopId;
3685
+ this._pendingCancelPromise = (async () => {
3686
+ if (siblingId) await this.broker.cancelOrder(siblingId).catch(() => {
3687
+ });
3688
+ this.brackets.delete(sym);
3689
+ this._record("position:closed", { symbol: sym, reason: order.orderId === bracket.stopId ? "SL" : "TP" });
3690
+ })();
3691
+ return;
3692
+ }
3567
3693
  }
3568
3694
  }
3569
3695
  async start() {
@@ -3582,18 +3708,21 @@ var TradingSession = class _TradingSession {
3582
3708
  this.running = false;
3583
3709
  this._record("shutdown", {});
3584
3710
  }
3585
- async pushBar(b) {
3586
- this.lastPrice = b.close;
3711
+ async pushBar(b, symbol) {
3712
+ const sym = this._resolveSymbol(symbol);
3713
+ this._lastPrice.set(sym, b.close);
3587
3714
  if (typeof this.broker.simulateBar === "function") {
3588
- await this.broker.simulateBar(this.symbol, this.interval, b);
3715
+ await this.broker.simulateBar(sym, this.interval, b);
3589
3716
  }
3590
3717
  if (this._pendingCancelPromise) {
3591
3718
  await this._pendingCancelPromise;
3592
3719
  this._pendingCancelPromise = null;
3593
3720
  }
3594
- this.candleBuffer.push(b);
3595
- if (this.candleBuffer.length > 200) this.candleBuffer.shift();
3596
- this._record("bar", { close: b.close, time: b.time });
3721
+ const buf = this._candleBuffers.get(sym) ?? [];
3722
+ buf.push(b);
3723
+ if (buf.length > 200) buf.shift();
3724
+ this._candleBuffers.set(sym, buf);
3725
+ this._record("bar", { symbol: sym, close: b.close, time: b.time });
3597
3726
  await this._syncEquityAndRisk();
3598
3727
  await this.refresh();
3599
3728
  }
@@ -3601,14 +3730,15 @@ var TradingSession = class _TradingSession {
3601
3730
  const state = this.riskManager.getState?.() || {};
3602
3731
  return Boolean(state.halted);
3603
3732
  }
3604
- async placeOrder({ side, type = "market", qty, riskPct, stop, target, rr, limitPrice } = {}) {
3733
+ async placeOrder({ side, type = "market", qty, riskPct, stop, target, rr, limitPrice, rationale, symbol } = {}) {
3605
3734
  if (!this.running) throw new Error("session not started");
3606
3735
  if (this._riskHalted()) throw new Error("session is risk-halted for the day");
3607
- const entryRef = type === "limit" ? limitPrice : this.lastPrice;
3736
+ const sym = this._resolveSymbol(symbol);
3737
+ const entryRef = type === "limit" ? limitPrice : this.lastPriceFor(sym);
3608
3738
  if (!Number.isFinite(entryRef)) throw new Error("no price available; pushBar() a price first");
3739
+ const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
3609
3740
  let size = qty;
3610
3741
  if (!Number.isFinite(size)) {
3611
- const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
3612
3742
  if (!Number.isFinite(stop)) throw new Error("risk-based sizing requires a stop");
3613
3743
  size = calculatePositionSize({
3614
3744
  equity: this.equity,
@@ -3622,53 +3752,101 @@ var TradingSession = class _TradingSession {
3622
3752
  }
3623
3753
  size = roundStep(size, this.qtyStep);
3624
3754
  if (!(size >= this.minQty)) throw new Error(`sized below minQty (${size})`);
3755
+ const targetPx = Number.isFinite(target) ? target : Number.isFinite(rr) && Number.isFinite(stop) ? side === "long" || side === "buy" ? entryRef + rr * Math.abs(entryRef - stop) : entryRef - rr * Math.abs(entryRef - stop) : null;
3756
+ const sizing = {
3757
+ entry: entryRef,
3758
+ stop: Number.isFinite(stop) ? stop : null,
3759
+ target: targetPx,
3760
+ rr: Number.isFinite(rr) ? rr : null,
3761
+ riskFraction: fraction,
3762
+ riskAmount: this.equity * fraction,
3763
+ qty: size,
3764
+ notional: size * entryRef
3765
+ };
3766
+ const positions = this._cachedPositions ?? [];
3767
+ const newNotional = sizing.notional * (side === "long" || side === "buy" ? 1 : -1);
3768
+ let gross = Math.abs(sizing.notional);
3769
+ let net = newNotional;
3770
+ for (const p of positions) {
3771
+ const px = p.avgEntry ?? p.avgPrice ?? p.entryPrice ?? entryRef;
3772
+ const pv = Number.isFinite(p.marketValue) ? p.marketValue : (p.qty ?? 0) * px;
3773
+ const signed = p.side === "long" || p.side === "buy" ? pv : -pv;
3774
+ gross += Math.abs(pv);
3775
+ net += signed;
3776
+ }
3777
+ const gate = this.riskManager.checkExposure({
3778
+ grossExposure: gross,
3779
+ netExposure: net,
3780
+ equity: this.equity
3781
+ });
3782
+ if (!gate.ok) throw new Error(`risk rejected: ${gate.reason}`);
3783
+ const entryClientOrderId = `${this.id}-entry-${Date.now()}-${++this._coidSeq}`;
3784
+ this._entryMeta.set(entryClientOrderId, { sizing, rationale });
3625
3785
  const receipt = await this.broker.submitOrder({
3626
- symbol: this.symbol,
3786
+ symbol: sym,
3627
3787
  side: toBrokerSide(side),
3628
3788
  type,
3629
3789
  qty: size,
3630
3790
  limitPrice: type === "limit" ? limitPrice : void 0,
3631
- clientOrderId: `${this.id}-entry-${Date.now()}`
3791
+ clientOrderId: entryClientOrderId
3632
3792
  });
3633
3793
  if (Number.isFinite(stop) || Number.isFinite(target) || Number.isFinite(rr)) {
3794
+ const parentEntryId = receipt?.clientOrderId ?? entryClientOrderId;
3634
3795
  if (receipt.status === "filled") {
3635
- await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt });
3796
+ await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt, parentEntryId, symbol: sym });
3797
+ } else if (receipt.status !== "rejected") {
3798
+ this._pendingBrackets.set(sym, {
3799
+ side,
3800
+ size,
3801
+ stop,
3802
+ target,
3803
+ rr,
3804
+ entryRef,
3805
+ orderId: receipt.orderId,
3806
+ clientOrderId: receipt.clientOrderId || entryClientOrderId,
3807
+ parentEntryId
3808
+ });
3636
3809
  } else {
3637
- this._pendingBracket = { side, size, stop, target, rr, entryRef };
3810
+ this._pendingBrackets.delete(sym);
3638
3811
  }
3639
3812
  }
3640
3813
  await this.refresh();
3641
3814
  return receipt;
3642
3815
  }
3643
- async _attachBracket({ side, size, stop, target, rr, entryRef, receipt }) {
3816
+ async _attachBracket({ side, size, stop, target, rr, entryRef, receipt, parentEntryId, symbol }) {
3817
+ const sym = symbol ?? this.symbol;
3644
3818
  const entryFill = receipt?.avgFillPrice ?? entryRef;
3645
3819
  const risk = Number.isFinite(stop) ? Math.abs(entryFill - stop) : null;
3646
3820
  const targetPrice = Number.isFinite(target) ? target : Number.isFinite(rr) && risk ? side === "long" || side === "buy" ? entryFill + rr * risk : entryFill - rr * risk : null;
3647
3821
  const exitSide = oppositeSide2(side);
3648
3822
  const bracket = {};
3649
3823
  if (Number.isFinite(stop)) {
3824
+ const stopCoid = `${this.id}-stop-${Date.now()}-${++this._coidSeq}`;
3825
+ if (parentEntryId) this._legMeta.set(stopCoid, { parentEntryId, leg: "stop" });
3650
3826
  const stopOrder = await this.broker.submitOrder({
3651
- symbol: this.symbol,
3827
+ symbol: sym,
3652
3828
  side: exitSide,
3653
3829
  type: "stop",
3654
3830
  qty: size,
3655
3831
  stopPrice: stop,
3656
- clientOrderId: `${this.id}-stop-${Date.now()}`
3832
+ clientOrderId: stopCoid
3657
3833
  });
3658
3834
  bracket.stopId = stopOrder.orderId;
3659
3835
  }
3660
3836
  if (Number.isFinite(targetPrice)) {
3837
+ const tgtCoid = `${this.id}-target-${Date.now()}-${++this._coidSeq}`;
3838
+ if (parentEntryId) this._legMeta.set(tgtCoid, { parentEntryId, leg: "target" });
3661
3839
  const tgtOrder = await this.broker.submitOrder({
3662
- symbol: this.symbol,
3840
+ symbol: sym,
3663
3841
  side: exitSide,
3664
3842
  type: "limit",
3665
3843
  qty: size,
3666
3844
  limitPrice: targetPrice,
3667
- clientOrderId: `${this.id}-target-${Date.now()}`
3845
+ clientOrderId: tgtCoid
3668
3846
  });
3669
3847
  bracket.targetId = tgtOrder.orderId;
3670
3848
  }
3671
- this.brackets.set(this.symbol, bracket);
3849
+ this.brackets.set(sym, bracket);
3672
3850
  }
3673
3851
  async _syncEquityAndRisk() {
3674
3852
  const acct = await this.broker.getAccount?.().catch(() => null);
@@ -3681,6 +3859,11 @@ var TradingSession = class _TradingSession {
3681
3859
  } else {
3682
3860
  this.riskManager.update({ timeMs: Date.now(), equity: this.equity });
3683
3861
  }
3862
+ const nowHalted = Boolean(this.riskManager.getState?.().halted);
3863
+ if (nowHalted && !this._wasHalted) {
3864
+ this._record("risk:halt", { reason: this.riskManager.haltReason ?? this.riskManager.getState?.().haltReason ?? "risk halt" });
3865
+ }
3866
+ this._wasHalted = nowHalted;
3684
3867
  }
3685
3868
  async closePosition(symbol = this.symbol) {
3686
3869
  const positions = await this.broker.getPositions();
@@ -3731,6 +3914,7 @@ var TradingSession = class _TradingSession {
3731
3914
  return {
3732
3915
  id: this.id,
3733
3916
  symbol: this.symbol,
3917
+ symbols: this.symbols,
3734
3918
  interval: this.interval,
3735
3919
  mode: this.mode,
3736
3920
  running: this.running,
@@ -3816,6 +4000,49 @@ var SessionManager = class {
3816
4000
  function createSessionManager(opts) {
3817
4001
  return new SessionManager(opts);
3818
4002
  }
4003
+
4004
+ // src/live/notify.js
4005
+ var DEFAULT_EVENTS = ["order:filled", "risk:halt"];
4006
+ function attachNotifier(session, { onEvent, webhookUrl, events = DEFAULT_EVENTS, drawdownPct = 0 } = {}) {
4007
+ const wanted = new Set(events);
4008
+ let peak = null;
4009
+ const deliver = async (event, payload) => {
4010
+ if (typeof onEvent === "function") {
4011
+ try {
4012
+ await onEvent({ event, payload });
4013
+ } catch {
4014
+ }
4015
+ }
4016
+ if (webhookUrl && typeof fetch === "function") {
4017
+ try {
4018
+ await fetch(webhookUrl, {
4019
+ method: "POST",
4020
+ headers: { "content-type": "application/json" },
4021
+ body: JSON.stringify({ event, payload })
4022
+ });
4023
+ } catch {
4024
+ }
4025
+ }
4026
+ };
4027
+ const handler = ({ event, payload }) => {
4028
+ if (wanted.has(event)) {
4029
+ deliver(event, payload).catch(() => {
4030
+ });
4031
+ return;
4032
+ }
4033
+ if (drawdownPct > 0 && event === "equity:update") {
4034
+ const eq = payload?.equity;
4035
+ if (Number.isFinite(eq)) {
4036
+ if (peak === null || eq > peak) peak = eq;
4037
+ if (peak > 0 && (peak - eq) / peak * 100 >= drawdownPct) {
4038
+ deliver("drawdown:breach", { equity: eq, peak, drawdownPct: (peak - eq) / peak * 100 }).catch(() => {
4039
+ });
4040
+ }
4041
+ }
4042
+ }
4043
+ };
4044
+ return session.eventBus.onAny(handler);
4045
+ }
3819
4046
  // Annotate the CommonJS export names for ESM import in node:
3820
4047
  0 && (module.exports = {
3821
4048
  AlpacaBroker,
@@ -3840,6 +4067,7 @@ function createSessionManager(opts) {
3840
4067
  StateManager,
3841
4068
  StorageProvider,
3842
4069
  TradingSession,
4070
+ attachNotifier,
3843
4071
  createAlpacaBroker,
3844
4072
  createBinanceBroker,
3845
4073
  createBrokerFeed,
@@ -49,7 +49,7 @@ console.log(engine.getStatus());
49
49
  await engine.stop();
50
50
  ```
51
51
 
52
- `PaperEngine` implements the broker interface in memory. Use it first for CLI runs, dashboard checks, and strategy wiring.
52
+ `PaperEngine` implements the broker interface in memory. Use it first for CLI runs, dashboard checks, and strategy wiring. Market orders need a price reference, so call `pushBar()`, `simulateBar()`, or the MCP `feed_price` tool before submitting one.
53
53
 
54
54
  ## Signal Contract
55
55
 
@@ -151,6 +151,136 @@ tradelab paper \
151
151
  --once true
152
152
  ```
153
153
 
154
+ ## Multi-Symbol Portfolio Sessions
155
+
156
+ `TradingSession` accepts a `symbols` array so one session can trade multiple instruments independently against a shared broker.
157
+
158
+ ```js
159
+ import { SessionManager, PaperEngine } from "tradelab/live";
160
+
161
+ const manager = new SessionManager();
162
+ const session = await manager.create({
163
+ id: "crypto-portfolio",
164
+ symbols: ["BTC", "ETH"],
165
+ interval: "1h",
166
+ equity: 20_000,
167
+ riskPct: 1,
168
+ maxGrossExposurePct: 150, // cap total gross notional at 150% of equity
169
+ broker: new PaperEngine({ equity: 20_000 }),
170
+ });
171
+ ```
172
+
173
+ Feed bars and place orders per symbol:
174
+
175
+ ```js
176
+ await session.pushBar({ time, open, high, low, close, volume }, "BTC");
177
+ await session.pushBar({ time, open, high, low, close, volume }, "ETH");
178
+
179
+ await session.placeOrder({ symbol: "BTC", side: "long", riskPct: 1, stop: 29_500, rr: 3 });
180
+ await session.placeOrder({ symbol: "ETH", side: "long", riskPct: 1, stop: 1_900, rr: 2 });
181
+ ```
182
+
183
+ Close a single position without touching the other:
184
+
185
+ ```js
186
+ await session.closePosition("ETH");
187
+ ```
188
+
189
+ Per-symbol accessors:
190
+
191
+ ```js
192
+ session.lastPriceFor("BTC"); // last close fed via pushBar
193
+ session.candleBufferFor("ETH"); // up to 200 candles
194
+ ```
195
+
196
+ `getStatus()` now includes a `symbols` array alongside the primary `symbol`:
197
+
198
+ ```js
199
+ const status = session.getStatus();
200
+ // { id, symbol: "BTC", symbols: ["BTC", "ETH"], positions, openOrders, equity, ... }
201
+ ```
202
+
203
+ Single-symbol usage (`symbol: "AAPL"`) is unchanged. `session.symbol`, `session.lastPrice`, and `session.candleBuffer` all still work as before.
204
+
205
+ ## Exposure Caps
206
+
207
+ Pass `maxGrossExposurePct` or `maxNetExposurePct` to `SessionManager.create()` (or directly to `TradingSession`) to cap portfolio exposure. Both default to `0` (off).
208
+
209
+ | Option | Meaning |
210
+ | --------------------- | --------------------------------------------------------------------------- |
211
+ | `maxGrossExposurePct` | Maximum sum of absolute position notional as a percent of equity |
212
+ | `maxNetExposurePct` | Maximum absolute net long/short notional imbalance as a percent of equity |
213
+
214
+ When a `placeOrder()` call would push exposure past a cap, it throws:
215
+
216
+ ```
217
+ Error: risk rejected: max gross exposure exceeded
218
+ ```
219
+
220
+ The check includes the pending order size, so the cap is evaluated before a fill, not after.
221
+
222
+ ## Trade Attribution
223
+
224
+ Every `order:submitted` and `order:filled` event now carries a `sizing` block that records how the position was sized:
225
+
226
+ ```js
227
+ session.eventBus.onAny(({ event, payload }) => {
228
+ if (event === "order:filled") {
229
+ console.log(payload.sizing);
230
+ // {
231
+ // entry: 100, stop: 98, target: 104, rr: 2,
232
+ // riskFraction: 0.01, riskAmount: 100,
233
+ // qty: 50, notional: 5000
234
+ // }
235
+ if (payload.rationale) console.log(payload.rationale);
236
+ }
237
+ });
238
+ ```
239
+
240
+ Pass `rationale` to `placeOrder()` to attach a free-text note that propagates to all fill events for that order:
241
+
242
+ ```js
243
+ await session.placeOrder({
244
+ symbol: "AAPL",
245
+ side: "long",
246
+ riskPct: 1,
247
+ stop: 148,
248
+ rr: 2,
249
+ rationale: "EMA cross on hourly, trend continuation",
250
+ });
251
+ ```
252
+
253
+ Bracket legs carry `parentEntryId` (the client order id of the entry) and a `leg` field (`"stop"` or `"target"`), making it straightforward to correlate fills across entry and exit legs.
254
+
255
+ ## Event Notifier
256
+
257
+ `attachNotifier()` wires a callback and/or a webhook URL to a session's event bus.
258
+
259
+ ```js
260
+ import { attachNotifier } from "tradelab/live";
261
+
262
+ const unsubscribe = attachNotifier(session, {
263
+ events: ["order:filled", "risk:halt"],
264
+ onEvent({ event, payload }) {
265
+ console.log(event, payload);
266
+ },
267
+ webhookUrl: "https://hooks.example.com/tradelab",
268
+ drawdownPct: 5, // also fires "drawdown:breach" when equity drops 5% from peak
269
+ });
270
+
271
+ // When done:
272
+ unsubscribe();
273
+ ```
274
+
275
+ `attachNotifier` options:
276
+
277
+ | Option | Default | Meaning |
278
+ | ------------- | -------------------------------- | -------------------------------------------------------- |
279
+ | `events` | `["order:filled","risk:halt"]` | Events to forward |
280
+ | `onEvent` | `undefined` | Async callback `({ event, payload }) => void` |
281
+ | `webhookUrl` | `undefined` | HTTP endpoint; receives `POST` with JSON body |
282
+ | `drawdownPct` | `0` | Also fires `drawdown:breach` when equity falls this far |
283
+
154
284
  ## Run Multiple Systems
155
285
 
156
286
  Use `LiveOrchestrator` when several systems share one account and broker.