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
@@ -34,7 +34,7 @@ function strictHistoryView(candles, currentIndex) {
34
34
  get(target, property, receiver) {
35
35
  if (isArrayIndexKey(property) && Number(property) >= target.length) {
36
36
  throw new Error(
37
- `strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
37
+ `strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
38
38
  );
39
39
  }
40
40
  return Reflect.get(target, property, receiver);
@@ -42,20 +42,61 @@ function strictHistoryView(candles, currentIndex) {
42
42
  });
43
43
  }
44
44
 
45
- function normalizeSide(value) {
45
+ function describeValue(value) {
46
+ if (Array.isArray(value)) return `array(length=${value.length})`;
47
+ if (value === null) return "null";
48
+ return typeof value;
49
+ }
50
+
51
+ function formatIsoTime(time) {
52
+ return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
53
+ }
54
+
55
+ export function callSignalWithContext({ signal, context, index, bar, symbol }) {
56
+ try {
57
+ return signal(context);
58
+ } catch (error) {
59
+ const cause = error instanceof Error ? error.message : String(error);
60
+ throw new Error(
61
+ `signal() threw at index=${index}, time=${formatIsoTime(bar?.time)}, symbol=${symbol}: ${cause}`
62
+ );
63
+ }
64
+ }
65
+
66
+ export function snapshotOpenPosition(open, markPrice) {
67
+ if (!open) return null;
68
+ const entryPrice = open.entryFill ?? open.entry;
69
+ const direction = open.side === "long" ? 1 : -1;
70
+ const unrealizedPnl = (markPrice - entryPrice) * direction * open.size;
71
+ return {
72
+ id: open.id,
73
+ symbol: open.symbol,
74
+ side: open.side,
75
+ size: open.size,
76
+ entry: open.entry,
77
+ entryFill: open.entryFill,
78
+ stop: open.stop,
79
+ takeProfit: open.takeProfit,
80
+ openTime: open.openTime,
81
+ markPrice,
82
+ unrealizedPnl,
83
+ _initRisk: open._initRisk,
84
+ };
85
+ }
86
+
87
+ export function normalizeSide(value) {
46
88
  if (value === "long" || value === "buy") return "long";
47
89
  if (value === "short" || value === "sell") return "short";
48
90
  return null;
49
91
  }
50
92
 
51
- function normalizeSignal(signal, bar, fallbackR) {
93
+ export function normalizeSignal(signal, bar, fallbackR) {
52
94
  if (!signal) return null;
53
95
 
54
96
  const side = normalizeSide(signal.side ?? signal.direction ?? signal.action);
55
97
  if (!side) return null;
56
98
 
57
- const entry =
58
- asNumber(signal.entry ?? signal.limit ?? signal.price) ?? asNumber(bar?.close);
99
+ const entry = asNumber(signal.entry ?? signal.limit ?? signal.price) ?? asNumber(bar?.close);
59
100
  const stop = asNumber(signal.stop ?? signal.stopLoss ?? signal.sl);
60
101
  if (entry === null || stop === null) return null;
61
102
 
@@ -67,8 +108,7 @@ function normalizeSignal(signal, bar, fallbackR) {
67
108
  const targetR = rrHint ?? fallbackR;
68
109
 
69
110
  if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
70
- takeProfit =
71
- side === "long" ? entry + risk * targetR : entry - risk * targetR;
111
+ takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
72
112
  }
73
113
  if (takeProfit === null) return null;
74
114
 
@@ -86,7 +126,7 @@ function normalizeSignal(signal, bar, fallbackR) {
86
126
  };
87
127
  }
88
128
 
89
- function mergeOptions(options) {
129
+ export function mergeOptions(options) {
90
130
  const normalizedRiskPct = Number.isFinite(options.riskFraction)
91
131
  ? options.riskFraction * 100
92
132
  : options.riskPct;
@@ -160,7 +200,7 @@ function mergeOptions(options) {
160
200
  };
161
201
  }
162
202
 
163
- function capitalForSize(entryPrice, size, maxLeverage) {
203
+ export function capitalForSize(entryPrice, size, maxLeverage) {
164
204
  const leverage = Math.max(1, Number(maxLeverage) || 1);
165
205
  return (Math.abs(entryPrice) * Math.max(0, size)) / leverage;
166
206
  }
@@ -171,10 +211,18 @@ export class BarSystemRunner {
171
211
  const { candles, signal } = this.options;
172
212
 
173
213
  if (!Array.isArray(candles) || candles.length === 0) {
174
- throw new Error("backtestPortfolio() requires each system to include non-empty candles");
214
+ throw new Error(
215
+ `backtestPortfolio() requires each system to include non-empty candles, got ${describeValue(
216
+ candles
217
+ )} for ${this.options.symbol}`
218
+ );
175
219
  }
176
220
  if (typeof signal !== "function") {
177
- throw new Error("backtestPortfolio() requires each system to include a signal function");
221
+ throw new Error(
222
+ `backtestPortfolio() requires each system to include a signal function, got ${describeValue(
223
+ signal
224
+ )} for ${this.options.symbol}`
225
+ );
178
226
  }
179
227
 
180
228
  this.symbol = this.options.symbol;
@@ -197,9 +245,7 @@ export class BarSystemRunner {
197
245
  this.atrValues = needAtr ? atr(candles, atrSourcePeriod) : null;
198
246
  this.wantEqSeries = Boolean(this.options.collectEqSeries);
199
247
  this.wantReplay = Boolean(this.options.collectReplay);
200
- this.eqSeries = this.wantEqSeries
201
- ? [equityPoint(candles[0].time, this.currentEquity)]
202
- : [];
248
+ this.eqSeries = this.wantEqSeries ? [equityPoint(candles[0].time, this.currentEquity)] : [];
203
249
  this.replayFrames = this.wantReplay ? [] : [];
204
250
  this.replayEvents = this.wantReplay ? [] : [];
205
251
  this.startIndex = Math.min(Math.max(1, this.options.warmupBars), candles.length);
@@ -218,7 +264,11 @@ export class BarSystemRunner {
218
264
 
219
265
  getLockedCapital() {
220
266
  if (!this.open) return 0;
221
- return capitalForSize(this.open.entryFill ?? this.open.entry, this.open.size, this.options.maxLeverage);
267
+ return capitalForSize(
268
+ this.open.entryFill ?? this.open.entry,
269
+ this.open.size,
270
+ this.options.maxLeverage
271
+ );
222
272
  }
223
273
 
224
274
  getMarkPrice() {
@@ -229,9 +279,7 @@ export class BarSystemRunner {
229
279
  if (!this.open || !this.lastBar) return this.currentEquity;
230
280
  const direction = this.open.side === "long" ? 1 : -1;
231
281
  const markPnl =
232
- (this.lastBar.close - (this.open.entryFill ?? this.open.entry)) *
233
- direction *
234
- this.open.size;
282
+ (this.lastBar.close - (this.open.entryFill ?? this.open.entry)) * direction * this.open.size;
235
283
  return this.currentEquity + markPnl;
236
284
  }
237
285
 
@@ -256,8 +304,7 @@ export class BarSystemRunner {
256
304
  const direction = openPos.side === "long" ? 1 : -1;
257
305
  const entryFill = openPos.entryFill;
258
306
  const grossPnl = (exitPx - entryFill) * direction * qty;
259
- const entryFeePortion =
260
- (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
307
+ const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
261
308
  const pnl = grossPnl - entryFeePortion - exitFeeTotal;
262
309
 
263
310
  this.currentEquity += pnl;
@@ -272,14 +319,14 @@ export class BarSystemRunner {
272
319
  reason === "SCALE"
273
320
  ? "scale-out"
274
321
  : reason === "TP"
275
- ? "tp"
276
- : reason === "SL"
277
- ? "sl"
278
- : reason === "EOD"
279
- ? "eod"
280
- : remaining <= 0
281
- ? "exit"
282
- : "scale-out";
322
+ ? "tp"
323
+ : reason === "SL"
324
+ ? "sl"
325
+ : reason === "EOD"
326
+ ? "eod"
327
+ : remaining <= 0
328
+ ? "exit"
329
+ : "scale-out";
283
330
 
284
331
  if (this.wantReplay) {
285
332
  this.replayEvents.push({
@@ -324,9 +371,7 @@ export class BarSystemRunner {
324
371
  const direction = openPos.side === "long" ? 1 : -1;
325
372
  const breakevenDelta = Math.abs(realized / openPos.size);
326
373
  const breakevenPrice =
327
- direction === 1
328
- ? openPos.entryFill - breakevenDelta
329
- : openPos.entryFill + breakevenDelta;
374
+ direction === 1 ? openPos.entryFill - breakevenDelta : openPos.entryFill + breakevenDelta;
330
375
 
331
376
  const tightened =
332
377
  direction === 1
@@ -381,10 +426,7 @@ export class BarSystemRunner {
381
426
  let stopPrice = this.pending.stop;
382
427
  if (this.options.reanchorStopOnFill) {
383
428
  const direction = this.pending.side === "long" ? 1 : -1;
384
- stopPrice =
385
- direction === 1
386
- ? entryPrice - plannedRisk
387
- : entryPrice + plannedRisk;
429
+ stopPrice = direction === 1 ? entryPrice - plannedRisk : entryPrice + plannedRisk;
388
430
  }
389
431
 
390
432
  let takeProfit = this.pending.tp;
@@ -420,30 +462,27 @@ export class BarSystemRunner {
420
462
  maxLeverage: this.options.maxLeverage,
421
463
  });
422
464
 
423
- const approvedSize = typeof resolveEntrySize === "function"
424
- ? resolveEntrySize({
425
- runner: this,
426
- desiredSize,
427
- entryPrice,
428
- stopPrice,
429
- pending: this.pending,
430
- fillKind,
431
- })
432
- : desiredSize;
465
+ const approvedSize =
466
+ typeof resolveEntrySize === "function"
467
+ ? resolveEntrySize({
468
+ runner: this,
469
+ desiredSize,
470
+ entryPrice,
471
+ stopPrice,
472
+ pending: this.pending,
473
+ fillKind,
474
+ })
475
+ : desiredSize;
433
476
  const size = roundStep(approvedSize, this.options.qtyStep);
434
477
  if (size < this.options.minQty) return false;
435
478
 
436
- const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
437
- entryPrice,
438
- this.pending.side,
439
- {
440
- slippageBps: this.options.slippageBps,
441
- feeBps: this.options.feeBps,
442
- kind: fillKind,
443
- qty: size,
444
- costs: this.options.costs,
445
- }
446
- );
479
+ const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, this.pending.side, {
480
+ slippageBps: this.options.slippageBps,
481
+ feeBps: this.options.feeBps,
482
+ kind: fillKind,
483
+ qty: size,
484
+ costs: this.options.costs,
485
+ });
447
486
 
448
487
  this.open = {
449
488
  symbol: this.symbol,
@@ -514,9 +553,7 @@ export class BarSystemRunner {
514
553
 
515
554
  const trigger = this.options.triggerMode || this.options.oco.mode || "intrabar";
516
555
  const dayKey =
517
- this.options.flattenAtClose || trigger === "close"
518
- ? dayKeyET(bar.time)
519
- : dayKeyUTC(bar.time);
556
+ this.options.flattenAtClose || trigger === "close" ? dayKeyET(bar.time) : dayKeyUTC(bar.time);
520
557
  if (this.currentDay === null || dayKey !== this.currentDay) {
521
558
  this.currentDay = dayKey;
522
559
  this.dayPnl = 0;
@@ -567,11 +604,7 @@ export class BarSystemRunner {
567
604
  this.open._mfeR = Math.max(this.open._mfeR ?? -Infinity, highR);
568
605
  this.open._maeR = Math.min(this.open._maeR ?? Infinity, lowR);
569
606
 
570
- if (
571
- this.open._breakevenAtR > 0 &&
572
- highR >= this.open._breakevenAtR &&
573
- !this.open._beArmed
574
- ) {
607
+ if (this.open._breakevenAtR > 0 && highR >= this.open._breakevenAtR && !this.open._beArmed) {
575
608
  const tightened =
576
609
  this.open.side === "long"
577
610
  ? Math.max(this.open.stop, this.open.entry)
@@ -583,8 +616,7 @@ export class BarSystemRunner {
583
616
  }
584
617
 
585
618
  if (this.open._trailAfterR > 0 && highR >= this.open._trailAfterR) {
586
- const candidate =
587
- this.open.side === "long" ? bar.close - risk : bar.close + risk;
619
+ const candidate = this.open.side === "long" ? bar.close - risk : bar.close + risk;
588
620
  const tightened =
589
621
  this.open.side === "long"
590
622
  ? Math.max(this.open.stop, candidate)
@@ -595,10 +627,7 @@ export class BarSystemRunner {
595
627
  }
596
628
 
597
629
  if (this.options.mfeTrail.enabled && this.open._mfeR >= this.options.mfeTrail.armR) {
598
- const targetR = Math.max(
599
- 0,
600
- this.open._mfeR - Math.max(0, this.options.mfeTrail.givebackR)
601
- );
630
+ const targetR = Math.max(0, this.open._mfeR - Math.max(0, this.options.mfeTrail.givebackR));
602
631
  const candidate =
603
632
  this.open.side === "long"
604
633
  ? this.open.entry + targetR * risk
@@ -612,12 +641,14 @@ export class BarSystemRunner {
612
641
  : tightened;
613
642
  }
614
643
 
615
- if (this.options.atrTrailMult > 0 && this.atrValues && this.atrValues[this.index] !== undefined) {
644
+ if (
645
+ this.options.atrTrailMult > 0 &&
646
+ this.atrValues &&
647
+ this.atrValues[this.index] !== undefined
648
+ ) {
616
649
  const trailDistance = this.atrValues[this.index] * this.options.atrTrailMult;
617
650
  const candidate =
618
- this.open.side === "long"
619
- ? bar.close - trailDistance
620
- : bar.close + trailDistance;
651
+ this.open.side === "long" ? bar.close - trailDistance : bar.close + trailDistance;
621
652
  const tightened =
622
653
  this.open.side === "long"
623
654
  ? Math.max(this.open.stop, candidate)
@@ -641,7 +672,10 @@ export class BarSystemRunner {
641
672
  !this.open._volCutDone;
642
673
 
643
674
  if (shouldCut) {
644
- const cutQty = roundStep(this.open.size * this.options.volScale.cutFrac, this.options.qtyStep);
675
+ const cutQty = roundStep(
676
+ this.open.size * this.options.volScale.cutFrac,
677
+ this.options.qtyStep
678
+ );
645
679
  if (cutQty >= this.options.minQty && cutQty < this.open.size) {
646
680
  const exitSide = this.open.side === "long" ? "short" : "long";
647
681
  const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide, {
@@ -666,7 +700,10 @@ export class BarSystemRunner {
666
700
  }
667
701
 
668
702
  let addedThisBar = false;
669
- if (this.options.pyramiding.enabled && (this.open._adds ?? 0) < this.options.pyramiding.maxAdds) {
703
+ if (
704
+ this.options.pyramiding.enabled &&
705
+ (this.open._adds ?? 0) < this.options.pyramiding.maxAdds
706
+ ) {
670
707
  const addNumber = (this.open._adds || 0) + 1;
671
708
  const triggerR = this.options.pyramiding.addAtR * addNumber;
672
709
  const triggerPrice =
@@ -683,37 +720,45 @@ export class BarSystemRunner {
683
720
  ? bar.high >= triggerPrice
684
721
  : bar.close >= triggerPrice
685
722
  : trigger === "intrabar"
686
- ? bar.low <= triggerPrice
687
- : bar.close <= triggerPrice;
723
+ ? bar.low <= triggerPrice
724
+ : bar.close <= triggerPrice;
688
725
 
689
726
  if (breakEvenSatisfied && touched) {
690
727
  const baseSize = this.open.baseSize || this.open.initSize;
691
- const requestedQty = roundStep(baseSize * this.options.pyramiding.addFrac, this.options.qtyStep);
692
- const addQty = typeof resolveEntrySize === "function"
693
- ? roundStep(
694
- resolveEntrySize({
695
- runner: this,
696
- desiredSize: requestedQty,
697
- entryPrice: triggerPrice,
698
- stopPrice: this.open.stop,
699
- pending: {
700
- side: this.open.side,
701
- meta: this.open,
702
- riskFrac: this.options.riskPct / 100,
703
- },
704
- fillKind: "limit",
705
- }),
706
- this.options.qtyStep
707
- )
708
- : requestedQty;
728
+ const requestedQty = roundStep(
729
+ baseSize * this.options.pyramiding.addFrac,
730
+ this.options.qtyStep
731
+ );
732
+ const addQty =
733
+ typeof resolveEntrySize === "function"
734
+ ? roundStep(
735
+ resolveEntrySize({
736
+ runner: this,
737
+ desiredSize: requestedQty,
738
+ entryPrice: triggerPrice,
739
+ stopPrice: this.open.stop,
740
+ pending: {
741
+ side: this.open.side,
742
+ meta: this.open,
743
+ riskFrac: this.options.riskPct / 100,
744
+ },
745
+ fillKind: "limit",
746
+ }),
747
+ this.options.qtyStep
748
+ )
749
+ : requestedQty;
709
750
  if (addQty >= this.options.minQty) {
710
- const { price: addFill, feeTotal: addFeeTotal } = applyFill(triggerPrice, this.open.side, {
711
- slippageBps: this.options.slippageBps,
712
- feeBps: this.options.feeBps,
713
- kind: "limit",
714
- qty: addQty,
715
- costs: this.options.costs,
716
- });
751
+ const { price: addFill, feeTotal: addFeeTotal } = applyFill(
752
+ triggerPrice,
753
+ this.open.side,
754
+ {
755
+ slippageBps: this.options.slippageBps,
756
+ feeBps: this.options.feeBps,
757
+ kind: "limit",
758
+ qty: addQty,
759
+ costs: this.options.costs,
760
+ }
761
+ );
717
762
  const newSize = this.open.size + addQty;
718
763
  this.open.entryFeeTotal += addFeeTotal;
719
764
  this.open.entryFill =
@@ -738,8 +783,8 @@ export class BarSystemRunner {
738
783
  ? bar.high >= triggerPrice
739
784
  : bar.close >= triggerPrice
740
785
  : trigger === "intrabar"
741
- ? bar.low <= triggerPrice
742
- : bar.close <= triggerPrice;
786
+ ? bar.low <= triggerPrice
787
+ : bar.close <= triggerPrice;
743
788
 
744
789
  if (touched) {
745
790
  const exitSide = this.open.side === "long" ? "short" : "long";
@@ -818,7 +863,7 @@ export class BarSystemRunner {
818
863
  } else if (this.index > this.pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
819
864
  if (this.options.entryChase.enabled && this.options.entryChase.convertOnExpiry) {
820
865
  const riskAtEdge = Math.abs(
821
- this.pending.meta._initRisk ?? (this.pending.entry - this.pending.stop)
866
+ this.pending.meta._initRisk ?? this.pending.entry - this.pending.stop
822
867
  );
823
868
  const priceNow = bar.close;
824
869
  const direction = this.pending.side === "long" ? 1 : -1;
@@ -830,28 +875,36 @@ export class BarSystemRunner {
830
875
 
831
876
  if (slippedR > this.options.maxSlipROnFill) {
832
877
  this.pending = null;
833
- } else if (!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)) {
878
+ } else if (
879
+ !this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)
880
+ ) {
834
881
  this.pending = null;
835
882
  }
836
883
  } else {
837
884
  this.pending = null;
838
885
  }
839
886
  } else if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
840
- if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
887
+ if (
888
+ !this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)
889
+ ) {
841
890
  this.pending = null;
842
891
  }
843
892
  } else if (this.options.entryChase.enabled) {
844
893
  const elapsedBars = this.index - (this.pending.startedAtIndex ?? this.index);
845
- const midpoint = this.pending.meta?._imb?.mid;
894
+ const midpoint = asNumber(this.pending.meta?._imb?.mid);
846
895
 
847
- if (!this.pending._chasedCE && midpoint !== undefined && elapsedBars >= Math.max(1, this.options.entryChase.afterBars)) {
896
+ if (
897
+ !this.pending._chasedCE &&
898
+ midpoint !== null &&
899
+ elapsedBars >= Math.max(1, this.options.entryChase.afterBars)
900
+ ) {
848
901
  this.pending.entry = midpoint;
849
902
  this.pending._chasedCE = true;
850
903
  }
851
904
 
852
905
  if (this.pending._chasedCE) {
853
906
  const riskRef = Math.abs(
854
- this.pending.meta?._initRisk ?? (this.pending.entry - this.pending.stop)
907
+ this.pending.meta?._initRisk ?? this.pending.entry - this.pending.stop
855
908
  );
856
909
  const priceNow = bar.close;
857
910
  const direction = this.pending.side === "long" ? 1 : -1;
@@ -887,15 +940,21 @@ export class BarSystemRunner {
887
940
  }
888
941
 
889
942
  if (!this.pending) {
890
- const rawSignal = this.options.signal(this.buildSignalContext(this.index, bar, signalEquity));
943
+ const rawSignal = callSignalWithContext({
944
+ signal: this.options.signal,
945
+ context: this.buildSignalContext(this.index, bar, signalEquity),
946
+ index: this.index,
947
+ bar,
948
+ symbol: this.symbol,
949
+ });
891
950
  const nextSignal = normalizeSignal(rawSignal, bar, this.options.finalTP_R);
892
951
 
893
952
  if (nextSignal) {
894
953
  const signalRiskFraction = Number.isFinite(nextSignal.riskFraction)
895
954
  ? nextSignal.riskFraction
896
955
  : Number.isFinite(nextSignal.riskPct)
897
- ? nextSignal.riskPct / 100
898
- : this.options.riskPct / 100;
956
+ ? nextSignal.riskPct / 100
957
+ : this.options.riskPct / 100;
899
958
  const expiryBars = nextSignal._entryExpiryBars ?? 5;
900
959
  this.pending = {
901
960
  side: nextSignal.side,
@@ -907,13 +966,13 @@ export class BarSystemRunner {
907
966
  expiresAt: this.index + Math.max(1, expiryBars),
908
967
  startedAtIndex: this.index,
909
968
  meta: nextSignal,
910
- plannedRiskAbs: Math.abs(
911
- nextSignal._initRisk ?? (nextSignal.entry - nextSignal.stop)
912
- ),
969
+ plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop),
913
970
  };
914
971
 
915
972
  if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
916
- if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
973
+ if (
974
+ !this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)
975
+ ) {
917
976
  this.pending = null;
918
977
  }
919
978
  }
@@ -935,6 +994,10 @@ export class BarSystemRunner {
935
994
  eqSeries: this.eqSeries,
936
995
  });
937
996
  const positions = this.closed.filter((trade) => trade.exit.reason !== "SCALE");
997
+ const lastPrice = asNumber(this.candles[this.candles.length - 1]?.close);
998
+ const openPositions = this.open
999
+ ? [snapshotOpenPosition(this.open, lastPrice ?? this.open.entryFill ?? this.open.entry)]
1000
+ : [];
938
1001
 
939
1002
  return {
940
1003
  symbol: this.options.symbol,
@@ -942,6 +1005,7 @@ export class BarSystemRunner {
942
1005
  range: this.options.range,
943
1006
  trades: this.closed,
944
1007
  positions,
1008
+ openPositions,
945
1009
  metrics,
946
1010
  eqSeries: this.eqSeries,
947
1011
  replay: {
@@ -17,20 +17,10 @@ export function applyFill(
17
17
  { slippageBps = 0, feeBps = 0, kind = "market", qty = 0, costs = {} } = {}
18
18
  ) {
19
19
  const model = costs || {};
20
- const modelSlippageBps = Number.isFinite(model.slippageBps)
21
- ? model.slippageBps
22
- : slippageBps;
23
- const modelFeeBps = Number.isFinite(model.commissionBps)
24
- ? model.commissionBps
25
- : feeBps;
26
- const effectiveSlippageBps = resolveSlippageBps(
27
- kind,
28
- modelSlippageBps,
29
- model.slippageByKind
30
- );
31
- const halfSpreadBps = Number.isFinite(model.spreadBps)
32
- ? model.spreadBps / 2
33
- : 0;
20
+ const modelSlippageBps = Number.isFinite(model.slippageBps) ? model.slippageBps : slippageBps;
21
+ const modelFeeBps = Number.isFinite(model.commissionBps) ? model.commissionBps : feeBps;
22
+ const effectiveSlippageBps = resolveSlippageBps(kind, modelSlippageBps, model.slippageByKind);
23
+ const halfSpreadBps = Number.isFinite(model.spreadBps) ? model.spreadBps / 2 : 0;
34
24
 
35
25
  const slippage = ((effectiveSlippageBps + halfSpreadBps) / 10000) * price;
36
26
  const filledPrice = side === "long" ? price + slippage : price - slippage;
@@ -38,16 +28,13 @@ export function applyFill(
38
28
  ((modelFeeBps || 0) / 10000) * Math.abs(filledPrice) +
39
29
  (Number.isFinite(model.commissionPerUnit) ? model.commissionPerUnit : 0);
40
30
  const variableFeeTotal = variableFeePerUnit * Math.max(0, qty);
41
- const fixedFeeTotal = Number.isFinite(model.commissionPerOrder)
42
- ? model.commissionPerOrder
43
- : 0;
31
+ const fixedFeeTotal = Number.isFinite(model.commissionPerOrder) ? model.commissionPerOrder : 0;
44
32
  const grossFeeTotal = variableFeeTotal + fixedFeeTotal;
45
33
  const feeTotal = Math.max(
46
34
  Number.isFinite(model.minCommission) ? model.minCommission : 0,
47
35
  grossFeeTotal
48
36
  );
49
- const feePerUnit =
50
- qty > 0 ? feeTotal / qty : variableFeePerUnit;
37
+ const feePerUnit = qty > 0 ? feeTotal / qty : variableFeePerUnit;
51
38
 
52
39
  return { price: filledPrice, fee: feePerUnit, feeTotal };
53
40
  }
@@ -63,21 +50,12 @@ export function clampStop(marketPrice, proposedStop, side, oco) {
63
50
  export function touchedLimit(side, limitPrice, bar, mode = "intrabar") {
64
51
  if (!bar || limitPrice === undefined || limitPrice === null) return false;
65
52
  if (mode === "close") {
66
- return side === "long"
67
- ? bar.close <= limitPrice
68
- : bar.close >= limitPrice;
53
+ return side === "long" ? bar.close <= limitPrice : bar.close >= limitPrice;
69
54
  }
70
55
  return side === "long" ? bar.low <= limitPrice : bar.high >= limitPrice;
71
56
  }
72
57
 
73
- export function ocoExitCheck({
74
- side,
75
- stop,
76
- tp,
77
- bar,
78
- mode = "intrabar",
79
- tieBreak = "pessimistic",
80
- }) {
58
+ export function ocoExitCheck({ side, stop, tp, bar, mode = "intrabar", tieBreak = "pessimistic" }) {
81
59
  if (mode === "close") {
82
60
  const close = bar.close;
83
61
  if (side === "long") {
@@ -94,9 +72,7 @@ export function ocoExitCheck({
94
72
  const hitTarget = side === "long" ? bar.high >= tp : bar.low <= tp;
95
73
 
96
74
  if (hitStop && hitTarget) {
97
- return tieBreak === "optimistic"
98
- ? { hit: "TP", px: tp }
99
- : { hit: "SL", px: stop };
75
+ return tieBreak === "optimistic" ? { hit: "TP", px: tp } : { hit: "SL", px: stop };
100
76
  }
101
77
 
102
78
  if (hitStop) return { hit: "SL", px: stop };
@@ -123,10 +99,7 @@ export function estimateBarMs(candles) {
123
99
  if (deltas.length) {
124
100
  deltas.sort((a, b) => a - b);
125
101
  const middle = Math.floor(deltas.length / 2);
126
- const median =
127
- deltas.length % 2
128
- ? deltas[middle]
129
- : (deltas[middle - 1] + deltas[middle]) / 2;
102
+ const median = deltas.length % 2 ? deltas[middle] : (deltas[middle - 1] + deltas[middle]) / 2;
130
103
  return Math.max(60e3, Math.min(median, 60 * 60e3));
131
104
  }
132
105
  }
@@ -151,7 +124,6 @@ export function dayKeyET(timeMs) {
151
124
  const anchor = new Date(
152
125
  Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0)
153
126
  );
154
- const pseudoEtTime =
155
- anchor.getTime() + hoursET * 60 * 60 * 1000 + minutesETDay * 60 * 1000;
127
+ const pseudoEtTime = anchor.getTime() + hoursET * 60 * 60 * 1000 + minutesETDay * 60 * 1000;
156
128
  return dayKeyUTC(pseudoEtTime);
157
129
  }