tradelab 0.5.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.
Files changed (54) hide show
  1. package/README.md +89 -41
  2. package/bin/tradelab.js +276 -30
  3. package/dist/cjs/data.cjs +134 -104
  4. package/dist/cjs/index.cjs +378 -177
  5. package/dist/cjs/live.cjs +3350 -0
  6. package/docs/README.md +21 -9
  7. package/docs/api-reference.md +87 -29
  8. package/docs/backtest-engine.md +37 -53
  9. package/docs/data-reporting-cli.md +60 -34
  10. package/docs/examples.md +6 -12
  11. package/docs/live-trading.md +186 -0
  12. package/examples/yahooEmaCross.js +1 -6
  13. package/package.json +18 -3
  14. package/src/data/csv.js +24 -14
  15. package/src/data/index.js +1 -5
  16. package/src/data/yahoo.js +6 -19
  17. package/src/engine/backtest.js +137 -144
  18. package/src/engine/backtestTicks.js +89 -37
  19. package/src/engine/barSystemRunner.js +182 -118
  20. package/src/engine/execution.js +11 -39
  21. package/src/engine/portfolio.js +54 -6
  22. package/src/engine/walkForward.js +37 -14
  23. package/src/index.js +2 -11
  24. package/src/live/broker/alpaca.js +254 -0
  25. package/src/live/broker/binance.js +351 -0
  26. package/src/live/broker/coinbase.js +339 -0
  27. package/src/live/broker/interactiveBrokers.js +123 -0
  28. package/src/live/broker/interface.js +74 -0
  29. package/src/live/clock.js +56 -0
  30. package/src/live/engine/candleAggregator.js +154 -0
  31. package/src/live/engine/liveEngine.js +694 -0
  32. package/src/live/engine/paperEngine.js +453 -0
  33. package/src/live/engine/riskManager.js +185 -0
  34. package/src/live/engine/stateManager.js +112 -0
  35. package/src/live/events.js +48 -0
  36. package/src/live/feed/brokerFeed.js +35 -0
  37. package/src/live/feed/interface.js +28 -0
  38. package/src/live/feed/pollingFeed.js +105 -0
  39. package/src/live/index.js +27 -0
  40. package/src/live/logger.js +82 -0
  41. package/src/live/orchestrator.js +133 -0
  42. package/src/live/storage/interface.js +36 -0
  43. package/src/live/storage/jsonFileStorage.js +112 -0
  44. package/src/metrics/buildMetrics.js +18 -41
  45. package/src/reporting/exportBacktestArtifacts.js +1 -4
  46. package/src/reporting/exportTradesCsv.js +2 -7
  47. package/src/reporting/renderHtmlReport.js +8 -13
  48. package/src/utils/indicators.js +1 -2
  49. package/src/utils/positionSizing.js +16 -2
  50. package/src/utils/time.js +4 -12
  51. package/templates/report.html +23 -9
  52. package/templates/report.js +83 -69
  53. package/types/index.d.ts +21 -3
  54. package/types/live.d.ts +382 -0
@@ -0,0 +1,694 @@
1
+ import { calculatePositionSize } from "../../utils/positionSizing.js";
2
+ import { normalizeCandles } from "../../data/csv.js";
3
+ import { isEODBar, ocoExitCheck } from "../../engine/execution.js";
4
+ import {
5
+ callSignalWithContext,
6
+ normalizeSignal,
7
+ snapshotOpenPosition,
8
+ } from "../../engine/barSystemRunner.js";
9
+ import { BrokerClock } from "../clock.js";
10
+ import { EventBus } from "../events.js";
11
+ import { BrokerFeed } from "../feed/brokerFeed.js";
12
+ import { PollingFeed } from "../feed/pollingFeed.js";
13
+ import { RiskManager } from "./riskManager.js";
14
+ import { StateManager } from "./stateManager.js";
15
+ import { JsonFileStorage } from "../storage/jsonFileStorage.js";
16
+
17
+ function asNumber(value, fallback = null) {
18
+ const numeric = Number(value);
19
+ return Number.isFinite(numeric) ? numeric : fallback;
20
+ }
21
+
22
+ function oppositeSide(side) {
23
+ return side === "long" ? "sell" : "buy";
24
+ }
25
+
26
+ function nowIso() {
27
+ return new Date().toISOString();
28
+ }
29
+
30
+ function matchesPendingOrder(pendingOrder, order) {
31
+ if (!pendingOrder || !order) return false;
32
+ if (order.orderId && pendingOrder.orderId && order.orderId === pendingOrder.orderId) return true;
33
+ if (
34
+ order.clientOrderId &&
35
+ pendingOrder.clientOrderId &&
36
+ order.clientOrderId === pendingOrder.clientOrderId
37
+ ) {
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+
43
+ function isOrderForSymbol(order, symbol) {
44
+ return !order?.symbol || order.symbol === symbol;
45
+ }
46
+
47
+ /**
48
+ * Bar-driven live engine that reuses the same signal contract as backtest.
49
+ */
50
+ export class LiveEngine {
51
+ constructor(options = {}) {
52
+ if (typeof options.signal !== "function") {
53
+ throw new Error(`liveEngine requires a signal function, got ${typeof options.signal}`);
54
+ }
55
+ if (!options.broker) {
56
+ throw new Error("liveEngine requires a broker adapter");
57
+ }
58
+ if (!options.symbol) {
59
+ throw new Error("liveEngine requires symbol");
60
+ }
61
+
62
+ this.options = {
63
+ interval: "1m",
64
+ mode: "streaming",
65
+ pollIntervalMs: 60_000,
66
+ warmupBars: 200,
67
+ equity: 10_000,
68
+ riskPct: 1,
69
+ finalTP_R: 3,
70
+ flattenAtClose: false,
71
+ qtyStep: 0.001,
72
+ minQty: 0.001,
73
+ maxLeverage: 2,
74
+ dailyMaxTrades: 0,
75
+ entryChase: {
76
+ enabled: true,
77
+ afterBars: 2,
78
+ maxSlipR: 0.2,
79
+ convertOnExpiry: false,
80
+ },
81
+ logLevel: "info",
82
+ ...options,
83
+ };
84
+
85
+ this.symbol = this.options.symbol;
86
+ this.interval = this.options.interval;
87
+ this.namespace =
88
+ this.options.id || `${this.symbol}-${this.interval}`.replace(/[^a-zA-Z0-9._-]/g, "_");
89
+ this.broker = this.options.broker;
90
+ this.feed =
91
+ this.options.feed ||
92
+ (this.options.mode === "polling"
93
+ ? new PollingFeed({
94
+ broker: this.broker,
95
+ pollIntervalMs: this.options.pollIntervalMs,
96
+ })
97
+ : new BrokerFeed({ broker: this.broker }));
98
+ this.eventBus = this.options.eventBus || new EventBus();
99
+ this.storage = this.options.storage || new JsonFileStorage();
100
+ this.stateManager = new StateManager({ storage: this.storage });
101
+ this.riskManager = new RiskManager({
102
+ maxDailyLossPct: this.options.maxDailyLossPct,
103
+ maxDailyTrades: this.options.dailyMaxTrades,
104
+ ...(this.options.risk || {}),
105
+ });
106
+ this.clock = new BrokerClock();
107
+
108
+ this.running = false;
109
+ this.connected = false;
110
+ this.subscriptions = [];
111
+ this.candleBuffer = [];
112
+ this.lastBarTime = null;
113
+ this.openPosition = null;
114
+ this.pendingOrder = null;
115
+ this.tradeIdCounter = 0;
116
+ this.trades = [];
117
+ this.eqSeries = [];
118
+ this.equity = this.options.equity;
119
+ this.dayPnl = 0;
120
+ this.dayTrades = 0;
121
+ this.startedAt = null;
122
+
123
+ this._boundOrderSubmitted = (payload) => this._forwardBrokerEvent("order:submitted", payload);
124
+ this._boundOrderFilled = (payload) => this._handleOrderFilled(payload);
125
+ this._boundOrderCanceled = (payload) => this._handleOrderCanceled(payload);
126
+ this._boundOrderRejected = (payload) => this._handleOrderRejected(payload);
127
+ this._boundOrderModified = (payload) => this._forwardBrokerEvent("order:modified", payload);
128
+ }
129
+
130
+ _emit(event, payload = {}) {
131
+ this.eventBus.emitEvent(event, payload);
132
+ }
133
+
134
+ _forwardBrokerEvent(event, payload = {}) {
135
+ if (!isOrderForSymbol(payload, this.symbol)) return;
136
+ this._emit(event, { ...payload, symbol: payload.symbol || this.symbol });
137
+ }
138
+
139
+ _attachBrokerListeners() {
140
+ this.broker.on("order:submitted", this._boundOrderSubmitted);
141
+ this.broker.on("order:filled", this._boundOrderFilled);
142
+ this.broker.on("order:canceled", this._boundOrderCanceled);
143
+ this.broker.on("order:rejected", this._boundOrderRejected);
144
+ this.broker.on("order:modified", this._boundOrderModified);
145
+ }
146
+
147
+ _detachBrokerListeners() {
148
+ this.broker.off("order:submitted", this._boundOrderSubmitted);
149
+ this.broker.off("order:filled", this._boundOrderFilled);
150
+ this.broker.off("order:canceled", this._boundOrderCanceled);
151
+ this.broker.off("order:rejected", this._boundOrderRejected);
152
+ this.broker.off("order:modified", this._boundOrderModified);
153
+ }
154
+
155
+ _appendBar(bar) {
156
+ this.candleBuffer.push(bar);
157
+ const maxSize = Math.max(10, Number(this.options.warmupBars || 200) + 100);
158
+ if (this.candleBuffer.length > maxSize) {
159
+ this.candleBuffer.splice(0, this.candleBuffer.length - maxSize);
160
+ }
161
+ this.lastBarTime = bar.time;
162
+ }
163
+
164
+ _currentMarkPrice(defaultPrice = null) {
165
+ return this.candleBuffer.length
166
+ ? this.candleBuffer[this.candleBuffer.length - 1].close
167
+ : defaultPrice;
168
+ }
169
+
170
+ _markedEquity(markPrice = null) {
171
+ if (!this.openPosition) return this.equity;
172
+ const mark = Number.isFinite(markPrice)
173
+ ? markPrice
174
+ : this._currentMarkPrice(this.openPosition.entryFill);
175
+ const direction = this.openPosition.side === "long" ? 1 : -1;
176
+ return this.equity + (mark - this.openPosition.entryFill) * direction * this.openPosition.size;
177
+ }
178
+
179
+ _signalContext(bar) {
180
+ const markEquity = this._markedEquity(bar.close);
181
+ return {
182
+ candles: this.candleBuffer,
183
+ index: this.candleBuffer.length - 1,
184
+ bar,
185
+ equity: markEquity,
186
+ openPosition: this.openPosition ? snapshotOpenPosition(this.openPosition, bar.close) : null,
187
+ pendingOrder: this.pendingOrder,
188
+ };
189
+ }
190
+
191
+ async _persistState() {
192
+ await this.stateManager.save(this.namespace, {
193
+ openPosition: this.openPosition,
194
+ pendingOrder: this.pendingOrder,
195
+ equity: this.equity,
196
+ candleBuffer: this.candleBuffer,
197
+ strategyState: {},
198
+ lastBarTime: this.lastBarTime,
199
+ dayPnl: this.dayPnl,
200
+ dayTrades: this.dayTrades,
201
+ tradeIdCounter: this.tradeIdCounter,
202
+ savedAt: Date.now(),
203
+ });
204
+ }
205
+
206
+ async _recordEquity(timeMs, markPrice) {
207
+ const point = {
208
+ time: timeMs,
209
+ timestamp: timeMs,
210
+ equity: this._markedEquity(markPrice),
211
+ };
212
+ this.eqSeries.push(point);
213
+ await this.stateManager.appendEquityPoint(this.namespace, point);
214
+ this._emit("equity:update", {
215
+ symbol: this.symbol,
216
+ equity: point.equity,
217
+ time: point.time,
218
+ });
219
+ }
220
+
221
+ async _submitEntry(signalDecision, { hasExplicitEntry }) {
222
+ const riskFraction = Number.isFinite(signalDecision.riskFraction)
223
+ ? signalDecision.riskFraction
224
+ : Number.isFinite(signalDecision.riskPct)
225
+ ? signalDecision.riskPct / 100
226
+ : this.options.riskPct / 100;
227
+
228
+ const requestedSize = Number.isFinite(signalDecision.qty)
229
+ ? signalDecision.qty
230
+ : calculatePositionSize({
231
+ equity: this._markedEquity(signalDecision.entry),
232
+ entry: signalDecision.entry,
233
+ stop: signalDecision.stop,
234
+ riskFraction,
235
+ qtyStep: this.options.qtyStep,
236
+ minQty: this.options.minQty,
237
+ maxLeverage: this.options.maxLeverage,
238
+ });
239
+ if (!(requestedSize >= this.options.minQty)) return;
240
+
241
+ const positionValue = Math.abs(signalDecision.entry * requestedSize);
242
+ const canOpen = this.riskManager.canOpenPosition({
243
+ timeMs: this.lastBarTime || Date.now(),
244
+ positionCount: this.openPosition ? 1 : 0,
245
+ positionValue,
246
+ equity: this._markedEquity(signalDecision.entry),
247
+ });
248
+ if (!canOpen.ok) {
249
+ this._emit("risk:warning", { symbol: this.symbol, reason: canOpen.reason });
250
+ return;
251
+ }
252
+
253
+ const side = signalDecision.side === "long" ? "buy" : "sell";
254
+ const orderType = hasExplicitEntry ? "limit" : "market";
255
+ const clientOrderId = `${this.namespace}-entry-${Date.now()}`;
256
+ const expiryBars = signalDecision._entryExpiryBars ?? 5;
257
+ this.pendingOrder = {
258
+ side: signalDecision.side,
259
+ entry: signalDecision.entry,
260
+ stop: signalDecision.stop,
261
+ tp: signalDecision.takeProfit,
262
+ riskFrac: riskFraction,
263
+ fixedQty: signalDecision.qty ?? requestedSize,
264
+ expiresAt: this.candleBuffer.length - 1 + Math.max(1, expiryBars),
265
+ startedAtIndex: this.candleBuffer.length - 1,
266
+ meta: signalDecision,
267
+ plannedRiskAbs: Math.abs(
268
+ signalDecision._initRisk ?? signalDecision.entry - signalDecision.stop
269
+ ),
270
+ orderId: null,
271
+ clientOrderId,
272
+ type: orderType,
273
+ _chasedCE: false,
274
+ };
275
+
276
+ const receipt = await this.broker.submitOrder({
277
+ symbol: this.symbol,
278
+ side,
279
+ type: orderType,
280
+ qty: requestedSize,
281
+ limitPrice: orderType === "limit" ? signalDecision.entry : undefined,
282
+ clientOrderId,
283
+ });
284
+ if (!this.pendingOrder) return;
285
+ this.pendingOrder.orderId = receipt.orderId || this.pendingOrder.orderId;
286
+ if (receipt.clientOrderId) this.pendingOrder.clientOrderId = receipt.clientOrderId;
287
+ await this._persistState();
288
+ if (receipt.status === "filled") {
289
+ await this._handleOrderFilled(receipt);
290
+ }
291
+ }
292
+
293
+ async _submitExit(reason, priceHint, kind = "market") {
294
+ if (!this.openPosition) return;
295
+ this.openPosition._pendingExitReason = reason;
296
+ this.openPosition._pendingExitPriceHint = priceHint;
297
+ const receipt = await this.broker.submitOrder({
298
+ symbol: this.symbol,
299
+ side: oppositeSide(this.openPosition.side),
300
+ type: kind,
301
+ qty: this.openPosition.size,
302
+ limitPrice: kind === "limit" ? priceHint : undefined,
303
+ stopPrice: kind === "stop" ? priceHint : undefined,
304
+ clientOrderId: `${this.namespace}-exit-${Date.now()}`,
305
+ });
306
+ if (
307
+ receipt.status === "filled" &&
308
+ this.openPosition &&
309
+ isOrderForSymbol(receipt, this.symbol)
310
+ ) {
311
+ await this._handleOrderFilled(receipt);
312
+ }
313
+ await this._persistState();
314
+ }
315
+
316
+ async _managePending(_bar) {
317
+ if (!this.pendingOrder) return;
318
+ const index = this.candleBuffer.length - 1;
319
+
320
+ if (index > this.pendingOrder.expiresAt) {
321
+ if (this.pendingOrder.orderId) {
322
+ await this.broker.cancelOrder(this.pendingOrder.orderId).catch(() => {});
323
+ }
324
+ this.pendingOrder = null;
325
+ await this._persistState();
326
+ return;
327
+ }
328
+
329
+ if (this.options.entryChase?.enabled) {
330
+ const elapsedBars = index - (this.pendingOrder.startedAtIndex ?? index);
331
+ const midpoint = asNumber(this.pendingOrder.meta?._imb?.mid);
332
+ if (
333
+ midpoint !== null &&
334
+ !this.pendingOrder._chasedCE &&
335
+ elapsedBars >= Math.max(1, this.options.entryChase.afterBars || 2) &&
336
+ this.pendingOrder.orderId
337
+ ) {
338
+ await this.broker
339
+ .modifyOrder(this.pendingOrder.orderId, { limitPrice: midpoint })
340
+ .catch(() => {});
341
+ this.pendingOrder.entry = midpoint;
342
+ this.pendingOrder._chasedCE = true;
343
+ await this._persistState();
344
+ }
345
+ }
346
+ }
347
+
348
+ async _manageOpenPosition(bar) {
349
+ if (!this.openPosition) return;
350
+
351
+ if (this.options.flattenAtClose && isEODBar(bar.time)) {
352
+ await this._submitExit("EOD", bar.close);
353
+ return;
354
+ }
355
+
356
+ const barsHeld = this.candleBuffer.length - (this.openPosition._openedAtIndex ?? 0);
357
+ if (
358
+ Number.isFinite(this.openPosition._maxBarsInTrade) &&
359
+ this.openPosition._maxBarsInTrade > 0 &&
360
+ barsHeld >= this.openPosition._maxBarsInTrade
361
+ ) {
362
+ await this._submitExit("TIME", bar.close);
363
+ return;
364
+ }
365
+
366
+ const { hit, px } = ocoExitCheck({
367
+ side: this.openPosition.side,
368
+ stop: this.openPosition.stop,
369
+ tp: this.openPosition.takeProfit,
370
+ bar,
371
+ mode: this.options.oco?.mode || "intrabar",
372
+ tieBreak: this.options.oco?.tieBreak || "pessimistic",
373
+ });
374
+ if (hit) {
375
+ const kind = hit === "TP" ? "limit" : "stop";
376
+ await this._submitExit(hit, px, kind);
377
+ }
378
+ }
379
+
380
+ async _handleOrderFilled(order) {
381
+ if (!isOrderForSymbol(order, this.symbol)) return;
382
+ this._emit("order:filled", { symbol: this.symbol, ...order });
383
+ const pendingMatches = matchesPendingOrder(this.pendingOrder, order);
384
+ if (pendingMatches) {
385
+ const entryFill = asNumber(order.avgFillPrice, this.pendingOrder.entry);
386
+ this.openPosition = {
387
+ id: ++this.tradeIdCounter,
388
+ symbol: this.symbol,
389
+ side: this.pendingOrder.side,
390
+ entry: this.pendingOrder.entry,
391
+ entryFill,
392
+ stop: this.pendingOrder.stop,
393
+ takeProfit: this.pendingOrder.tp,
394
+ size: Number(order.filledQty || this.pendingOrder.fixedQty || 0),
395
+ openTime: asNumber(order.filledAt, this.lastBarTime || Date.now()),
396
+ _initRisk: Math.abs(
397
+ this.pendingOrder.meta?._initRisk ?? this.pendingOrder.entry - this.pendingOrder.stop
398
+ ),
399
+ _maxBarsInTrade: this.pendingOrder.meta?._maxBarsInTrade,
400
+ _maxHoldMin: this.pendingOrder.meta?._maxHoldMin,
401
+ _openedAtIndex: this.candleBuffer.length - 1,
402
+ };
403
+ this.pendingOrder = null;
404
+ this.dayTrades += 1;
405
+ this._emit("position:opened", {
406
+ symbol: this.symbol,
407
+ position: snapshotOpenPosition(this.openPosition, entryFill),
408
+ });
409
+ await this._persistState();
410
+ return;
411
+ }
412
+
413
+ if (this.openPosition && order.side === oppositeSide(this.openPosition.side)) {
414
+ const closingPosition = this.openPosition;
415
+ const exitPrice = asNumber(
416
+ order.avgFillPrice,
417
+ closingPosition._pendingExitPriceHint ?? this._currentMarkPrice(closingPosition.entryFill)
418
+ );
419
+ const direction = closingPosition.side === "long" ? 1 : -1;
420
+ const qty = Number(order.filledQty || closingPosition.size || 0);
421
+ const pnl = (exitPrice - closingPosition.entryFill) * direction * qty;
422
+ this.equity += pnl;
423
+ this.dayPnl += pnl;
424
+ this.openPosition = null;
425
+ this.riskManager.recordTrade({
426
+ pnl,
427
+ timeMs: asNumber(order.filledAt, Date.now()),
428
+ equity: this.equity,
429
+ });
430
+ const trade = {
431
+ symbol: this.symbol,
432
+ id: closingPosition.id,
433
+ side: closingPosition.side,
434
+ entry: closingPosition.entry,
435
+ stop: closingPosition.stop,
436
+ takeProfit: closingPosition.takeProfit,
437
+ size: qty,
438
+ openTime: closingPosition.openTime,
439
+ entryFill: closingPosition.entryFill,
440
+ _initRisk: closingPosition._initRisk,
441
+ exit: {
442
+ price: exitPrice,
443
+ time: asNumber(order.filledAt, Date.now()),
444
+ reason: closingPosition._pendingExitReason || "EXIT",
445
+ pnl,
446
+ },
447
+ };
448
+ this.trades.push(trade);
449
+ await this.stateManager.appendTrade(this.namespace, trade);
450
+ this._emit("position:closed", {
451
+ symbol: this.symbol,
452
+ trade,
453
+ });
454
+ await this._persistState();
455
+ }
456
+ }
457
+
458
+ async _handleOrderCanceled(order) {
459
+ if (!isOrderForSymbol(order, this.symbol)) return;
460
+ this._emit("order:canceled", { symbol: this.symbol, ...order });
461
+ const pendingMatches = matchesPendingOrder(this.pendingOrder, order);
462
+ if (pendingMatches) {
463
+ this.pendingOrder = null;
464
+ await this._persistState();
465
+ }
466
+ }
467
+
468
+ async _handleOrderRejected(order) {
469
+ if (!isOrderForSymbol(order, this.symbol)) return;
470
+ this._emit("order:rejected", { symbol: this.symbol, ...order });
471
+ const pendingMatches = matchesPendingOrder(this.pendingOrder, order);
472
+ if (pendingMatches) {
473
+ this.pendingOrder = null;
474
+ await this._persistState();
475
+ }
476
+ }
477
+
478
+ async handleBar(rawBar) {
479
+ const normalized = normalizeCandles([rawBar]);
480
+ const bar = normalized[0];
481
+ if (!bar) return;
482
+ if (Number.isFinite(this.lastBarTime) && bar.time <= this.lastBarTime) return;
483
+ if (!this.running) return;
484
+
485
+ this._appendBar(bar);
486
+ this._emit("bar", { symbol: this.symbol, bar });
487
+ this.riskManager.update({
488
+ timeMs: bar.time,
489
+ equity: this._markedEquity(bar.close),
490
+ });
491
+
492
+ if (this.openPosition) {
493
+ await this._manageOpenPosition(bar);
494
+ }
495
+
496
+ if (this.pendingOrder) {
497
+ await this._managePending(bar);
498
+ }
499
+
500
+ const canTrade = this.riskManager.canTrade({ timeMs: bar.time });
501
+ if (!canTrade.ok && this.pendingOrder) {
502
+ if (this.pendingOrder.orderId) {
503
+ await this.broker.cancelOrder(this.pendingOrder.orderId).catch(() => {});
504
+ }
505
+ this.pendingOrder = null;
506
+ await this._persistState();
507
+ }
508
+ if (!canTrade.ok) {
509
+ this._emit("risk:halt", { symbol: this.symbol, reason: canTrade.reason });
510
+ await this._recordEquity(bar.time, bar.close);
511
+ return;
512
+ }
513
+
514
+ if (!this.openPosition && !this.pendingOrder) {
515
+ const context = this._signalContext(bar);
516
+ const rawSignal = callSignalWithContext({
517
+ signal: this.options.signal,
518
+ context,
519
+ index: context.index,
520
+ bar,
521
+ symbol: this.symbol,
522
+ });
523
+ if (rawSignal) {
524
+ this._emit("signal", {
525
+ symbol: this.symbol,
526
+ t: nowIso(),
527
+ signal: rawSignal,
528
+ });
529
+ }
530
+ const nextSignal = normalizeSignal(rawSignal, bar, this.options.finalTP_R);
531
+ if (nextSignal) {
532
+ const hasExplicitEntry =
533
+ rawSignal?.entry !== undefined ||
534
+ rawSignal?.limit !== undefined ||
535
+ rawSignal?.price !== undefined;
536
+ await this._submitEntry(nextSignal, { hasExplicitEntry });
537
+ }
538
+ }
539
+
540
+ await this._recordEquity(bar.time, bar.close);
541
+ }
542
+
543
+ async pollOnce() {
544
+ if (typeof this.feed.pollOnce === "function") {
545
+ await this.feed.pollOnce();
546
+ return;
547
+ }
548
+ const bars = await this.feed.getHistoricalBars(this.symbol, this.interval, 2);
549
+ const ordered = [...bars].sort((left, right) => left.time - right.time);
550
+ for (const bar of ordered) {
551
+ await this.handleBar(bar);
552
+ }
553
+ }
554
+
555
+ async start() {
556
+ if (this.running) return;
557
+
558
+ if (!(typeof this.broker.isConnected === "function" && this.broker.isConnected())) {
559
+ await this.broker.connect(this.options.brokerConfig || {});
560
+ }
561
+ await this.feed.connect();
562
+ this._attachBrokerListeners();
563
+
564
+ const clock = await this.clock.syncWithBroker(this.broker);
565
+ if (clock.warning) {
566
+ this._emit("risk:warning", {
567
+ symbol: this.symbol,
568
+ reason: clock.warning,
569
+ });
570
+ }
571
+
572
+ if (this.options.useBrokerAccountEquity !== false) {
573
+ try {
574
+ const account = await this.broker.getAccount();
575
+ if (Number.isFinite(account?.equity) && account.equity > 0) {
576
+ this.equity = account.equity;
577
+ }
578
+ } catch {
579
+ this.equity = this.options.equity;
580
+ }
581
+ }
582
+
583
+ const persisted = await this.stateManager.load(this.namespace);
584
+ if (persisted) {
585
+ this.openPosition = persisted.openPosition || null;
586
+ this.pendingOrder = persisted.pendingOrder || null;
587
+ this.equity = Number.isFinite(persisted.equity) ? persisted.equity : this.equity;
588
+ this.candleBuffer = Array.isArray(persisted.candleBuffer) ? persisted.candleBuffer : [];
589
+ this.lastBarTime = Number.isFinite(persisted.lastBarTime) ? persisted.lastBarTime : null;
590
+ this.dayPnl = Number.isFinite(persisted.dayPnl) ? persisted.dayPnl : 0;
591
+ this.dayTrades = Number.isFinite(persisted.dayTrades) ? persisted.dayTrades : 0;
592
+ this.tradeIdCounter = Number.isFinite(persisted.tradeIdCounter)
593
+ ? persisted.tradeIdCounter
594
+ : 0;
595
+ this._emit("stateRestored", { symbol: this.symbol, namespace: this.namespace });
596
+ }
597
+
598
+ const warmup = await this.feed.getHistoricalBars(
599
+ this.symbol,
600
+ this.interval,
601
+ Math.max(1, this.options.warmupBars)
602
+ );
603
+ const normalizedWarmup = normalizeCandles(warmup || []);
604
+ for (const bar of normalizedWarmup) {
605
+ if (this.lastBarTime !== null && bar.time <= this.lastBarTime) continue;
606
+ this._appendBar(bar);
607
+ }
608
+
609
+ const reconcile = this.stateManager.reconcile({
610
+ persistedState: persisted,
611
+ brokerPositions: await this.broker.getPositions().catch(() => []),
612
+ symbol: this.symbol,
613
+ });
614
+ if (reconcile.action === "adopt-broker" && reconcile.adoptedPosition) {
615
+ this.openPosition = {
616
+ ...this.openPosition,
617
+ ...reconcile.adoptedPosition,
618
+ };
619
+ }
620
+ if (reconcile.action === "mismatch") {
621
+ this.riskManager.halt("position mismatch on restart");
622
+ }
623
+ this._emit("reconciled", { symbol: this.symbol, reconcile });
624
+
625
+ this.riskManager.initialize(this.equity, this.lastBarTime || Date.now());
626
+ if (this.dayTrades > 0 || this.dayPnl !== 0) {
627
+ this.riskManager.dayTrades = this.dayTrades;
628
+ this.riskManager.dayPnl = this.dayPnl;
629
+ }
630
+
631
+ const subscription = await this.feed.subscribeBars(this.symbol, this.interval, async (bar) => {
632
+ await this.handleBar(bar);
633
+ });
634
+ this.subscriptions.push(subscription);
635
+ if (this.options.mode === "polling" && typeof this.feed.startPolling === "function") {
636
+ this.feed.startPolling();
637
+ }
638
+
639
+ this.startedAt = Date.now();
640
+ this.connected = true;
641
+ this.running = true;
642
+ this._emit("connected", { symbol: this.symbol, namespace: this.namespace });
643
+ await this._persistState();
644
+ }
645
+
646
+ async stop({ flattenOnShutdown = false } = {}) {
647
+ if (!this.connected) return;
648
+ if (flattenOnShutdown && this.openPosition) {
649
+ await this._submitExit("SHUTDOWN", this._currentMarkPrice(this.openPosition.entryFill));
650
+ }
651
+ if (typeof this.feed.stopPolling === "function") {
652
+ this.feed.stopPolling();
653
+ }
654
+ for (const subscription of this.subscriptions) {
655
+ if (subscription && typeof subscription.unsubscribe === "function") {
656
+ subscription.unsubscribe();
657
+ }
658
+ }
659
+ this.subscriptions = [];
660
+ await this._persistState();
661
+ await this.feed.disconnect();
662
+ await this.broker.disconnect();
663
+ this._detachBrokerListeners();
664
+ this.running = false;
665
+ this.connected = false;
666
+ this._emit("shutdown", { symbol: this.symbol, namespace: this.namespace });
667
+ }
668
+
669
+ getStatus() {
670
+ return {
671
+ id: this.namespace,
672
+ symbol: this.symbol,
673
+ interval: this.interval,
674
+ running: this.running,
675
+ connected: this.connected,
676
+ startedAt: this.startedAt,
677
+ lastBarTime: this.lastBarTime,
678
+ equity: this._markedEquity(),
679
+ realizedEquity: this.equity,
680
+ openPosition: this.openPosition
681
+ ? snapshotOpenPosition(this.openPosition, this._currentMarkPrice())
682
+ : null,
683
+ pendingOrder: this.pendingOrder,
684
+ dayPnl: this.dayPnl,
685
+ dayTrades: this.dayTrades,
686
+ trades: this.trades.length,
687
+ risk: this.riskManager.getState(),
688
+ };
689
+ }
690
+ }
691
+
692
+ export function createLiveEngine(options) {
693
+ return new LiveEngine(options);
694
+ }