tradelab 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +31 -6
- package/bin/tradelab.js +36 -0
- package/dist/cjs/index.cjs +98 -3
- package/dist/cjs/live.cjs +286 -58
- package/docs/live-trading.md +131 -1
- package/docs/mcp.md +90 -21
- package/examples/agentResearchLoop.js +188 -0
- package/examples/multiSymbolPortfolio.js +122 -0
- package/package.json +1 -1
- package/src/cli/runPreset.js +42 -0
- package/src/engine/portfolio.js +2 -1
- package/src/index.js +2 -0
- package/src/live/engine/paperEngine.js +16 -11
- package/src/live/engine/riskManager.js +38 -0
- package/src/live/index.js +1 -0
- package/src/live/notify.js +42 -0
- package/src/live/session.js +200 -49
- package/src/mcp/liveTools.js +42 -15
- package/src/mcp/researchSession.js +24 -0
- package/src/mcp/schemas.js +5 -1
- package/src/mcp/tools.js +27 -2
- package/src/reporting/summarize.js +43 -0
- package/src/research/monteCarlo.js +6 -2
- package/src/research/store.js +67 -0
- package/types/index.d.ts +30 -0
- package/types/live.d.ts +3 -1
|
@@ -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
|
@@ -12,10 +12,24 @@ function toBrokerSide(side) {
|
|
|
12
12
|
return side === "long" || side === "buy" ? "buy" : "sell";
|
|
13
13
|
}
|
|
14
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
|
+
|
|
15
28
|
export class TradingSession {
|
|
16
29
|
constructor({
|
|
17
30
|
id,
|
|
18
31
|
symbol,
|
|
32
|
+
symbols,
|
|
19
33
|
interval = "1m",
|
|
20
34
|
broker,
|
|
21
35
|
mode = "paper",
|
|
@@ -23,6 +37,8 @@ export class TradingSession {
|
|
|
23
37
|
riskPct = 1,
|
|
24
38
|
maxDailyLossPct = 0,
|
|
25
39
|
maxPositionPct = 1,
|
|
40
|
+
maxGrossExposurePct = 0,
|
|
41
|
+
maxNetExposurePct = 0,
|
|
26
42
|
qtyStep = 0.001,
|
|
27
43
|
minQty = 0.001,
|
|
28
44
|
maxLeverage = 2,
|
|
@@ -35,10 +51,14 @@ export class TradingSession {
|
|
|
35
51
|
);
|
|
36
52
|
}
|
|
37
53
|
if (!broker) throw new Error("TradingSession requires a broker (PaperEngine by default)");
|
|
38
|
-
if (!symbol) throw new Error("TradingSession requires a symbol");
|
|
39
54
|
|
|
40
|
-
|
|
41
|
-
|
|
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}`;
|
|
42
62
|
this.interval = interval;
|
|
43
63
|
this.broker = broker;
|
|
44
64
|
this.mode = mode;
|
|
@@ -50,20 +70,50 @@ export class TradingSession {
|
|
|
50
70
|
this.minQty = minQty;
|
|
51
71
|
this.maxLeverage = maxLeverage;
|
|
52
72
|
this.eventBus = eventBus || new EventBus();
|
|
53
|
-
this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0 });
|
|
54
|
-
this.lastPrice = null;
|
|
73
|
+
this.riskManager = new RiskManager({ maxDailyLossPct, maxDrawdownPct: 0, maxGrossExposurePct, maxNetExposurePct });
|
|
55
74
|
this.running = false;
|
|
56
75
|
this.events = [];
|
|
57
76
|
this.brackets = new Map(); // symbol -> { stopId, targetId }
|
|
58
|
-
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 }
|
|
59
80
|
this._cachedPositions = [];
|
|
60
81
|
this._cachedOpenOrders = [];
|
|
61
|
-
this.
|
|
62
|
-
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;
|
|
63
89
|
|
|
64
90
|
this._wireBrokerEvents();
|
|
65
91
|
}
|
|
66
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
|
+
|
|
67
117
|
static liveAllowed() {
|
|
68
118
|
return process.env.TRADELAB_ALLOW_LIVE === "true";
|
|
69
119
|
}
|
|
@@ -78,37 +128,70 @@ export class TradingSession {
|
|
|
78
128
|
_wireBrokerEvents() {
|
|
79
129
|
// Forward broker fills/cancels onto the session bus, and run OCO bracket logic.
|
|
80
130
|
this.broker.on?.("order:filled", (order) => this._onBrokerFillSync(order));
|
|
81
|
-
this.broker.on?.("order:submitted", (order) => this._record("order:submitted", order));
|
|
82
|
-
this.broker.on?.("order:canceled", (order) =>
|
|
131
|
+
this.broker.on?.("order:submitted", (order) => this._record("order:submitted", this._withMeta(order)));
|
|
132
|
+
this.broker.on?.("order:canceled", (order) =>
|
|
133
|
+
this._onBrokerTerminalOrderSync("order:canceled", order)
|
|
134
|
+
);
|
|
135
|
+
this.broker.on?.("order:rejected", (order) =>
|
|
136
|
+
this._onBrokerTerminalOrderSync("order:rejected", order)
|
|
137
|
+
);
|
|
83
138
|
this.broker.on?.("equity:update", (acct) => this._record("equity:update", acct));
|
|
84
139
|
}
|
|
85
140
|
|
|
141
|
+
_onBrokerTerminalOrderSync(event, order) {
|
|
142
|
+
this._record(event, order);
|
|
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
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
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
|
+
|
|
86
164
|
// Sync event handler — fire-and-forget async OCO work via a stored promise
|
|
87
165
|
_onBrokerFillSync(order) {
|
|
88
|
-
this._record("order:filled", order);
|
|
166
|
+
this._record("order:filled", this._withMeta(order));
|
|
89
167
|
|
|
90
168
|
// Resting entry order (e.g. a limit) just filled — attach its staged bracket.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
}
|
|
99
180
|
}
|
|
100
181
|
|
|
101
|
-
// Track bracket leg fills for OCO
|
|
102
|
-
const bracket
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
}
|
|
112
195
|
}
|
|
113
196
|
}
|
|
114
197
|
|
|
@@ -130,19 +213,22 @@ export class TradingSession {
|
|
|
130
213
|
this._record("shutdown", {});
|
|
131
214
|
}
|
|
132
215
|
|
|
133
|
-
async pushBar(b) {
|
|
134
|
-
|
|
216
|
+
async pushBar(b, symbol) {
|
|
217
|
+
const sym = this._resolveSymbol(symbol);
|
|
218
|
+
this._lastPrice.set(sym, b.close);
|
|
135
219
|
if (typeof this.broker.simulateBar === "function") {
|
|
136
|
-
await this.broker.simulateBar(
|
|
220
|
+
await this.broker.simulateBar(sym, this.interval, b);
|
|
137
221
|
}
|
|
138
222
|
// Wait for any pending OCO cancel triggered by simulateBar fills
|
|
139
223
|
if (this._pendingCancelPromise) {
|
|
140
224
|
await this._pendingCancelPromise;
|
|
141
225
|
this._pendingCancelPromise = null;
|
|
142
226
|
}
|
|
143
|
-
this.
|
|
144
|
-
|
|
145
|
-
|
|
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 });
|
|
146
232
|
await this._syncEquityAndRisk();
|
|
147
233
|
await this.refresh();
|
|
148
234
|
}
|
|
@@ -152,15 +238,16 @@ export class TradingSession {
|
|
|
152
238
|
return Boolean(state.halted);
|
|
153
239
|
}
|
|
154
240
|
|
|
155
|
-
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 } = {}) {
|
|
156
242
|
if (!this.running) throw new Error("session not started");
|
|
157
243
|
if (this._riskHalted()) throw new Error("session is risk-halted for the day");
|
|
158
|
-
const
|
|
244
|
+
const sym = this._resolveSymbol(symbol);
|
|
245
|
+
const entryRef = type === "limit" ? limitPrice : this.lastPriceFor(sym);
|
|
159
246
|
if (!Number.isFinite(entryRef)) throw new Error("no price available; pushBar() a price first");
|
|
160
247
|
|
|
248
|
+
const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
|
|
161
249
|
let size = qty;
|
|
162
250
|
if (!Number.isFinite(size)) {
|
|
163
|
-
const fraction = Number.isFinite(riskPct) ? riskPct / 100 : this.riskPct / 100;
|
|
164
251
|
if (!Number.isFinite(stop)) throw new Error("risk-based sizing requires a stop");
|
|
165
252
|
size = calculatePositionSize({
|
|
166
253
|
equity: this.equity,
|
|
@@ -175,21 +262,73 @@ export class TradingSession {
|
|
|
175
262
|
size = roundStep(size, this.qtyStep);
|
|
176
263
|
if (!(size >= this.minQty)) throw new Error(`sized below minQty (${size})`);
|
|
177
264
|
|
|
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
|
+
|
|
178
304
|
const receipt = await this.broker.submitOrder({
|
|
179
|
-
symbol:
|
|
305
|
+
symbol: sym,
|
|
180
306
|
side: toBrokerSide(side),
|
|
181
307
|
type,
|
|
182
308
|
qty: size,
|
|
183
309
|
limitPrice: type === "limit" ? limitPrice : undefined,
|
|
184
|
-
clientOrderId:
|
|
310
|
+
clientOrderId: entryClientOrderId,
|
|
185
311
|
});
|
|
186
312
|
|
|
187
313
|
// Stage bracket if needed — market orders fill synchronously in PaperEngine
|
|
188
314
|
if (Number.isFinite(stop) || Number.isFinite(target) || Number.isFinite(rr)) {
|
|
315
|
+
const parentEntryId = receipt?.clientOrderId ?? entryClientOrderId;
|
|
189
316
|
if (receipt.status === "filled") {
|
|
190
|
-
await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt });
|
|
317
|
+
await this._attachBracket({ side, size, stop, target, rr, entryRef, receipt, parentEntryId, symbol: sym });
|
|
318
|
+
} else if (receipt.status !== "rejected") {
|
|
319
|
+
this._pendingBrackets.set(sym, {
|
|
320
|
+
side,
|
|
321
|
+
size,
|
|
322
|
+
stop,
|
|
323
|
+
target,
|
|
324
|
+
rr,
|
|
325
|
+
entryRef,
|
|
326
|
+
orderId: receipt.orderId,
|
|
327
|
+
clientOrderId: receipt.clientOrderId || entryClientOrderId,
|
|
328
|
+
parentEntryId,
|
|
329
|
+
});
|
|
191
330
|
} else {
|
|
192
|
-
this.
|
|
331
|
+
this._pendingBrackets.delete(sym);
|
|
193
332
|
}
|
|
194
333
|
}
|
|
195
334
|
|
|
@@ -197,7 +336,8 @@ export class TradingSession {
|
|
|
197
336
|
return receipt;
|
|
198
337
|
}
|
|
199
338
|
|
|
200
|
-
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;
|
|
201
341
|
const entryFill = receipt?.avgFillPrice ?? entryRef;
|
|
202
342
|
const risk = Number.isFinite(stop) ? Math.abs(entryFill - stop) : null;
|
|
203
343
|
const targetPrice = Number.isFinite(target)
|
|
@@ -211,28 +351,32 @@ export class TradingSession {
|
|
|
211
351
|
const bracket = {};
|
|
212
352
|
|
|
213
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" });
|
|
214
356
|
const stopOrder = await this.broker.submitOrder({
|
|
215
|
-
symbol:
|
|
357
|
+
symbol: sym,
|
|
216
358
|
side: exitSide,
|
|
217
359
|
type: "stop",
|
|
218
360
|
qty: size,
|
|
219
361
|
stopPrice: stop,
|
|
220
|
-
clientOrderId:
|
|
362
|
+
clientOrderId: stopCoid,
|
|
221
363
|
});
|
|
222
364
|
bracket.stopId = stopOrder.orderId;
|
|
223
365
|
}
|
|
224
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" });
|
|
225
369
|
const tgtOrder = await this.broker.submitOrder({
|
|
226
|
-
symbol:
|
|
370
|
+
symbol: sym,
|
|
227
371
|
side: exitSide,
|
|
228
372
|
type: "limit",
|
|
229
373
|
qty: size,
|
|
230
374
|
limitPrice: targetPrice,
|
|
231
|
-
clientOrderId:
|
|
375
|
+
clientOrderId: tgtCoid,
|
|
232
376
|
});
|
|
233
377
|
bracket.targetId = tgtOrder.orderId;
|
|
234
378
|
}
|
|
235
|
-
this.brackets.set(
|
|
379
|
+
this.brackets.set(sym, bracket);
|
|
236
380
|
}
|
|
237
381
|
|
|
238
382
|
async _syncEquityAndRisk() {
|
|
@@ -247,6 +391,12 @@ export class TradingSession {
|
|
|
247
391
|
} else {
|
|
248
392
|
this.riskManager.update({ timeMs: Date.now(), equity: this.equity });
|
|
249
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;
|
|
250
400
|
}
|
|
251
401
|
|
|
252
402
|
async closePosition(symbol = this.symbol) {
|
|
@@ -305,6 +455,7 @@ export class TradingSession {
|
|
|
305
455
|
return {
|
|
306
456
|
id: this.id,
|
|
307
457
|
symbol: this.symbol,
|
|
458
|
+
symbols: this.symbols,
|
|
308
459
|
interval: this.interval,
|
|
309
460
|
mode: this.mode,
|
|
310
461
|
running: this.running,
|
package/src/mcp/liveTools.js
CHANGED
|
@@ -10,6 +10,19 @@ function requireSession(sessionId) {
|
|
|
10
10
|
return s;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
function signalToOrder(signal) {
|
|
14
|
+
return {
|
|
15
|
+
side: signal.side ?? signal.direction ?? signal.action,
|
|
16
|
+
type: signal.type ?? "market",
|
|
17
|
+
qty: signal.qty ?? signal.size,
|
|
18
|
+
riskPct: signal.riskPct,
|
|
19
|
+
stop: signal.stop ?? signal.stopLoss ?? signal.sl,
|
|
20
|
+
target: signal.target ?? signal.takeProfit ?? signal.tp,
|
|
21
|
+
rr: signal.rr ?? signal._rr,
|
|
22
|
+
limitPrice: signal.limitPrice ?? signal.limit ?? signal.entry ?? signal.price,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
export { manager as sessionManager };
|
|
14
27
|
|
|
15
28
|
export const liveTools = {
|
|
@@ -19,6 +32,7 @@ export const liveTools = {
|
|
|
19
32
|
handler: async ({
|
|
20
33
|
sessionId,
|
|
21
34
|
symbol,
|
|
35
|
+
symbols,
|
|
22
36
|
mode = "paper",
|
|
23
37
|
interval = "1m",
|
|
24
38
|
equity = 10_000,
|
|
@@ -29,6 +43,7 @@ export const liveTools = {
|
|
|
29
43
|
const session = await manager.create({
|
|
30
44
|
id: sessionId,
|
|
31
45
|
symbol,
|
|
46
|
+
symbols,
|
|
32
47
|
mode,
|
|
33
48
|
interval,
|
|
34
49
|
equity,
|
|
@@ -59,21 +74,36 @@ export const liveTools = {
|
|
|
59
74
|
feed_price: {
|
|
60
75
|
description:
|
|
61
76
|
"Feed a price bar (or single price) to a session, advancing paper simulations and triggering fills.",
|
|
62
|
-
handler: async ({ sessionId, bar, price } = {}) => {
|
|
77
|
+
handler: async ({ sessionId, bar, price, symbol } = {}) => {
|
|
63
78
|
const session = requireSession(sessionId);
|
|
64
79
|
let b = bar;
|
|
65
80
|
if (!b && Number.isFinite(price)) {
|
|
66
81
|
b = { time: Date.now(), open: price, high: price, low: price, close: price, volume: 0 };
|
|
67
82
|
}
|
|
68
83
|
if (!b) throw new Error("Provide either `bar` (OHLCV) or `price` (number)");
|
|
69
|
-
await session.pushBar(b);
|
|
70
|
-
|
|
71
|
-
// If a strategy is attached and session is flat, evaluate it
|
|
72
|
-
|
|
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) {
|
|
73
92
|
try {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
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);
|
|
105
|
+
if (signal && (signal.side || signal.direction || signal.action)) {
|
|
106
|
+
await session.placeOrder({ ...signalToOrder(signal), symbol: effectiveSym }).catch(() => {});
|
|
77
107
|
}
|
|
78
108
|
} catch {
|
|
79
109
|
// strategy errors are non-fatal
|
|
@@ -97,9 +127,10 @@ export const liveTools = {
|
|
|
97
127
|
target,
|
|
98
128
|
rr,
|
|
99
129
|
limitPrice,
|
|
130
|
+
symbol,
|
|
100
131
|
} = {}) => {
|
|
101
132
|
const session = requireSession(sessionId);
|
|
102
|
-
return session.placeOrder({ side, type, qty, riskPct, stop, target, rr, limitPrice });
|
|
133
|
+
return session.placeOrder({ side, type, qty, riskPct, stop, target, rr, limitPrice, symbol });
|
|
103
134
|
},
|
|
104
135
|
},
|
|
105
136
|
|
|
@@ -156,15 +187,11 @@ export const liveTools = {
|
|
|
156
187
|
attach_strategy: {
|
|
157
188
|
description:
|
|
158
189
|
"Attach a named built-in strategy to a session. It will auto-evaluate on each feed_price and place orders when flat.",
|
|
159
|
-
handler: async ({ sessionId, strategy, params = {} } = {}) => {
|
|
190
|
+
handler: async ({ sessionId, strategy, params = {}, symbol } = {}) => {
|
|
160
191
|
const session = requireSession(sessionId);
|
|
161
192
|
const factory = getStrategy(strategy);
|
|
162
193
|
const signal = factory(params);
|
|
163
|
-
|
|
164
|
-
session._strategy = (candleBuffer) => {
|
|
165
|
-
if (!candleBuffer || candleBuffer.length === 0) return null;
|
|
166
|
-
return signal(candleBuffer[candleBuffer.length - 1], candleBuffer);
|
|
167
|
-
};
|
|
194
|
+
session._strategies.set(symbol ?? session.symbol, signal);
|
|
168
195
|
return { ok: true, strategy, params };
|
|
169
196
|
},
|
|
170
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
|
};
|
package/src/mcp/tools.js
CHANGED
|
@@ -5,6 +5,10 @@ import { getStrategy, listStrategies } from "../strategies/index.js";
|
|
|
5
5
|
import { grid } from "../engine/grid.js";
|
|
6
6
|
import { monteCarlo, deflatedSharpe } from "../research/index.js";
|
|
7
7
|
import { liveTools } from "./liveTools.js";
|
|
8
|
+
import { researchTools as researchSessionTools } from "./researchSession.js";
|
|
9
|
+
import { createResearchStore } from "../research/store.js";
|
|
10
|
+
|
|
11
|
+
const researchStore = createResearchStore();
|
|
8
12
|
|
|
9
13
|
function summarizeMetrics(metrics) {
|
|
10
14
|
const {
|
|
@@ -93,10 +97,31 @@ export const researchTools = {
|
|
|
93
97
|
collectReplay: false,
|
|
94
98
|
...(args.backtestOptions || {}),
|
|
95
99
|
});
|
|
100
|
+
const metrics = summarizeMetrics(result.metrics);
|
|
101
|
+
if (args.researchId) {
|
|
102
|
+
let verdict = null;
|
|
103
|
+
try {
|
|
104
|
+
const psr = deflatedSharpe({
|
|
105
|
+
sharpe: result.metrics.sharpe,
|
|
106
|
+
sampleSize: result.metrics.trades,
|
|
107
|
+
numTrials: args.numTrials ?? 1,
|
|
108
|
+
});
|
|
109
|
+
verdict = {
|
|
110
|
+
deflatedSharpe: psr,
|
|
111
|
+
overfit: Number.isFinite(psr) ? psr < 0.9 : false,
|
|
112
|
+
note: Number.isFinite(psr) ? `PSR ${(psr * 100).toFixed(1)}%` : "insufficient data",
|
|
113
|
+
};
|
|
114
|
+
} catch {
|
|
115
|
+
verdict = { deflatedSharpe: null, overfit: false, note: "verdict unavailable" };
|
|
116
|
+
}
|
|
117
|
+
await researchStore.log(args.researchId, {
|
|
118
|
+
hypothesis: args.strategy, params: args.params || {}, metrics, verdict,
|
|
119
|
+
}).catch(() => {});
|
|
120
|
+
}
|
|
96
121
|
return {
|
|
97
122
|
symbol: result.symbol,
|
|
98
123
|
interval: result.interval,
|
|
99
|
-
metrics
|
|
124
|
+
metrics,
|
|
100
125
|
tradesPreview: result.positions.slice(0, 10).map((p) => ({
|
|
101
126
|
side: p.side,
|
|
102
127
|
entry: p.entryFill ?? p.entry,
|
|
@@ -262,4 +287,4 @@ export const researchPlusTools = {
|
|
|
262
287
|
},
|
|
263
288
|
};
|
|
264
289
|
|
|
265
|
-
export const mcpTools = { ...researchTools, ...researchPlusTools, ...liveTools };
|
|
290
|
+
export const mcpTools = { ...researchTools, ...researchPlusTools, ...liveTools, ...researchSessionTools() };
|