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/CHANGELOG.md +42 -0
- package/README.md +17 -5
- package/bin/tradelab.js +36 -0
- package/dist/cjs/index.cjs +90 -0
- package/dist/cjs/live.cjs +242 -51
- package/docs/live-trading.md +130 -0
- package/docs/mcp.md +89 -20
- package/examples/agentResearchLoop.js +188 -0
- package/examples/multiSymbolPortfolio.js +122 -0
- package/package.json +1 -1
- package/src/cli/runPreset.js +42 -0
- package/src/index.js +2 -0
- package/src/live/engine/riskManager.js +38 -0
- package/src/live/index.js +1 -0
- package/src/live/notify.js +42 -0
- package/src/live/session.js +166 -52
- package/src/mcp/liveTools.js +28 -24
- package/src/mcp/researchSession.js +24 -0
- package/src/mcp/schemas.js +5 -1
- package/src/mcp/tools.js +27 -2
- package/src/reporting/summarize.js +43 -0
- package/src/research/store.js +67 -0
- package/types/index.d.ts +30 -0
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
|
-
|
|
3519
|
-
|
|
3520
|
-
this.
|
|
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.
|
|
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.
|
|
3541
|
-
this.
|
|
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
|
-
|
|
3569
|
-
|
|
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
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
this.
|
|
3580
|
-
|
|
3581
|
-
|
|
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
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
3620
|
-
|
|
3621
|
-
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
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.
|
|
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:
|
|
3827
|
+
symbol: sym,
|
|
3689
3828
|
side: exitSide,
|
|
3690
3829
|
type: "stop",
|
|
3691
3830
|
qty: size,
|
|
3692
3831
|
stopPrice: stop,
|
|
3693
|
-
clientOrderId:
|
|
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:
|
|
3840
|
+
symbol: sym,
|
|
3700
3841
|
side: exitSide,
|
|
3701
3842
|
type: "limit",
|
|
3702
3843
|
qty: size,
|
|
3703
3844
|
limitPrice: targetPrice,
|
|
3704
|
-
clientOrderId:
|
|
3845
|
+
clientOrderId: tgtCoid
|
|
3705
3846
|
});
|
|
3706
3847
|
bracket.targetId = tgtOrder.orderId;
|
|
3707
3848
|
}
|
|
3708
|
-
this.brackets.set(
|
|
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,
|
package/docs/live-trading.md
CHANGED
|
@@ -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.
|