tradelab 0.4.0 → 1.0.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/README.md +121 -52
- package/bin/tradelab.js +340 -49
- package/dist/cjs/data.cjs +210 -155
- package/dist/cjs/index.cjs +1782 -274
- package/dist/cjs/live.cjs +3350 -0
- package/docs/README.md +26 -9
- package/docs/api-reference.md +89 -26
- package/docs/backtest-engine.md +74 -60
- package/docs/data-reporting-cli.md +66 -36
- package/docs/examples.md +275 -0
- package/docs/live-trading.md +186 -0
- package/examples/yahooEmaCross.js +1 -6
- package/package.json +18 -3
- package/src/data/csv.js +24 -14
- package/src/data/index.js +1 -5
- package/src/data/yahoo.js +6 -19
- package/src/engine/backtest.js +137 -144
- package/src/engine/backtestTicks.js +481 -0
- package/src/engine/barSystemRunner.js +1027 -0
- package/src/engine/execution.js +11 -39
- package/src/engine/portfolio.js +237 -66
- package/src/engine/walkForward.js +132 -13
- package/src/index.js +3 -11
- package/src/live/broker/alpaca.js +254 -0
- package/src/live/broker/binance.js +351 -0
- package/src/live/broker/coinbase.js +339 -0
- package/src/live/broker/interactiveBrokers.js +123 -0
- package/src/live/broker/interface.js +74 -0
- package/src/live/clock.js +56 -0
- package/src/live/engine/candleAggregator.js +154 -0
- package/src/live/engine/liveEngine.js +694 -0
- package/src/live/engine/paperEngine.js +453 -0
- package/src/live/engine/riskManager.js +185 -0
- package/src/live/engine/stateManager.js +112 -0
- package/src/live/events.js +48 -0
- package/src/live/feed/brokerFeed.js +35 -0
- package/src/live/feed/interface.js +28 -0
- package/src/live/feed/pollingFeed.js +105 -0
- package/src/live/index.js +27 -0
- package/src/live/logger.js +82 -0
- package/src/live/orchestrator.js +133 -0
- package/src/live/storage/interface.js +36 -0
- package/src/live/storage/jsonFileStorage.js +112 -0
- package/src/metrics/buildMetrics.js +103 -100
- package/src/reporting/exportBacktestArtifacts.js +1 -4
- package/src/reporting/exportTradesCsv.js +2 -7
- package/src/reporting/renderHtmlReport.js +8 -13
- package/src/utils/indicators.js +1 -2
- package/src/utils/positionSizing.js +16 -2
- package/src/utils/time.js +4 -12
- package/templates/report.html +23 -9
- package/templates/report.js +83 -69
- package/types/index.d.ts +98 -4
- package/types/live.d.ts +382 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { applyFill, roundStep, touchedLimit } from "../../engine/execution.js";
|
|
2
|
+
import { BrokerAdapter } from "../broker/interface.js";
|
|
3
|
+
|
|
4
|
+
function asNumber(value, fallback = null) {
|
|
5
|
+
const numeric = Number(value);
|
|
6
|
+
return Number.isFinite(numeric) ? numeric : fallback;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalizeOrderSide(side) {
|
|
10
|
+
const normalized = String(side || "").toLowerCase();
|
|
11
|
+
if (normalized === "buy") return "buy";
|
|
12
|
+
if (normalized === "sell") return "sell";
|
|
13
|
+
throw new Error(`Unsupported paper order side "${side}"`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeOrderType(type) {
|
|
17
|
+
const normalized = String(type || "market").toLowerCase();
|
|
18
|
+
if (normalized === "market") return "market";
|
|
19
|
+
if (normalized === "limit") return "limit";
|
|
20
|
+
if (normalized === "stop") return "stop";
|
|
21
|
+
if (normalized === "stop_limit") return "stop_limit";
|
|
22
|
+
throw new Error(`Unsupported paper order type "${type}"`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cloneOrder(order) {
|
|
26
|
+
return {
|
|
27
|
+
orderId: order.orderId,
|
|
28
|
+
clientOrderId: order.clientOrderId,
|
|
29
|
+
status: order.status,
|
|
30
|
+
filledQty: order.filledQty,
|
|
31
|
+
avgFillPrice: order.avgFillPrice,
|
|
32
|
+
filledAt: order.filledAt,
|
|
33
|
+
symbol: order.symbol,
|
|
34
|
+
side: order.side,
|
|
35
|
+
type: order.type,
|
|
36
|
+
qty: order.qty,
|
|
37
|
+
limitPrice: order.limitPrice,
|
|
38
|
+
stopPrice: order.stopPrice,
|
|
39
|
+
timeInForce: order.timeInForce,
|
|
40
|
+
rejectReason: order.rejectReason,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sideToDirection(side) {
|
|
45
|
+
return side === "buy" ? 1 : -1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* In-process broker simulator that implements the BrokerAdapter interface.
|
|
50
|
+
*/
|
|
51
|
+
export class PaperEngine extends BrokerAdapter {
|
|
52
|
+
constructor({
|
|
53
|
+
equity = 10_000,
|
|
54
|
+
currency = "USD",
|
|
55
|
+
slippageBps = 0,
|
|
56
|
+
feeBps = 0,
|
|
57
|
+
costs = null,
|
|
58
|
+
qtyStep = 0.001,
|
|
59
|
+
} = {}) {
|
|
60
|
+
super();
|
|
61
|
+
this.connected = false;
|
|
62
|
+
this.config = {};
|
|
63
|
+
this.currency = currency;
|
|
64
|
+
this.startingEquity = Math.max(0, Number(equity) || 0);
|
|
65
|
+
this.cash = this.startingEquity;
|
|
66
|
+
this.slippageBps = slippageBps;
|
|
67
|
+
this.feeBps = feeBps;
|
|
68
|
+
this.costs = costs;
|
|
69
|
+
this.qtyStep = qtyStep;
|
|
70
|
+
this.positions = new Map();
|
|
71
|
+
this.openOrders = new Map();
|
|
72
|
+
this.orderHistory = new Map();
|
|
73
|
+
this.lastPrices = new Map();
|
|
74
|
+
this.barSubscribers = new Map();
|
|
75
|
+
this.tradeSubscribers = new Map();
|
|
76
|
+
this.quoteSubscribers = new Map();
|
|
77
|
+
this.historicalBars = new Map();
|
|
78
|
+
this.orderIdCounter = 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async connect(config = {}) {
|
|
82
|
+
this.config = { ...config };
|
|
83
|
+
this.connected = true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async disconnect() {
|
|
87
|
+
this.connected = false;
|
|
88
|
+
this.barSubscribers.clear();
|
|
89
|
+
this.tradeSubscribers.clear();
|
|
90
|
+
this.quoteSubscribers.clear();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
isConnected() {
|
|
94
|
+
return this.connected;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
supportsPaperNative() {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async getServerTime() {
|
|
102
|
+
return Date.now();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_positionMark(position) {
|
|
106
|
+
const mark = this.lastPrices.get(position.symbol) ?? position.avgEntry;
|
|
107
|
+
if (position.side === "long") {
|
|
108
|
+
return {
|
|
109
|
+
mark,
|
|
110
|
+
marketValue: mark * position.qty,
|
|
111
|
+
unrealizedPnl: (mark - position.avgEntry) * position.qty,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
mark,
|
|
116
|
+
marketValue: mark * position.qty,
|
|
117
|
+
unrealizedPnl: (position.avgEntry - mark) * position.qty,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_realizedUnrealizedSummary() {
|
|
122
|
+
let unrealized = 0;
|
|
123
|
+
let marketValue = 0;
|
|
124
|
+
for (const position of this.positions.values()) {
|
|
125
|
+
const marked = this._positionMark(position);
|
|
126
|
+
unrealized += marked.unrealizedPnl;
|
|
127
|
+
marketValue += marked.marketValue;
|
|
128
|
+
}
|
|
129
|
+
return { unrealized, marketValue };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async getAccount() {
|
|
133
|
+
const { unrealized, marketValue } = this._realizedUnrealizedSummary();
|
|
134
|
+
const equity = this.cash + unrealized;
|
|
135
|
+
return {
|
|
136
|
+
equity,
|
|
137
|
+
buyingPower: Math.max(0, equity),
|
|
138
|
+
cash: this.cash,
|
|
139
|
+
currency: this.currency,
|
|
140
|
+
marginUsed: Math.max(0, marketValue - this.cash),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async getPositions() {
|
|
145
|
+
const rows = [];
|
|
146
|
+
for (const position of this.positions.values()) {
|
|
147
|
+
const marked = this._positionMark(position);
|
|
148
|
+
rows.push({
|
|
149
|
+
symbol: position.symbol,
|
|
150
|
+
side: position.side,
|
|
151
|
+
qty: position.qty,
|
|
152
|
+
avgEntry: position.avgEntry,
|
|
153
|
+
marketValue: marked.marketValue,
|
|
154
|
+
unrealizedPnl: marked.unrealizedPnl,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return rows;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
_streamKey(symbol, interval = "*") {
|
|
161
|
+
return `${symbol}::${interval}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_subscribe(map, key, handler) {
|
|
165
|
+
const list = map.get(key) || [];
|
|
166
|
+
list.push(handler);
|
|
167
|
+
map.set(key, list);
|
|
168
|
+
return {
|
|
169
|
+
unsubscribe: () => {
|
|
170
|
+
const current = map.get(key) || [];
|
|
171
|
+
map.set(
|
|
172
|
+
key,
|
|
173
|
+
current.filter((candidate) => candidate !== handler)
|
|
174
|
+
);
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async subscribeBars(symbol, interval, handler) {
|
|
180
|
+
return this._subscribe(this.barSubscribers, this._streamKey(symbol, interval), handler);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async subscribeTrades(symbol, handler) {
|
|
184
|
+
return this._subscribe(this.tradeSubscribers, symbol, handler);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async subscribeQuotes(symbol, handler) {
|
|
188
|
+
return this._subscribe(this.quoteSubscribers, symbol, handler);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async _emitTo(map, key, payload) {
|
|
192
|
+
const handlers = map.get(key) || [];
|
|
193
|
+
for (const handler of handlers) {
|
|
194
|
+
await Promise.resolve(handler(payload));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
setHistoricalBars(symbol, interval, bars) {
|
|
199
|
+
const streamKey = this._streamKey(symbol, interval);
|
|
200
|
+
this.historicalBars.set(streamKey, [...bars]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async getHistoricalBars(symbol, interval, limit = 200) {
|
|
204
|
+
const streamKey = this._streamKey(symbol, interval);
|
|
205
|
+
const all = this.historicalBars.get(streamKey) || [];
|
|
206
|
+
return all.slice(Math.max(0, all.length - limit));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
_nextOrderId() {
|
|
210
|
+
const id = `paper-${this.orderIdCounter}`;
|
|
211
|
+
this.orderIdCounter += 1;
|
|
212
|
+
return id;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_recordOrder(order) {
|
|
216
|
+
this.orderHistory.set(order.orderId, { ...order });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
_fillOrder(order, fillPrice, kind = "market", fillTime = Date.now()) {
|
|
220
|
+
const side = normalizeOrderSide(order.side);
|
|
221
|
+
const qty = Math.max(0, asNumber(order.qty, 0));
|
|
222
|
+
if (!(qty > 0)) {
|
|
223
|
+
order.status = "rejected";
|
|
224
|
+
order.rejectReason = "invalid quantity";
|
|
225
|
+
this._recordOrder(order);
|
|
226
|
+
this.emit("order:rejected", cloneOrder(order));
|
|
227
|
+
return cloneOrder(order);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const sideForFill = side === "buy" ? "long" : "short";
|
|
231
|
+
const filled = applyFill(fillPrice, sideForFill, {
|
|
232
|
+
slippageBps: this.slippageBps,
|
|
233
|
+
feeBps: this.feeBps,
|
|
234
|
+
kind,
|
|
235
|
+
qty,
|
|
236
|
+
costs: this.costs,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const direction = sideToDirection(side);
|
|
240
|
+
let remaining = qty;
|
|
241
|
+
const position = this.positions.get(order.symbol) || null;
|
|
242
|
+
let realizedPnl = 0;
|
|
243
|
+
|
|
244
|
+
if (!position) {
|
|
245
|
+
const nextSide = direction > 0 ? "long" : "short";
|
|
246
|
+
this.positions.set(order.symbol, {
|
|
247
|
+
symbol: order.symbol,
|
|
248
|
+
side: nextSide,
|
|
249
|
+
qty: remaining,
|
|
250
|
+
avgEntry: filled.price,
|
|
251
|
+
});
|
|
252
|
+
} else {
|
|
253
|
+
const signedQty = position.side === "long" ? position.qty : -position.qty;
|
|
254
|
+
const signedIncoming = direction * remaining;
|
|
255
|
+
if ((signedQty >= 0 && signedIncoming >= 0) || (signedQty <= 0 && signedIncoming <= 0)) {
|
|
256
|
+
const totalAbs = Math.abs(signedQty) + Math.abs(signedIncoming);
|
|
257
|
+
const nextAvg =
|
|
258
|
+
totalAbs > 0
|
|
259
|
+
? (Math.abs(signedQty) * position.avgEntry + Math.abs(signedIncoming) * filled.price) /
|
|
260
|
+
totalAbs
|
|
261
|
+
: filled.price;
|
|
262
|
+
const nextSide = signedQty + signedIncoming >= 0 ? "long" : "short";
|
|
263
|
+
this.positions.set(order.symbol, {
|
|
264
|
+
symbol: order.symbol,
|
|
265
|
+
side: nextSide,
|
|
266
|
+
qty: Math.abs(signedQty + signedIncoming),
|
|
267
|
+
avgEntry: nextAvg,
|
|
268
|
+
});
|
|
269
|
+
} else {
|
|
270
|
+
const closeQty = Math.min(Math.abs(signedQty), Math.abs(signedIncoming));
|
|
271
|
+
if (position.side === "long") {
|
|
272
|
+
realizedPnl += (filled.price - position.avgEntry) * closeQty;
|
|
273
|
+
} else {
|
|
274
|
+
realizedPnl += (position.avgEntry - filled.price) * closeQty;
|
|
275
|
+
}
|
|
276
|
+
const remainder = Math.abs(signedIncoming) - closeQty;
|
|
277
|
+
if (remainder > 0) {
|
|
278
|
+
const nextSide = direction > 0 ? "long" : "short";
|
|
279
|
+
this.positions.set(order.symbol, {
|
|
280
|
+
symbol: order.symbol,
|
|
281
|
+
side: nextSide,
|
|
282
|
+
qty: remainder,
|
|
283
|
+
avgEntry: filled.price,
|
|
284
|
+
});
|
|
285
|
+
} else if (Math.abs(signedQty) - closeQty > 0) {
|
|
286
|
+
this.positions.set(order.symbol, {
|
|
287
|
+
symbol: order.symbol,
|
|
288
|
+
side: position.side,
|
|
289
|
+
qty: Math.abs(signedQty) - closeQty,
|
|
290
|
+
avgEntry: position.avgEntry,
|
|
291
|
+
});
|
|
292
|
+
} else {
|
|
293
|
+
this.positions.delete(order.symbol);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
this.cash -= filled.feeTotal;
|
|
299
|
+
this.cash += realizedPnl;
|
|
300
|
+
|
|
301
|
+
order.status = "filled";
|
|
302
|
+
order.filledQty = qty;
|
|
303
|
+
order.avgFillPrice = filled.price;
|
|
304
|
+
order.filledAt = fillTime;
|
|
305
|
+
this._recordOrder(order);
|
|
306
|
+
this.openOrders.delete(order.orderId);
|
|
307
|
+
|
|
308
|
+
const receipt = cloneOrder(order);
|
|
309
|
+
this.emit("order:filled", receipt);
|
|
310
|
+
const account = {
|
|
311
|
+
cash: this.cash,
|
|
312
|
+
realizedPnl,
|
|
313
|
+
feeTotal: filled.feeTotal,
|
|
314
|
+
equity: this.cash + this._realizedUnrealizedSummary().unrealized,
|
|
315
|
+
};
|
|
316
|
+
this.emit("equity:update", account);
|
|
317
|
+
return receipt;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
_touchesLimit(order, bar) {
|
|
321
|
+
const side = order.side === "buy" ? "long" : "short";
|
|
322
|
+
return touchedLimit(side, order.limitPrice, bar, "intrabar");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
_touchesStop(order, bar) {
|
|
326
|
+
if (order.side === "buy") return bar.high >= order.stopPrice;
|
|
327
|
+
return bar.low <= order.stopPrice;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async submitOrder(order) {
|
|
331
|
+
const normalized = {
|
|
332
|
+
orderId: this._nextOrderId(),
|
|
333
|
+
clientOrderId: order.clientOrderId,
|
|
334
|
+
status: "new",
|
|
335
|
+
filledQty: 0,
|
|
336
|
+
avgFillPrice: undefined,
|
|
337
|
+
filledAt: undefined,
|
|
338
|
+
symbol: String(order.symbol),
|
|
339
|
+
side: normalizeOrderSide(order.side),
|
|
340
|
+
type: normalizeOrderType(order.type),
|
|
341
|
+
qty: roundStep(Math.max(0, asNumber(order.qty, 0)), this.qtyStep),
|
|
342
|
+
limitPrice: asNumber(order.limitPrice),
|
|
343
|
+
stopPrice: asNumber(order.stopPrice),
|
|
344
|
+
timeInForce: order.timeInForce || "day",
|
|
345
|
+
rejectReason: undefined,
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
if (!(normalized.qty > 0)) {
|
|
349
|
+
normalized.status = "rejected";
|
|
350
|
+
normalized.rejectReason = "invalid quantity";
|
|
351
|
+
this._recordOrder(normalized);
|
|
352
|
+
this.emit("order:rejected", cloneOrder(normalized));
|
|
353
|
+
return cloneOrder(normalized);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
this._recordOrder(normalized);
|
|
357
|
+
this.emit("order:submitted", cloneOrder(normalized));
|
|
358
|
+
|
|
359
|
+
if (normalized.type === "market") {
|
|
360
|
+
const mark = this.lastPrices.get(normalized.symbol);
|
|
361
|
+
const fillPrice = mark ?? normalized.limitPrice ?? normalized.stopPrice ?? 0;
|
|
362
|
+
return this._fillOrder(normalized, fillPrice, "market");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
this.openOrders.set(normalized.orderId, normalized);
|
|
366
|
+
return cloneOrder(normalized);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async cancelOrder(orderId) {
|
|
370
|
+
const order = this.openOrders.get(orderId);
|
|
371
|
+
if (!order) return;
|
|
372
|
+
order.status = "canceled";
|
|
373
|
+
this._recordOrder(order);
|
|
374
|
+
this.openOrders.delete(orderId);
|
|
375
|
+
this.emit("order:canceled", cloneOrder(order));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async modifyOrder(orderId, changes = {}) {
|
|
379
|
+
const order = this.openOrders.get(orderId);
|
|
380
|
+
if (!order) {
|
|
381
|
+
throw new Error(`paper order "${orderId}" not found or already closed`);
|
|
382
|
+
}
|
|
383
|
+
if (changes.qty !== undefined) {
|
|
384
|
+
order.qty = roundStep(Math.max(0, asNumber(changes.qty, order.qty)), this.qtyStep);
|
|
385
|
+
}
|
|
386
|
+
if (changes.limitPrice !== undefined) {
|
|
387
|
+
order.limitPrice = asNumber(changes.limitPrice);
|
|
388
|
+
}
|
|
389
|
+
if (changes.stopPrice !== undefined) {
|
|
390
|
+
order.stopPrice = asNumber(changes.stopPrice);
|
|
391
|
+
}
|
|
392
|
+
this._recordOrder(order);
|
|
393
|
+
const receipt = cloneOrder(order);
|
|
394
|
+
this.emit("order:modified", receipt);
|
|
395
|
+
return receipt;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async getOpenOrders() {
|
|
399
|
+
return [...this.openOrders.values()].map((order) => cloneOrder(order));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async getOrderStatus(orderId) {
|
|
403
|
+
const order = this.openOrders.get(orderId) || this.orderHistory.get(orderId);
|
|
404
|
+
if (!order) throw new Error(`paper order "${orderId}" not found`);
|
|
405
|
+
return cloneOrder(order);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async simulateBar(symbol, interval, bar) {
|
|
409
|
+
const normalizedBar = {
|
|
410
|
+
time: Number(bar.time),
|
|
411
|
+
open: Number(bar.open),
|
|
412
|
+
high: Number(bar.high),
|
|
413
|
+
low: Number(bar.low),
|
|
414
|
+
close: Number(bar.close),
|
|
415
|
+
volume: asNumber(bar.volume, 0),
|
|
416
|
+
};
|
|
417
|
+
this.lastPrices.set(symbol, normalizedBar.close);
|
|
418
|
+
await this._emitTo(this.barSubscribers, this._streamKey(symbol, interval), normalizedBar);
|
|
419
|
+
await this._emitTo(this.tradeSubscribers, symbol, {
|
|
420
|
+
time: normalizedBar.time,
|
|
421
|
+
price: normalizedBar.close,
|
|
422
|
+
size: normalizedBar.volume ?? 0,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const orders = [...this.openOrders.values()].filter((order) => order.symbol === symbol);
|
|
426
|
+
for (const order of orders) {
|
|
427
|
+
if (order.type === "limit") {
|
|
428
|
+
if (this._touchesLimit(order, normalizedBar)) {
|
|
429
|
+
this._fillOrder(order, order.limitPrice, "limit", normalizedBar.time);
|
|
430
|
+
}
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (order.type === "stop") {
|
|
435
|
+
if (this._touchesStop(order, normalizedBar)) {
|
|
436
|
+
this._fillOrder(order, order.stopPrice, "stop", normalizedBar.time);
|
|
437
|
+
}
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (order.type === "stop_limit") {
|
|
442
|
+
order._triggered = Boolean(order._triggered) || this._touchesStop(order, normalizedBar);
|
|
443
|
+
if (order._triggered && this._touchesLimit(order, normalizedBar)) {
|
|
444
|
+
this._fillOrder(order, order.limitPrice, "limit", normalizedBar.time);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function createPaperEngine(options) {
|
|
452
|
+
return new PaperEngine(options);
|
|
453
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { dayKeyET } from "../../engine/execution.js";
|
|
2
|
+
import { inWindowsET, isSession, parseWindowsCSV } from "../../utils/time.js";
|
|
3
|
+
|
|
4
|
+
function pctToFraction(value, fallback = 0) {
|
|
5
|
+
if (!Number.isFinite(value)) return fallback;
|
|
6
|
+
return Math.abs(value) / 100;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Live-trading risk gate and circuit breaker manager.
|
|
11
|
+
*/
|
|
12
|
+
export class RiskManager {
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.options = {
|
|
15
|
+
maxDailyLossPct: 2,
|
|
16
|
+
maxDailyLossDollars: null,
|
|
17
|
+
maxDrawdownPct: 20,
|
|
18
|
+
maxPositions: 10,
|
|
19
|
+
maxPositionPct: 50,
|
|
20
|
+
maxDailyTrades: 0,
|
|
21
|
+
cooldownAfterLossMs: 0,
|
|
22
|
+
allowedSessions: "AUTO",
|
|
23
|
+
allowedWindows: null,
|
|
24
|
+
...options,
|
|
25
|
+
};
|
|
26
|
+
this.allowedWindows = parseWindowsCSV(this.options.allowedWindows);
|
|
27
|
+
this.startEquity = null;
|
|
28
|
+
this.currentEquity = null;
|
|
29
|
+
this.peakEquity = null;
|
|
30
|
+
this.currentDayKey = null;
|
|
31
|
+
this.dayPnl = 0;
|
|
32
|
+
this.dayTrades = 0;
|
|
33
|
+
this.lastLossAt = null;
|
|
34
|
+
this.halted = false;
|
|
35
|
+
this.haltReason = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
initialize(equity, timeMs = Date.now()) {
|
|
39
|
+
const value = Number.isFinite(equity) ? equity : 0;
|
|
40
|
+
this.startEquity = value;
|
|
41
|
+
this.currentEquity = value;
|
|
42
|
+
this.peakEquity = value;
|
|
43
|
+
this.currentDayKey = dayKeyET(timeMs);
|
|
44
|
+
this.dayPnl = 0;
|
|
45
|
+
this.dayTrades = 0;
|
|
46
|
+
this.lastLossAt = null;
|
|
47
|
+
this.halted = false;
|
|
48
|
+
this.haltReason = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
update({ timeMs, equity }) {
|
|
52
|
+
if (this.startEquity === null) this.initialize(equity, timeMs);
|
|
53
|
+
const nextDay = dayKeyET(timeMs);
|
|
54
|
+
if (this.currentDayKey !== nextDay) {
|
|
55
|
+
this.currentDayKey = nextDay;
|
|
56
|
+
this.dayPnl = 0;
|
|
57
|
+
this.dayTrades = 0;
|
|
58
|
+
this.halted = false;
|
|
59
|
+
this.haltReason = null;
|
|
60
|
+
}
|
|
61
|
+
this.currentEquity = Number.isFinite(equity) ? equity : this.currentEquity;
|
|
62
|
+
if (this.currentEquity > this.peakEquity) this.peakEquity = this.currentEquity;
|
|
63
|
+
this._maybeHaltForDrawdown();
|
|
64
|
+
this._maybeHaltForDailyLoss();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_maybeHaltForDrawdown() {
|
|
68
|
+
if (this.halted || !Number.isFinite(this.currentEquity) || !(this.peakEquity > 0)) return;
|
|
69
|
+
const drawdown = (this.peakEquity - this.currentEquity) / this.peakEquity;
|
|
70
|
+
const maxDrawdown = pctToFraction(this.options.maxDrawdownPct, 0.2);
|
|
71
|
+
if (maxDrawdown > 0 && drawdown >= maxDrawdown) {
|
|
72
|
+
this.halt(`max drawdown reached (${(drawdown * 100).toFixed(2)}%)`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_maybeHaltForDailyLoss() {
|
|
77
|
+
if (this.halted) return;
|
|
78
|
+
const maxLossPct = pctToFraction(this.options.maxDailyLossPct, 0.02);
|
|
79
|
+
const maxLossDollars = Number.isFinite(this.options.maxDailyLossDollars)
|
|
80
|
+
? Math.abs(this.options.maxDailyLossDollars)
|
|
81
|
+
: null;
|
|
82
|
+
const lossesExceededPct =
|
|
83
|
+
maxLossPct > 0 && this.dayPnl <= -Math.abs(this.startEquity * maxLossPct);
|
|
84
|
+
const lossesExceededAbs =
|
|
85
|
+
Number.isFinite(maxLossDollars) && this.dayPnl <= -Math.abs(maxLossDollars);
|
|
86
|
+
if (lossesExceededPct || lossesExceededAbs) {
|
|
87
|
+
this.halt("daily loss limit reached");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
isSessionAllowed(timeMs) {
|
|
92
|
+
const sessionName = this.options.allowedSessions || "AUTO";
|
|
93
|
+
if (!isSession(timeMs, sessionName)) return false;
|
|
94
|
+
return inWindowsET(timeMs, this.allowedWindows);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
canTrade({ timeMs = Date.now() } = {}) {
|
|
98
|
+
if (this.halted) return { ok: false, reason: this.haltReason || "risk halt active" };
|
|
99
|
+
if (!this.isSessionAllowed(timeMs))
|
|
100
|
+
return { ok: false, reason: "outside allowed session/window" };
|
|
101
|
+
if (
|
|
102
|
+
Number.isFinite(this.options.cooldownAfterLossMs) &&
|
|
103
|
+
this.options.cooldownAfterLossMs > 0 &&
|
|
104
|
+
Number.isFinite(this.lastLossAt) &&
|
|
105
|
+
timeMs - this.lastLossAt < this.options.cooldownAfterLossMs
|
|
106
|
+
) {
|
|
107
|
+
return { ok: false, reason: "cooldown after loss active" };
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, reason: null };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
canOpenPosition({
|
|
113
|
+
timeMs = Date.now(),
|
|
114
|
+
positionCount = 0,
|
|
115
|
+
positionValue = 0,
|
|
116
|
+
equity = null,
|
|
117
|
+
} = {}) {
|
|
118
|
+
const base = this.canTrade({ timeMs });
|
|
119
|
+
if (!base.ok) return base;
|
|
120
|
+
|
|
121
|
+
if (this.options.maxPositions > 0 && positionCount >= this.options.maxPositions) {
|
|
122
|
+
return { ok: false, reason: "max positions reached" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (this.options.maxDailyTrades > 0 && this.dayTrades >= this.options.maxDailyTrades) {
|
|
126
|
+
return { ok: false, reason: "max daily trades reached" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const eq = Number.isFinite(equity) ? equity : this.currentEquity;
|
|
130
|
+
const maxPositionFraction = pctToFraction(this.options.maxPositionPct, 0.5);
|
|
131
|
+
if (maxPositionFraction > 0 && Number.isFinite(eq) && eq > 0) {
|
|
132
|
+
const fraction = Math.abs(positionValue) / eq;
|
|
133
|
+
if (fraction > maxPositionFraction) {
|
|
134
|
+
return { ok: false, reason: "max position size exceeded" };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { ok: true, reason: null };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
recordTrade({ pnl = 0, timeMs = Date.now(), equity = null } = {}) {
|
|
142
|
+
if (this.currentDayKey !== dayKeyET(timeMs)) {
|
|
143
|
+
this.currentDayKey = dayKeyET(timeMs);
|
|
144
|
+
this.dayPnl = 0;
|
|
145
|
+
this.dayTrades = 0;
|
|
146
|
+
this.halted = false;
|
|
147
|
+
this.haltReason = null;
|
|
148
|
+
}
|
|
149
|
+
const realized = Number.isFinite(pnl) ? pnl : 0;
|
|
150
|
+
this.dayPnl += realized;
|
|
151
|
+
this.dayTrades += 1;
|
|
152
|
+
if (realized < 0) this.lastLossAt = timeMs;
|
|
153
|
+
if (Number.isFinite(equity)) this.currentEquity = equity;
|
|
154
|
+
this._maybeHaltForDailyLoss();
|
|
155
|
+
this._maybeHaltForDrawdown();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
halt(reason = "manual halt") {
|
|
159
|
+
this.halted = true;
|
|
160
|
+
this.haltReason = reason;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
clearHalt() {
|
|
164
|
+
this.halted = false;
|
|
165
|
+
this.haltReason = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
getState() {
|
|
169
|
+
return {
|
|
170
|
+
startEquity: this.startEquity,
|
|
171
|
+
currentEquity: this.currentEquity,
|
|
172
|
+
peakEquity: this.peakEquity,
|
|
173
|
+
dayPnl: this.dayPnl,
|
|
174
|
+
dayTrades: this.dayTrades,
|
|
175
|
+
currentDayKey: this.currentDayKey,
|
|
176
|
+
halted: this.halted,
|
|
177
|
+
haltReason: this.haltReason,
|
|
178
|
+
lastLossAt: this.lastLossAt,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function createRiskManager(options) {
|
|
184
|
+
return new RiskManager(options);
|
|
185
|
+
}
|