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/src/engine/portfolio.js
CHANGED
|
@@ -148,6 +148,7 @@ function forceExitAll(runners, time) {
|
|
|
148
148
|
export function backtestPortfolio({
|
|
149
149
|
systems = [],
|
|
150
150
|
equity = 10_000,
|
|
151
|
+
interval,
|
|
151
152
|
allocation = "equal",
|
|
152
153
|
collectEqSeries = true,
|
|
153
154
|
collectReplay = false,
|
|
@@ -307,18 +308,21 @@ export function backtestPortfolio({
|
|
|
307
308
|
const replay = combineReplay(systemResults, eqSeries, collectReplay);
|
|
308
309
|
const allCandles = systems.flatMap((system) => system.candles || []);
|
|
309
310
|
const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
|
|
311
|
+
const metricsInterval = interval ?? systems[0]?.interval;
|
|
312
|
+
const finalState = portfolioState(runners, equity);
|
|
310
313
|
const metrics = buildMetrics({
|
|
311
314
|
closed: trades,
|
|
312
315
|
equityStart: equity,
|
|
313
|
-
equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity :
|
|
316
|
+
equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : finalState.markedEquity,
|
|
314
317
|
candles: orderedCandles,
|
|
315
318
|
estBarMs: estimateBarMs(orderedCandles),
|
|
316
319
|
eqSeries,
|
|
320
|
+
interval: metricsInterval,
|
|
317
321
|
});
|
|
318
322
|
|
|
319
323
|
return {
|
|
320
324
|
symbol: "PORTFOLIO",
|
|
321
|
-
interval:
|
|
325
|
+
interval: metricsInterval,
|
|
322
326
|
range: undefined,
|
|
323
327
|
trades,
|
|
324
328
|
positions,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
@@ -22,13 +22,26 @@ const FALLBACK_HTML = `<!doctype html>
|
|
|
22
22
|
</body>
|
|
23
23
|
</html>`;
|
|
24
24
|
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
function callerModuleDir() {
|
|
26
|
+
const stack = new Error().stack || "";
|
|
27
|
+
const lines = stack.split("\n").slice(1);
|
|
28
|
+
const match = lines
|
|
29
|
+
.map((line) => line.match(/(?:\()?(file:\/\/\/[^\s)]+|\/[^\s)]+):\d+:\d+/))
|
|
30
|
+
.find(Boolean);
|
|
31
|
+
if (!match) return process.cwd();
|
|
32
|
+
const filePath = match[1].startsWith("file://") ? fileURLToPath(match[1]) : match[1];
|
|
33
|
+
return path.dirname(filePath);
|
|
34
|
+
}
|
|
31
35
|
|
|
36
|
+
function readDashboardHtml() {
|
|
37
|
+
const here = callerModuleDir();
|
|
38
|
+
const candidates = [
|
|
39
|
+
path.join(here, "..", "..", "..", "templates", "dashboard.html"),
|
|
40
|
+
path.join(here, "..", "..", "templates", "dashboard.html"),
|
|
41
|
+
path.join(process.cwd(), "templates", "dashboard.html"),
|
|
42
|
+
];
|
|
43
|
+
const htmlPath = candidates.find((candidate) => existsSync(candidate));
|
|
44
|
+
if (htmlPath) return readFileSync(htmlPath, "utf8");
|
|
32
45
|
try {
|
|
33
46
|
return readFileSync(path.join(process.cwd(), "templates", "dashboard.html"), "utf8");
|
|
34
47
|
} catch {
|
|
@@ -61,7 +74,7 @@ export function createDashboardServer({ source, port = 4317, maxBuffer = 200 })
|
|
|
61
74
|
for (const res of clients) res.write(frame);
|
|
62
75
|
});
|
|
63
76
|
|
|
64
|
-
const server = http.createServer((req, res) => {
|
|
77
|
+
const server = http.createServer(async (req, res) => {
|
|
65
78
|
const url = (req.url || "/").split("?")[0];
|
|
66
79
|
|
|
67
80
|
if (url === "/") {
|
|
@@ -71,12 +84,58 @@ export function createDashboardServer({ source, port = 4317, maxBuffer = 200 })
|
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
if (url === "/state") {
|
|
87
|
+
if (typeof source.refresh === "function") await source.refresh().catch(() => {});
|
|
74
88
|
const status = typeof source.getStatus === "function" ? source.getStatus() : {};
|
|
75
89
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
76
90
|
res.end(JSON.stringify(status));
|
|
77
91
|
return;
|
|
78
92
|
}
|
|
79
93
|
|
|
94
|
+
if (url === "/command" && req.method === "POST") {
|
|
95
|
+
const WHITELIST = {
|
|
96
|
+
flatten: "flatten",
|
|
97
|
+
stop: "stop",
|
|
98
|
+
closePosition: "closePosition",
|
|
99
|
+
cancelOrder: "cancelOrder",
|
|
100
|
+
};
|
|
101
|
+
let body = "";
|
|
102
|
+
req.on("data", (c) => (body += c));
|
|
103
|
+
req.on("end", async () => {
|
|
104
|
+
let cmd;
|
|
105
|
+
try {
|
|
106
|
+
cmd = JSON.parse(body || "{}");
|
|
107
|
+
} catch {
|
|
108
|
+
cmd = {};
|
|
109
|
+
}
|
|
110
|
+
const method = WHITELIST[cmd.type];
|
|
111
|
+
if (!method || typeof source[method] !== "function") {
|
|
112
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
113
|
+
res.end(JSON.stringify({ ok: false, error: `unsupported command "${cmd.type}"` }));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const arg =
|
|
118
|
+
cmd.type === "closePosition"
|
|
119
|
+
? cmd.symbol
|
|
120
|
+
: cmd.type === "cancelOrder"
|
|
121
|
+
? cmd.orderId
|
|
122
|
+
: undefined;
|
|
123
|
+
await source[method](arg);
|
|
124
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
125
|
+
res.end(JSON.stringify({ ok: true }));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
128
|
+
res.end(
|
|
129
|
+
JSON.stringify({
|
|
130
|
+
ok: false,
|
|
131
|
+
error: error instanceof Error ? error.message : String(error),
|
|
132
|
+
})
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
80
139
|
if (url === "/events") {
|
|
81
140
|
res.writeHead(200, {
|
|
82
141
|
"Content-Type": "text/event-stream",
|
|
@@ -216,15 +216,21 @@ export class PaperEngine extends BrokerAdapter {
|
|
|
216
216
|
this.orderHistory.set(order.orderId, { ...order });
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
_rejectOrder(order, reason) {
|
|
220
|
+
order.status = "rejected";
|
|
221
|
+
order.rejectReason = reason;
|
|
222
|
+
this._recordOrder(order);
|
|
223
|
+
this.openOrders.delete(order.orderId);
|
|
224
|
+
const receipt = cloneOrder(order);
|
|
225
|
+
this.emit("order:rejected", receipt);
|
|
226
|
+
return receipt;
|
|
227
|
+
}
|
|
228
|
+
|
|
219
229
|
_fillOrder(order, fillPrice, kind = "market", fillTime = Date.now()) {
|
|
220
230
|
const side = normalizeOrderSide(order.side);
|
|
221
231
|
const qty = Math.max(0, asNumber(order.qty, 0));
|
|
222
232
|
if (!(qty > 0)) {
|
|
223
|
-
order
|
|
224
|
-
order.rejectReason = "invalid quantity";
|
|
225
|
-
this._recordOrder(order);
|
|
226
|
-
this.emit("order:rejected", cloneOrder(order));
|
|
227
|
-
return cloneOrder(order);
|
|
233
|
+
return this._rejectOrder(order, "invalid quantity");
|
|
228
234
|
}
|
|
229
235
|
|
|
230
236
|
const sideForFill = side === "buy" ? "long" : "short";
|
|
@@ -346,11 +352,7 @@ export class PaperEngine extends BrokerAdapter {
|
|
|
346
352
|
};
|
|
347
353
|
|
|
348
354
|
if (!(normalized.qty > 0)) {
|
|
349
|
-
normalized
|
|
350
|
-
normalized.rejectReason = "invalid quantity";
|
|
351
|
-
this._recordOrder(normalized);
|
|
352
|
-
this.emit("order:rejected", cloneOrder(normalized));
|
|
353
|
-
return cloneOrder(normalized);
|
|
355
|
+
return this._rejectOrder(normalized, "invalid quantity");
|
|
354
356
|
}
|
|
355
357
|
|
|
356
358
|
this._recordOrder(normalized);
|
|
@@ -358,7 +360,10 @@ export class PaperEngine extends BrokerAdapter {
|
|
|
358
360
|
|
|
359
361
|
if (normalized.type === "market") {
|
|
360
362
|
const mark = this.lastPrices.get(normalized.symbol);
|
|
361
|
-
const fillPrice = mark ?? normalized.limitPrice ?? normalized.stopPrice
|
|
363
|
+
const fillPrice = mark ?? normalized.limitPrice ?? normalized.stopPrice;
|
|
364
|
+
if (!Number.isFinite(fillPrice)) {
|
|
365
|
+
return this._rejectOrder(normalized, "no price available for market order");
|
|
366
|
+
}
|
|
362
367
|
return this._fillOrder(normalized, fillPrice, "market");
|
|
363
368
|
}
|
|
364
369
|
|
|
@@ -424,6 +429,11 @@ export class PaperEngine extends BrokerAdapter {
|
|
|
424
429
|
|
|
425
430
|
const orders = [...this.openOrders.values()].filter((order) => order.symbol === symbol);
|
|
426
431
|
for (const order of orders) {
|
|
432
|
+
// Skip orders already consumed this pass (e.g. an OCO sibling about to be
|
|
433
|
+
// canceled, or any order removed by a prior fill). _fillOrder deletes from
|
|
434
|
+
// openOrders before emitting, so this guard prevents bracket double-fills
|
|
435
|
+
// when one bar straddles both stop and target.
|
|
436
|
+
if (!this.openOrders.has(order.orderId)) continue;
|
|
427
437
|
if (order.type === "limit") {
|
|
428
438
|
if (this._touchesLimit(order, normalizedBar)) {
|
|
429
439
|
this._fillOrder(order, order.limitPrice, "limit", normalizedBar.time);
|
package/src/live/index.js
CHANGED
|
@@ -26,3 +26,5 @@ export { LiveEngine, createLiveEngine } from "./engine/liveEngine.js";
|
|
|
26
26
|
|
|
27
27
|
export { LiveOrchestrator, createLiveOrchestrator } from "./orchestrator.js";
|
|
28
28
|
export { createDashboardServer } from "./dashboard/server.js";
|
|
29
|
+
|
|
30
|
+
export { TradingSession, SessionManager, createSessionManager } from "./session.js";
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { EventBus } from "./events.js";
|
|
2
|
+
import { RiskManager } from "./engine/riskManager.js";
|
|
3
|
+
import { calculatePositionSize } from "../utils/positionSizing.js";
|
|
4
|
+
import { roundStep } from "../engine/execution.js";
|
|
5
|
+
import { PaperEngine } from "./engine/paperEngine.js";
|
|
6
|
+
|
|
7
|
+
function oppositeSide(side) {
|
|
8
|
+
return side === "long" || side === "buy" ? "sell" : "buy";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function toBrokerSide(side) {
|
|
12
|
+
return side === "long" || side === "buy" ? "buy" : "sell";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function matchesOrderRef(reference, order) {
|
|
16
|
+
if (!reference || !order) return false;
|
|
17
|
+
if (reference.orderId && order.orderId && reference.orderId === order.orderId) return true;
|
|
18
|
+
if (
|
|
19
|
+
reference.clientOrderId &&
|
|
20
|
+
order.clientOrderId &&
|
|
21
|
+
reference.clientOrderId === order.clientOrderId
|
|
22
|
+
) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class TradingSession {
|
|
29
|
+
constructor({
|
|
30
|
+
id,
|
|
31
|
+
symbol,
|
|
32
|
+
interval = "1m",
|
|
33
|
+
broker,
|
|
34
|
+
mode = "paper",
|
|
35
|
+
equity = 10_000,
|
|
36
|
+
riskPct = 1,
|
|
37
|
+
maxDailyLossPct = 0,
|
|
38
|
+
maxPositionPct = 1,
|
|
39
|
+
qtyStep = 0.001,
|
|
40
|
+
minQty = 0.001,
|
|
41
|
+
maxLeverage = 2,
|
|
42
|
+
confirmLive = false,
|
|
43
|
+
eventBus,
|
|
44
|
+
} = {}) {
|
|
45
|
+
if (mode === "live" && (!TradingSession.liveAllowed() || !confirmLive)) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
"live trading is gated: set TRADELAB_ALLOW_LIVE=true and pass confirmLive:true with a credentialed broker"
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (!broker) throw new Error("TradingSession requires a broker (PaperEngine by default)");
|
|
51
|
+
if (!symbol) throw new Error("TradingSession requires a symbol");
|
|
52
|
+
|
|
53
|
+
this.id = id || `${symbol}-${interval}`;
|
|
54
|
+
this.symbol = symbol;
|
|
55
|
+
this.interval = interval;
|
|
56
|
+
this.broker = broker;
|
|
57
|
+
this.mode = mode;
|
|
58
|
+
this.equity = equity;
|
|
59
|
+
this._startEquity = equity;
|
|
60
|
+
this.riskPct = riskPct;
|
|
61
|
+
this.maxPositionPct = maxPositionPct;
|
|
62
|
+
this.qtyStep = qtyStep;
|
|
63
|
+
this.minQty = minQty;
|
|
64
|
+
this.maxLeverage = maxLeverage;
|
|
65
|
+
this.eventBus = eventBus || new EventBus();
|
|
66
|
+
this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0 });
|
|
67
|
+
this.lastPrice = null;
|
|
68
|
+
this.running = false;
|
|
69
|
+
this.events = [];
|
|
70
|
+
this.brackets = new Map(); // symbol -> { stopId, targetId }
|
|
71
|
+
this._pendingBracket = null;
|
|
72
|
+
this._cachedPositions = [];
|
|
73
|
+
this._cachedOpenOrders = [];
|
|
74
|
+
this.candleBuffer = [];
|
|
75
|
+
this._strategy = null;
|
|
76
|
+
|
|
77
|
+
this._wireBrokerEvents();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
static liveAllowed() {
|
|
81
|
+
return process.env.TRADELAB_ALLOW_LIVE === "true";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_record(event, payload) {
|
|
85
|
+
const msg = { event, payload, t: Date.now() };
|
|
86
|
+
this.events.push(msg);
|
|
87
|
+
if (this.events.length > 500) this.events.shift();
|
|
88
|
+
this.eventBus.emitEvent(event, { sessionId: this.id, symbol: this.symbol, ...payload });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_wireBrokerEvents() {
|
|
92
|
+
// Forward broker fills/cancels onto the session bus, and run OCO bracket logic.
|
|
93
|
+
this.broker.on?.("order:filled", (order) => this._onBrokerFillSync(order));
|
|
94
|
+
this.broker.on?.("order:submitted", (order) => this._record("order:submitted", order));
|
|
95
|
+
this.broker.on?.("order:canceled", (order) =>
|
|
96
|
+
this._onBrokerTerminalOrderSync("order:canceled", order)
|
|
97
|
+
);
|
|
98
|
+
this.broker.on?.("order:rejected", (order) =>
|
|
99
|
+
this._onBrokerTerminalOrderSync("order:rejected", order)
|
|
100
|
+
);
|
|
101
|
+
this.broker.on?.("equity:update", (acct) => this._record("equity:update", acct));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_onBrokerTerminalOrderSync(event, order) {
|
|
105
|
+
this._record(event, order);
|
|
106
|
+
if (matchesOrderRef(this._pendingBracket, order)) {
|
|
107
|
+
this._pendingBracket = null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Sync event handler — fire-and-forget async OCO work via a stored promise
|
|
112
|
+
_onBrokerFillSync(order) {
|
|
113
|
+
this._record("order:filled", order);
|
|
114
|
+
|
|
115
|
+
// Resting entry order (e.g. a limit) just filled — attach its staged bracket.
|
|
116
|
+
if (matchesOrderRef(this._pendingBracket, order)) {
|
|
117
|
+
const staged = this._pendingBracket;
|
|
118
|
+
this._pendingBracket = null;
|
|
119
|
+
// simulateBar may still be iterating orders, so schedule attach without awaiting.
|
|
120
|
+
this._pendingCancelPromise = Promise.resolve(
|
|
121
|
+
this._attachBracket({ ...staged, receipt: order })
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Track bracket leg fills for OCO
|
|
127
|
+
const bracket = this.brackets.get(this.symbol);
|
|
128
|
+
if (bracket && (order.orderId === bracket.stopId || order.orderId === bracket.targetId)) {
|
|
129
|
+
const siblingId = order.orderId === bracket.stopId ? bracket.targetId : bracket.stopId;
|
|
130
|
+
// Schedule the cancel — simulateBar is still iterating orders, so we must not await here.
|
|
131
|
+
// We keep a pending cancel promise that refresh() awaits.
|
|
132
|
+
this._pendingCancelPromise = (async () => {
|
|
133
|
+
if (siblingId) await this.broker.cancelOrder(siblingId).catch(() => {});
|
|
134
|
+
this.brackets.delete(this.symbol);
|
|
135
|
+
this._record("position:closed", { reason: order.orderId === bracket.stopId ? "SL" : "TP" });
|
|
136
|
+
})();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async start() {
|
|
141
|
+
if (!this.broker.isConnected?.()) await this.broker.connect?.({});
|
|
142
|
+
const acct = await this.broker.getAccount?.().catch(() => null);
|
|
143
|
+
if (Number.isFinite(acct?.equity)) {
|
|
144
|
+
this.equity = acct.equity;
|
|
145
|
+
this._startEquity = acct.equity;
|
|
146
|
+
}
|
|
147
|
+
this.riskManager.initialize(this.equity, Date.now());
|
|
148
|
+
this.running = true;
|
|
149
|
+
this._record("connected", { mode: this.mode });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async stop({ flatten = false } = {}) {
|
|
153
|
+
if (flatten) await this.flatten();
|
|
154
|
+
this.running = false;
|
|
155
|
+
this._record("shutdown", {});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async pushBar(b) {
|
|
159
|
+
this.lastPrice = b.close;
|
|
160
|
+
if (typeof this.broker.simulateBar === "function") {
|
|
161
|
+
await this.broker.simulateBar(this.symbol, this.interval, b);
|
|
162
|
+
}
|
|
163
|
+
// Wait for any pending OCO cancel triggered by simulateBar fills
|
|
164
|
+
if (this._pendingCancelPromise) {
|
|
165
|
+
await this._pendingCancelPromise;
|
|
166
|
+
this._pendingCancelPromise = null;
|
|
167
|
+
}
|
|
168
|
+
this.candleBuffer.push(b);
|
|
169
|
+
if (this.candleBuffer.length > 200) this.candleBuffer.shift();
|
|
170
|
+
this._record("bar", { close: b.close, time: b.time });
|
|
171
|
+
await this._syncEquityAndRisk();
|
|
172
|
+
await this.refresh();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
_riskHalted() {
|
|
176
|
+
const state = this.riskManager.getState?.() || {};
|
|
177
|
+
return Boolean(state.halted);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async placeOrder({ side, type = "market", qty, riskPct, stop, target, rr, limitPrice } = {}) {
|
|
181
|
+
if (!this.running) throw new Error("session not started");
|
|
182
|
+
if (this._riskHalted()) throw new Error("session is risk-halted for the day");
|
|
183
|
+
const entryRef = type === "limit" ? limitPrice : this.lastPrice;
|
|
184
|
+
if (!Number.isFinite(entryRef)) throw new Error("no price available; pushBar() a price first");
|
|
185
|
+
|
|
186
|
+
let size = qty;
|
|
187
|
+
if (!Number.isFinite(size)) {
|
|
188
|
+
const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
|
|
189
|
+
if (!Number.isFinite(stop)) throw new Error("risk-based sizing requires a stop");
|
|
190
|
+
size = calculatePositionSize({
|
|
191
|
+
equity: this.equity,
|
|
192
|
+
entry: entryRef,
|
|
193
|
+
stop,
|
|
194
|
+
riskFraction: fraction,
|
|
195
|
+
qtyStep: this.qtyStep,
|
|
196
|
+
minQty: this.minQty,
|
|
197
|
+
maxLeverage: this.maxLeverage,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
size = roundStep(size, this.qtyStep);
|
|
201
|
+
if (!(size >= this.minQty)) throw new Error(`sized below minQty (${size})`);
|
|
202
|
+
|
|
203
|
+
const entryClientOrderId = `${this.id}-entry-${Date.now()}`;
|
|
204
|
+
const receipt = await this.broker.submitOrder({
|
|
205
|
+
symbol: this.symbol,
|
|
206
|
+
side: toBrokerSide(side),
|
|
207
|
+
type,
|
|
208
|
+
qty: size,
|
|
209
|
+
limitPrice: type === "limit" ? limitPrice : undefined,
|
|
210
|
+
clientOrderId: entryClientOrderId,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Stage bracket if needed — market orders fill synchronously in PaperEngine
|
|
214
|
+
if (Number.isFinite(stop) || Number.isFinite(target) || Number.isFinite(rr)) {
|
|
215
|
+
if (receipt.status === "filled") {
|
|
216
|
+
await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt });
|
|
217
|
+
} else if (receipt.status !== "rejected") {
|
|
218
|
+
this._pendingBracket = {
|
|
219
|
+
side,
|
|
220
|
+
size,
|
|
221
|
+
stop,
|
|
222
|
+
target,
|
|
223
|
+
rr,
|
|
224
|
+
entryRef,
|
|
225
|
+
orderId: receipt.orderId,
|
|
226
|
+
clientOrderId: receipt.clientOrderId || entryClientOrderId,
|
|
227
|
+
};
|
|
228
|
+
} else {
|
|
229
|
+
this._pendingBracket = null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await this.refresh();
|
|
234
|
+
return receipt;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async _attachBracket({ side, size, stop, target, rr, entryRef, receipt }) {
|
|
238
|
+
const entryFill = receipt?.avgFillPrice ?? entryRef;
|
|
239
|
+
const risk = Number.isFinite(stop) ? Math.abs(entryFill - stop) : null;
|
|
240
|
+
const targetPrice = Number.isFinite(target)
|
|
241
|
+
? target
|
|
242
|
+
: Number.isFinite(rr) && risk
|
|
243
|
+
? side === "long" || side === "buy"
|
|
244
|
+
? entryFill + rr * risk
|
|
245
|
+
: entryFill - rr * risk
|
|
246
|
+
: null;
|
|
247
|
+
const exitSide = oppositeSide(side);
|
|
248
|
+
const bracket = {};
|
|
249
|
+
|
|
250
|
+
if (Number.isFinite(stop)) {
|
|
251
|
+
const stopOrder = await this.broker.submitOrder({
|
|
252
|
+
symbol: this.symbol,
|
|
253
|
+
side: exitSide,
|
|
254
|
+
type: "stop",
|
|
255
|
+
qty: size,
|
|
256
|
+
stopPrice: stop,
|
|
257
|
+
clientOrderId: `${this.id}-stop-${Date.now()}`,
|
|
258
|
+
});
|
|
259
|
+
bracket.stopId = stopOrder.orderId;
|
|
260
|
+
}
|
|
261
|
+
if (Number.isFinite(targetPrice)) {
|
|
262
|
+
const tgtOrder = await this.broker.submitOrder({
|
|
263
|
+
symbol: this.symbol,
|
|
264
|
+
side: exitSide,
|
|
265
|
+
type: "limit",
|
|
266
|
+
qty: size,
|
|
267
|
+
limitPrice: targetPrice,
|
|
268
|
+
clientOrderId: `${this.id}-target-${Date.now()}`,
|
|
269
|
+
});
|
|
270
|
+
bracket.targetId = tgtOrder.orderId;
|
|
271
|
+
}
|
|
272
|
+
this.brackets.set(this.symbol, bracket);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async _syncEquityAndRisk() {
|
|
276
|
+
const acct = await this.broker.getAccount?.().catch(() => null);
|
|
277
|
+
if (!Number.isFinite(acct?.equity)) return;
|
|
278
|
+
const prevEquity = this.equity;
|
|
279
|
+
this.equity = acct.equity;
|
|
280
|
+
const pnlDelta = this.equity - prevEquity;
|
|
281
|
+
// Record the trade pnl change so RiskManager can check daily loss
|
|
282
|
+
if (pnlDelta !== 0) {
|
|
283
|
+
this.riskManager.recordTrade({ pnl: pnlDelta, timeMs: Date.now(), equity: this.equity });
|
|
284
|
+
} else {
|
|
285
|
+
this.riskManager.update({ timeMs: Date.now(), equity: this.equity });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async closePosition(symbol = this.symbol) {
|
|
290
|
+
const positions = await this.broker.getPositions();
|
|
291
|
+
const pos = positions.find((p) => p.symbol === symbol);
|
|
292
|
+
if (!pos) return null;
|
|
293
|
+
|
|
294
|
+
// cancel any resting bracket first
|
|
295
|
+
const bracket = this.brackets.get(symbol);
|
|
296
|
+
if (bracket) {
|
|
297
|
+
for (const id of [bracket.stopId, bracket.targetId]) {
|
|
298
|
+
if (id) await this.broker.cancelOrder(id).catch(() => {});
|
|
299
|
+
}
|
|
300
|
+
this.brackets.delete(symbol);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const receipt = await this.broker.submitOrder({
|
|
304
|
+
symbol,
|
|
305
|
+
side: oppositeSide(pos.side),
|
|
306
|
+
type: "market",
|
|
307
|
+
qty: pos.qty,
|
|
308
|
+
clientOrderId: `${this.id}-close-${Date.now()}`,
|
|
309
|
+
});
|
|
310
|
+
await this._syncEquityAndRisk();
|
|
311
|
+
await this.refresh();
|
|
312
|
+
return receipt;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async flatten() {
|
|
316
|
+
const positions = await this.broker.getPositions();
|
|
317
|
+
for (const p of positions) await this.closePosition(p.symbol);
|
|
318
|
+
const open = (await this.broker.getOpenOrders?.().catch(() => [])) ?? [];
|
|
319
|
+
for (const o of open) await this.broker.cancelOrder(o.orderId).catch(() => {});
|
|
320
|
+
await this.refresh();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async cancelOrder(orderId) {
|
|
324
|
+
await this.broker.cancelOrder(orderId);
|
|
325
|
+
await this.refresh();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async getAccount() {
|
|
329
|
+
return this.broker.getAccount();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async getPositions() {
|
|
333
|
+
return this.broker.getPositions();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
recentEvents(limit = 50) {
|
|
337
|
+
return this.events.slice(-limit);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
getStatus() {
|
|
341
|
+
const risk = this.riskManager.getState?.() || {};
|
|
342
|
+
return {
|
|
343
|
+
id: this.id,
|
|
344
|
+
symbol: this.symbol,
|
|
345
|
+
interval: this.interval,
|
|
346
|
+
mode: this.mode,
|
|
347
|
+
running: this.running,
|
|
348
|
+
equity: this.equity,
|
|
349
|
+
dayPnl: risk.dayPnl ?? 0,
|
|
350
|
+
lastPrice: this.lastPrice,
|
|
351
|
+
positions: this._cachedPositions ?? [],
|
|
352
|
+
openOrders: this._cachedOpenOrders ?? [],
|
|
353
|
+
risk: { halted: Boolean(risk.halted), ...risk },
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Refresh sync caches used by getStatus() */
|
|
358
|
+
async refresh() {
|
|
359
|
+
// Wait for any pending OCO cancel before refreshing state
|
|
360
|
+
if (this._pendingCancelPromise) {
|
|
361
|
+
await this._pendingCancelPromise;
|
|
362
|
+
this._pendingCancelPromise = null;
|
|
363
|
+
}
|
|
364
|
+
this._cachedPositions = await this.broker.getPositions().catch(() => []);
|
|
365
|
+
this._cachedOpenOrders = (await this.broker.getOpenOrders?.().catch(() => [])) ?? [];
|
|
366
|
+
const acct = await this.broker.getAccount?.().catch(() => null);
|
|
367
|
+
if (Number.isFinite(acct?.equity)) this.equity = acct.equity;
|
|
368
|
+
return this.getStatus();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export class SessionManager {
|
|
373
|
+
constructor({ brokerFactory } = {}) {
|
|
374
|
+
this.sessions = new Map();
|
|
375
|
+
this.brokerFactory = brokerFactory;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async create({
|
|
379
|
+
id,
|
|
380
|
+
mode = "paper",
|
|
381
|
+
symbol,
|
|
382
|
+
interval = "1m",
|
|
383
|
+
equity = 10_000,
|
|
384
|
+
confirmLive = false,
|
|
385
|
+
broker,
|
|
386
|
+
...rest
|
|
387
|
+
} = {}) {
|
|
388
|
+
if (this.sessions.has(id)) throw new Error(`session "${id}" already exists`);
|
|
389
|
+
let resolvedBroker = broker;
|
|
390
|
+
if (mode === "live") {
|
|
391
|
+
if (!TradingSession.liveAllowed() || !confirmLive) {
|
|
392
|
+
throw new Error("live mode requires TRADELAB_ALLOW_LIVE=true and confirmLive:true");
|
|
393
|
+
}
|
|
394
|
+
if (!resolvedBroker && this.brokerFactory) {
|
|
395
|
+
resolvedBroker = this.brokerFactory({ symbol, ...rest });
|
|
396
|
+
}
|
|
397
|
+
if (!resolvedBroker) throw new Error("live mode requires a credentialed broker");
|
|
398
|
+
}
|
|
399
|
+
if (!resolvedBroker) resolvedBroker = new PaperEngine({ equity });
|
|
400
|
+
const session = new TradingSession({
|
|
401
|
+
id,
|
|
402
|
+
symbol,
|
|
403
|
+
interval,
|
|
404
|
+
broker: resolvedBroker,
|
|
405
|
+
mode,
|
|
406
|
+
equity,
|
|
407
|
+
confirmLive,
|
|
408
|
+
...rest,
|
|
409
|
+
});
|
|
410
|
+
await session.start();
|
|
411
|
+
this.sessions.set(session.id, session);
|
|
412
|
+
return session;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
get(id) {
|
|
416
|
+
return this.sessions.get(id) ?? null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
list() {
|
|
420
|
+
return [...this.sessions.values()];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async remove(id, { flatten = true } = {}) {
|
|
424
|
+
const s = this.sessions.get(id);
|
|
425
|
+
if (!s) return;
|
|
426
|
+
await s.stop({ flatten });
|
|
427
|
+
this.sessions.delete(id);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async haltAll() {
|
|
431
|
+
for (const s of this.sessions.values()) await s.stop({ flatten: true });
|
|
432
|
+
// Remove stopped sessions so list() does not retain them and re-runs don't collide.
|
|
433
|
+
this.sessions.clear();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function createSessionManager(opts) {
|
|
438
|
+
return new SessionManager(opts);
|
|
439
|
+
}
|