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,6 +42,48 @@ function strictHistoryView(candles, currentIndex) {
42
42
  });
43
43
  }
44
44
 
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
+ 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
+ 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
+
45
87
  function mergeOptions(options) {
46
88
  const normalizedRiskPct = Number.isFinite(options.riskFraction)
47
89
  ? options.riskFraction * 100
@@ -128,8 +170,7 @@ function normalizeSignal(signal, bar, fallbackR) {
128
170
  const side = normalizeSide(signal.side ?? signal.direction ?? signal.action);
129
171
  if (!side) return null;
130
172
 
131
- const entry =
132
- asNumber(signal.entry ?? signal.limit ?? signal.price) ?? asNumber(bar?.close);
173
+ const entry = asNumber(signal.entry ?? signal.limit ?? signal.price) ?? asNumber(bar?.close);
133
174
  const stop = asNumber(signal.stop ?? signal.stopLoss ?? signal.sl);
134
175
  if (entry === null || stop === null) return null;
135
176
 
@@ -141,8 +182,7 @@ function normalizeSignal(signal, bar, fallbackR) {
141
182
  const targetR = rrHint ?? fallbackR;
142
183
 
143
184
  if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
144
- takeProfit =
145
- side === "long" ? entry + risk * targetR : entry - risk * targetR;
185
+ takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
146
186
  }
147
187
  if (takeProfit === null) return null;
148
188
 
@@ -208,11 +248,11 @@ export function backtest(rawOptions) {
208
248
  } = options;
209
249
 
210
250
  if (!Array.isArray(candles) || candles.length === 0) {
211
- throw new Error("backtest() requires a non-empty candles array");
251
+ throw new Error(`backtest() requires a non-empty candles array, got ${describeValue(candles)}`);
212
252
  }
213
253
 
214
254
  if (typeof signal !== "function") {
215
- throw new Error("backtest() requires a signal function");
255
+ throw new Error(`backtest() requires a signal function, got ${describeValue(signal)}`);
216
256
  }
217
257
 
218
258
  const closed = [];
@@ -264,8 +304,7 @@ export function backtest(rawOptions) {
264
304
  const direction = openPos.side === "long" ? 1 : -1;
265
305
  const entryFill = openPos.entryFill;
266
306
  const grossPnl = (exitPx - entryFill) * direction * qty;
267
- const entryFeePortion =
268
- (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
307
+ const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
269
308
  const pnl = grossPnl - entryFeePortion - exitFeeTotal;
270
309
 
271
310
  currentEquity += pnl;
@@ -280,14 +319,14 @@ export function backtest(rawOptions) {
280
319
  reason === "SCALE"
281
320
  ? "scale-out"
282
321
  : reason === "TP"
283
- ? "tp"
284
- : reason === "SL"
285
- ? "sl"
286
- : reason === "EOD"
287
- ? "eod"
288
- : remaining <= 0
289
- ? "exit"
290
- : "scale-out";
322
+ ? "tp"
323
+ : reason === "SL"
324
+ ? "sl"
325
+ : reason === "EOD"
326
+ ? "eod"
327
+ : remaining <= 0
328
+ ? "exit"
329
+ : "scale-out";
291
330
 
292
331
  if (wantReplay) {
293
332
  replayEvents.push({
@@ -331,18 +370,14 @@ export function backtest(rawOptions) {
331
370
  const direction = openPos.side === "long" ? 1 : -1;
332
371
  const breakevenDelta = Math.abs(realized / openPos.size);
333
372
  const breakevenPrice =
334
- direction === 1
335
- ? openPos.entryFill - breakevenDelta
336
- : openPos.entryFill + breakevenDelta;
373
+ direction === 1 ? openPos.entryFill - breakevenDelta : openPos.entryFill + breakevenDelta;
337
374
 
338
375
  const tightened =
339
376
  direction === 1
340
377
  ? Math.max(openPos.stop, breakevenPrice)
341
378
  : Math.min(openPos.stop, breakevenPrice);
342
379
 
343
- openPos.stop = oco.clampStops
344
- ? clampStop(lastClose, tightened, openPos.side, oco)
345
- : tightened;
380
+ openPos.stop = oco.clampStops ? clampStop(lastClose, tightened, openPos.side, oco) : tightened;
346
381
  }
347
382
 
348
383
  function forceExit(reason, bar) {
@@ -383,10 +418,7 @@ export function backtest(rawOptions) {
383
418
  let stopPrice = pending.stop;
384
419
  if (reanchorStopOnFill) {
385
420
  const direction = pending.side === "long" ? 1 : -1;
386
- stopPrice =
387
- direction === 1
388
- ? entryPrice - plannedRisk
389
- : entryPrice + plannedRisk;
421
+ stopPrice = direction === 1 ? entryPrice - plannedRisk : entryPrice + plannedRisk;
390
422
  }
391
423
 
392
424
  let takeProfit = pending.tp;
@@ -424,17 +456,13 @@ export function backtest(rawOptions) {
424
456
  const size = roundStep(rawSize, qtyStep);
425
457
  if (size < minQty) return false;
426
458
 
427
- const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
428
- entryPrice,
429
- pending.side,
430
- {
431
- slippageBps,
432
- feeBps,
433
- kind: fillKind,
434
- qty: size,
435
- costs,
436
- }
437
- );
459
+ const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, pending.side, {
460
+ slippageBps,
461
+ feeBps,
462
+ kind: fillKind,
463
+ qty: size,
464
+ costs,
465
+ });
438
466
 
439
467
  open = {
440
468
  symbol,
@@ -485,10 +513,7 @@ export function backtest(rawOptions) {
485
513
  const bar = candles[index];
486
514
  history.push(bar);
487
515
 
488
- const dayKey =
489
- flattenAtClose || trigger === "close"
490
- ? dayKeyET(bar.time)
491
- : dayKeyUTC(bar.time);
516
+ const dayKey = flattenAtClose || trigger === "close" ? dayKeyET(bar.time) : dayKeyUTC(bar.time);
492
517
  if (currentDay === null || dayKey !== currentDay) {
493
518
  currentDay = dayKey;
494
519
  dayPnl = 0;
@@ -497,10 +522,7 @@ export function backtest(rawOptions) {
497
522
  }
498
523
 
499
524
  if (open && open._maxBarsInTrade > 0) {
500
- const barsHeld = Math.max(
501
- 1,
502
- Math.round((bar.time - open.openTime) / estimatedBarMs)
503
- );
525
+ const barsHeld = Math.max(1, Math.round((bar.time - open.openTime) / estimatedBarMs));
504
526
  if (barsHeld >= open._maxBarsInTrade) {
505
527
  forceExit("TIME", bar);
506
528
  }
@@ -521,17 +543,11 @@ export function backtest(rawOptions) {
521
543
  const direction = open.side === "long" ? 1 : -1;
522
544
  const risk = open._initRisk || 1e-8;
523
545
  const highR =
524
- open.side === "long"
525
- ? (bar.high - open.entry) / risk
526
- : (open.entry - bar.low) / risk;
546
+ open.side === "long" ? (bar.high - open.entry) / risk : (open.entry - bar.low) / risk;
527
547
  const lowR =
528
- open.side === "long"
529
- ? (bar.low - open.entry) / risk
530
- : (open.entry - bar.high) / risk;
548
+ open.side === "long" ? (bar.low - open.entry) / risk : (open.entry - bar.high) / risk;
531
549
  const markR =
532
- direction === 1
533
- ? (bar.close - open.entry) / risk
534
- : (open.entry - bar.close) / risk;
550
+ direction === 1 ? (bar.close - open.entry) / risk : (open.entry - bar.close) / risk;
535
551
 
536
552
  if (atrValues && atrValues[index] !== undefined) {
537
553
  open._lastATR = atrValues[index];
@@ -542,55 +558,34 @@ export function backtest(rawOptions) {
542
558
 
543
559
  if (open._breakevenAtR > 0 && highR >= open._breakevenAtR && !open._beArmed) {
544
560
  const tightened =
545
- open.side === "long"
546
- ? Math.max(open.stop, open.entry)
547
- : Math.min(open.stop, open.entry);
548
- open.stop = oco.clampStops
549
- ? clampStop(bar.close, tightened, open.side, oco)
550
- : tightened;
561
+ open.side === "long" ? Math.max(open.stop, open.entry) : Math.min(open.stop, open.entry);
562
+ open.stop = oco.clampStops ? clampStop(bar.close, tightened, open.side, oco) : tightened;
551
563
  open._beArmed = true;
552
564
  }
553
565
 
554
566
  if (open._trailAfterR > 0 && highR >= open._trailAfterR) {
555
- const candidate =
556
- open.side === "long" ? bar.close - risk : bar.close + risk;
567
+ const candidate = open.side === "long" ? bar.close - risk : bar.close + risk;
557
568
  const tightened =
558
- open.side === "long"
559
- ? Math.max(open.stop, candidate)
560
- : Math.min(open.stop, candidate);
561
- open.stop = oco.clampStops
562
- ? clampStop(bar.close, tightened, open.side, oco)
563
- : tightened;
569
+ open.side === "long" ? Math.max(open.stop, candidate) : Math.min(open.stop, candidate);
570
+ open.stop = oco.clampStops ? clampStop(bar.close, tightened, open.side, oco) : tightened;
564
571
  }
565
572
 
566
573
  if (useMfeTrail && open._mfeR >= mfeTrail.armR) {
567
574
  const targetR = Math.max(0, open._mfeR - Math.max(0, mfeTrail.givebackR));
568
575
  const candidate =
569
- open.side === "long"
570
- ? open.entry + targetR * risk
571
- : open.entry - targetR * risk;
576
+ open.side === "long" ? open.entry + targetR * risk : open.entry - targetR * risk;
572
577
  const tightened =
573
- open.side === "long"
574
- ? Math.max(open.stop, candidate)
575
- : Math.min(open.stop, candidate);
576
- open.stop = oco.clampStops
577
- ? clampStop(bar.close, tightened, open.side, oco)
578
- : tightened;
578
+ open.side === "long" ? Math.max(open.stop, candidate) : Math.min(open.stop, candidate);
579
+ open.stop = oco.clampStops ? clampStop(bar.close, tightened, open.side, oco) : tightened;
579
580
  }
580
581
 
581
582
  if (useAtrTrail && atrValues && atrValues[index] !== undefined) {
582
583
  const trailDistance = atrValues[index] * atrTrailMult;
583
584
  const candidate =
584
- open.side === "long"
585
- ? bar.close - trailDistance
586
- : bar.close + trailDistance;
585
+ open.side === "long" ? bar.close - trailDistance : bar.close + trailDistance;
587
586
  const tightened =
588
- open.side === "long"
589
- ? Math.max(open.stop, candidate)
590
- : Math.min(open.stop, candidate);
591
- open.stop = oco.clampStops
592
- ? clampStop(bar.close, tightened, open.side, oco)
593
- : tightened;
587
+ open.side === "long" ? Math.max(open.stop, candidate) : Math.min(open.stop, candidate);
588
+ open.stop = oco.clampStops ? clampStop(bar.close, tightened, open.side, oco) : tightened;
594
589
  }
595
590
 
596
591
  if (
@@ -602,19 +597,19 @@ export function backtest(rawOptions) {
602
597
  ) {
603
598
  const ratio = atrValues[index] / Math.max(1e-12, open.entryATR);
604
599
  const shouldCut =
605
- ratio >= volScale.cutIfAtrX &&
606
- markR < volScale.noCutAboveR &&
607
- !open._volCutDone;
600
+ ratio >= volScale.cutIfAtrX && markR < volScale.noCutAboveR && !open._volCutDone;
608
601
 
609
602
  if (shouldCut) {
610
603
  const cutQty = roundStep(open.size * volScale.cutFrac, qtyStep);
611
604
  if (cutQty >= minQty && cutQty < open.size) {
612
605
  const exitSide = open.side === "long" ? "short" : "long";
613
- const { price: filled, feeTotal: exitFeeTotal } = applyFill(
614
- bar.close,
615
- exitSide,
616
- { slippageBps, feeBps, kind: "market", qty: cutQty, costs }
617
- );
606
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide, {
607
+ slippageBps,
608
+ feeBps,
609
+ kind: "market",
610
+ qty: cutQty,
611
+ costs,
612
+ });
618
613
  closeLeg({
619
614
  openPos: open,
620
615
  qty: cutQty,
@@ -634,9 +629,7 @@ export function backtest(rawOptions) {
634
629
  const addNumber = (open._adds || 0) + 1;
635
630
  const triggerR = pyramiding.addAtR * addNumber;
636
631
  const triggerPrice =
637
- open.side === "long"
638
- ? open.entry + triggerR * risk
639
- : open.entry - triggerR * risk;
632
+ open.side === "long" ? open.entry + triggerR * risk : open.entry - triggerR * risk;
640
633
  const breakEvenSatisfied =
641
634
  !pyramiding.onlyAfterBreakEven ||
642
635
  (open.side === "long" && open.stop >= open.entry) ||
@@ -647,22 +640,23 @@ export function backtest(rawOptions) {
647
640
  ? bar.high >= triggerPrice
648
641
  : bar.close >= triggerPrice
649
642
  : trigger === "intrabar"
650
- ? bar.low <= triggerPrice
651
- : bar.close <= triggerPrice;
643
+ ? bar.low <= triggerPrice
644
+ : bar.close <= triggerPrice;
652
645
 
653
646
  if (breakEvenSatisfied && touched) {
654
647
  const baseSize = open.baseSize || open.initSize;
655
648
  const addQty = roundStep(baseSize * pyramiding.addFrac, qtyStep);
656
649
  if (addQty >= minQty) {
657
- const { price: addFill, feeTotal: addFeeTotal } = applyFill(
658
- triggerPrice,
659
- open.side,
660
- { slippageBps, feeBps, kind: "limit", qty: addQty, costs }
661
- );
650
+ const { price: addFill, feeTotal: addFeeTotal } = applyFill(triggerPrice, open.side, {
651
+ slippageBps,
652
+ feeBps,
653
+ kind: "limit",
654
+ qty: addQty,
655
+ costs,
656
+ });
662
657
  const newSize = open.size + addQty;
663
658
  open.entryFeeTotal += addFeeTotal;
664
- open.entryFill =
665
- (open.entryFill * open.size + addFill * addQty) / newSize;
659
+ open.entryFill = (open.entryFill * open.size + addFill * addQty) / newSize;
666
660
  open.size = newSize;
667
661
  open.initSize += addQty;
668
662
  if (!open.baseSize) open.baseSize = baseSize;
@@ -674,17 +668,15 @@ export function backtest(rawOptions) {
674
668
 
675
669
  if (!addedThisBar && !open._scaled && scaleOutAtR > 0) {
676
670
  const triggerPrice =
677
- open.side === "long"
678
- ? open.entry + scaleOutAtR * risk
679
- : open.entry - scaleOutAtR * risk;
671
+ open.side === "long" ? open.entry + scaleOutAtR * risk : open.entry - scaleOutAtR * risk;
680
672
  const touched =
681
673
  open.side === "long"
682
674
  ? trigger === "intrabar"
683
675
  ? bar.high >= triggerPrice
684
676
  : bar.close >= triggerPrice
685
677
  : trigger === "intrabar"
686
- ? bar.low <= triggerPrice
687
- : bar.close <= triggerPrice;
678
+ ? bar.low <= triggerPrice
679
+ : bar.close <= triggerPrice;
688
680
 
689
681
  if (touched) {
690
682
  const exitSide = open.side === "long" ? "short" : "long";
@@ -707,9 +699,7 @@ export function backtest(rawOptions) {
707
699
  });
708
700
  open._scaled = true;
709
701
  open.takeProfit =
710
- open.side === "long"
711
- ? open.entry + finalTP_R * risk
712
- : open.entry - finalTP_R * risk;
702
+ open.side === "long" ? open.entry + finalTP_R * risk : open.entry - finalTP_R * risk;
713
703
  tightenStopToNetBreakeven(open, bar.close);
714
704
  open._beArmed = true;
715
705
  }
@@ -745,9 +735,7 @@ export function backtest(rawOptions) {
745
735
  reason: hit,
746
736
  });
747
737
  cooldown =
748
- (hit === "SL"
749
- ? Math.max(cooldown, postLossCooldownBars)
750
- : cooldown) || localCooldown;
738
+ (hit === "SL" ? Math.max(cooldown, postLossCooldownBars) : cooldown) || localCooldown;
751
739
  open = null;
752
740
  }
753
741
  }
@@ -759,16 +747,12 @@ export function backtest(rawOptions) {
759
747
  if (!open && pending) {
760
748
  if (index > pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
761
749
  if (entryChase.enabled && entryChase.convertOnExpiry) {
762
- const riskAtEdge = Math.abs(
763
- pending.meta._initRisk ?? (pending.entry - pending.stop)
764
- );
750
+ const riskAtEdge = Math.abs(pending.meta._initRisk ?? pending.entry - pending.stop);
765
751
  const priceNow = bar.close;
766
752
  const direction = pending.side === "long" ? 1 : -1;
767
753
  const slippedR =
768
- Math.max(
769
- 0,
770
- direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
771
- ) / Math.max(1e-8, riskAtEdge);
754
+ Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) /
755
+ Math.max(1e-8, riskAtEdge);
772
756
 
773
757
  if (slippedR > maxSlipROnFill) {
774
758
  pending = null;
@@ -784,24 +768,24 @@ export function backtest(rawOptions) {
784
768
  }
785
769
  } else if (entryChase.enabled) {
786
770
  const elapsedBars = index - (pending.startedAtIndex ?? index);
787
- const midpoint = pending.meta?._imb?.mid;
771
+ const midpoint = asNumber(pending.meta?._imb?.mid);
788
772
 
789
- if (!pending._chasedCE && midpoint !== undefined && elapsedBars >= Math.max(1, entryChase.afterBars)) {
773
+ if (
774
+ !pending._chasedCE &&
775
+ midpoint !== null &&
776
+ elapsedBars >= Math.max(1, entryChase.afterBars)
777
+ ) {
790
778
  pending.entry = midpoint;
791
779
  pending._chasedCE = true;
792
780
  }
793
781
 
794
782
  if (pending._chasedCE) {
795
- const riskRef = Math.abs(
796
- pending.meta?._initRisk ?? (pending.entry - pending.stop)
797
- );
783
+ const riskRef = Math.abs(pending.meta?._initRisk ?? pending.entry - pending.stop);
798
784
  const priceNow = bar.close;
799
785
  const direction = pending.side === "long" ? 1 : -1;
800
786
  const slippedR =
801
- Math.max(
802
- 0,
803
- direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
804
- ) / Math.max(1e-8, riskRef);
787
+ Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) /
788
+ Math.max(1e-8, riskRef);
805
789
 
806
790
  if (slippedR > maxSlipROnFill) {
807
791
  pending = null;
@@ -834,13 +818,19 @@ export function backtest(rawOptions) {
834
818
  }
835
819
 
836
820
  const signalCandles = strict ? strictHistoryView(history, index) : history;
837
- const rawSignal = signal({
838
- candles: signalCandles,
821
+ const rawSignal = callSignalWithContext({
822
+ signal,
823
+ context: {
824
+ candles: signalCandles,
825
+ index,
826
+ bar,
827
+ equity: currentEquity,
828
+ openPosition: open,
829
+ pendingOrder: pending,
830
+ },
839
831
  index,
840
832
  bar,
841
- equity: currentEquity,
842
- openPosition: open,
843
- pendingOrder: pending,
833
+ symbol,
844
834
  });
845
835
  const nextSignal = normalizeSignal(rawSignal, bar, finalTP_R);
846
836
 
@@ -848,8 +838,8 @@ export function backtest(rawOptions) {
848
838
  const signalRiskFraction = Number.isFinite(nextSignal.riskFraction)
849
839
  ? nextSignal.riskFraction
850
840
  : Number.isFinite(nextSignal.riskPct)
851
- ? nextSignal.riskPct / 100
852
- : riskPct / 100;
841
+ ? nextSignal.riskPct / 100
842
+ : riskPct / 100;
853
843
  const expiryBars = nextSignal._entryExpiryBars ?? 5;
854
844
  pending = {
855
845
  side: nextSignal.side,
@@ -861,9 +851,7 @@ export function backtest(rawOptions) {
861
851
  expiresAt: index + Math.max(1, expiryBars),
862
852
  startedAtIndex: index,
863
853
  meta: nextSignal,
864
- plannedRiskAbs: Math.abs(
865
- nextSignal._initRisk ?? (nextSignal.entry - nextSignal.stop)
866
- ),
854
+ plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop),
867
855
  };
868
856
 
869
857
  if (touchedLimit(pending.side, pending.entry, bar, trigger)) {
@@ -886,6 +874,10 @@ export function backtest(rawOptions) {
886
874
  eqSeries,
887
875
  });
888
876
  const positions = closed.filter((trade) => trade.exit.reason !== "SCALE");
877
+ const lastPrice = asNumber(candles[candles.length - 1]?.close);
878
+ const openPositions = open
879
+ ? [snapshotOpenPosition(open, lastPrice ?? open.entryFill ?? open.entry)]
880
+ : [];
889
881
 
890
882
  return {
891
883
  symbol: options.symbol,
@@ -893,6 +885,7 @@ export function backtest(rawOptions) {
893
885
  range: options.range,
894
886
  trades: closed,
895
887
  positions,
888
+ openPositions,
896
889
  metrics,
897
890
  eqSeries,
898
891
  replay: {