tradelab 1.2.1 → 1.3.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 +48 -0
- package/README.md +24 -11
- package/bin/tradelab.js +37 -1
- package/dist/cjs/index.cjs +90 -0
- package/dist/cjs/live.cjs +243 -52
- package/docs/README.md +6 -4
- package/docs/api-reference.md +15 -1
- package/docs/data-reporting-cli.md +9 -0
- package/docs/live-trading.md +130 -0
- package/docs/mcp.md +92 -23
- package/docs/research.md +15 -0
- package/examples/agentResearchLoop.js +188 -0
- package/examples/multiSymbolPortfolio.js +122 -0
- package/package.json +1 -1
- package/src/cli/runPreset.js +42 -0
- package/src/index.js +2 -0
- package/src/live/engine/riskManager.js +38 -0
- package/src/live/index.js +1 -0
- package/src/live/notify.js +42 -0
- package/src/live/session.js +170 -56
- package/src/mcp/liveTools.js +28 -24
- package/src/mcp/researchSession.js +24 -0
- package/src/mcp/schemas.js +5 -1
- package/src/mcp/tools.js +28 -3
- package/src/reporting/summarize.js +43 -0
- package/src/research/store.js +67 -0
- package/types/index.d.ts +30 -0
package/src/index.js
CHANGED
|
@@ -27,10 +27,12 @@ export {
|
|
|
27
27
|
saveCandlesToCache,
|
|
28
28
|
} from "./data/index.js";
|
|
29
29
|
|
|
30
|
+
export { createResearchStore } from "./research/store.js";
|
|
30
31
|
export { renderHtmlReport, exportHtmlReport } from "./reporting/renderHtmlReport.js";
|
|
31
32
|
export { exportTradesCsv } from "./reporting/exportTradesCsv.js";
|
|
32
33
|
export { exportMetricsJSON } from "./reporting/exportMetricsJson.js";
|
|
33
34
|
export { exportBacktestArtifacts } from "./reporting/exportBacktestArtifacts.js";
|
|
35
|
+
export { summarize } from "./reporting/summarize.js";
|
|
34
36
|
|
|
35
37
|
export {
|
|
36
38
|
ema,
|
|
@@ -21,6 +21,8 @@ export class RiskManager {
|
|
|
21
21
|
cooldownAfterLossMs: 0,
|
|
22
22
|
allowedSessions: "AUTO",
|
|
23
23
|
allowedWindows: null,
|
|
24
|
+
maxGrossExposurePct: 0,
|
|
25
|
+
maxNetExposurePct: 0,
|
|
24
26
|
...options,
|
|
25
27
|
};
|
|
26
28
|
this.allowedWindows = parseWindowsCSV(this.options.allowedWindows);
|
|
@@ -114,6 +116,8 @@ export class RiskManager {
|
|
|
114
116
|
positionCount = 0,
|
|
115
117
|
positionValue = 0,
|
|
116
118
|
equity = null,
|
|
119
|
+
grossExposure = undefined,
|
|
120
|
+
netExposure = undefined,
|
|
117
121
|
} = {}) {
|
|
118
122
|
const base = this.canTrade({ timeMs });
|
|
119
123
|
if (!base.ok) return base;
|
|
@@ -135,6 +139,40 @@ export class RiskManager {
|
|
|
135
139
|
}
|
|
136
140
|
}
|
|
137
141
|
|
|
142
|
+
return this._checkExposureCaps({ grossExposure, netExposure, equity: eq });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check only the portfolio exposure caps (no session/halt/trade-count checks).
|
|
147
|
+
* Called from placeOrder after the halt check has already run.
|
|
148
|
+
*/
|
|
149
|
+
checkExposure({ grossExposure = undefined, netExposure = undefined, equity = null } = {}) {
|
|
150
|
+
const eq = Number.isFinite(equity) ? equity : this.currentEquity;
|
|
151
|
+
return this._checkExposureCaps({ grossExposure, netExposure, equity: eq });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Shared gross/net exposure cap logic used by canOpenPosition and checkExposure.
|
|
156
|
+
* Expects a resolved equity value (NaN/null fallback already applied by caller).
|
|
157
|
+
* Returns { ok: true, reason: null } when within caps or caps are disabled.
|
|
158
|
+
*/
|
|
159
|
+
_checkExposureCaps({ grossExposure = undefined, netExposure = undefined, equity } = {}) {
|
|
160
|
+
const eq = equity;
|
|
161
|
+
|
|
162
|
+
const grossCap = pctToFraction(this.options.maxGrossExposurePct, 0);
|
|
163
|
+
if (grossCap > 0 && Number.isFinite(eq) && eq > 0 && Number.isFinite(grossExposure)) {
|
|
164
|
+
if (Math.abs(grossExposure) / eq > grossCap) {
|
|
165
|
+
return { ok: false, reason: "max gross exposure exceeded" };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const netCap = pctToFraction(this.options.maxNetExposurePct, 0);
|
|
170
|
+
if (netCap > 0 && Number.isFinite(eq) && eq > 0 && Number.isFinite(netExposure)) {
|
|
171
|
+
if (Math.abs(netExposure) / eq > netCap) {
|
|
172
|
+
return { ok: false, reason: "max net exposure exceeded" };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
138
176
|
return { ok: true, reason: null };
|
|
139
177
|
}
|
|
140
178
|
|
package/src/live/index.js
CHANGED
|
@@ -28,3 +28,4 @@ export { LiveOrchestrator, createLiveOrchestrator } from "./orchestrator.js";
|
|
|
28
28
|
export { createDashboardServer } from "./dashboard/server.js";
|
|
29
29
|
|
|
30
30
|
export { TradingSession, SessionManager, createSessionManager } from "./session.js";
|
|
31
|
+
export { attachNotifier } from "./notify.js";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// src/live/notify.js
|
|
2
|
+
const DEFAULT_EVENTS = ["order:filled", "risk:halt"];
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Subscribe a notifier to a trading session's event bus. Returns an unsubscribe
|
|
6
|
+
* function. Fires onEvent and/or POSTs to webhookUrl for the configured events,
|
|
7
|
+
* plus a drawdown breach on equity updates.
|
|
8
|
+
*/
|
|
9
|
+
export function attachNotifier(session, { onEvent, webhookUrl, events = DEFAULT_EVENTS, drawdownPct = 0 } = {}) {
|
|
10
|
+
const wanted = new Set(events);
|
|
11
|
+
let peak = null;
|
|
12
|
+
|
|
13
|
+
const deliver = async (event, payload) => {
|
|
14
|
+
if (typeof onEvent === "function") {
|
|
15
|
+
try { await onEvent({ event, payload }); } catch { /* non-fatal */ }
|
|
16
|
+
}
|
|
17
|
+
if (webhookUrl && typeof fetch === "function") {
|
|
18
|
+
try {
|
|
19
|
+
await fetch(webhookUrl, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "content-type": "application/json" },
|
|
22
|
+
body: JSON.stringify({ event, payload }),
|
|
23
|
+
});
|
|
24
|
+
} catch { /* non-fatal */ }
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const handler = ({ event, payload }) => {
|
|
29
|
+
if (wanted.has(event)) { deliver(event, payload).catch(() => {}); return; }
|
|
30
|
+
if (drawdownPct > 0 && event === "equity:update") {
|
|
31
|
+
const eq = payload?.equity;
|
|
32
|
+
if (Number.isFinite(eq)) {
|
|
33
|
+
if (peak === null || eq > peak) peak = eq;
|
|
34
|
+
if (peak > 0 && ((peak - eq) / peak) * 100 >= drawdownPct) {
|
|
35
|
+
deliver("drawdown:breach", { equity: eq, peak, drawdownPct: ((peak - eq) / peak) * 100 }).catch(() => {});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return session.eventBus.onAny(handler);
|
|
42
|
+
}
|
package/src/live/session.js
CHANGED
|
@@ -29,6 +29,7 @@ export class TradingSession {
|
|
|
29
29
|
constructor({
|
|
30
30
|
id,
|
|
31
31
|
symbol,
|
|
32
|
+
symbols,
|
|
32
33
|
interval = "1m",
|
|
33
34
|
broker,
|
|
34
35
|
mode = "paper",
|
|
@@ -36,6 +37,8 @@ export class TradingSession {
|
|
|
36
37
|
riskPct = 1,
|
|
37
38
|
maxDailyLossPct = 0,
|
|
38
39
|
maxPositionPct = 1,
|
|
40
|
+
maxGrossExposurePct = 0,
|
|
41
|
+
maxNetExposurePct = 0,
|
|
39
42
|
qtyStep = 0.001,
|
|
40
43
|
minQty = 0.001,
|
|
41
44
|
maxLeverage = 2,
|
|
@@ -48,10 +51,14 @@ export class TradingSession {
|
|
|
48
51
|
);
|
|
49
52
|
}
|
|
50
53
|
if (!broker) throw new Error("TradingSession requires a broker (PaperEngine by default)");
|
|
51
|
-
if (!symbol) throw new Error("TradingSession requires a symbol");
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
const symbolList = Array.isArray(symbols) && symbols.length ? symbols : symbol ? [symbol] : null;
|
|
56
|
+
if (!symbolList) throw new Error("TradingSession requires a symbol or symbols");
|
|
57
|
+
|
|
58
|
+
this.symbols = symbolList;
|
|
59
|
+
this.symbol = symbolList[0]; // back-compat primary symbol
|
|
60
|
+
|
|
61
|
+
this.id = id || `${this.symbol}-${interval}`;
|
|
55
62
|
this.interval = interval;
|
|
56
63
|
this.broker = broker;
|
|
57
64
|
this.mode = mode;
|
|
@@ -63,20 +70,50 @@ export class TradingSession {
|
|
|
63
70
|
this.minQty = minQty;
|
|
64
71
|
this.maxLeverage = maxLeverage;
|
|
65
72
|
this.eventBus = eventBus || new EventBus();
|
|
66
|
-
this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0 });
|
|
67
|
-
this.lastPrice = null;
|
|
73
|
+
this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0, maxGrossExposurePct, maxNetExposurePct });
|
|
68
74
|
this.running = false;
|
|
69
75
|
this.events = [];
|
|
70
76
|
this.brackets = new Map(); // symbol -> { stopId, targetId }
|
|
71
|
-
this.
|
|
77
|
+
this._pendingBrackets = new Map(); // symbol -> staged bracket
|
|
78
|
+
this._entryMeta = new Map(); // clientOrderId -> { sizing, rationale }
|
|
79
|
+
this._legMeta = new Map(); // clientOrderId -> { parentEntryId, leg }
|
|
72
80
|
this._cachedPositions = [];
|
|
73
81
|
this._cachedOpenOrders = [];
|
|
74
|
-
this.
|
|
75
|
-
this.
|
|
82
|
+
this._lastPrice = new Map(); // symbol -> price
|
|
83
|
+
this._candleBuffers = new Map(); // symbol -> bar[]
|
|
84
|
+
this._strategies = new Map(); // symbol -> signalFn
|
|
85
|
+
for (const sym of this.symbols) this._candleBuffers.set(sym, []);
|
|
86
|
+
|
|
87
|
+
this._wasHalted = false;
|
|
88
|
+
this._coidSeq = 0;
|
|
76
89
|
|
|
77
90
|
this._wireBrokerEvents();
|
|
78
91
|
}
|
|
79
92
|
|
|
93
|
+
// Back-compat getters/setters for single-symbol usage and MCP feed_price handler
|
|
94
|
+
get lastPrice() { return this._lastPrice.get(this.symbol) ?? null; }
|
|
95
|
+
set lastPrice(v) { this._lastPrice.set(this.symbol, v); }
|
|
96
|
+
get candleBuffer() { return this._candleBuffers.get(this.symbol) ?? []; }
|
|
97
|
+
set candleBuffer(v) { this._candleBuffers.set(this.symbol, v); }
|
|
98
|
+
get _strategy() { return this._strategies.get(this.symbol) ?? null; }
|
|
99
|
+
set _strategy(fn) { this._strategies.set(this.symbol, fn); }
|
|
100
|
+
// Back-compat for tests that read/write _pendingBracket directly (primary symbol only)
|
|
101
|
+
get _pendingBracket() { return this._pendingBrackets.get(this.symbol) ?? null; }
|
|
102
|
+
set _pendingBracket(v) {
|
|
103
|
+
if (v == null) this._pendingBrackets.delete(this.symbol);
|
|
104
|
+
else this._pendingBrackets.set(this.symbol, v);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Per-symbol accessors
|
|
108
|
+
lastPriceFor(sym = this.symbol) { return this._lastPrice.get(sym) ?? null; }
|
|
109
|
+
candleBufferFor(sym = this.symbol) { return this._candleBuffers.get(sym) ?? []; }
|
|
110
|
+
|
|
111
|
+
_resolveSymbol(symbol) {
|
|
112
|
+
if (symbol) return symbol;
|
|
113
|
+
if (this.symbols.length === 1) return this.symbol;
|
|
114
|
+
throw new Error("symbol is required for a multi-symbol session");
|
|
115
|
+
}
|
|
116
|
+
|
|
80
117
|
static liveAllowed() {
|
|
81
118
|
return process.env.TRADELAB_ALLOW_LIVE === "true";
|
|
82
119
|
}
|
|
@@ -91,7 +128,7 @@ export class TradingSession {
|
|
|
91
128
|
_wireBrokerEvents() {
|
|
92
129
|
// Forward broker fills/cancels onto the session bus, and run OCO bracket logic.
|
|
93
130
|
this.broker.on?.("order:filled", (order) => this._onBrokerFillSync(order));
|
|
94
|
-
this.broker.on?.("order:submitted", (order) => this._record("order:submitted", order));
|
|
131
|
+
this.broker.on?.("order:submitted", (order) => this._record("order:submitted", this._withMeta(order)));
|
|
95
132
|
this.broker.on?.("order:canceled", (order) =>
|
|
96
133
|
this._onBrokerTerminalOrderSync("order:canceled", order)
|
|
97
134
|
);
|
|
@@ -103,37 +140,58 @@ export class TradingSession {
|
|
|
103
140
|
|
|
104
141
|
_onBrokerTerminalOrderSync(event, order) {
|
|
105
142
|
this._record(event, order);
|
|
106
|
-
|
|
107
|
-
|
|
143
|
+
// Scan all pending brackets to clear any that match this terminal order
|
|
144
|
+
for (const [sym, staged] of this._pendingBrackets) {
|
|
145
|
+
if (matchesOrderRef(staged, order)) {
|
|
146
|
+
this._pendingBrackets.delete(sym);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
108
149
|
}
|
|
109
150
|
}
|
|
110
151
|
|
|
111
|
-
|
|
152
|
+
_withMeta(order) {
|
|
153
|
+
const key = order.clientOrderId;
|
|
154
|
+
if (key && this._entryMeta?.has(key)) {
|
|
155
|
+
const m = this._entryMeta.get(key);
|
|
156
|
+
return { ...order, sizing: m.sizing, ...(m.rationale ? { rationale: m.rationale } : {}) };
|
|
157
|
+
}
|
|
158
|
+
if (key && this._legMeta?.has(key)) {
|
|
159
|
+
return { ...order, ...this._legMeta.get(key) };
|
|
160
|
+
}
|
|
161
|
+
return order;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Sync event handler; fire-and-forget async OCO work via a stored promise
|
|
112
165
|
_onBrokerFillSync(order) {
|
|
113
|
-
this._record("order:filled", order);
|
|
114
|
-
|
|
115
|
-
// Resting entry order (e.g. a limit) just filled
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
166
|
+
this._record("order:filled", this._withMeta(order));
|
|
167
|
+
|
|
168
|
+
// Resting entry order (e.g. a limit) just filled; attach its staged bracket.
|
|
169
|
+
// Scan _pendingBrackets for a match (works for both single- and multi-symbol sessions).
|
|
170
|
+
for (const [sym, staged] of this._pendingBrackets) {
|
|
171
|
+
if (matchesOrderRef(staged, order)) {
|
|
172
|
+
this._pendingBrackets.delete(sym);
|
|
173
|
+
const parentEntryId = staged.parentEntryId ?? order.clientOrderId;
|
|
174
|
+
// simulateBar may still be iterating orders, so schedule attach without awaiting.
|
|
175
|
+
this._pendingCancelPromise = Promise.resolve(
|
|
176
|
+
this._attachBracket({ ...staged, symbol: sym, receipt: order, parentEntryId })
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
124
180
|
}
|
|
125
181
|
|
|
126
|
-
// Track bracket leg fills for OCO
|
|
127
|
-
const bracket
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
182
|
+
// Track bracket leg fills for OCO; find which symbol this fill belongs to
|
|
183
|
+
for (const [sym, bracket] of this.brackets) {
|
|
184
|
+
if (bracket && (order.orderId === bracket.stopId || order.orderId === bracket.targetId)) {
|
|
185
|
+
const siblingId = order.orderId === bracket.stopId ? bracket.targetId : bracket.stopId;
|
|
186
|
+
// Schedule the cancel; simulateBar is still iterating orders, so we must not await here.
|
|
187
|
+
// We keep a pending cancel promise that refresh() awaits.
|
|
188
|
+
this._pendingCancelPromise = (async () => {
|
|
189
|
+
if (siblingId) await this.broker.cancelOrder(siblingId).catch(() => {});
|
|
190
|
+
this.brackets.delete(sym);
|
|
191
|
+
this._record("position:closed", { symbol: sym, reason: order.orderId === bracket.stopId ? "SL" : "TP" });
|
|
192
|
+
})();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
137
195
|
}
|
|
138
196
|
}
|
|
139
197
|
|
|
@@ -155,19 +213,22 @@ export class TradingSession {
|
|
|
155
213
|
this._record("shutdown", {});
|
|
156
214
|
}
|
|
157
215
|
|
|
158
|
-
async pushBar(b) {
|
|
159
|
-
|
|
216
|
+
async pushBar(b, symbol) {
|
|
217
|
+
const sym = this._resolveSymbol(symbol);
|
|
218
|
+
this._lastPrice.set(sym, b.close);
|
|
160
219
|
if (typeof this.broker.simulateBar === "function") {
|
|
161
|
-
await this.broker.simulateBar(
|
|
220
|
+
await this.broker.simulateBar(sym, this.interval, b);
|
|
162
221
|
}
|
|
163
222
|
// Wait for any pending OCO cancel triggered by simulateBar fills
|
|
164
223
|
if (this._pendingCancelPromise) {
|
|
165
224
|
await this._pendingCancelPromise;
|
|
166
225
|
this._pendingCancelPromise = null;
|
|
167
226
|
}
|
|
168
|
-
this.
|
|
169
|
-
|
|
170
|
-
|
|
227
|
+
const buf = this._candleBuffers.get(sym) ?? [];
|
|
228
|
+
buf.push(b);
|
|
229
|
+
if (buf.length > 200) buf.shift();
|
|
230
|
+
this._candleBuffers.set(sym, buf);
|
|
231
|
+
this._record("bar", { symbol: sym, close: b.close, time: b.time });
|
|
171
232
|
await this._syncEquityAndRisk();
|
|
172
233
|
await this.refresh();
|
|
173
234
|
}
|
|
@@ -177,15 +238,16 @@ export class TradingSession {
|
|
|
177
238
|
return Boolean(state.halted);
|
|
178
239
|
}
|
|
179
240
|
|
|
180
|
-
async placeOrder({ side, type = "market", qty, riskPct, stop, target, rr, limitPrice } = {}) {
|
|
241
|
+
async placeOrder({ side, type = "market", qty, riskPct, stop, target, rr, limitPrice, rationale, symbol } = {}) {
|
|
181
242
|
if (!this.running) throw new Error("session not started");
|
|
182
243
|
if (this._riskHalted()) throw new Error("session is risk-halted for the day");
|
|
183
|
-
const
|
|
244
|
+
const sym = this._resolveSymbol(symbol);
|
|
245
|
+
const entryRef = type === "limit" ? limitPrice : this.lastPriceFor(sym);
|
|
184
246
|
if (!Number.isFinite(entryRef)) throw new Error("no price available; pushBar() a price first");
|
|
185
247
|
|
|
248
|
+
const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
|
|
186
249
|
let size = qty;
|
|
187
250
|
if (!Number.isFinite(size)) {
|
|
188
|
-
const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
|
|
189
251
|
if (!Number.isFinite(stop)) throw new Error("risk-based sizing requires a stop");
|
|
190
252
|
size = calculatePositionSize({
|
|
191
253
|
equity: this.equity,
|
|
@@ -200,9 +262,47 @@ export class TradingSession {
|
|
|
200
262
|
size = roundStep(size, this.qtyStep);
|
|
201
263
|
if (!(size >= this.minQty)) throw new Error(`sized below minQty (${size})`);
|
|
202
264
|
|
|
203
|
-
const
|
|
265
|
+
const targetPx = Number.isFinite(target)
|
|
266
|
+
? target
|
|
267
|
+
: Number.isFinite(rr) && Number.isFinite(stop)
|
|
268
|
+
? (side === "long" || side === "buy"
|
|
269
|
+
? entryRef + rr * Math.abs(entryRef - stop)
|
|
270
|
+
: entryRef - rr * Math.abs(entryRef - stop))
|
|
271
|
+
: null;
|
|
272
|
+
const sizing = {
|
|
273
|
+
entry: entryRef,
|
|
274
|
+
stop: Number.isFinite(stop) ? stop : null,
|
|
275
|
+
target: targetPx,
|
|
276
|
+
rr: Number.isFinite(rr) ? rr : null,
|
|
277
|
+
riskFraction: fraction,
|
|
278
|
+
riskAmount: this.equity * fraction,
|
|
279
|
+
qty: size,
|
|
280
|
+
notional: size * entryRef,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const positions = this._cachedPositions ?? [];
|
|
284
|
+
const newNotional = sizing.notional * ((side === "long" || side === "buy") ? 1 : -1);
|
|
285
|
+
let gross = Math.abs(sizing.notional);
|
|
286
|
+
let net = newNotional;
|
|
287
|
+
for (const p of positions) {
|
|
288
|
+
const px = p.avgEntry ?? p.avgPrice ?? p.entryPrice ?? entryRef;
|
|
289
|
+
const pv = Number.isFinite(p.marketValue) ? p.marketValue : (p.qty ?? 0) * px;
|
|
290
|
+
const signed = (p.side === "long" || p.side === "buy") ? pv : -pv;
|
|
291
|
+
gross += Math.abs(pv);
|
|
292
|
+
net += signed;
|
|
293
|
+
}
|
|
294
|
+
const gate = this.riskManager.checkExposure({
|
|
295
|
+
grossExposure: gross,
|
|
296
|
+
netExposure: net,
|
|
297
|
+
equity: this.equity,
|
|
298
|
+
});
|
|
299
|
+
if (!gate.ok) throw new Error(`risk rejected: ${gate.reason}`);
|
|
300
|
+
|
|
301
|
+
const entryClientOrderId = `${this.id}-entry-${Date.now()}-${++this._coidSeq}`;
|
|
302
|
+
this._entryMeta.set(entryClientOrderId, { sizing, rationale });
|
|
303
|
+
|
|
204
304
|
const receipt = await this.broker.submitOrder({
|
|
205
|
-
symbol:
|
|
305
|
+
symbol: sym,
|
|
206
306
|
side: toBrokerSide(side),
|
|
207
307
|
type,
|
|
208
308
|
qty: size,
|
|
@@ -210,12 +310,13 @@ export class TradingSession {
|
|
|
210
310
|
clientOrderId: entryClientOrderId,
|
|
211
311
|
});
|
|
212
312
|
|
|
213
|
-
// Stage bracket if needed
|
|
313
|
+
// Stage bracket if needed; market orders fill synchronously in PaperEngine
|
|
214
314
|
if (Number.isFinite(stop) || Number.isFinite(target) || Number.isFinite(rr)) {
|
|
315
|
+
const parentEntryId = receipt?.clientOrderId ?? entryClientOrderId;
|
|
215
316
|
if (receipt.status === "filled") {
|
|
216
|
-
await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt });
|
|
317
|
+
await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt, parentEntryId, symbol: sym });
|
|
217
318
|
} else if (receipt.status !== "rejected") {
|
|
218
|
-
this.
|
|
319
|
+
this._pendingBrackets.set(sym, {
|
|
219
320
|
side,
|
|
220
321
|
size,
|
|
221
322
|
stop,
|
|
@@ -224,9 +325,10 @@ export class TradingSession {
|
|
|
224
325
|
entryRef,
|
|
225
326
|
orderId: receipt.orderId,
|
|
226
327
|
clientOrderId: receipt.clientOrderId || entryClientOrderId,
|
|
227
|
-
|
|
328
|
+
parentEntryId,
|
|
329
|
+
});
|
|
228
330
|
} else {
|
|
229
|
-
this.
|
|
331
|
+
this._pendingBrackets.delete(sym);
|
|
230
332
|
}
|
|
231
333
|
}
|
|
232
334
|
|
|
@@ -234,7 +336,8 @@ export class TradingSession {
|
|
|
234
336
|
return receipt;
|
|
235
337
|
}
|
|
236
338
|
|
|
237
|
-
async _attachBracket({ side, size, stop, target, rr, entryRef, receipt }) {
|
|
339
|
+
async _attachBracket({ side, size, stop, target, rr, entryRef, receipt, parentEntryId, symbol }) {
|
|
340
|
+
const sym = symbol ?? this.symbol;
|
|
238
341
|
const entryFill = receipt?.avgFillPrice ?? entryRef;
|
|
239
342
|
const risk = Number.isFinite(stop) ? Math.abs(entryFill - stop) : null;
|
|
240
343
|
const targetPrice = Number.isFinite(target)
|
|
@@ -248,28 +351,32 @@ export class TradingSession {
|
|
|
248
351
|
const bracket = {};
|
|
249
352
|
|
|
250
353
|
if (Number.isFinite(stop)) {
|
|
354
|
+
const stopCoid = `${this.id}-stop-${Date.now()}-${++this._coidSeq}`;
|
|
355
|
+
if (parentEntryId) this._legMeta.set(stopCoid, { parentEntryId, leg: "stop" });
|
|
251
356
|
const stopOrder = await this.broker.submitOrder({
|
|
252
|
-
symbol:
|
|
357
|
+
symbol: sym,
|
|
253
358
|
side: exitSide,
|
|
254
359
|
type: "stop",
|
|
255
360
|
qty: size,
|
|
256
361
|
stopPrice: stop,
|
|
257
|
-
clientOrderId:
|
|
362
|
+
clientOrderId: stopCoid,
|
|
258
363
|
});
|
|
259
364
|
bracket.stopId = stopOrder.orderId;
|
|
260
365
|
}
|
|
261
366
|
if (Number.isFinite(targetPrice)) {
|
|
367
|
+
const tgtCoid = `${this.id}-target-${Date.now()}-${++this._coidSeq}`;
|
|
368
|
+
if (parentEntryId) this._legMeta.set(tgtCoid, { parentEntryId, leg: "target" });
|
|
262
369
|
const tgtOrder = await this.broker.submitOrder({
|
|
263
|
-
symbol:
|
|
370
|
+
symbol: sym,
|
|
264
371
|
side: exitSide,
|
|
265
372
|
type: "limit",
|
|
266
373
|
qty: size,
|
|
267
374
|
limitPrice: targetPrice,
|
|
268
|
-
clientOrderId:
|
|
375
|
+
clientOrderId: tgtCoid,
|
|
269
376
|
});
|
|
270
377
|
bracket.targetId = tgtOrder.orderId;
|
|
271
378
|
}
|
|
272
|
-
this.brackets.set(
|
|
379
|
+
this.brackets.set(sym, bracket);
|
|
273
380
|
}
|
|
274
381
|
|
|
275
382
|
async _syncEquityAndRisk() {
|
|
@@ -284,6 +391,12 @@ export class TradingSession {
|
|
|
284
391
|
} else {
|
|
285
392
|
this.riskManager.update({ timeMs: Date.now(), equity: this.equity });
|
|
286
393
|
}
|
|
394
|
+
// Emit risk:halt once per halt transition
|
|
395
|
+
const nowHalted = Boolean(this.riskManager.getState?.().halted);
|
|
396
|
+
if (nowHalted && !this._wasHalted) {
|
|
397
|
+
this._record("risk:halt", { reason: this.riskManager.haltReason ?? this.riskManager.getState?.().haltReason ?? "risk halt" });
|
|
398
|
+
}
|
|
399
|
+
this._wasHalted = nowHalted;
|
|
287
400
|
}
|
|
288
401
|
|
|
289
402
|
async closePosition(symbol = this.symbol) {
|
|
@@ -342,6 +455,7 @@ export class TradingSession {
|
|
|
342
455
|
return {
|
|
343
456
|
id: this.id,
|
|
344
457
|
symbol: this.symbol,
|
|
458
|
+
symbols: this.symbols,
|
|
345
459
|
interval: this.interval,
|
|
346
460
|
mode: this.mode,
|
|
347
461
|
running: this.running,
|
package/src/mcp/liveTools.js
CHANGED
|
@@ -10,20 +10,6 @@ function requireSession(sessionId) {
|
|
|
10
10
|
return s;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
function strategyContext(session) {
|
|
14
|
-
const candles = session.candleBuffer;
|
|
15
|
-
const bar = candles[candles.length - 1] ?? null;
|
|
16
|
-
const status = session.getStatus();
|
|
17
|
-
return {
|
|
18
|
-
candles,
|
|
19
|
-
index: candles.length - 1,
|
|
20
|
-
bar,
|
|
21
|
-
equity: status.equity,
|
|
22
|
-
openPosition: status.positions[0] ?? null,
|
|
23
|
-
pendingOrder: null,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
13
|
function signalToOrder(signal) {
|
|
28
14
|
return {
|
|
29
15
|
side: signal.side ?? signal.direction ?? signal.action,
|
|
@@ -46,6 +32,7 @@ export const liveTools = {
|
|
|
46
32
|
handler: async ({
|
|
47
33
|
sessionId,
|
|
48
34
|
symbol,
|
|
35
|
+
symbols,
|
|
49
36
|
mode = "paper",
|
|
50
37
|
interval = "1m",
|
|
51
38
|
equity = 10_000,
|
|
@@ -56,6 +43,7 @@ export const liveTools = {
|
|
|
56
43
|
const session = await manager.create({
|
|
57
44
|
id: sessionId,
|
|
58
45
|
symbol,
|
|
46
|
+
symbols,
|
|
59
47
|
mode,
|
|
60
48
|
interval,
|
|
61
49
|
equity,
|
|
@@ -86,21 +74,36 @@ export const liveTools = {
|
|
|
86
74
|
feed_price: {
|
|
87
75
|
description:
|
|
88
76
|
"Feed a price bar (or single price) to a session, advancing paper simulations and triggering fills.",
|
|
89
|
-
handler: async ({ sessionId, bar, price } = {}) => {
|
|
77
|
+
handler: async ({ sessionId, bar, price, symbol } = {}) => {
|
|
90
78
|
const session = requireSession(sessionId);
|
|
91
79
|
let b = bar;
|
|
92
80
|
if (!b && Number.isFinite(price)) {
|
|
93
81
|
b = { time: Date.now(), open: price, high: price, low: price, close: price, volume: 0 };
|
|
94
82
|
}
|
|
95
83
|
if (!b) throw new Error("Provide either `bar` (OHLCV) or `price` (number)");
|
|
96
|
-
await session.pushBar(b);
|
|
97
|
-
|
|
98
|
-
// If a strategy is attached and session is flat, evaluate it
|
|
99
|
-
|
|
84
|
+
await session.pushBar(b, symbol);
|
|
85
|
+
|
|
86
|
+
// If a strategy is attached for this symbol and the session is flat for it, evaluate it
|
|
87
|
+
const effectiveSym = symbol ?? session.symbol;
|
|
88
|
+
const strategyFn = session._strategies.get(effectiveSym);
|
|
89
|
+
const positions = session.getStatus().positions;
|
|
90
|
+
const symPositions = positions.filter((p) => !p.symbol || p.symbol === effectiveSym);
|
|
91
|
+
if (strategyFn && symPositions.length === 0) {
|
|
100
92
|
try {
|
|
101
|
-
const
|
|
93
|
+
const candles = session.candleBufferFor(effectiveSym);
|
|
94
|
+
const bar = candles[candles.length - 1] ?? null;
|
|
95
|
+
const status = session.getStatus();
|
|
96
|
+
const ctx = {
|
|
97
|
+
candles,
|
|
98
|
+
index: candles.length - 1,
|
|
99
|
+
bar,
|
|
100
|
+
equity: status.equity,
|
|
101
|
+
openPosition: positions.find((p) => !p.symbol || p.symbol === effectiveSym) ?? null,
|
|
102
|
+
pendingOrder: null,
|
|
103
|
+
};
|
|
104
|
+
const signal = strategyFn(ctx);
|
|
102
105
|
if (signal && (signal.side || signal.direction || signal.action)) {
|
|
103
|
-
await session.placeOrder(signalToOrder(signal)).catch(() => {});
|
|
106
|
+
await session.placeOrder({ ...signalToOrder(signal), symbol: effectiveSym }).catch(() => {});
|
|
104
107
|
}
|
|
105
108
|
} catch {
|
|
106
109
|
// strategy errors are non-fatal
|
|
@@ -124,9 +127,10 @@ export const liveTools = {
|
|
|
124
127
|
target,
|
|
125
128
|
rr,
|
|
126
129
|
limitPrice,
|
|
130
|
+
symbol,
|
|
127
131
|
} = {}) => {
|
|
128
132
|
const session = requireSession(sessionId);
|
|
129
|
-
return session.placeOrder({ side, type, qty, riskPct, stop, target, rr, limitPrice });
|
|
133
|
+
return session.placeOrder({ side, type, qty, riskPct, stop, target, rr, limitPrice, symbol });
|
|
130
134
|
},
|
|
131
135
|
},
|
|
132
136
|
|
|
@@ -183,11 +187,11 @@ export const liveTools = {
|
|
|
183
187
|
attach_strategy: {
|
|
184
188
|
description:
|
|
185
189
|
"Attach a named built-in strategy to a session. It will auto-evaluate on each feed_price and place orders when flat.",
|
|
186
|
-
handler: async ({ sessionId, strategy, params = {} } = {}) => {
|
|
190
|
+
handler: async ({ sessionId, strategy, params = {}, symbol } = {}) => {
|
|
187
191
|
const session = requireSession(sessionId);
|
|
188
192
|
const factory = getStrategy(strategy);
|
|
189
193
|
const signal = factory(params);
|
|
190
|
-
session.
|
|
194
|
+
session._strategies.set(symbol ?? session.symbol, signal);
|
|
191
195
|
return { ok: true, strategy, params };
|
|
192
196
|
},
|
|
193
197
|
},
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createResearchStore } from "../research/store.js";
|
|
2
|
+
|
|
3
|
+
export function researchTools({ dir } = {}) {
|
|
4
|
+
const store = createResearchStore(dir ? { dir } : {});
|
|
5
|
+
return {
|
|
6
|
+
research_open: {
|
|
7
|
+
description: "Open or resume a persistent research session for iterating on strategy hypotheses.",
|
|
8
|
+
handler: async ({ id, goal } = {}) => store.open(id, goal),
|
|
9
|
+
},
|
|
10
|
+
research_log: {
|
|
11
|
+
description: "Append a tested hypothesis (params, metrics, optional overfitting verdict) to a research session.",
|
|
12
|
+
handler: async ({ id, hypothesis, params, metrics, verdict } = {}) =>
|
|
13
|
+
store.log(id, { hypothesis, params, metrics, verdict }),
|
|
14
|
+
},
|
|
15
|
+
research_recall: {
|
|
16
|
+
description: "Recall recent research entries plus a synthesized summary (best Sharpe, overfit count).",
|
|
17
|
+
handler: async ({ id, limit } = {}) => store.recall(id, limit),
|
|
18
|
+
},
|
|
19
|
+
research_close: {
|
|
20
|
+
description: "Mark a research session complete and return its final record.",
|
|
21
|
+
handler: async ({ id } = {}) => store.close(id),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
package/src/mcp/schemas.js
CHANGED
|
@@ -109,7 +109,8 @@ export const schemas = {
|
|
|
109
109
|
// Live trading tools
|
|
110
110
|
create_session: {
|
|
111
111
|
sessionId: z.string(),
|
|
112
|
-
symbol: z.string(),
|
|
112
|
+
symbol: z.string().optional(),
|
|
113
|
+
symbols: z.array(z.string()).optional(),
|
|
113
114
|
mode: sessionMode,
|
|
114
115
|
interval: z.string().optional(),
|
|
115
116
|
equity: z.number().optional(),
|
|
@@ -125,6 +126,7 @@ export const schemas = {
|
|
|
125
126
|
sessionId: z.string(),
|
|
126
127
|
bar: barShape.optional(),
|
|
127
128
|
price: z.number().optional(),
|
|
129
|
+
symbol: z.string().optional(),
|
|
128
130
|
},
|
|
129
131
|
place_order: {
|
|
130
132
|
sessionId: z.string(),
|
|
@@ -136,6 +138,7 @@ export const schemas = {
|
|
|
136
138
|
target: z.number().optional(),
|
|
137
139
|
rr: z.number().optional(),
|
|
138
140
|
limitPrice: z.number().optional(),
|
|
141
|
+
symbol: z.string().optional(),
|
|
139
142
|
},
|
|
140
143
|
close_position: {
|
|
141
144
|
sessionId: z.string(),
|
|
@@ -162,6 +165,7 @@ export const schemas = {
|
|
|
162
165
|
sessionId: z.string(),
|
|
163
166
|
strategy: z.string(),
|
|
164
167
|
params: z.record(z.string(), z.any()).optional(),
|
|
168
|
+
symbol: z.string().optional(),
|
|
165
169
|
},
|
|
166
170
|
halt_all: {},
|
|
167
171
|
};
|