tradelab 1.1.0 → 1.2.1
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 +57 -0
- package/README.md +183 -373
- package/dist/cjs/index.cjs +39 -12
- package/dist/cjs/live.cjs +457 -18
- package/docs/README.md +32 -66
- package/docs/api-reference.md +269 -144
- package/docs/backtest-engine.md +167 -321
- package/docs/data-reporting-cli.md +114 -156
- package/docs/examples.md +6 -6
- package/docs/live-trading.md +254 -134
- package/docs/mcp.md +244 -23
- package/docs/research.md +99 -45
- package/examples/mcpLiveTrading.js +77 -0
- package/package.json +11 -3
- package/src/engine/optimize.js +25 -1
- package/src/engine/portfolio.js +6 -2
- package/src/live/dashboard/server.js +67 -8
- package/src/live/engine/paperEngine.js +21 -11
- package/src/live/index.js +2 -0
- package/src/live/session.js +439 -0
- package/src/mcp/liveTools.js +202 -0
- package/src/mcp/schemas.js +119 -0
- package/src/mcp/server.js +5 -1
- package/src/mcp/tools.js +125 -2
- package/src/research/monteCarlo.js +6 -2
- package/templates/dashboard.html +595 -108
- package/types/index.d.ts +25 -0
- package/types/live.d.ts +102 -1
- package/types/mcp.d.ts +17 -0
- package/docs/superpowers/plans/2026-00-overview.md +0 -101
- package/docs/superpowers/plans/2026-01-metrics-correctness.md +0 -873
- package/docs/superpowers/plans/2026-02-indicator-library.md +0 -677
- package/docs/superpowers/plans/2026-03-overfitting-toolkit.md +0 -882
- package/docs/superpowers/plans/2026-04-async-signals-seeding.md +0 -981
- package/docs/superpowers/plans/2026-05-mcp-server.md +0 -758
- package/docs/superpowers/plans/2026-06-parallel-param-sweep.md +0 -508
- package/docs/superpowers/plans/2026-07-funding-carry-costs.md +0 -535
- package/docs/superpowers/plans/2026-08-live-dashboard.md +0 -547
- package/docs/superpowers/plans/HANDOFF.md +0 -88
package/dist/cjs/index.cjs
CHANGED
|
@@ -3194,6 +3194,7 @@ function forceExitAll(runners, time) {
|
|
|
3194
3194
|
function backtestPortfolio({
|
|
3195
3195
|
systems = [],
|
|
3196
3196
|
equity = 1e4,
|
|
3197
|
+
interval,
|
|
3197
3198
|
allocation = "equal",
|
|
3198
3199
|
collectEqSeries = true,
|
|
3199
3200
|
collectReplay = false,
|
|
@@ -3327,17 +3328,20 @@ function backtestPortfolio({
|
|
|
3327
3328
|
const replay = combineReplay(systemResults, eqSeries, collectReplay);
|
|
3328
3329
|
const allCandles = systems.flatMap((system) => system.candles || []);
|
|
3329
3330
|
const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
|
|
3331
|
+
const metricsInterval = interval ?? systems[0]?.interval;
|
|
3332
|
+
const finalState = portfolioState(runners, equity);
|
|
3330
3333
|
const metrics = buildMetrics({
|
|
3331
3334
|
closed: trades,
|
|
3332
3335
|
equityStart: equity,
|
|
3333
|
-
equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity :
|
|
3336
|
+
equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : finalState.markedEquity,
|
|
3334
3337
|
candles: orderedCandles,
|
|
3335
3338
|
estBarMs: estimateBarMs(orderedCandles),
|
|
3336
|
-
eqSeries
|
|
3339
|
+
eqSeries,
|
|
3340
|
+
interval: metricsInterval
|
|
3337
3341
|
});
|
|
3338
3342
|
return {
|
|
3339
3343
|
symbol: "PORTFOLIO",
|
|
3340
|
-
interval:
|
|
3344
|
+
interval: metricsInterval,
|
|
3341
3345
|
range: void 0,
|
|
3342
3346
|
trades,
|
|
3343
3347
|
positions,
|
|
@@ -3632,7 +3636,9 @@ function walkForwardOptimize({
|
|
|
3632
3636
|
// src/engine/optimize.js
|
|
3633
3637
|
var import_node_worker_threads = require("node:worker_threads");
|
|
3634
3638
|
var import_node_os = __toESM(require("node:os"), 1);
|
|
3635
|
-
var
|
|
3639
|
+
var import_node_fs = require("node:fs");
|
|
3640
|
+
var import_node_path = __toESM(require("node:path"), 1);
|
|
3641
|
+
var import_node_url = require("node:url");
|
|
3636
3642
|
function defaultConcurrency() {
|
|
3637
3643
|
return Math.max(1, (import_node_os.default.cpus()?.length ?? 2) - 1);
|
|
3638
3644
|
}
|
|
@@ -3640,6 +3646,23 @@ function scoreValue(metrics, scoreBy) {
|
|
|
3640
3646
|
const v = metrics?.[scoreBy];
|
|
3641
3647
|
return Number.isFinite(v) ? v : -Infinity;
|
|
3642
3648
|
}
|
|
3649
|
+
function callerModuleDir() {
|
|
3650
|
+
const stack = new Error().stack || "";
|
|
3651
|
+
const lines = stack.split("\n").slice(1);
|
|
3652
|
+
const match = lines.map((line) => line.match(/(?:\()?(file:\/\/\/[^\s)]+|\/[^\s)]+):\d+:\d+/)).find(Boolean);
|
|
3653
|
+
if (!match) return process.cwd();
|
|
3654
|
+
const filePath = match[1].startsWith("file://") ? (0, import_node_url.fileURLToPath)(match[1]) : match[1];
|
|
3655
|
+
return import_node_path.default.dirname(filePath);
|
|
3656
|
+
}
|
|
3657
|
+
function workerUrl() {
|
|
3658
|
+
const here = callerModuleDir();
|
|
3659
|
+
const candidates = [
|
|
3660
|
+
import_node_path.default.join(here, "optimizeWorker.js"),
|
|
3661
|
+
import_node_path.default.join(here, "..", "..", "src", "engine", "optimizeWorker.js"),
|
|
3662
|
+
import_node_path.default.join(process.cwd(), "src", "engine", "optimizeWorker.js")
|
|
3663
|
+
];
|
|
3664
|
+
return (0, import_node_url.pathToFileURL)(candidates.find((candidate) => (0, import_node_fs.existsSync)(candidate)) || candidates[0]);
|
|
3665
|
+
}
|
|
3643
3666
|
function optimize({
|
|
3644
3667
|
candles,
|
|
3645
3668
|
signalModulePath,
|
|
@@ -3682,7 +3705,7 @@ function optimize({
|
|
|
3682
3705
|
worker.postMessage({ type: "run", index, params: parameterSets[index] });
|
|
3683
3706
|
};
|
|
3684
3707
|
for (let i = 0; i < poolSize; i += 1) {
|
|
3685
|
-
const worker = new import_node_worker_threads.Worker(
|
|
3708
|
+
const worker = new import_node_worker_threads.Worker(workerUrl(), {
|
|
3686
3709
|
workerData: { candles, signalModulePath, interval, backtestOptions }
|
|
3687
3710
|
});
|
|
3688
3711
|
workers.push(worker);
|
|
@@ -3945,29 +3968,33 @@ function monteCarlo({
|
|
|
3945
3968
|
if (!Array.isArray(tradePnls) || tradePnls.length === 0) {
|
|
3946
3969
|
throw new Error("monteCarlo() requires a non-empty tradePnls array");
|
|
3947
3970
|
}
|
|
3971
|
+
const runCount = Math.floor(Number(iterations));
|
|
3972
|
+
if (!Number.isFinite(runCount) || runCount < 1) {
|
|
3973
|
+
throw new Error("monteCarlo() requires positive iterations");
|
|
3974
|
+
}
|
|
3948
3975
|
const rng = makeRng(seed);
|
|
3949
3976
|
const n = tradePnls.length;
|
|
3950
3977
|
const block = Math.max(1, Math.floor(blockSize));
|
|
3951
3978
|
const finals = [];
|
|
3952
3979
|
const drawdowns = [];
|
|
3953
3980
|
const pathSamples = Array.from({ length: n + 1 }, () => []);
|
|
3954
|
-
for (let it = 0; it <
|
|
3955
|
-
const
|
|
3981
|
+
for (let it = 0; it < runCount; it += 1) {
|
|
3982
|
+
const path7 = [equityStart];
|
|
3956
3983
|
let equity = equityStart;
|
|
3957
3984
|
let filled = 0;
|
|
3958
3985
|
while (filled < n) {
|
|
3959
3986
|
const start = randInt(rng, n);
|
|
3960
3987
|
for (let k = 0; k < block && filled < n; k += 1) {
|
|
3961
3988
|
equity += tradePnls[(start + k) % n];
|
|
3962
|
-
|
|
3989
|
+
path7.push(equity);
|
|
3963
3990
|
filled += 1;
|
|
3964
3991
|
}
|
|
3965
3992
|
}
|
|
3966
|
-
for (let step = 0; step <
|
|
3967
|
-
pathSamples[step].push(
|
|
3993
|
+
for (let step = 0; step < path7.length; step += 1) {
|
|
3994
|
+
pathSamples[step].push(path7[step]);
|
|
3968
3995
|
}
|
|
3969
3996
|
finals.push(equity);
|
|
3970
|
-
drawdowns.push(maxDrawdownOf(
|
|
3997
|
+
drawdowns.push(maxDrawdownOf(path7));
|
|
3971
3998
|
}
|
|
3972
3999
|
const sortedFinals = [...finals].sort((a, b) => a - b);
|
|
3973
4000
|
const sortedDd = [...drawdowns].sort((a, b) => a - b);
|
|
@@ -3983,7 +4010,7 @@ function monteCarlo({
|
|
|
3983
4010
|
p95: percentile2(sorted, 0.95)
|
|
3984
4011
|
});
|
|
3985
4012
|
return {
|
|
3986
|
-
iterations,
|
|
4013
|
+
iterations: runCount,
|
|
3987
4014
|
blockSize: block,
|
|
3988
4015
|
finalEquity: bands(sortedFinals),
|
|
3989
4016
|
maxDrawdown: bands(sortedDd),
|
package/dist/cjs/live.cjs
CHANGED
|
@@ -48,8 +48,10 @@ __export(index_exports, {
|
|
|
48
48
|
PaperEngine: () => PaperEngine,
|
|
49
49
|
PollingFeed: () => PollingFeed,
|
|
50
50
|
RiskManager: () => RiskManager,
|
|
51
|
+
SessionManager: () => SessionManager,
|
|
51
52
|
StateManager: () => StateManager,
|
|
52
53
|
StorageProvider: () => StorageProvider,
|
|
54
|
+
TradingSession: () => TradingSession,
|
|
53
55
|
createAlpacaBroker: () => createAlpacaBroker,
|
|
54
56
|
createBinanceBroker: () => createBinanceBroker,
|
|
55
57
|
createBrokerFeed: () => createBrokerFeed,
|
|
@@ -66,6 +68,7 @@ __export(index_exports, {
|
|
|
66
68
|
createPaperEngine: () => createPaperEngine,
|
|
67
69
|
createPollingFeed: () => createPollingFeed,
|
|
68
70
|
createRiskManager: () => createRiskManager,
|
|
71
|
+
createSessionManager: () => createSessionManager,
|
|
69
72
|
createStateManager: () => createStateManager
|
|
70
73
|
});
|
|
71
74
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -2298,15 +2301,20 @@ var PaperEngine = class extends BrokerAdapter {
|
|
|
2298
2301
|
_recordOrder(order) {
|
|
2299
2302
|
this.orderHistory.set(order.orderId, { ...order });
|
|
2300
2303
|
}
|
|
2304
|
+
_rejectOrder(order, reason) {
|
|
2305
|
+
order.status = "rejected";
|
|
2306
|
+
order.rejectReason = reason;
|
|
2307
|
+
this._recordOrder(order);
|
|
2308
|
+
this.openOrders.delete(order.orderId);
|
|
2309
|
+
const receipt = cloneOrder(order);
|
|
2310
|
+
this.emit("order:rejected", receipt);
|
|
2311
|
+
return receipt;
|
|
2312
|
+
}
|
|
2301
2313
|
_fillOrder(order, fillPrice, kind = "market", fillTime = Date.now()) {
|
|
2302
2314
|
const side = normalizeOrderSide(order.side);
|
|
2303
2315
|
const qty = Math.max(0, asNumber(order.qty, 0));
|
|
2304
2316
|
if (!(qty > 0)) {
|
|
2305
|
-
order
|
|
2306
|
-
order.rejectReason = "invalid quantity";
|
|
2307
|
-
this._recordOrder(order);
|
|
2308
|
-
this.emit("order:rejected", cloneOrder(order));
|
|
2309
|
-
return cloneOrder(order);
|
|
2317
|
+
return this._rejectOrder(order, "invalid quantity");
|
|
2310
2318
|
}
|
|
2311
2319
|
const sideForFill = side === "buy" ? "long" : "short";
|
|
2312
2320
|
const filled = applyFill(fillPrice, sideForFill, {
|
|
@@ -2414,17 +2422,16 @@ var PaperEngine = class extends BrokerAdapter {
|
|
|
2414
2422
|
rejectReason: void 0
|
|
2415
2423
|
};
|
|
2416
2424
|
if (!(normalized.qty > 0)) {
|
|
2417
|
-
normalized
|
|
2418
|
-
normalized.rejectReason = "invalid quantity";
|
|
2419
|
-
this._recordOrder(normalized);
|
|
2420
|
-
this.emit("order:rejected", cloneOrder(normalized));
|
|
2421
|
-
return cloneOrder(normalized);
|
|
2425
|
+
return this._rejectOrder(normalized, "invalid quantity");
|
|
2422
2426
|
}
|
|
2423
2427
|
this._recordOrder(normalized);
|
|
2424
2428
|
this.emit("order:submitted", cloneOrder(normalized));
|
|
2425
2429
|
if (normalized.type === "market") {
|
|
2426
2430
|
const mark = this.lastPrices.get(normalized.symbol);
|
|
2427
|
-
const fillPrice = mark ?? normalized.limitPrice ?? normalized.stopPrice
|
|
2431
|
+
const fillPrice = mark ?? normalized.limitPrice ?? normalized.stopPrice;
|
|
2432
|
+
if (!Number.isFinite(fillPrice)) {
|
|
2433
|
+
return this._rejectOrder(normalized, "no price available for market order");
|
|
2434
|
+
}
|
|
2428
2435
|
return this._fillOrder(normalized, fillPrice, "market");
|
|
2429
2436
|
}
|
|
2430
2437
|
this.openOrders.set(normalized.orderId, normalized);
|
|
@@ -2483,6 +2490,7 @@ var PaperEngine = class extends BrokerAdapter {
|
|
|
2483
2490
|
});
|
|
2484
2491
|
const orders = [...this.openOrders.values()].filter((order) => order.symbol === symbol);
|
|
2485
2492
|
for (const order of orders) {
|
|
2493
|
+
if (!this.openOrders.has(order.orderId)) continue;
|
|
2486
2494
|
if (order.type === "limit") {
|
|
2487
2495
|
if (this._touchesLimit(order, normalizedBar)) {
|
|
2488
2496
|
this._fillOrder(order, order.limitPrice, "limit", normalizedBar.time);
|
|
@@ -3317,7 +3325,6 @@ var import_node_http = __toESM(require("node:http"), 1);
|
|
|
3317
3325
|
var import_node_fs2 = require("node:fs");
|
|
3318
3326
|
var import_node_path2 = __toESM(require("node:path"), 1);
|
|
3319
3327
|
var import_node_url4 = require("node:url");
|
|
3320
|
-
var import_meta = {};
|
|
3321
3328
|
var FALLBACK_HTML = `<!doctype html>
|
|
3322
3329
|
<html lang="en">
|
|
3323
3330
|
<head>
|
|
@@ -3336,12 +3343,23 @@ var FALLBACK_HTML = `<!doctype html>
|
|
|
3336
3343
|
</script>
|
|
3337
3344
|
</body>
|
|
3338
3345
|
</html>`;
|
|
3346
|
+
function callerModuleDir() {
|
|
3347
|
+
const stack = new Error().stack || "";
|
|
3348
|
+
const lines = stack.split("\n").slice(1);
|
|
3349
|
+
const match = lines.map((line) => line.match(/(?:\()?(file:\/\/\/[^\s)]+|\/[^\s)]+):\d+:\d+/)).find(Boolean);
|
|
3350
|
+
if (!match) return process.cwd();
|
|
3351
|
+
const filePath = match[1].startsWith("file://") ? (0, import_node_url4.fileURLToPath)(match[1]) : match[1];
|
|
3352
|
+
return import_node_path2.default.dirname(filePath);
|
|
3353
|
+
}
|
|
3339
3354
|
function readDashboardHtml() {
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3355
|
+
const here = callerModuleDir();
|
|
3356
|
+
const candidates = [
|
|
3357
|
+
import_node_path2.default.join(here, "..", "..", "..", "templates", "dashboard.html"),
|
|
3358
|
+
import_node_path2.default.join(here, "..", "..", "templates", "dashboard.html"),
|
|
3359
|
+
import_node_path2.default.join(process.cwd(), "templates", "dashboard.html")
|
|
3360
|
+
];
|
|
3361
|
+
const htmlPath = candidates.find((candidate) => (0, import_node_fs2.existsSync)(candidate));
|
|
3362
|
+
if (htmlPath) return (0, import_node_fs2.readFileSync)(htmlPath, "utf8");
|
|
3345
3363
|
try {
|
|
3346
3364
|
return (0, import_node_fs2.readFileSync)(import_node_path2.default.join(process.cwd(), "templates", "dashboard.html"), "utf8");
|
|
3347
3365
|
} catch {
|
|
@@ -3363,7 +3381,7 @@ function createDashboardServer({ source, port = 4317, maxBuffer = 200 }) {
|
|
|
3363
3381
|
`;
|
|
3364
3382
|
for (const res of clients) res.write(frame);
|
|
3365
3383
|
});
|
|
3366
|
-
const server = import_node_http.default.createServer((req, res) => {
|
|
3384
|
+
const server = import_node_http.default.createServer(async (req, res) => {
|
|
3367
3385
|
const url = (req.url || "/").split("?")[0];
|
|
3368
3386
|
if (url === "/") {
|
|
3369
3387
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
@@ -3371,11 +3389,52 @@ function createDashboardServer({ source, port = 4317, maxBuffer = 200 }) {
|
|
|
3371
3389
|
return;
|
|
3372
3390
|
}
|
|
3373
3391
|
if (url === "/state") {
|
|
3392
|
+
if (typeof source.refresh === "function") await source.refresh().catch(() => {
|
|
3393
|
+
});
|
|
3374
3394
|
const status = typeof source.getStatus === "function" ? source.getStatus() : {};
|
|
3375
3395
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3376
3396
|
res.end(JSON.stringify(status));
|
|
3377
3397
|
return;
|
|
3378
3398
|
}
|
|
3399
|
+
if (url === "/command" && req.method === "POST") {
|
|
3400
|
+
const WHITELIST = {
|
|
3401
|
+
flatten: "flatten",
|
|
3402
|
+
stop: "stop",
|
|
3403
|
+
closePosition: "closePosition",
|
|
3404
|
+
cancelOrder: "cancelOrder"
|
|
3405
|
+
};
|
|
3406
|
+
let body = "";
|
|
3407
|
+
req.on("data", (c) => body += c);
|
|
3408
|
+
req.on("end", async () => {
|
|
3409
|
+
let cmd;
|
|
3410
|
+
try {
|
|
3411
|
+
cmd = JSON.parse(body || "{}");
|
|
3412
|
+
} catch {
|
|
3413
|
+
cmd = {};
|
|
3414
|
+
}
|
|
3415
|
+
const method = WHITELIST[cmd.type];
|
|
3416
|
+
if (!method || typeof source[method] !== "function") {
|
|
3417
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
3418
|
+
res.end(JSON.stringify({ ok: false, error: `unsupported command "${cmd.type}"` }));
|
|
3419
|
+
return;
|
|
3420
|
+
}
|
|
3421
|
+
try {
|
|
3422
|
+
const arg = cmd.type === "closePosition" ? cmd.symbol : cmd.type === "cancelOrder" ? cmd.orderId : void 0;
|
|
3423
|
+
await source[method](arg);
|
|
3424
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3425
|
+
res.end(JSON.stringify({ ok: true }));
|
|
3426
|
+
} catch (error) {
|
|
3427
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3428
|
+
res.end(
|
|
3429
|
+
JSON.stringify({
|
|
3430
|
+
ok: false,
|
|
3431
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3432
|
+
})
|
|
3433
|
+
);
|
|
3434
|
+
}
|
|
3435
|
+
});
|
|
3436
|
+
return;
|
|
3437
|
+
}
|
|
3379
3438
|
if (url === "/events") {
|
|
3380
3439
|
res.writeHead(200, {
|
|
3381
3440
|
"Content-Type": "text/event-stream",
|
|
@@ -3417,6 +3476,383 @@ function createDashboardServer({ source, port = 4317, maxBuffer = 200 }) {
|
|
|
3417
3476
|
server
|
|
3418
3477
|
};
|
|
3419
3478
|
}
|
|
3479
|
+
|
|
3480
|
+
// src/live/session.js
|
|
3481
|
+
function oppositeSide2(side) {
|
|
3482
|
+
return side === "long" || side === "buy" ? "sell" : "buy";
|
|
3483
|
+
}
|
|
3484
|
+
function toBrokerSide(side) {
|
|
3485
|
+
return side === "long" || side === "buy" ? "buy" : "sell";
|
|
3486
|
+
}
|
|
3487
|
+
function matchesOrderRef(reference, order) {
|
|
3488
|
+
if (!reference || !order) return false;
|
|
3489
|
+
if (reference.orderId && order.orderId && reference.orderId === order.orderId) return true;
|
|
3490
|
+
if (reference.clientOrderId && order.clientOrderId && reference.clientOrderId === order.clientOrderId) {
|
|
3491
|
+
return true;
|
|
3492
|
+
}
|
|
3493
|
+
return false;
|
|
3494
|
+
}
|
|
3495
|
+
var TradingSession = class _TradingSession {
|
|
3496
|
+
constructor({
|
|
3497
|
+
id,
|
|
3498
|
+
symbol,
|
|
3499
|
+
interval = "1m",
|
|
3500
|
+
broker,
|
|
3501
|
+
mode = "paper",
|
|
3502
|
+
equity = 1e4,
|
|
3503
|
+
riskPct = 1,
|
|
3504
|
+
maxDailyLossPct = 0,
|
|
3505
|
+
maxPositionPct = 1,
|
|
3506
|
+
qtyStep = 1e-3,
|
|
3507
|
+
minQty = 1e-3,
|
|
3508
|
+
maxLeverage = 2,
|
|
3509
|
+
confirmLive = false,
|
|
3510
|
+
eventBus
|
|
3511
|
+
} = {}) {
|
|
3512
|
+
if (mode === "live" && (!_TradingSession.liveAllowed() || !confirmLive)) {
|
|
3513
|
+
throw new Error(
|
|
3514
|
+
"live trading is gated: set TRADELAB_ALLOW_LIVE=true and pass confirmLive:true with a credentialed broker"
|
|
3515
|
+
);
|
|
3516
|
+
}
|
|
3517
|
+
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;
|
|
3521
|
+
this.interval = interval;
|
|
3522
|
+
this.broker = broker;
|
|
3523
|
+
this.mode = mode;
|
|
3524
|
+
this.equity = equity;
|
|
3525
|
+
this._startEquity = equity;
|
|
3526
|
+
this.riskPct = riskPct;
|
|
3527
|
+
this.maxPositionPct = maxPositionPct;
|
|
3528
|
+
this.qtyStep = qtyStep;
|
|
3529
|
+
this.minQty = minQty;
|
|
3530
|
+
this.maxLeverage = maxLeverage;
|
|
3531
|
+
this.eventBus = eventBus || new EventBus();
|
|
3532
|
+
this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0 });
|
|
3533
|
+
this.lastPrice = null;
|
|
3534
|
+
this.running = false;
|
|
3535
|
+
this.events = [];
|
|
3536
|
+
this.brackets = /* @__PURE__ */ new Map();
|
|
3537
|
+
this._pendingBracket = null;
|
|
3538
|
+
this._cachedPositions = [];
|
|
3539
|
+
this._cachedOpenOrders = [];
|
|
3540
|
+
this.candleBuffer = [];
|
|
3541
|
+
this._strategy = null;
|
|
3542
|
+
this._wireBrokerEvents();
|
|
3543
|
+
}
|
|
3544
|
+
static liveAllowed() {
|
|
3545
|
+
return process.env.TRADELAB_ALLOW_LIVE === "true";
|
|
3546
|
+
}
|
|
3547
|
+
_record(event, payload) {
|
|
3548
|
+
const msg = { event, payload, t: Date.now() };
|
|
3549
|
+
this.events.push(msg);
|
|
3550
|
+
if (this.events.length > 500) this.events.shift();
|
|
3551
|
+
this.eventBus.emitEvent(event, { sessionId: this.id, symbol: this.symbol, ...payload });
|
|
3552
|
+
}
|
|
3553
|
+
_wireBrokerEvents() {
|
|
3554
|
+
this.broker.on?.("order:filled", (order) => this._onBrokerFillSync(order));
|
|
3555
|
+
this.broker.on?.("order:submitted", (order) => this._record("order:submitted", order));
|
|
3556
|
+
this.broker.on?.(
|
|
3557
|
+
"order:canceled",
|
|
3558
|
+
(order) => this._onBrokerTerminalOrderSync("order:canceled", order)
|
|
3559
|
+
);
|
|
3560
|
+
this.broker.on?.(
|
|
3561
|
+
"order:rejected",
|
|
3562
|
+
(order) => this._onBrokerTerminalOrderSync("order:rejected", order)
|
|
3563
|
+
);
|
|
3564
|
+
this.broker.on?.("equity:update", (acct) => this._record("equity:update", acct));
|
|
3565
|
+
}
|
|
3566
|
+
_onBrokerTerminalOrderSync(event, order) {
|
|
3567
|
+
this._record(event, order);
|
|
3568
|
+
if (matchesOrderRef(this._pendingBracket, order)) {
|
|
3569
|
+
this._pendingBracket = null;
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
// Sync event handler — fire-and-forget async OCO work via a stored promise
|
|
3573
|
+
_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;
|
|
3582
|
+
}
|
|
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
|
+
})();
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
async start() {
|
|
3595
|
+
if (!this.broker.isConnected?.()) await this.broker.connect?.({});
|
|
3596
|
+
const acct = await this.broker.getAccount?.().catch(() => null);
|
|
3597
|
+
if (Number.isFinite(acct?.equity)) {
|
|
3598
|
+
this.equity = acct.equity;
|
|
3599
|
+
this._startEquity = acct.equity;
|
|
3600
|
+
}
|
|
3601
|
+
this.riskManager.initialize(this.equity, Date.now());
|
|
3602
|
+
this.running = true;
|
|
3603
|
+
this._record("connected", { mode: this.mode });
|
|
3604
|
+
}
|
|
3605
|
+
async stop({ flatten = false } = {}) {
|
|
3606
|
+
if (flatten) await this.flatten();
|
|
3607
|
+
this.running = false;
|
|
3608
|
+
this._record("shutdown", {});
|
|
3609
|
+
}
|
|
3610
|
+
async pushBar(b) {
|
|
3611
|
+
this.lastPrice = b.close;
|
|
3612
|
+
if (typeof this.broker.simulateBar === "function") {
|
|
3613
|
+
await this.broker.simulateBar(this.symbol, this.interval, b);
|
|
3614
|
+
}
|
|
3615
|
+
if (this._pendingCancelPromise) {
|
|
3616
|
+
await this._pendingCancelPromise;
|
|
3617
|
+
this._pendingCancelPromise = null;
|
|
3618
|
+
}
|
|
3619
|
+
this.candleBuffer.push(b);
|
|
3620
|
+
if (this.candleBuffer.length > 200) this.candleBuffer.shift();
|
|
3621
|
+
this._record("bar", { close: b.close, time: b.time });
|
|
3622
|
+
await this._syncEquityAndRisk();
|
|
3623
|
+
await this.refresh();
|
|
3624
|
+
}
|
|
3625
|
+
_riskHalted() {
|
|
3626
|
+
const state = this.riskManager.getState?.() || {};
|
|
3627
|
+
return Boolean(state.halted);
|
|
3628
|
+
}
|
|
3629
|
+
async placeOrder({ side, type = "market", qty, riskPct, stop, target, rr, limitPrice } = {}) {
|
|
3630
|
+
if (!this.running) throw new Error("session not started");
|
|
3631
|
+
if (this._riskHalted()) throw new Error("session is risk-halted for the day");
|
|
3632
|
+
const entryRef = type === "limit" ? limitPrice : this.lastPrice;
|
|
3633
|
+
if (!Number.isFinite(entryRef)) throw new Error("no price available; pushBar() a price first");
|
|
3634
|
+
let size = qty;
|
|
3635
|
+
if (!Number.isFinite(size)) {
|
|
3636
|
+
const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
|
|
3637
|
+
if (!Number.isFinite(stop)) throw new Error("risk-based sizing requires a stop");
|
|
3638
|
+
size = calculatePositionSize({
|
|
3639
|
+
equity: this.equity,
|
|
3640
|
+
entry: entryRef,
|
|
3641
|
+
stop,
|
|
3642
|
+
riskFraction: fraction,
|
|
3643
|
+
qtyStep: this.qtyStep,
|
|
3644
|
+
minQty: this.minQty,
|
|
3645
|
+
maxLeverage: this.maxLeverage
|
|
3646
|
+
});
|
|
3647
|
+
}
|
|
3648
|
+
size = roundStep(size, this.qtyStep);
|
|
3649
|
+
if (!(size >= this.minQty)) throw new Error(`sized below minQty (${size})`);
|
|
3650
|
+
const entryClientOrderId = `${this.id}-entry-${Date.now()}`;
|
|
3651
|
+
const receipt = await this.broker.submitOrder({
|
|
3652
|
+
symbol: this.symbol,
|
|
3653
|
+
side: toBrokerSide(side),
|
|
3654
|
+
type,
|
|
3655
|
+
qty: size,
|
|
3656
|
+
limitPrice: type === "limit" ? limitPrice : void 0,
|
|
3657
|
+
clientOrderId: entryClientOrderId
|
|
3658
|
+
});
|
|
3659
|
+
if (Number.isFinite(stop) || Number.isFinite(target) || Number.isFinite(rr)) {
|
|
3660
|
+
if (receipt.status === "filled") {
|
|
3661
|
+
await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt });
|
|
3662
|
+
} else if (receipt.status !== "rejected") {
|
|
3663
|
+
this._pendingBracket = {
|
|
3664
|
+
side,
|
|
3665
|
+
size,
|
|
3666
|
+
stop,
|
|
3667
|
+
target,
|
|
3668
|
+
rr,
|
|
3669
|
+
entryRef,
|
|
3670
|
+
orderId: receipt.orderId,
|
|
3671
|
+
clientOrderId: receipt.clientOrderId || entryClientOrderId
|
|
3672
|
+
};
|
|
3673
|
+
} else {
|
|
3674
|
+
this._pendingBracket = null;
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
await this.refresh();
|
|
3678
|
+
return receipt;
|
|
3679
|
+
}
|
|
3680
|
+
async _attachBracket({ side, size, stop, target, rr, entryRef, receipt }) {
|
|
3681
|
+
const entryFill = receipt?.avgFillPrice ?? entryRef;
|
|
3682
|
+
const risk = Number.isFinite(stop) ? Math.abs(entryFill - stop) : null;
|
|
3683
|
+
const targetPrice = Number.isFinite(target) ? target : Number.isFinite(rr) && risk ? side === "long" || side === "buy" ? entryFill + rr * risk : entryFill - rr * risk : null;
|
|
3684
|
+
const exitSide = oppositeSide2(side);
|
|
3685
|
+
const bracket = {};
|
|
3686
|
+
if (Number.isFinite(stop)) {
|
|
3687
|
+
const stopOrder = await this.broker.submitOrder({
|
|
3688
|
+
symbol: this.symbol,
|
|
3689
|
+
side: exitSide,
|
|
3690
|
+
type: "stop",
|
|
3691
|
+
qty: size,
|
|
3692
|
+
stopPrice: stop,
|
|
3693
|
+
clientOrderId: `${this.id}-stop-${Date.now()}`
|
|
3694
|
+
});
|
|
3695
|
+
bracket.stopId = stopOrder.orderId;
|
|
3696
|
+
}
|
|
3697
|
+
if (Number.isFinite(targetPrice)) {
|
|
3698
|
+
const tgtOrder = await this.broker.submitOrder({
|
|
3699
|
+
symbol: this.symbol,
|
|
3700
|
+
side: exitSide,
|
|
3701
|
+
type: "limit",
|
|
3702
|
+
qty: size,
|
|
3703
|
+
limitPrice: targetPrice,
|
|
3704
|
+
clientOrderId: `${this.id}-target-${Date.now()}`
|
|
3705
|
+
});
|
|
3706
|
+
bracket.targetId = tgtOrder.orderId;
|
|
3707
|
+
}
|
|
3708
|
+
this.brackets.set(this.symbol, bracket);
|
|
3709
|
+
}
|
|
3710
|
+
async _syncEquityAndRisk() {
|
|
3711
|
+
const acct = await this.broker.getAccount?.().catch(() => null);
|
|
3712
|
+
if (!Number.isFinite(acct?.equity)) return;
|
|
3713
|
+
const prevEquity = this.equity;
|
|
3714
|
+
this.equity = acct.equity;
|
|
3715
|
+
const pnlDelta = this.equity - prevEquity;
|
|
3716
|
+
if (pnlDelta !== 0) {
|
|
3717
|
+
this.riskManager.recordTrade({ pnl: pnlDelta, timeMs: Date.now(), equity: this.equity });
|
|
3718
|
+
} else {
|
|
3719
|
+
this.riskManager.update({ timeMs: Date.now(), equity: this.equity });
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
async closePosition(symbol = this.symbol) {
|
|
3723
|
+
const positions = await this.broker.getPositions();
|
|
3724
|
+
const pos = positions.find((p) => p.symbol === symbol);
|
|
3725
|
+
if (!pos) return null;
|
|
3726
|
+
const bracket = this.brackets.get(symbol);
|
|
3727
|
+
if (bracket) {
|
|
3728
|
+
for (const id of [bracket.stopId, bracket.targetId]) {
|
|
3729
|
+
if (id) await this.broker.cancelOrder(id).catch(() => {
|
|
3730
|
+
});
|
|
3731
|
+
}
|
|
3732
|
+
this.brackets.delete(symbol);
|
|
3733
|
+
}
|
|
3734
|
+
const receipt = await this.broker.submitOrder({
|
|
3735
|
+
symbol,
|
|
3736
|
+
side: oppositeSide2(pos.side),
|
|
3737
|
+
type: "market",
|
|
3738
|
+
qty: pos.qty,
|
|
3739
|
+
clientOrderId: `${this.id}-close-${Date.now()}`
|
|
3740
|
+
});
|
|
3741
|
+
await this._syncEquityAndRisk();
|
|
3742
|
+
await this.refresh();
|
|
3743
|
+
return receipt;
|
|
3744
|
+
}
|
|
3745
|
+
async flatten() {
|
|
3746
|
+
const positions = await this.broker.getPositions();
|
|
3747
|
+
for (const p of positions) await this.closePosition(p.symbol);
|
|
3748
|
+
const open = await this.broker.getOpenOrders?.().catch(() => []) ?? [];
|
|
3749
|
+
for (const o of open) await this.broker.cancelOrder(o.orderId).catch(() => {
|
|
3750
|
+
});
|
|
3751
|
+
await this.refresh();
|
|
3752
|
+
}
|
|
3753
|
+
async cancelOrder(orderId) {
|
|
3754
|
+
await this.broker.cancelOrder(orderId);
|
|
3755
|
+
await this.refresh();
|
|
3756
|
+
}
|
|
3757
|
+
async getAccount() {
|
|
3758
|
+
return this.broker.getAccount();
|
|
3759
|
+
}
|
|
3760
|
+
async getPositions() {
|
|
3761
|
+
return this.broker.getPositions();
|
|
3762
|
+
}
|
|
3763
|
+
recentEvents(limit = 50) {
|
|
3764
|
+
return this.events.slice(-limit);
|
|
3765
|
+
}
|
|
3766
|
+
getStatus() {
|
|
3767
|
+
const risk = this.riskManager.getState?.() || {};
|
|
3768
|
+
return {
|
|
3769
|
+
id: this.id,
|
|
3770
|
+
symbol: this.symbol,
|
|
3771
|
+
interval: this.interval,
|
|
3772
|
+
mode: this.mode,
|
|
3773
|
+
running: this.running,
|
|
3774
|
+
equity: this.equity,
|
|
3775
|
+
dayPnl: risk.dayPnl ?? 0,
|
|
3776
|
+
lastPrice: this.lastPrice,
|
|
3777
|
+
positions: this._cachedPositions ?? [],
|
|
3778
|
+
openOrders: this._cachedOpenOrders ?? [],
|
|
3779
|
+
risk: { halted: Boolean(risk.halted), ...risk }
|
|
3780
|
+
};
|
|
3781
|
+
}
|
|
3782
|
+
/** Refresh sync caches used by getStatus() */
|
|
3783
|
+
async refresh() {
|
|
3784
|
+
if (this._pendingCancelPromise) {
|
|
3785
|
+
await this._pendingCancelPromise;
|
|
3786
|
+
this._pendingCancelPromise = null;
|
|
3787
|
+
}
|
|
3788
|
+
this._cachedPositions = await this.broker.getPositions().catch(() => []);
|
|
3789
|
+
this._cachedOpenOrders = await this.broker.getOpenOrders?.().catch(() => []) ?? [];
|
|
3790
|
+
const acct = await this.broker.getAccount?.().catch(() => null);
|
|
3791
|
+
if (Number.isFinite(acct?.equity)) this.equity = acct.equity;
|
|
3792
|
+
return this.getStatus();
|
|
3793
|
+
}
|
|
3794
|
+
};
|
|
3795
|
+
var SessionManager = class {
|
|
3796
|
+
constructor({ brokerFactory } = {}) {
|
|
3797
|
+
this.sessions = /* @__PURE__ */ new Map();
|
|
3798
|
+
this.brokerFactory = brokerFactory;
|
|
3799
|
+
}
|
|
3800
|
+
async create({
|
|
3801
|
+
id,
|
|
3802
|
+
mode = "paper",
|
|
3803
|
+
symbol,
|
|
3804
|
+
interval = "1m",
|
|
3805
|
+
equity = 1e4,
|
|
3806
|
+
confirmLive = false,
|
|
3807
|
+
broker,
|
|
3808
|
+
...rest
|
|
3809
|
+
} = {}) {
|
|
3810
|
+
if (this.sessions.has(id)) throw new Error(`session "${id}" already exists`);
|
|
3811
|
+
let resolvedBroker = broker;
|
|
3812
|
+
if (mode === "live") {
|
|
3813
|
+
if (!TradingSession.liveAllowed() || !confirmLive) {
|
|
3814
|
+
throw new Error("live mode requires TRADELAB_ALLOW_LIVE=true and confirmLive:true");
|
|
3815
|
+
}
|
|
3816
|
+
if (!resolvedBroker && this.brokerFactory) {
|
|
3817
|
+
resolvedBroker = this.brokerFactory({ symbol, ...rest });
|
|
3818
|
+
}
|
|
3819
|
+
if (!resolvedBroker) throw new Error("live mode requires a credentialed broker");
|
|
3820
|
+
}
|
|
3821
|
+
if (!resolvedBroker) resolvedBroker = new PaperEngine({ equity });
|
|
3822
|
+
const session = new TradingSession({
|
|
3823
|
+
id,
|
|
3824
|
+
symbol,
|
|
3825
|
+
interval,
|
|
3826
|
+
broker: resolvedBroker,
|
|
3827
|
+
mode,
|
|
3828
|
+
equity,
|
|
3829
|
+
confirmLive,
|
|
3830
|
+
...rest
|
|
3831
|
+
});
|
|
3832
|
+
await session.start();
|
|
3833
|
+
this.sessions.set(session.id, session);
|
|
3834
|
+
return session;
|
|
3835
|
+
}
|
|
3836
|
+
get(id) {
|
|
3837
|
+
return this.sessions.get(id) ?? null;
|
|
3838
|
+
}
|
|
3839
|
+
list() {
|
|
3840
|
+
return [...this.sessions.values()];
|
|
3841
|
+
}
|
|
3842
|
+
async remove(id, { flatten = true } = {}) {
|
|
3843
|
+
const s = this.sessions.get(id);
|
|
3844
|
+
if (!s) return;
|
|
3845
|
+
await s.stop({ flatten });
|
|
3846
|
+
this.sessions.delete(id);
|
|
3847
|
+
}
|
|
3848
|
+
async haltAll() {
|
|
3849
|
+
for (const s of this.sessions.values()) await s.stop({ flatten: true });
|
|
3850
|
+
this.sessions.clear();
|
|
3851
|
+
}
|
|
3852
|
+
};
|
|
3853
|
+
function createSessionManager(opts) {
|
|
3854
|
+
return new SessionManager(opts);
|
|
3855
|
+
}
|
|
3420
3856
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3421
3857
|
0 && (module.exports = {
|
|
3422
3858
|
AlpacaBroker,
|
|
@@ -3437,8 +3873,10 @@ function createDashboardServer({ source, port = 4317, maxBuffer = 200 }) {
|
|
|
3437
3873
|
PaperEngine,
|
|
3438
3874
|
PollingFeed,
|
|
3439
3875
|
RiskManager,
|
|
3876
|
+
SessionManager,
|
|
3440
3877
|
StateManager,
|
|
3441
3878
|
StorageProvider,
|
|
3879
|
+
TradingSession,
|
|
3442
3880
|
createAlpacaBroker,
|
|
3443
3881
|
createBinanceBroker,
|
|
3444
3882
|
createBrokerFeed,
|
|
@@ -3455,5 +3893,6 @@ function createDashboardServer({ source, port = 4317, maxBuffer = 200 }) {
|
|
|
3455
3893
|
createPaperEngine,
|
|
3456
3894
|
createPollingFeed,
|
|
3457
3895
|
createRiskManager,
|
|
3896
|
+
createSessionManager,
|
|
3458
3897
|
createStateManager
|
|
3459
3898
|
});
|