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.
Files changed (54) hide show
  1. package/README.md +121 -52
  2. package/bin/tradelab.js +340 -49
  3. package/dist/cjs/data.cjs +210 -155
  4. package/dist/cjs/index.cjs +1782 -274
  5. package/dist/cjs/live.cjs +3350 -0
  6. package/docs/README.md +26 -9
  7. package/docs/api-reference.md +89 -26
  8. package/docs/backtest-engine.md +74 -60
  9. package/docs/data-reporting-cli.md +66 -36
  10. package/docs/examples.md +275 -0
  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 +481 -0
  19. package/src/engine/barSystemRunner.js +1027 -0
  20. package/src/engine/execution.js +11 -39
  21. package/src/engine/portfolio.js +237 -66
  22. package/src/engine/walkForward.js +132 -13
  23. package/src/index.js +3 -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 +103 -100
  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 +98 -4
  54. package/types/live.d.ts +382 -0
package/dist/cjs/data.cjs CHANGED
@@ -1,3 +1,4 @@
1
+ "use strict";
1
2
  var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -87,6 +88,14 @@ function atr(bars, period = 14) {
87
88
  function roundStep(value, step) {
88
89
  return Math.floor(value / step) * step;
89
90
  }
91
+ var warnedNonPositiveEquity = false;
92
+ function warnNonPositiveEquity(equity) {
93
+ if (warnedNonPositiveEquity) return;
94
+ warnedNonPositiveEquity = true;
95
+ console.warn(
96
+ `[tradelab] calculatePositionSize() received non-positive equity (${equity}); returning size 0`
97
+ );
98
+ }
90
99
  function calculatePositionSize({
91
100
  equity,
92
101
  entry,
@@ -96,6 +105,10 @@ function calculatePositionSize({
96
105
  minQty = 1e-3,
97
106
  maxLeverage = 2
98
107
  }) {
108
+ if (!Number.isFinite(equity) || equity <= 0) {
109
+ warnNonPositiveEquity(equity);
110
+ return 0;
111
+ }
99
112
  const riskPerUnit = Math.abs(entry - stop);
100
113
  if (!Number.isFinite(riskPerUnit) || riskPerUnit <= 0) return 0;
101
114
  const maxRiskDollars = Math.max(0, equity * riskFraction);
@@ -207,64 +220,95 @@ function percentile(values, percentileRank) {
207
220
  const index = Math.floor((sorted.length - 1) * percentileRank);
208
221
  return sorted[index];
209
222
  }
210
- function buildMetrics({
211
- closed,
212
- equityStart,
213
- equityFinal,
214
- candles,
215
- estBarMs,
216
- eqSeries
217
- }) {
218
- const completedTrades = closed.filter((trade) => trade.exit.reason !== "SCALE");
219
- const winningTrades = completedTrades.filter((trade) => trade.exit.pnl > 0);
220
- const losingTrades = completedTrades.filter((trade) => trade.exit.pnl < 0);
221
- const tradeRs = completedTrades.map(tradeRMultiple);
222
- const totalR = sum(tradeRs);
223
- const avgR = mean(tradeRs);
224
- const labels = completedTrades.map(
225
- (trade) => trade.exit.pnl > 0 ? "win" : trade.exit.pnl < 0 ? "loss" : "flat"
226
- );
227
- const { maxWin, maxLoss } = streaks(labels);
228
- const tradePnls = completedTrades.map((trade) => trade.exit.pnl);
229
- const expectancy = mean(tradePnls);
230
- const tradeReturns = completedTrades.map(
231
- (trade) => trade.exit.pnl / Math.max(1e-12, equityStart)
232
- );
233
- const tradeReturnStd = stddev(tradeReturns);
234
- const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
235
- const sortinoPerTrade = sortino(tradeReturns);
236
- const grossProfitPositions = sum(winningTrades.map((trade) => trade.exit.pnl));
237
- const grossLossPositions = Math.abs(
238
- sum(losingTrades.map((trade) => trade.exit.pnl))
239
- );
240
- const profitFactorPositions = grossLossPositions === 0 ? grossProfitPositions > 0 ? Infinity : 0 : grossProfitPositions / grossLossPositions;
223
+ var PROFIT_FACTOR_CAP = 1e6;
224
+ function finiteProfitFactor(grossProfit, grossLoss) {
225
+ if (grossLoss === 0) {
226
+ return grossProfit > 0 ? PROFIT_FACTOR_CAP : 0;
227
+ }
228
+ return grossProfit / grossLoss;
229
+ }
230
+ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqSeries }) {
241
231
  const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
242
- const winningLegs = legs.filter((trade) => trade.exit.pnl > 0);
243
- const losingLegs = legs.filter((trade) => trade.exit.pnl < 0);
244
- const grossProfitLegs = sum(winningLegs.map((trade) => trade.exit.pnl));
245
- const grossLossLegs = Math.abs(sum(losingLegs.map((trade) => trade.exit.pnl)));
246
- const profitFactorLegs = grossLossLegs === 0 ? grossProfitLegs > 0 ? Infinity : 0 : grossProfitLegs / grossLossLegs;
232
+ const completedTrades = [];
233
+ const tradeRs = [];
234
+ const tradePnls = [];
235
+ const tradeReturns = [];
236
+ const holdDurationsMinutes = [];
237
+ const labels = [];
238
+ const longRs = [];
239
+ const shortRs = [];
240
+ let totalR = 0;
241
+ let realizedPnL = 0;
242
+ let winningTradeCount = 0;
243
+ let grossProfitPositions = 0;
244
+ let grossLossPositions = 0;
245
+ let grossProfitLegs = 0;
246
+ let grossLossLegs = 0;
247
+ let winningLegCount = 0;
248
+ let openBars = 0;
249
+ let longTradesCount = 0;
250
+ let longTradeWins = 0;
251
+ let longPnLSum = 0;
252
+ let shortTradesCount = 0;
253
+ let shortTradeWins = 0;
254
+ let shortPnLSum = 0;
247
255
  let peakEquity = equityStart;
248
256
  let currentEquity = equityStart;
249
257
  let maxDrawdown = 0;
250
- for (const leg of legs) {
251
- currentEquity += leg.exit.pnl;
258
+ for (const trade of legs) {
259
+ const pnl = trade.exit.pnl;
260
+ realizedPnL += pnl;
261
+ if (pnl > 0) {
262
+ grossProfitLegs += pnl;
263
+ winningLegCount += 1;
264
+ } else if (pnl < 0) {
265
+ grossLossLegs += Math.abs(pnl);
266
+ }
267
+ currentEquity += pnl;
252
268
  if (currentEquity > peakEquity) peakEquity = currentEquity;
253
269
  const drawdown = (peakEquity - currentEquity) / Math.max(1e-12, peakEquity);
254
270
  if (drawdown > maxDrawdown) maxDrawdown = drawdown;
271
+ if (trade.exit.reason === "SCALE") continue;
272
+ completedTrades.push(trade);
273
+ tradePnls.push(pnl);
274
+ tradeReturns.push(pnl / Math.max(1e-12, equityStart));
275
+ const tradeR = tradeRMultiple(trade);
276
+ tradeRs.push(tradeR);
277
+ totalR += tradeR;
278
+ labels.push(pnl > 0 ? "win" : pnl < 0 ? "loss" : "flat");
279
+ const holdMinutes = (trade.exit.time - trade.openTime) / (1e3 * 60);
280
+ holdDurationsMinutes.push(holdMinutes);
281
+ openBars += Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
282
+ if (pnl > 0) {
283
+ winningTradeCount += 1;
284
+ grossProfitPositions += pnl;
285
+ } else if (pnl < 0) {
286
+ grossLossPositions += Math.abs(pnl);
287
+ }
288
+ if (trade.side === "long") {
289
+ longTradesCount += 1;
290
+ longPnLSum += pnl;
291
+ longRs.push(tradeR);
292
+ if (pnl > 0) longTradeWins += 1;
293
+ } else if (trade.side === "short") {
294
+ shortTradesCount += 1;
295
+ shortPnLSum += pnl;
296
+ shortRs.push(tradeR);
297
+ if (pnl > 0) shortTradeWins += 1;
298
+ }
255
299
  }
256
- const realizedPnL = sum(closed.map((trade) => trade.exit.pnl));
300
+ const avgR = mean(tradeRs);
301
+ const { maxWin, maxLoss } = streaks(labels);
302
+ const expectancy = mean(tradePnls);
303
+ const tradeReturnStd = stddev(tradeReturns);
304
+ const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
305
+ const sortinoPerTrade = sortino(tradeReturns);
306
+ const profitFactorPositions = finiteProfitFactor(grossProfitPositions, grossLossPositions);
307
+ const profitFactorLegs = finiteProfitFactor(grossProfitLegs, grossLossLegs);
257
308
  const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
258
309
  const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
259
310
  const totalBars = Math.max(1, candles.length);
260
- const openBars = completedTrades.reduce((total, trade) => {
261
- const barsHeld = Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
262
- return total + barsHeld;
263
- }, 0);
264
311
  const exposurePct = openBars / totalBars;
265
- const holdDurationsMinutes = completedTrades.map(
266
- (trade) => (trade.exit.time - trade.openTime) / (1e3 * 60)
267
- );
268
312
  const avgHoldMin = mean(holdDurationsMinutes);
269
313
  const equitySeries = eqSeries && eqSeries.length ? eqSeries : buildEquitySeriesFromLegs({ legs, equityStart });
270
314
  const dailyReturnsSeries = dailyReturns(equitySeries);
@@ -272,12 +316,6 @@ function buildMetrics({
272
316
  const sharpeDaily = dailyStd === 0 ? dailyReturnsSeries.length ? Infinity : 0 : mean(dailyReturnsSeries) / dailyStd;
273
317
  const sortinoDaily = sortino(dailyReturnsSeries);
274
318
  const dailyWinRate = dailyReturnsSeries.length ? dailyReturnsSeries.filter((value) => value > 0).length / dailyReturnsSeries.length : 0;
275
- const longTrades = completedTrades.filter((trade) => trade.side === "long");
276
- const shortTrades = completedTrades.filter((trade) => trade.side === "short");
277
- const longRs = longTrades.map(tradeRMultiple);
278
- const shortRs = shortTrades.map(tradeRMultiple);
279
- const longPnls = longTrades.map((trade) => trade.exit.pnl);
280
- const shortPnls = shortTrades.map((trade) => trade.exit.pnl);
281
319
  const rDistribution = {
282
320
  p10: percentile(tradeRs, 0.1),
283
321
  p25: percentile(tradeRs, 0.25),
@@ -294,21 +332,21 @@ function buildMetrics({
294
332
  };
295
333
  const sideBreakdown = {
296
334
  long: {
297
- trades: longTrades.length,
298
- winRate: longTrades.length ? longTrades.filter((trade) => trade.exit.pnl > 0).length / longTrades.length : 0,
299
- avgPnL: mean(longPnls),
335
+ trades: longTradesCount,
336
+ winRate: longTradesCount ? longTradeWins / longTradesCount : 0,
337
+ avgPnL: longTradesCount ? longPnLSum / longTradesCount : 0,
300
338
  avgR: mean(longRs)
301
339
  },
302
340
  short: {
303
- trades: shortTrades.length,
304
- winRate: shortTrades.length ? shortTrades.filter((trade) => trade.exit.pnl > 0).length / shortTrades.length : 0,
305
- avgPnL: mean(shortPnls),
341
+ trades: shortTradesCount,
342
+ winRate: shortTradesCount ? shortTradeWins / shortTradesCount : 0,
343
+ avgPnL: shortTradesCount ? shortPnLSum / shortTradesCount : 0,
306
344
  avgR: mean(shortRs)
307
345
  }
308
346
  };
309
347
  return {
310
348
  trades: completedTrades.length,
311
- winRate: completedTrades.length ? winningTrades.length / completedTrades.length : 0,
349
+ winRate: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
312
350
  profitFactor: profitFactorPositions,
313
351
  expectancy,
314
352
  totalR,
@@ -330,8 +368,8 @@ function buildMetrics({
330
368
  startEquity: equityStart,
331
369
  profitFactor_pos: profitFactorPositions,
332
370
  profitFactor_leg: profitFactorLegs,
333
- winRate_pos: completedTrades.length ? winningTrades.length / completedTrades.length : 0,
334
- winRate_leg: legs.length ? winningLegs.length / legs.length : 0,
371
+ winRate_pos: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
372
+ winRate_leg: legs.length ? winningLegCount / legs.length : 0,
335
373
  sharpeDaily,
336
374
  sortinoDaily,
337
375
  sideBreakdown,
@@ -435,7 +473,7 @@ function normalizeDateBoundary(value, fallback) {
435
473
  }
436
474
  function normalizeCandles(candles) {
437
475
  if (!Array.isArray(candles)) return [];
438
- const normalized = candles.map((bar) => {
476
+ const parsed = candles.map((bar) => {
439
477
  try {
440
478
  const time = resolveDate(bar?.time ?? bar?.timestamp ?? bar?.date);
441
479
  const open = Number(bar?.open ?? bar?.o);
@@ -457,7 +495,16 @@ function normalizeCandles(candles) {
457
495
  } catch {
458
496
  return null;
459
497
  }
460
- }).filter(Boolean).sort((left, right) => left.time - right.time);
498
+ }).filter(Boolean);
499
+ let reordered = false;
500
+ let duplicateCount = 0;
501
+ for (let index = 1; index < parsed.length; index += 1) {
502
+ const prev = parsed[index - 1].time;
503
+ const current = parsed[index].time;
504
+ if (current < prev) reordered = true;
505
+ if (current === prev) duplicateCount += 1;
506
+ }
507
+ const normalized = parsed.sort((left, right) => left.time - right.time);
461
508
  const deduped = [];
462
509
  let lastTime = null;
463
510
  for (const candle of normalized) {
@@ -465,6 +512,12 @@ function normalizeCandles(candles) {
465
512
  deduped.push(candle);
466
513
  lastTime = candle.time;
467
514
  }
515
+ const removedDuplicates = normalized.length - deduped.length;
516
+ if (reordered || removedDuplicates > 0 || duplicateCount > 0) {
517
+ console.warn(
518
+ `[tradelab] normalizeCandles() reordered or deduplicated candles (input=${candles.length}, valid=${parsed.length}, output=${deduped.length})`
519
+ );
520
+ }
468
521
  return deduped;
469
522
  }
470
523
  function loadCandlesFromCSV(filePath, options = {}) {
@@ -507,9 +560,7 @@ function loadCandlesFromCSV(filePath, options = {}) {
507
560
  const closeIdx = resolveColumn(closeCol, headerIndex, ["c", "adj close"]);
508
561
  const volumeIdx = resolveColumn(volumeCol, headerIndex, ["v", "vol", "quantity"]);
509
562
  if (timeIdx < 0 || openIdx < 0 || highIdx < 0 || lowIdx < 0 || closeIdx < 0) {
510
- throw new Error(
511
- `Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`
512
- );
563
+ throw new Error(`Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`);
513
564
  }
514
565
  const minTime = normalizeDateBoundary(startDate, -Infinity);
515
566
  const maxTime = normalizeDateBoundary(endDate, Infinity);
@@ -624,18 +675,12 @@ function usDstBoundsUTC(year) {
624
675
  if (sundaysSeen === 2) break;
625
676
  marchCursor = new Date(marchCursor.getTime() + 24 * 60 * 60 * 1e3);
626
677
  }
627
- const dstStart = new Date(
628
- Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0)
629
- );
678
+ const dstStart = new Date(Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0));
630
679
  let novemberCursor = new Date(Date.UTC(year, 10, 1, 6, 0, 0));
631
680
  while (novemberCursor.getUTCDay() !== 0) {
632
- novemberCursor = new Date(
633
- novemberCursor.getTime() + 24 * 60 * 60 * 1e3
634
- );
681
+ novemberCursor = new Date(novemberCursor.getTime() + 24 * 60 * 60 * 1e3);
635
682
  }
636
- const dstEnd = new Date(
637
- Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0)
638
- );
683
+ const dstEnd = new Date(Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0));
639
684
  return { dstStart, dstEnd };
640
685
  }
641
686
  function isUsEasternDST(timeMs) {
@@ -666,11 +711,7 @@ function applyFill(price, side, { slippageBps = 0, feeBps = 0, kind = "market",
666
711
  const model = costs || {};
667
712
  const modelSlippageBps = Number.isFinite(model.slippageBps) ? model.slippageBps : slippageBps;
668
713
  const modelFeeBps = Number.isFinite(model.commissionBps) ? model.commissionBps : feeBps;
669
- const effectiveSlippageBps = resolveSlippageBps(
670
- kind,
671
- modelSlippageBps,
672
- model.slippageByKind
673
- );
714
+ const effectiveSlippageBps = resolveSlippageBps(kind, modelSlippageBps, model.slippageByKind);
674
715
  const halfSpreadBps = Number.isFinite(model.spreadBps) ? model.spreadBps / 2 : 0;
675
716
  const slippage = (effectiveSlippageBps + halfSpreadBps) / 1e4 * price;
676
717
  const filledPrice = side === "long" ? price + slippage : price - slippage;
@@ -697,14 +738,7 @@ function touchedLimit(side, limitPrice, bar, mode = "intrabar") {
697
738
  }
698
739
  return side === "long" ? bar.low <= limitPrice : bar.high >= limitPrice;
699
740
  }
700
- function ocoExitCheck({
701
- side,
702
- stop,
703
- tp,
704
- bar,
705
- mode = "intrabar",
706
- tieBreak = "pessimistic"
707
- }) {
741
+ function ocoExitCheck({ side, stop, tp, bar, mode = "intrabar", tieBreak = "pessimistic" }) {
708
742
  if (mode === "close") {
709
743
  const close = bar.close;
710
744
  if (side === "long") {
@@ -785,13 +819,51 @@ function strictHistoryView(candles, currentIndex) {
785
819
  get(target, property, receiver) {
786
820
  if (isArrayIndexKey(property) && Number(property) >= target.length) {
787
821
  throw new Error(
788
- `strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
822
+ `strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
789
823
  );
790
824
  }
791
825
  return Reflect.get(target, property, receiver);
792
826
  }
793
827
  });
794
828
  }
829
+ function describeValue(value) {
830
+ if (Array.isArray(value)) return `array(length=${value.length})`;
831
+ if (value === null) return "null";
832
+ return typeof value;
833
+ }
834
+ function formatIsoTime(time) {
835
+ return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
836
+ }
837
+ function callSignalWithContext({ signal, context, index, bar, symbol }) {
838
+ try {
839
+ return signal(context);
840
+ } catch (error) {
841
+ const cause = error instanceof Error ? error.message : String(error);
842
+ throw new Error(
843
+ `signal() threw at index=${index}, time=${formatIsoTime(bar?.time)}, symbol=${symbol}: ${cause}`
844
+ );
845
+ }
846
+ }
847
+ function snapshotOpenPosition(open, markPrice) {
848
+ if (!open) return null;
849
+ const entryPrice = open.entryFill ?? open.entry;
850
+ const direction = open.side === "long" ? 1 : -1;
851
+ const unrealizedPnl = (markPrice - entryPrice) * direction * open.size;
852
+ return {
853
+ id: open.id,
854
+ symbol: open.symbol,
855
+ side: open.side,
856
+ size: open.size,
857
+ entry: open.entry,
858
+ entryFill: open.entryFill,
859
+ stop: open.stop,
860
+ takeProfit: open.takeProfit,
861
+ openTime: open.openTime,
862
+ markPrice,
863
+ unrealizedPnl,
864
+ _initRisk: open._initRisk
865
+ };
866
+ }
795
867
  function mergeOptions(options) {
796
868
  const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
797
869
  return {
@@ -933,10 +1005,10 @@ function backtest(rawOptions) {
933
1005
  strict
934
1006
  } = options;
935
1007
  if (!Array.isArray(candles) || candles.length === 0) {
936
- throw new Error("backtest() requires a non-empty candles array");
1008
+ throw new Error(`backtest() requires a non-empty candles array, got ${describeValue(candles)}`);
937
1009
  }
938
1010
  if (typeof signal !== "function") {
939
- throw new Error("backtest() requires a signal function");
1011
+ throw new Error(`backtest() requires a signal function, got ${describeValue(signal)}`);
940
1012
  }
941
1013
  const closed = [];
942
1014
  let currentEquity = equity;
@@ -1085,17 +1157,13 @@ function backtest(rawOptions) {
1085
1157
  });
1086
1158
  const size = roundStep2(rawSize, qtyStep);
1087
1159
  if (size < minQty) return false;
1088
- const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
1089
- entryPrice,
1090
- pending.side,
1091
- {
1092
- slippageBps,
1093
- feeBps,
1094
- kind: fillKind,
1095
- qty: size,
1096
- costs
1097
- }
1098
- );
1160
+ const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, pending.side, {
1161
+ slippageBps,
1162
+ feeBps,
1163
+ kind: fillKind,
1164
+ qty: size,
1165
+ costs
1166
+ });
1099
1167
  open = {
1100
1168
  symbol,
1101
1169
  ...pending.meta,
@@ -1146,10 +1214,7 @@ function backtest(rawOptions) {
1146
1214
  dayEquityStart = currentEquity;
1147
1215
  }
1148
1216
  if (open && open._maxBarsInTrade > 0) {
1149
- const barsHeld = Math.max(
1150
- 1,
1151
- Math.round((bar.time - open.openTime) / estimatedBarMs)
1152
- );
1217
+ const barsHeld = Math.max(1, Math.round((bar.time - open.openTime) / estimatedBarMs));
1153
1218
  if (barsHeld >= open._maxBarsInTrade) {
1154
1219
  forceExit("TIME", bar);
1155
1220
  }
@@ -1203,11 +1268,13 @@ function backtest(rawOptions) {
1203
1268
  const cutQty = roundStep2(open.size * volScale.cutFrac, qtyStep);
1204
1269
  if (cutQty >= minQty && cutQty < open.size) {
1205
1270
  const exitSide2 = open.side === "long" ? "short" : "long";
1206
- const { price: filled, feeTotal: exitFeeTotal } = applyFill(
1207
- bar.close,
1208
- exitSide2,
1209
- { slippageBps, feeBps, kind: "market", qty: cutQty, costs }
1210
- );
1271
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide2, {
1272
+ slippageBps,
1273
+ feeBps,
1274
+ kind: "market",
1275
+ qty: cutQty,
1276
+ costs
1277
+ });
1211
1278
  closeLeg({
1212
1279
  openPos: open,
1213
1280
  qty: cutQty,
@@ -1232,11 +1299,13 @@ function backtest(rawOptions) {
1232
1299
  const baseSize = open.baseSize || open.initSize;
1233
1300
  const addQty = roundStep2(baseSize * pyramiding.addFrac, qtyStep);
1234
1301
  if (addQty >= minQty) {
1235
- const { price: addFill, feeTotal: addFeeTotal } = applyFill(
1236
- triggerPrice,
1237
- open.side,
1238
- { slippageBps, feeBps, kind: "limit", qty: addQty, costs }
1239
- );
1302
+ const { price: addFill, feeTotal: addFeeTotal } = applyFill(triggerPrice, open.side, {
1303
+ slippageBps,
1304
+ feeBps,
1305
+ kind: "limit",
1306
+ qty: addQty,
1307
+ costs
1308
+ });
1240
1309
  const newSize = open.size + addQty;
1241
1310
  open.entryFeeTotal += addFeeTotal;
1242
1311
  open.entryFill = (open.entryFill * open.size + addFill * addQty) / newSize;
@@ -1314,15 +1383,10 @@ function backtest(rawOptions) {
1314
1383
  if (!open && pending) {
1315
1384
  if (index > pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
1316
1385
  if (entryChase.enabled && entryChase.convertOnExpiry) {
1317
- const riskAtEdge = Math.abs(
1318
- pending.meta._initRisk ?? pending.entry - pending.stop
1319
- );
1386
+ const riskAtEdge = Math.abs(pending.meta._initRisk ?? pending.entry - pending.stop);
1320
1387
  const priceNow = bar.close;
1321
1388
  const direction = pending.side === "long" ? 1 : -1;
1322
- const slippedR = Math.max(
1323
- 0,
1324
- direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
1325
- ) / Math.max(1e-8, riskAtEdge);
1389
+ const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskAtEdge);
1326
1390
  if (slippedR > maxSlipROnFill) {
1327
1391
  pending = null;
1328
1392
  } else if (!openFromPending(bar, index, priceNow, "market")) {
@@ -1337,21 +1401,16 @@ function backtest(rawOptions) {
1337
1401
  }
1338
1402
  } else if (entryChase.enabled) {
1339
1403
  const elapsedBars = index - (pending.startedAtIndex ?? index);
1340
- const midpoint = pending.meta?._imb?.mid;
1341
- if (!pending._chasedCE && midpoint !== void 0 && elapsedBars >= Math.max(1, entryChase.afterBars)) {
1404
+ const midpoint = asNumber(pending.meta?._imb?.mid);
1405
+ if (!pending._chasedCE && midpoint !== null && elapsedBars >= Math.max(1, entryChase.afterBars)) {
1342
1406
  pending.entry = midpoint;
1343
1407
  pending._chasedCE = true;
1344
1408
  }
1345
1409
  if (pending._chasedCE) {
1346
- const riskRef = Math.abs(
1347
- pending.meta?._initRisk ?? pending.entry - pending.stop
1348
- );
1410
+ const riskRef = Math.abs(pending.meta?._initRisk ?? pending.entry - pending.stop);
1349
1411
  const priceNow = bar.close;
1350
1412
  const direction = pending.side === "long" ? 1 : -1;
1351
- const slippedR = Math.max(
1352
- 0,
1353
- direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
1354
- ) / Math.max(1e-8, riskRef);
1413
+ const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskRef);
1355
1414
  if (slippedR > maxSlipROnFill) {
1356
1415
  pending = null;
1357
1416
  } else if (slippedR > 0 && slippedR <= entryChase.maxSlipR) {
@@ -1379,13 +1438,19 @@ function backtest(rawOptions) {
1379
1438
  );
1380
1439
  }
1381
1440
  const signalCandles = strict ? strictHistoryView(history, index) : history;
1382
- const rawSignal = signal({
1383
- candles: signalCandles,
1441
+ const rawSignal = callSignalWithContext({
1442
+ signal,
1443
+ context: {
1444
+ candles: signalCandles,
1445
+ index,
1446
+ bar,
1447
+ equity: currentEquity,
1448
+ openPosition: open,
1449
+ pendingOrder: pending
1450
+ },
1384
1451
  index,
1385
1452
  bar,
1386
- equity: currentEquity,
1387
- openPosition: open,
1388
- pendingOrder: pending
1453
+ symbol
1389
1454
  });
1390
1455
  const nextSignal = normalizeSignal(rawSignal, bar, finalTP_R);
1391
1456
  if (nextSignal) {
@@ -1401,9 +1466,7 @@ function backtest(rawOptions) {
1401
1466
  expiresAt: index + Math.max(1, expiryBars),
1402
1467
  startedAtIndex: index,
1403
1468
  meta: nextSignal,
1404
- plannedRiskAbs: Math.abs(
1405
- nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop
1406
- )
1469
+ plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
1407
1470
  };
1408
1471
  if (touchedLimit(pending.side, pending.entry, bar, trigger)) {
1409
1472
  if (!openFromPending(bar, index, pending.entry, "limit")) {
@@ -1423,12 +1486,15 @@ function backtest(rawOptions) {
1423
1486
  eqSeries
1424
1487
  });
1425
1488
  const positions = closed.filter((trade) => trade.exit.reason !== "SCALE");
1489
+ const lastPrice = asNumber(candles[candles.length - 1]?.close);
1490
+ const openPositions = open ? [snapshotOpenPosition(open, lastPrice ?? open.entryFill ?? open.entry)] : [];
1426
1491
  return {
1427
1492
  symbol: options.symbol,
1428
1493
  interval: options.interval,
1429
1494
  range: options.range,
1430
1495
  trades: closed,
1431
1496
  positions,
1497
+ openPositions,
1432
1498
  metrics,
1433
1499
  eqSeries,
1434
1500
  replay: {
@@ -1441,7 +1507,6 @@ function backtest(rawOptions) {
1441
1507
  // src/data/yahoo.js
1442
1508
  var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1443
1509
  var DAY_MS = 24 * 60 * 60 * 1e3;
1444
- var DAY_SEC = 24 * 60 * 60;
1445
1510
  var requestQueue = {
1446
1511
  lastRequestAt: 0,
1447
1512
  minDelayMs: 400
@@ -1596,13 +1661,7 @@ async function fetchYahooChartWithRetry(symbol, params, period, maxRetries = 3)
1596
1661
  }
1597
1662
  }
1598
1663
  throw new Error(
1599
- formatYahooFailureMessage(
1600
- symbol,
1601
- params.interval,
1602
- period,
1603
- lastError,
1604
- maxRetries
1605
- )
1664
+ formatYahooFailureMessage(symbol, params.interval, period, lastError, maxRetries)
1606
1665
  );
1607
1666
  }
1608
1667
  async function fetchHistorical(symbol, interval = "5m", period = "60d", options = {}) {
@@ -1642,9 +1701,9 @@ async function fetchHistorical(symbol, interval = "5m", period = "60d", options
1642
1701
  period
1643
1702
  );
1644
1703
  chunks.push(...candles);
1645
- chunkEndMs = chunkStartMs - 1e3;
1646
1704
  remainingMs -= takeMs;
1647
- if (chunks.length > 2e6) break;
1705
+ chunkEndMs = chunkStartMs - 1e3;
1706
+ if (chunkEndMs <= 0 || chunks.length > 2e6) break;
1648
1707
  }
1649
1708
  return sanitizeBars(chunks);
1650
1709
  }
@@ -1720,11 +1779,7 @@ async function getHistoricalCandles(options = {}) {
1720
1779
  }
1721
1780
  return candles;
1722
1781
  }
1723
- async function backtestHistorical({
1724
- backtestOptions = {},
1725
- data,
1726
- ...legacy
1727
- } = {}) {
1782
+ async function backtestHistorical({ backtestOptions = {}, data, ...legacy } = {}) {
1728
1783
  const candles = await getHistoricalCandles(data || legacy);
1729
1784
  return backtest({
1730
1785
  candles,