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/CHANGELOG.md +53 -0
- package/README.md +31 -6
- package/bin/tradelab.js +36 -0
- package/dist/cjs/index.cjs +98 -3
- package/dist/cjs/live.cjs +286 -58
- package/docs/live-trading.md +131 -1
- package/docs/mcp.md +90 -21
- 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/engine/portfolio.js +2 -1
- package/src/index.js +2 -0
- package/src/live/engine/paperEngine.js +16 -11
- 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 +200 -49
- package/src/mcp/liveTools.js +42 -15
- 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/monteCarlo.js +6 -2
- package/src/research/store.js +67 -0
- package/types/index.d.ts +30 -0
- package/types/live.d.ts +3 -1
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
|
|
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
|
|
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
|
|
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
|
-
|
|
3507
|
-
|
|
3508
|
-
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}`;
|
|
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.
|
|
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.
|
|
3529
|
-
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;
|
|
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?.(
|
|
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
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
this.
|
|
3555
|
-
|
|
3556
|
-
|
|
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
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
3595
|
-
|
|
3596
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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.
|
|
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:
|
|
3827
|
+
symbol: sym,
|
|
3652
3828
|
side: exitSide,
|
|
3653
3829
|
type: "stop",
|
|
3654
3830
|
qty: size,
|
|
3655
3831
|
stopPrice: stop,
|
|
3656
|
-
clientOrderId:
|
|
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:
|
|
3840
|
+
symbol: sym,
|
|
3663
3841
|
side: exitSide,
|
|
3664
3842
|
type: "limit",
|
|
3665
3843
|
qty: size,
|
|
3666
3844
|
limitPrice: targetPrice,
|
|
3667
|
-
clientOrderId:
|
|
3845
|
+
clientOrderId: tgtCoid
|
|
3668
3846
|
});
|
|
3669
3847
|
bracket.targetId = tgtOrder.orderId;
|
|
3670
3848
|
}
|
|
3671
|
-
this.brackets.set(
|
|
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,
|
package/docs/live-trading.md
CHANGED
|
@@ -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.
|