tradelab 1.2.1 → 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 } = {}) {
@@ -3496,6 +3530,7 @@ var TradingSession = class _TradingSession {
3496
3530
  constructor({
3497
3531
  id,
3498
3532
  symbol,
3533
+ symbols,
3499
3534
  interval = "1m",
3500
3535
  broker,
3501
3536
  mode = "paper",
@@ -3503,6 +3538,8 @@ var TradingSession = class _TradingSession {
3503
3538
  riskPct = 1,
3504
3539
  maxDailyLossPct = 0,
3505
3540
  maxPositionPct = 1,
3541
+ maxGrossExposurePct = 0,
3542
+ maxNetExposurePct = 0,
3506
3543
  qtyStep = 1e-3,
3507
3544
  minQty = 1e-3,
3508
3545
  maxLeverage = 2,
@@ -3515,9 +3552,11 @@ var TradingSession = class _TradingSession {
3515
3552
  );
3516
3553
  }
3517
3554
  if (!broker) throw new Error("TradingSession requires a broker (PaperEngine by default)");
3518
- if (!symbol) throw new Error("TradingSession requires a symbol");
3519
- this.id = id || `${symbol}-${interval}`;
3520
- 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}`;
3521
3560
  this.interval = interval;
3522
3561
  this.broker = broker;
3523
3562
  this.mode = mode;
@@ -3529,18 +3568,62 @@ var TradingSession = class _TradingSession {
3529
3568
  this.minQty = minQty;
3530
3569
  this.maxLeverage = maxLeverage;
3531
3570
  this.eventBus = eventBus || new EventBus();
3532
- this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0 });
3533
- this.lastPrice = null;
3571
+ this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0, maxGrossExposurePct, maxNetExposurePct });
3534
3572
  this.running = false;
3535
3573
  this.events = [];
3536
3574
  this.brackets = /* @__PURE__ */ new Map();
3537
- this._pendingBracket = null;
3575
+ this._pendingBrackets = /* @__PURE__ */ new Map();
3576
+ this._entryMeta = /* @__PURE__ */ new Map();
3577
+ this._legMeta = /* @__PURE__ */ new Map();
3538
3578
  this._cachedPositions = [];
3539
3579
  this._cachedOpenOrders = [];
3540
- this.candleBuffer = [];
3541
- 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;
3542
3586
  this._wireBrokerEvents();
3543
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
+ }
3544
3627
  static liveAllowed() {
3545
3628
  return process.env.TRADELAB_ALLOW_LIVE === "true";
3546
3629
  }
@@ -3552,7 +3635,7 @@ var TradingSession = class _TradingSession {
3552
3635
  }
3553
3636
  _wireBrokerEvents() {
3554
3637
  this.broker.on?.("order:filled", (order) => this._onBrokerFillSync(order));
3555
- this.broker.on?.("order:submitted", (order) => this._record("order:submitted", order));
3638
+ this.broker.on?.("order:submitted", (order) => this._record("order:submitted", this._withMeta(order)));
3556
3639
  this.broker.on?.(
3557
3640
  "order:canceled",
3558
3641
  (order) => this._onBrokerTerminalOrderSync("order:canceled", order)
@@ -3565,30 +3648,48 @@ var TradingSession = class _TradingSession {
3565
3648
  }
3566
3649
  _onBrokerTerminalOrderSync(event, order) {
3567
3650
  this._record(event, order);
3568
- if (matchesOrderRef(this._pendingBracket, order)) {
3569
- this._pendingBracket = null;
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) };
3570
3666
  }
3667
+ return order;
3571
3668
  }
3572
3669
  // Sync event handler — fire-and-forget async OCO work via a stored promise
3573
3670
  _onBrokerFillSync(order) {
3574
- this._record("order:filled", order);
3575
- if (matchesOrderRef(this._pendingBracket, order)) {
3576
- const staged = this._pendingBracket;
3577
- this._pendingBracket = null;
3578
- this._pendingCancelPromise = Promise.resolve(
3579
- this._attachBracket({ ...staged, receipt: order })
3580
- );
3581
- 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
+ }
3582
3681
  }
3583
- const bracket = this.brackets.get(this.symbol);
3584
- if (bracket && (order.orderId === bracket.stopId || order.orderId === bracket.targetId)) {
3585
- const siblingId = order.orderId === bracket.stopId ? bracket.targetId : bracket.stopId;
3586
- this._pendingCancelPromise = (async () => {
3587
- if (siblingId) await this.broker.cancelOrder(siblingId).catch(() => {
3588
- });
3589
- this.brackets.delete(this.symbol);
3590
- this._record("position:closed", { reason: order.orderId === bracket.stopId ? "SL" : "TP" });
3591
- })();
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
+ }
3592
3693
  }
3593
3694
  }
3594
3695
  async start() {
@@ -3607,18 +3708,21 @@ var TradingSession = class _TradingSession {
3607
3708
  this.running = false;
3608
3709
  this._record("shutdown", {});
3609
3710
  }
3610
- async pushBar(b) {
3611
- this.lastPrice = b.close;
3711
+ async pushBar(b, symbol) {
3712
+ const sym = this._resolveSymbol(symbol);
3713
+ this._lastPrice.set(sym, b.close);
3612
3714
  if (typeof this.broker.simulateBar === "function") {
3613
- await this.broker.simulateBar(this.symbol, this.interval, b);
3715
+ await this.broker.simulateBar(sym, this.interval, b);
3614
3716
  }
3615
3717
  if (this._pendingCancelPromise) {
3616
3718
  await this._pendingCancelPromise;
3617
3719
  this._pendingCancelPromise = null;
3618
3720
  }
3619
- this.candleBuffer.push(b);
3620
- if (this.candleBuffer.length > 200) this.candleBuffer.shift();
3621
- 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 });
3622
3726
  await this._syncEquityAndRisk();
3623
3727
  await this.refresh();
3624
3728
  }
@@ -3626,14 +3730,15 @@ var TradingSession = class _TradingSession {
3626
3730
  const state = this.riskManager.getState?.() || {};
3627
3731
  return Boolean(state.halted);
3628
3732
  }
3629
- 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 } = {}) {
3630
3734
  if (!this.running) throw new Error("session not started");
3631
3735
  if (this._riskHalted()) throw new Error("session is risk-halted for the day");
3632
- const entryRef = type === "limit" ? limitPrice : this.lastPrice;
3736
+ const sym = this._resolveSymbol(symbol);
3737
+ const entryRef = type === "limit" ? limitPrice : this.lastPriceFor(sym);
3633
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;
3634
3740
  let size = qty;
3635
3741
  if (!Number.isFinite(size)) {
3636
- const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
3637
3742
  if (!Number.isFinite(stop)) throw new Error("risk-based sizing requires a stop");
3638
3743
  size = calculatePositionSize({
3639
3744
  equity: this.equity,
@@ -3647,9 +3752,38 @@ var TradingSession = class _TradingSession {
3647
3752
  }
3648
3753
  size = roundStep(size, this.qtyStep);
3649
3754
  if (!(size >= this.minQty)) throw new Error(`sized below minQty (${size})`);
3650
- const entryClientOrderId = `${this.id}-entry-${Date.now()}`;
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 });
3651
3785
  const receipt = await this.broker.submitOrder({
3652
- symbol: this.symbol,
3786
+ symbol: sym,
3653
3787
  side: toBrokerSide(side),
3654
3788
  type,
3655
3789
  qty: size,
@@ -3657,10 +3791,11 @@ var TradingSession = class _TradingSession {
3657
3791
  clientOrderId: entryClientOrderId
3658
3792
  });
3659
3793
  if (Number.isFinite(stop) || Number.isFinite(target) || Number.isFinite(rr)) {
3794
+ const parentEntryId = receipt?.clientOrderId ?? entryClientOrderId;
3660
3795
  if (receipt.status === "filled") {
3661
- await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt });
3796
+ await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt, parentEntryId, symbol: sym });
3662
3797
  } else if (receipt.status !== "rejected") {
3663
- this._pendingBracket = {
3798
+ this._pendingBrackets.set(sym, {
3664
3799
  side,
3665
3800
  size,
3666
3801
  stop,
@@ -3668,44 +3803,50 @@ var TradingSession = class _TradingSession {
3668
3803
  rr,
3669
3804
  entryRef,
3670
3805
  orderId: receipt.orderId,
3671
- clientOrderId: receipt.clientOrderId || entryClientOrderId
3672
- };
3806
+ clientOrderId: receipt.clientOrderId || entryClientOrderId,
3807
+ parentEntryId
3808
+ });
3673
3809
  } else {
3674
- this._pendingBracket = null;
3810
+ this._pendingBrackets.delete(sym);
3675
3811
  }
3676
3812
  }
3677
3813
  await this.refresh();
3678
3814
  return receipt;
3679
3815
  }
3680
- 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;
3681
3818
  const entryFill = receipt?.avgFillPrice ?? entryRef;
3682
3819
  const risk = Number.isFinite(stop) ? Math.abs(entryFill - stop) : null;
3683
3820
  const targetPrice = Number.isFinite(target) ? target : Number.isFinite(rr) && risk ? side === "long" || side === "buy" ? entryFill + rr * risk : entryFill - rr * risk : null;
3684
3821
  const exitSide = oppositeSide2(side);
3685
3822
  const bracket = {};
3686
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" });
3687
3826
  const stopOrder = await this.broker.submitOrder({
3688
- symbol: this.symbol,
3827
+ symbol: sym,
3689
3828
  side: exitSide,
3690
3829
  type: "stop",
3691
3830
  qty: size,
3692
3831
  stopPrice: stop,
3693
- clientOrderId: `${this.id}-stop-${Date.now()}`
3832
+ clientOrderId: stopCoid
3694
3833
  });
3695
3834
  bracket.stopId = stopOrder.orderId;
3696
3835
  }
3697
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" });
3698
3839
  const tgtOrder = await this.broker.submitOrder({
3699
- symbol: this.symbol,
3840
+ symbol: sym,
3700
3841
  side: exitSide,
3701
3842
  type: "limit",
3702
3843
  qty: size,
3703
3844
  limitPrice: targetPrice,
3704
- clientOrderId: `${this.id}-target-${Date.now()}`
3845
+ clientOrderId: tgtCoid
3705
3846
  });
3706
3847
  bracket.targetId = tgtOrder.orderId;
3707
3848
  }
3708
- this.brackets.set(this.symbol, bracket);
3849
+ this.brackets.set(sym, bracket);
3709
3850
  }
3710
3851
  async _syncEquityAndRisk() {
3711
3852
  const acct = await this.broker.getAccount?.().catch(() => null);
@@ -3718,6 +3859,11 @@ var TradingSession = class _TradingSession {
3718
3859
  } else {
3719
3860
  this.riskManager.update({ timeMs: Date.now(), equity: this.equity });
3720
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;
3721
3867
  }
3722
3868
  async closePosition(symbol = this.symbol) {
3723
3869
  const positions = await this.broker.getPositions();
@@ -3768,6 +3914,7 @@ var TradingSession = class _TradingSession {
3768
3914
  return {
3769
3915
  id: this.id,
3770
3916
  symbol: this.symbol,
3917
+ symbols: this.symbols,
3771
3918
  interval: this.interval,
3772
3919
  mode: this.mode,
3773
3920
  running: this.running,
@@ -3853,6 +4000,49 @@ var SessionManager = class {
3853
4000
  function createSessionManager(opts) {
3854
4001
  return new SessionManager(opts);
3855
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
+ }
3856
4046
  // Annotate the CommonJS export names for ESM import in node:
3857
4047
  0 && (module.exports = {
3858
4048
  AlpacaBroker,
@@ -3877,6 +4067,7 @@ function createSessionManager(opts) {
3877
4067
  StateManager,
3878
4068
  StorageProvider,
3879
4069
  TradingSession,
4070
+ attachNotifier,
3880
4071
  createAlpacaBroker,
3881
4072
  createBinanceBroker,
3882
4073
  createBrokerFeed,
@@ -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.