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
@@ -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;
@@ -33,6 +34,7 @@ __export(index_exports, {
33
34
  backtest: () => backtest,
34
35
  backtestHistorical: () => backtestHistorical,
35
36
  backtestPortfolio: () => backtestPortfolio,
37
+ backtestTicks: () => backtestTicks,
36
38
  bpsOf: () => bpsOf,
37
39
  buildMetrics: () => buildMetrics,
38
40
  cachedCandlesPath: () => cachedCandlesPath,
@@ -188,6 +190,14 @@ var pct = (a, b) => (a - b) / b;
188
190
  function roundStep(value, step) {
189
191
  return Math.floor(value / step) * step;
190
192
  }
193
+ var warnedNonPositiveEquity = false;
194
+ function warnNonPositiveEquity(equity) {
195
+ if (warnedNonPositiveEquity) return;
196
+ warnedNonPositiveEquity = true;
197
+ console.warn(
198
+ `[tradelab] calculatePositionSize() received non-positive equity (${equity}); returning size 0`
199
+ );
200
+ }
191
201
  function calculatePositionSize({
192
202
  equity,
193
203
  entry,
@@ -197,6 +207,10 @@ function calculatePositionSize({
197
207
  minQty = 1e-3,
198
208
  maxLeverage = 2
199
209
  }) {
210
+ if (!Number.isFinite(equity) || equity <= 0) {
211
+ warnNonPositiveEquity(equity);
212
+ return 0;
213
+ }
200
214
  const riskPerUnit = Math.abs(entry - stop);
201
215
  if (!Number.isFinite(riskPerUnit) || riskPerUnit <= 0) return 0;
202
216
  const maxRiskDollars = Math.max(0, equity * riskFraction);
@@ -308,64 +322,95 @@ function percentile(values, percentileRank) {
308
322
  const index = Math.floor((sorted.length - 1) * percentileRank);
309
323
  return sorted[index];
310
324
  }
311
- function buildMetrics({
312
- closed,
313
- equityStart,
314
- equityFinal,
315
- candles,
316
- estBarMs,
317
- eqSeries
318
- }) {
319
- const completedTrades = closed.filter((trade) => trade.exit.reason !== "SCALE");
320
- const winningTrades = completedTrades.filter((trade) => trade.exit.pnl > 0);
321
- const losingTrades = completedTrades.filter((trade) => trade.exit.pnl < 0);
322
- const tradeRs = completedTrades.map(tradeRMultiple);
323
- const totalR = sum(tradeRs);
324
- const avgR = mean(tradeRs);
325
- const labels = completedTrades.map(
326
- (trade) => trade.exit.pnl > 0 ? "win" : trade.exit.pnl < 0 ? "loss" : "flat"
327
- );
328
- const { maxWin, maxLoss } = streaks(labels);
329
- const tradePnls = completedTrades.map((trade) => trade.exit.pnl);
330
- const expectancy = mean(tradePnls);
331
- const tradeReturns = completedTrades.map(
332
- (trade) => trade.exit.pnl / Math.max(1e-12, equityStart)
333
- );
334
- const tradeReturnStd = stddev(tradeReturns);
335
- const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
336
- const sortinoPerTrade = sortino(tradeReturns);
337
- const grossProfitPositions = sum(winningTrades.map((trade) => trade.exit.pnl));
338
- const grossLossPositions = Math.abs(
339
- sum(losingTrades.map((trade) => trade.exit.pnl))
340
- );
341
- const profitFactorPositions = grossLossPositions === 0 ? grossProfitPositions > 0 ? Infinity : 0 : grossProfitPositions / grossLossPositions;
325
+ var PROFIT_FACTOR_CAP = 1e6;
326
+ function finiteProfitFactor(grossProfit, grossLoss) {
327
+ if (grossLoss === 0) {
328
+ return grossProfit > 0 ? PROFIT_FACTOR_CAP : 0;
329
+ }
330
+ return grossProfit / grossLoss;
331
+ }
332
+ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqSeries }) {
342
333
  const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
343
- const winningLegs = legs.filter((trade) => trade.exit.pnl > 0);
344
- const losingLegs = legs.filter((trade) => trade.exit.pnl < 0);
345
- const grossProfitLegs = sum(winningLegs.map((trade) => trade.exit.pnl));
346
- const grossLossLegs = Math.abs(sum(losingLegs.map((trade) => trade.exit.pnl)));
347
- const profitFactorLegs = grossLossLegs === 0 ? grossProfitLegs > 0 ? Infinity : 0 : grossProfitLegs / grossLossLegs;
334
+ const completedTrades = [];
335
+ const tradeRs = [];
336
+ const tradePnls = [];
337
+ const tradeReturns = [];
338
+ const holdDurationsMinutes = [];
339
+ const labels = [];
340
+ const longRs = [];
341
+ const shortRs = [];
342
+ let totalR = 0;
343
+ let realizedPnL = 0;
344
+ let winningTradeCount = 0;
345
+ let grossProfitPositions = 0;
346
+ let grossLossPositions = 0;
347
+ let grossProfitLegs = 0;
348
+ let grossLossLegs = 0;
349
+ let winningLegCount = 0;
350
+ let openBars = 0;
351
+ let longTradesCount = 0;
352
+ let longTradeWins = 0;
353
+ let longPnLSum = 0;
354
+ let shortTradesCount = 0;
355
+ let shortTradeWins = 0;
356
+ let shortPnLSum = 0;
348
357
  let peakEquity = equityStart;
349
358
  let currentEquity = equityStart;
350
359
  let maxDrawdown = 0;
351
- for (const leg of legs) {
352
- currentEquity += leg.exit.pnl;
360
+ for (const trade of legs) {
361
+ const pnl = trade.exit.pnl;
362
+ realizedPnL += pnl;
363
+ if (pnl > 0) {
364
+ grossProfitLegs += pnl;
365
+ winningLegCount += 1;
366
+ } else if (pnl < 0) {
367
+ grossLossLegs += Math.abs(pnl);
368
+ }
369
+ currentEquity += pnl;
353
370
  if (currentEquity > peakEquity) peakEquity = currentEquity;
354
371
  const drawdown = (peakEquity - currentEquity) / Math.max(1e-12, peakEquity);
355
372
  if (drawdown > maxDrawdown) maxDrawdown = drawdown;
373
+ if (trade.exit.reason === "SCALE") continue;
374
+ completedTrades.push(trade);
375
+ tradePnls.push(pnl);
376
+ tradeReturns.push(pnl / Math.max(1e-12, equityStart));
377
+ const tradeR = tradeRMultiple(trade);
378
+ tradeRs.push(tradeR);
379
+ totalR += tradeR;
380
+ labels.push(pnl > 0 ? "win" : pnl < 0 ? "loss" : "flat");
381
+ const holdMinutes = (trade.exit.time - trade.openTime) / (1e3 * 60);
382
+ holdDurationsMinutes.push(holdMinutes);
383
+ openBars += Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
384
+ if (pnl > 0) {
385
+ winningTradeCount += 1;
386
+ grossProfitPositions += pnl;
387
+ } else if (pnl < 0) {
388
+ grossLossPositions += Math.abs(pnl);
389
+ }
390
+ if (trade.side === "long") {
391
+ longTradesCount += 1;
392
+ longPnLSum += pnl;
393
+ longRs.push(tradeR);
394
+ if (pnl > 0) longTradeWins += 1;
395
+ } else if (trade.side === "short") {
396
+ shortTradesCount += 1;
397
+ shortPnLSum += pnl;
398
+ shortRs.push(tradeR);
399
+ if (pnl > 0) shortTradeWins += 1;
400
+ }
356
401
  }
357
- const realizedPnL = sum(closed.map((trade) => trade.exit.pnl));
402
+ const avgR = mean(tradeRs);
403
+ const { maxWin, maxLoss } = streaks(labels);
404
+ const expectancy = mean(tradePnls);
405
+ const tradeReturnStd = stddev(tradeReturns);
406
+ const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
407
+ const sortinoPerTrade = sortino(tradeReturns);
408
+ const profitFactorPositions = finiteProfitFactor(grossProfitPositions, grossLossPositions);
409
+ const profitFactorLegs = finiteProfitFactor(grossProfitLegs, grossLossLegs);
358
410
  const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
359
411
  const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
360
412
  const totalBars = Math.max(1, candles.length);
361
- const openBars = completedTrades.reduce((total, trade) => {
362
- const barsHeld = Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
363
- return total + barsHeld;
364
- }, 0);
365
413
  const exposurePct = openBars / totalBars;
366
- const holdDurationsMinutes = completedTrades.map(
367
- (trade) => (trade.exit.time - trade.openTime) / (1e3 * 60)
368
- );
369
414
  const avgHoldMin = mean(holdDurationsMinutes);
370
415
  const equitySeries = eqSeries && eqSeries.length ? eqSeries : buildEquitySeriesFromLegs({ legs, equityStart });
371
416
  const dailyReturnsSeries = dailyReturns(equitySeries);
@@ -373,12 +418,6 @@ function buildMetrics({
373
418
  const sharpeDaily = dailyStd === 0 ? dailyReturnsSeries.length ? Infinity : 0 : mean(dailyReturnsSeries) / dailyStd;
374
419
  const sortinoDaily = sortino(dailyReturnsSeries);
375
420
  const dailyWinRate = dailyReturnsSeries.length ? dailyReturnsSeries.filter((value) => value > 0).length / dailyReturnsSeries.length : 0;
376
- const longTrades = completedTrades.filter((trade) => trade.side === "long");
377
- const shortTrades = completedTrades.filter((trade) => trade.side === "short");
378
- const longRs = longTrades.map(tradeRMultiple);
379
- const shortRs = shortTrades.map(tradeRMultiple);
380
- const longPnls = longTrades.map((trade) => trade.exit.pnl);
381
- const shortPnls = shortTrades.map((trade) => trade.exit.pnl);
382
421
  const rDistribution = {
383
422
  p10: percentile(tradeRs, 0.1),
384
423
  p25: percentile(tradeRs, 0.25),
@@ -395,21 +434,21 @@ function buildMetrics({
395
434
  };
396
435
  const sideBreakdown = {
397
436
  long: {
398
- trades: longTrades.length,
399
- winRate: longTrades.length ? longTrades.filter((trade) => trade.exit.pnl > 0).length / longTrades.length : 0,
400
- avgPnL: mean(longPnls),
437
+ trades: longTradesCount,
438
+ winRate: longTradesCount ? longTradeWins / longTradesCount : 0,
439
+ avgPnL: longTradesCount ? longPnLSum / longTradesCount : 0,
401
440
  avgR: mean(longRs)
402
441
  },
403
442
  short: {
404
- trades: shortTrades.length,
405
- winRate: shortTrades.length ? shortTrades.filter((trade) => trade.exit.pnl > 0).length / shortTrades.length : 0,
406
- avgPnL: mean(shortPnls),
443
+ trades: shortTradesCount,
444
+ winRate: shortTradesCount ? shortTradeWins / shortTradesCount : 0,
445
+ avgPnL: shortTradesCount ? shortPnLSum / shortTradesCount : 0,
407
446
  avgR: mean(shortRs)
408
447
  }
409
448
  };
410
449
  return {
411
450
  trades: completedTrades.length,
412
- winRate: completedTrades.length ? winningTrades.length / completedTrades.length : 0,
451
+ winRate: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
413
452
  profitFactor: profitFactorPositions,
414
453
  expectancy,
415
454
  totalR,
@@ -431,8 +470,8 @@ function buildMetrics({
431
470
  startEquity: equityStart,
432
471
  profitFactor_pos: profitFactorPositions,
433
472
  profitFactor_leg: profitFactorLegs,
434
- winRate_pos: completedTrades.length ? winningTrades.length / completedTrades.length : 0,
435
- winRate_leg: legs.length ? winningLegs.length / legs.length : 0,
473
+ winRate_pos: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
474
+ winRate_leg: legs.length ? winningLegCount / legs.length : 0,
436
475
  sharpeDaily,
437
476
  sortinoDaily,
438
477
  sideBreakdown,
@@ -536,7 +575,7 @@ function normalizeDateBoundary(value, fallback) {
536
575
  }
537
576
  function normalizeCandles(candles) {
538
577
  if (!Array.isArray(candles)) return [];
539
- const normalized = candles.map((bar) => {
578
+ const parsed = candles.map((bar) => {
540
579
  try {
541
580
  const time = resolveDate(bar?.time ?? bar?.timestamp ?? bar?.date);
542
581
  const open = Number(bar?.open ?? bar?.o);
@@ -558,7 +597,16 @@ function normalizeCandles(candles) {
558
597
  } catch {
559
598
  return null;
560
599
  }
561
- }).filter(Boolean).sort((left, right) => left.time - right.time);
600
+ }).filter(Boolean);
601
+ let reordered = false;
602
+ let duplicateCount = 0;
603
+ for (let index = 1; index < parsed.length; index += 1) {
604
+ const prev = parsed[index - 1].time;
605
+ const current = parsed[index].time;
606
+ if (current < prev) reordered = true;
607
+ if (current === prev) duplicateCount += 1;
608
+ }
609
+ const normalized = parsed.sort((left, right) => left.time - right.time);
562
610
  const deduped = [];
563
611
  let lastTime = null;
564
612
  for (const candle of normalized) {
@@ -566,6 +614,12 @@ function normalizeCandles(candles) {
566
614
  deduped.push(candle);
567
615
  lastTime = candle.time;
568
616
  }
617
+ const removedDuplicates = normalized.length - deduped.length;
618
+ if (reordered || removedDuplicates > 0 || duplicateCount > 0) {
619
+ console.warn(
620
+ `[tradelab] normalizeCandles() reordered or deduplicated candles (input=${candles.length}, valid=${parsed.length}, output=${deduped.length})`
621
+ );
622
+ }
569
623
  return deduped;
570
624
  }
571
625
  function loadCandlesFromCSV(filePath, options = {}) {
@@ -608,9 +662,7 @@ function loadCandlesFromCSV(filePath, options = {}) {
608
662
  const closeIdx = resolveColumn(closeCol, headerIndex, ["c", "adj close"]);
609
663
  const volumeIdx = resolveColumn(volumeCol, headerIndex, ["v", "vol", "quantity"]);
610
664
  if (timeIdx < 0 || openIdx < 0 || highIdx < 0 || lowIdx < 0 || closeIdx < 0) {
611
- throw new Error(
612
- `Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`
613
- );
665
+ throw new Error(`Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`);
614
666
  }
615
667
  const minTime = normalizeDateBoundary(startDate, -Infinity);
616
668
  const maxTime = normalizeDateBoundary(endDate, Infinity);
@@ -725,18 +777,12 @@ function usDstBoundsUTC(year) {
725
777
  if (sundaysSeen === 2) break;
726
778
  marchCursor = new Date(marchCursor.getTime() + 24 * 60 * 60 * 1e3);
727
779
  }
728
- const dstStart = new Date(
729
- Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0)
730
- );
780
+ const dstStart = new Date(Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0));
731
781
  let novemberCursor = new Date(Date.UTC(year, 10, 1, 6, 0, 0));
732
782
  while (novemberCursor.getUTCDay() !== 0) {
733
- novemberCursor = new Date(
734
- novemberCursor.getTime() + 24 * 60 * 60 * 1e3
735
- );
783
+ novemberCursor = new Date(novemberCursor.getTime() + 24 * 60 * 60 * 1e3);
736
784
  }
737
- const dstEnd = new Date(
738
- Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0)
739
- );
785
+ const dstEnd = new Date(Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0));
740
786
  return { dstStart, dstEnd };
741
787
  }
742
788
  function isUsEasternDST(timeMs) {
@@ -804,11 +850,7 @@ function applyFill(price, side, { slippageBps = 0, feeBps = 0, kind = "market",
804
850
  const model = costs || {};
805
851
  const modelSlippageBps = Number.isFinite(model.slippageBps) ? model.slippageBps : slippageBps;
806
852
  const modelFeeBps = Number.isFinite(model.commissionBps) ? model.commissionBps : feeBps;
807
- const effectiveSlippageBps = resolveSlippageBps(
808
- kind,
809
- modelSlippageBps,
810
- model.slippageByKind
811
- );
853
+ const effectiveSlippageBps = resolveSlippageBps(kind, modelSlippageBps, model.slippageByKind);
812
854
  const halfSpreadBps = Number.isFinite(model.spreadBps) ? model.spreadBps / 2 : 0;
813
855
  const slippage = (effectiveSlippageBps + halfSpreadBps) / 1e4 * price;
814
856
  const filledPrice = side === "long" ? price + slippage : price - slippage;
@@ -835,14 +877,7 @@ function touchedLimit(side, limitPrice, bar, mode = "intrabar") {
835
877
  }
836
878
  return side === "long" ? bar.low <= limitPrice : bar.high >= limitPrice;
837
879
  }
838
- function ocoExitCheck({
839
- side,
840
- stop,
841
- tp,
842
- bar,
843
- mode = "intrabar",
844
- tieBreak = "pessimistic"
845
- }) {
880
+ function ocoExitCheck({ side, stop, tp, bar, mode = "intrabar", tieBreak = "pessimistic" }) {
846
881
  if (mode === "close") {
847
882
  const close = bar.close;
848
883
  if (side === "long") {
@@ -923,13 +958,51 @@ function strictHistoryView(candles, currentIndex) {
923
958
  get(target, property, receiver) {
924
959
  if (isArrayIndexKey(property) && Number(property) >= target.length) {
925
960
  throw new Error(
926
- `strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
961
+ `strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
927
962
  );
928
963
  }
929
964
  return Reflect.get(target, property, receiver);
930
965
  }
931
966
  });
932
967
  }
968
+ function describeValue(value) {
969
+ if (Array.isArray(value)) return `array(length=${value.length})`;
970
+ if (value === null) return "null";
971
+ return typeof value;
972
+ }
973
+ function formatIsoTime(time) {
974
+ return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
975
+ }
976
+ function callSignalWithContext({ signal, context, index, bar, symbol }) {
977
+ try {
978
+ return signal(context);
979
+ } catch (error) {
980
+ const cause = error instanceof Error ? error.message : String(error);
981
+ throw new Error(
982
+ `signal() threw at index=${index}, time=${formatIsoTime(bar?.time)}, symbol=${symbol}: ${cause}`
983
+ );
984
+ }
985
+ }
986
+ function snapshotOpenPosition(open, markPrice) {
987
+ if (!open) return null;
988
+ const entryPrice = open.entryFill ?? open.entry;
989
+ const direction = open.side === "long" ? 1 : -1;
990
+ const unrealizedPnl = (markPrice - entryPrice) * direction * open.size;
991
+ return {
992
+ id: open.id,
993
+ symbol: open.symbol,
994
+ side: open.side,
995
+ size: open.size,
996
+ entry: open.entry,
997
+ entryFill: open.entryFill,
998
+ stop: open.stop,
999
+ takeProfit: open.takeProfit,
1000
+ openTime: open.openTime,
1001
+ markPrice,
1002
+ unrealizedPnl,
1003
+ _initRisk: open._initRisk
1004
+ };
1005
+ }
933
1006
  function mergeOptions(options) {
934
1007
  const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
935
1008
  return {
@@ -1071,10 +1144,10 @@ function backtest(rawOptions) {
1071
1144
  strict
1072
1145
  } = options;
1073
1146
  if (!Array.isArray(candles) || candles.length === 0) {
1074
- throw new Error("backtest() requires a non-empty candles array");
1147
+ throw new Error(`backtest() requires a non-empty candles array, got ${describeValue(candles)}`);
1075
1148
  }
1076
1149
  if (typeof signal !== "function") {
1077
- throw new Error("backtest() requires a signal function");
1150
+ throw new Error(`backtest() requires a signal function, got ${describeValue(signal)}`);
1078
1151
  }
1079
1152
  const closed = [];
1080
1153
  let currentEquity = equity;
@@ -1223,17 +1296,13 @@ function backtest(rawOptions) {
1223
1296
  });
1224
1297
  const size = roundStep2(rawSize, qtyStep);
1225
1298
  if (size < minQty) return false;
1226
- const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
1227
- entryPrice,
1228
- pending.side,
1229
- {
1230
- slippageBps,
1231
- feeBps,
1232
- kind: fillKind,
1233
- qty: size,
1234
- costs
1235
- }
1236
- );
1299
+ const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, pending.side, {
1300
+ slippageBps,
1301
+ feeBps,
1302
+ kind: fillKind,
1303
+ qty: size,
1304
+ costs
1305
+ });
1237
1306
  open = {
1238
1307
  symbol,
1239
1308
  ...pending.meta,
@@ -1284,10 +1353,7 @@ function backtest(rawOptions) {
1284
1353
  dayEquityStart = currentEquity;
1285
1354
  }
1286
1355
  if (open && open._maxBarsInTrade > 0) {
1287
- const barsHeld = Math.max(
1288
- 1,
1289
- Math.round((bar.time - open.openTime) / estimatedBarMs)
1290
- );
1356
+ const barsHeld = Math.max(1, Math.round((bar.time - open.openTime) / estimatedBarMs));
1291
1357
  if (barsHeld >= open._maxBarsInTrade) {
1292
1358
  forceExit("TIME", bar);
1293
1359
  }
@@ -1341,11 +1407,13 @@ function backtest(rawOptions) {
1341
1407
  const cutQty = roundStep2(open.size * volScale.cutFrac, qtyStep);
1342
1408
  if (cutQty >= minQty && cutQty < open.size) {
1343
1409
  const exitSide2 = open.side === "long" ? "short" : "long";
1344
- const { price: filled, feeTotal: exitFeeTotal } = applyFill(
1345
- bar.close,
1346
- exitSide2,
1347
- { slippageBps, feeBps, kind: "market", qty: cutQty, costs }
1348
- );
1410
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide2, {
1411
+ slippageBps,
1412
+ feeBps,
1413
+ kind: "market",
1414
+ qty: cutQty,
1415
+ costs
1416
+ });
1349
1417
  closeLeg({
1350
1418
  openPos: open,
1351
1419
  qty: cutQty,
@@ -1370,11 +1438,13 @@ function backtest(rawOptions) {
1370
1438
  const baseSize = open.baseSize || open.initSize;
1371
1439
  const addQty = roundStep2(baseSize * pyramiding.addFrac, qtyStep);
1372
1440
  if (addQty >= minQty) {
1373
- const { price: addFill, feeTotal: addFeeTotal } = applyFill(
1374
- triggerPrice,
1375
- open.side,
1376
- { slippageBps, feeBps, kind: "limit", qty: addQty, costs }
1377
- );
1441
+ const { price: addFill, feeTotal: addFeeTotal } = applyFill(triggerPrice, open.side, {
1442
+ slippageBps,
1443
+ feeBps,
1444
+ kind: "limit",
1445
+ qty: addQty,
1446
+ costs
1447
+ });
1378
1448
  const newSize = open.size + addQty;
1379
1449
  open.entryFeeTotal += addFeeTotal;
1380
1450
  open.entryFill = (open.entryFill * open.size + addFill * addQty) / newSize;
@@ -1452,15 +1522,10 @@ function backtest(rawOptions) {
1452
1522
  if (!open && pending) {
1453
1523
  if (index > pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
1454
1524
  if (entryChase.enabled && entryChase.convertOnExpiry) {
1455
- const riskAtEdge = Math.abs(
1456
- pending.meta._initRisk ?? pending.entry - pending.stop
1457
- );
1525
+ const riskAtEdge = Math.abs(pending.meta._initRisk ?? pending.entry - pending.stop);
1458
1526
  const priceNow = bar.close;
1459
1527
  const direction = pending.side === "long" ? 1 : -1;
1460
- const slippedR = Math.max(
1461
- 0,
1462
- direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
1463
- ) / Math.max(1e-8, riskAtEdge);
1528
+ const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskAtEdge);
1464
1529
  if (slippedR > maxSlipROnFill) {
1465
1530
  pending = null;
1466
1531
  } else if (!openFromPending(bar, index, priceNow, "market")) {
@@ -1475,21 +1540,16 @@ function backtest(rawOptions) {
1475
1540
  }
1476
1541
  } else if (entryChase.enabled) {
1477
1542
  const elapsedBars = index - (pending.startedAtIndex ?? index);
1478
- const midpoint = pending.meta?._imb?.mid;
1479
- if (!pending._chasedCE && midpoint !== void 0 && elapsedBars >= Math.max(1, entryChase.afterBars)) {
1543
+ const midpoint = asNumber(pending.meta?._imb?.mid);
1544
+ if (!pending._chasedCE && midpoint !== null && elapsedBars >= Math.max(1, entryChase.afterBars)) {
1480
1545
  pending.entry = midpoint;
1481
1546
  pending._chasedCE = true;
1482
1547
  }
1483
1548
  if (pending._chasedCE) {
1484
- const riskRef = Math.abs(
1485
- pending.meta?._initRisk ?? pending.entry - pending.stop
1486
- );
1549
+ const riskRef = Math.abs(pending.meta?._initRisk ?? pending.entry - pending.stop);
1487
1550
  const priceNow = bar.close;
1488
1551
  const direction = pending.side === "long" ? 1 : -1;
1489
- const slippedR = Math.max(
1490
- 0,
1491
- direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
1492
- ) / Math.max(1e-8, riskRef);
1552
+ const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskRef);
1493
1553
  if (slippedR > maxSlipROnFill) {
1494
1554
  pending = null;
1495
1555
  } else if (slippedR > 0 && slippedR <= entryChase.maxSlipR) {
@@ -1517,13 +1577,19 @@ function backtest(rawOptions) {
1517
1577
  );
1518
1578
  }
1519
1579
  const signalCandles = strict ? strictHistoryView(history, index) : history;
1520
- const rawSignal = signal({
1521
- candles: signalCandles,
1580
+ const rawSignal = callSignalWithContext({
1581
+ signal,
1582
+ context: {
1583
+ candles: signalCandles,
1584
+ index,
1585
+ bar,
1586
+ equity: currentEquity,
1587
+ openPosition: open,
1588
+ pendingOrder: pending
1589
+ },
1522
1590
  index,
1523
1591
  bar,
1524
- equity: currentEquity,
1525
- openPosition: open,
1526
- pendingOrder: pending
1592
+ symbol
1527
1593
  });
1528
1594
  const nextSignal = normalizeSignal(rawSignal, bar, finalTP_R);
1529
1595
  if (nextSignal) {
@@ -1539,9 +1605,7 @@ function backtest(rawOptions) {
1539
1605
  expiresAt: index + Math.max(1, expiryBars),
1540
1606
  startedAtIndex: index,
1541
1607
  meta: nextSignal,
1542
- plannedRiskAbs: Math.abs(
1543
- nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop
1544
- )
1608
+ plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
1545
1609
  };
1546
1610
  if (touchedLimit(pending.side, pending.entry, bar, trigger)) {
1547
1611
  if (!openFromPending(bar, index, pending.entry, "limit")) {
@@ -1561,12 +1625,15 @@ function backtest(rawOptions) {
1561
1625
  eqSeries
1562
1626
  });
1563
1627
  const positions = closed.filter((trade) => trade.exit.reason !== "SCALE");
1628
+ const lastPrice = asNumber(candles[candles.length - 1]?.close);
1629
+ const openPositions = open ? [snapshotOpenPosition(open, lastPrice ?? open.entryFill ?? open.entry)] : [];
1564
1630
  return {
1565
1631
  symbol: options.symbol,
1566
1632
  interval: options.interval,
1567
1633
  range: options.range,
1568
1634
  trades: closed,
1569
1635
  positions,
1636
+ openPositions,
1570
1637
  metrics,
1571
1638
  eqSeries,
1572
1639
  replay: {
@@ -1576,126 +1643,1474 @@ function backtest(rawOptions) {
1576
1643
  };
1577
1644
  }
1578
1645
 
1579
- // src/engine/portfolio.js
1580
- function asWeight(value) {
1581
- return Number.isFinite(value) && value > 0 ? value : 0;
1646
+ // src/engine/backtestTicks.js
1647
+ function asNumber2(value) {
1648
+ const numeric = Number(value);
1649
+ return Number.isFinite(numeric) ? numeric : null;
1582
1650
  }
1583
- function combineEquitySeries(systemRuns, totalEquity) {
1584
- const timeline = /* @__PURE__ */ new Set();
1585
- for (const run of systemRuns) {
1586
- for (const point of run.result.eqSeries || []) {
1587
- timeline.add(point.time);
1588
- }
1651
+ function describeValue2(value) {
1652
+ if (Array.isArray(value)) return `array(length=${value.length})`;
1653
+ if (value === null) return "null";
1654
+ return typeof value;
1655
+ }
1656
+ function formatIsoTime2(time) {
1657
+ return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
1658
+ }
1659
+ function callSignalWithContext2({ signal, context, index, bar, symbol }) {
1660
+ try {
1661
+ return signal(context);
1662
+ } catch (error) {
1663
+ const cause = error instanceof Error ? error.message : String(error);
1664
+ throw new Error(
1665
+ `signal() threw at index=${index}, time=${formatIsoTime2(bar?.time)}, symbol=${symbol}: ${cause}`
1666
+ );
1589
1667
  }
1590
- const times = [...timeline].sort((left, right) => left - right);
1591
- if (!times.length) {
1592
- return [{ time: 0, timestamp: 0, equity: totalEquity }];
1668
+ }
1669
+ function normalizeSide2(value) {
1670
+ if (value === "long" || value === "buy") return "long";
1671
+ if (value === "short" || value === "sell") return "short";
1672
+ return null;
1673
+ }
1674
+ function normalizeTick(tick) {
1675
+ const time = Number(tick?.time);
1676
+ const bid = asNumber2(tick?.bid);
1677
+ const ask = asNumber2(tick?.ask);
1678
+ const last = asNumber2(tick?.price ?? tick?.last ?? tick?.close);
1679
+ const mid = bid !== null && ask !== null ? (bid + ask) / 2 : last ?? bid ?? ask;
1680
+ if (!Number.isFinite(time) || !Number.isFinite(mid)) return null;
1681
+ const prices = [asNumber2(tick?.low), asNumber2(tick?.high), bid, ask, last, mid].filter(
1682
+ Number.isFinite
1683
+ );
1684
+ const low = prices.length ? Math.min(...prices) : mid;
1685
+ const high = prices.length ? Math.max(...prices) : mid;
1686
+ return {
1687
+ ...tick,
1688
+ time,
1689
+ open: mid,
1690
+ high,
1691
+ low,
1692
+ close: mid,
1693
+ volume: asNumber2(tick?.size ?? tick?.volume) ?? void 0
1694
+ };
1695
+ }
1696
+ function normalizeSignal2(signal, bar, fallbackR) {
1697
+ if (!signal) return null;
1698
+ const side = normalizeSide2(signal.side ?? signal.direction ?? signal.action);
1699
+ if (!side) return null;
1700
+ const hasExplicitEntry = signal.entry !== void 0 || signal.limit !== void 0 || signal.price !== void 0;
1701
+ const entry = asNumber2(signal.entry ?? signal.limit ?? signal.price) ?? asNumber2(bar?.close);
1702
+ const stop = asNumber2(signal.stop ?? signal.stopLoss ?? signal.sl);
1703
+ if (entry === null || stop === null) return null;
1704
+ const risk = Math.abs(entry - stop);
1705
+ if (!(risk > 0)) return null;
1706
+ let takeProfit = asNumber2(signal.takeProfit ?? signal.target ?? signal.tp);
1707
+ const rrHint = asNumber2(signal._rr ?? signal.rr);
1708
+ const targetR = rrHint ?? fallbackR;
1709
+ if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
1710
+ takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
1593
1711
  }
1594
- const states = systemRuns.map((run) => ({
1595
- points: run.result.eqSeries || [],
1596
- index: 0,
1597
- lastEquity: run.allocationEquity
1598
- }));
1599
- return times.map((time) => {
1600
- let equity = 0;
1601
- states.forEach((state) => {
1602
- while (state.index < state.points.length && state.points[state.index].time <= time) {
1603
- state.lastEquity = state.points[state.index].equity;
1604
- state.index += 1;
1605
- }
1606
- equity += state.lastEquity;
1607
- });
1608
- return { time, timestamp: time, equity };
1609
- });
1712
+ if (takeProfit === null) return null;
1713
+ return {
1714
+ ...signal,
1715
+ side,
1716
+ entry,
1717
+ stop,
1718
+ takeProfit,
1719
+ qty: asNumber2(signal.qty ?? signal.size),
1720
+ riskPct: asNumber2(signal.riskPct),
1721
+ riskFraction: asNumber2(signal.riskFraction),
1722
+ orderType: hasExplicitEntry ? "limit" : "market"
1723
+ };
1610
1724
  }
1611
- function combineReplay(systemRuns, eqSeries, collectReplay) {
1612
- if (!collectReplay) {
1613
- return { frames: [], events: [] };
1725
+ function equityPoint2(time, equity) {
1726
+ return { time, timestamp: time, equity };
1727
+ }
1728
+ function xmur3(seed) {
1729
+ let hash = 1779033703 ^ seed.length;
1730
+ for (let index = 0; index < seed.length; index += 1) {
1731
+ hash = Math.imul(hash ^ seed.charCodeAt(index), 3432918353);
1732
+ hash = hash << 13 | hash >>> 19;
1614
1733
  }
1615
- const events = systemRuns.flatMap(
1616
- (run) => (run.result.replay?.events || []).map((event) => ({
1617
- ...event,
1618
- symbol: event.symbol || run.symbol
1619
- }))
1620
- ).sort((left, right) => new Date(left.t).getTime() - new Date(right.t).getTime());
1621
- const frames = eqSeries.map((point) => ({
1622
- t: new Date(point.time).toISOString(),
1623
- price: 0,
1624
- equity: point.equity,
1625
- posSide: null,
1626
- posSize: 0
1627
- }));
1628
- return { frames, events };
1734
+ return () => {
1735
+ hash = Math.imul(hash ^ hash >>> 16, 2246822507);
1736
+ hash = Math.imul(hash ^ hash >>> 13, 3266489909);
1737
+ return (hash ^= hash >>> 16) >>> 0;
1738
+ };
1629
1739
  }
1630
- function backtestPortfolio({
1631
- systems = [],
1740
+ function mulberry32(seed) {
1741
+ let state = seed >>> 0;
1742
+ return () => {
1743
+ state = state + 1831565813 >>> 0;
1744
+ let value = Math.imul(state ^ state >>> 15, state | 1);
1745
+ value ^= value + Math.imul(value ^ value >>> 7, value | 61);
1746
+ return ((value ^ value >>> 14) >>> 0) / 4294967296;
1747
+ };
1748
+ }
1749
+ function seededUnitInterval(seedParts) {
1750
+ const seed = seedParts.map((part) => String(part)).join("|");
1751
+ const seedFn = xmur3(seed);
1752
+ return mulberry32(seedFn())();
1753
+ }
1754
+ function deterministicFill(probability, seedParts) {
1755
+ if (probability >= 1) return true;
1756
+ if (probability <= 0) return false;
1757
+ const normalized = seededUnitInterval(seedParts);
1758
+ return normalized <= probability;
1759
+ }
1760
+ function backtestTicks({
1761
+ ticks = [],
1762
+ symbol = "UNKNOWN",
1632
1763
  equity = 1e4,
1633
- allocation = "equal",
1764
+ riskPct = 1,
1765
+ signal,
1766
+ interval,
1767
+ range,
1768
+ slippageBps = 1,
1769
+ feeBps = 0,
1770
+ costs = null,
1771
+ finalTP_R = 3,
1772
+ maxDailyLossPct = 0,
1773
+ dailyMaxTrades = 0,
1774
+ qtyStep = 1e-3,
1775
+ minQty = 1e-3,
1776
+ maxLeverage = 2,
1634
1777
  collectEqSeries = true,
1635
- collectReplay = false
1778
+ collectReplay = true,
1779
+ queueFillProbability = 1,
1780
+ oco = {}
1636
1781
  } = {}) {
1637
- if (!Array.isArray(systems) || systems.length === 0) {
1638
- throw new Error("backtestPortfolio() requires a non-empty systems array");
1782
+ if (!Array.isArray(ticks) || ticks.length === 0) {
1783
+ throw new Error(
1784
+ `backtestTicks() requires a non-empty ticks array, got ${describeValue2(ticks)}`
1785
+ );
1639
1786
  }
1640
- const weights = allocation === "equal" ? systems.map(() => 1) : systems.map((system) => asWeight(system.weight || 0));
1641
- const totalWeight = weights.reduce((sum2, weight) => sum2 + weight, 0);
1642
- if (!(totalWeight > 0)) {
1643
- throw new Error("backtestPortfolio() requires positive allocation weights");
1644
- }
1645
- const systemRuns = systems.map((system, index) => {
1646
- const allocationEquity = equity * (weights[index] / totalWeight);
1647
- const result = backtest({
1648
- ...system,
1649
- equity: allocationEquity,
1650
- collectEqSeries,
1651
- collectReplay
1787
+ if (typeof signal !== "function") {
1788
+ throw new Error(`backtestTicks() requires a signal function, got ${describeValue2(signal)}`);
1789
+ }
1790
+ const normalizedTicks = ticks.map(normalizeTick).filter(Boolean);
1791
+ if (!normalizedTicks.length) {
1792
+ throw new Error(
1793
+ `backtestTicks() could not normalize any ticks from ${ticks.length} input rows`
1794
+ );
1795
+ }
1796
+ const ocoOptions = {
1797
+ mode: "intrabar",
1798
+ tieBreak: "pessimistic",
1799
+ ...oco
1800
+ };
1801
+ const trades = [];
1802
+ const eqSeries = collectEqSeries ? [equityPoint2(normalizedTicks[0].time, equity)] : [];
1803
+ const replayFrames = collectReplay ? [] : [];
1804
+ const replayEvents = collectReplay ? [] : [];
1805
+ const history = [];
1806
+ let open = null;
1807
+ let pending = null;
1808
+ let currentEquity = equity;
1809
+ let dayKey = null;
1810
+ let dayStartEquity = equity;
1811
+ let dayPnl = 0;
1812
+ let dayTrades = 0;
1813
+ let tradeIdCounter = 0;
1814
+ function markedEquity(tick) {
1815
+ if (!open) return currentEquity;
1816
+ const direction = open.side === "long" ? 1 : -1;
1817
+ return currentEquity + (tick.close - open.entryFill) * direction * open.size;
1818
+ }
1819
+ function recordFrame(tick) {
1820
+ const equityNow = markedEquity(tick);
1821
+ if (collectEqSeries) {
1822
+ eqSeries.push(equityPoint2(tick.time, equityNow));
1823
+ }
1824
+ if (collectReplay) {
1825
+ replayFrames.push({
1826
+ t: new Date(tick.time).toISOString(),
1827
+ price: tick.close,
1828
+ equity: equityNow,
1829
+ posSide: open?.side ?? null,
1830
+ posSize: open?.size ?? 0
1831
+ });
1832
+ }
1833
+ }
1834
+ function closePosition(tick, reason, rawPrice, fillKind) {
1835
+ if (!open) return;
1836
+ const exitSide = open.side === "long" ? "short" : "long";
1837
+ const { price, feeTotal } = applyFill(rawPrice, exitSide, {
1838
+ slippageBps,
1839
+ feeBps,
1840
+ kind: fillKind,
1841
+ qty: open.size,
1842
+ costs
1652
1843
  });
1653
- return {
1654
- symbol: system.symbol ?? result.symbol ?? `system-${index + 1}`,
1655
- weight: weights[index],
1656
- allocationEquity,
1657
- result
1844
+ const direction = open.side === "long" ? 1 : -1;
1845
+ const grossPnl = (price - open.entryFill) * direction * open.size;
1846
+ const pnl = grossPnl - (open.entryFeeTotal || 0) - feeTotal;
1847
+ currentEquity += pnl;
1848
+ dayPnl += pnl;
1849
+ const trade = {
1850
+ ...open,
1851
+ exit: {
1852
+ price,
1853
+ time: tick.time,
1854
+ reason,
1855
+ pnl
1856
+ }
1658
1857
  };
1659
- });
1660
- const trades = systemRuns.flatMap(
1661
- (run) => run.result.trades.map((trade) => ({
1662
- ...trade,
1663
- symbol: trade.symbol || run.symbol
1664
- }))
1665
- ).sort((left, right) => left.exit.time - right.exit.time);
1666
- const positions = systemRuns.flatMap(
1667
- (run) => run.result.positions.map((trade) => ({
1668
- ...trade,
1669
- symbol: trade.symbol || run.symbol
1670
- }))
1671
- ).sort((left, right) => left.exit.time - right.exit.time);
1672
- const eqSeries = collectEqSeries ? combineEquitySeries(systemRuns, equity) : [];
1673
- const replay = combineReplay(systemRuns, eqSeries, collectReplay);
1674
- const allCandles = systems.flatMap((system) => system.candles || []);
1675
- const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
1858
+ trades.push(trade);
1859
+ if (collectReplay) {
1860
+ replayEvents.push({
1861
+ t: new Date(tick.time).toISOString(),
1862
+ price,
1863
+ type: reason === "TP" ? "tp" : reason === "SL" ? "sl" : "exit",
1864
+ side: open.side,
1865
+ size: open.size,
1866
+ tradeId: open.id,
1867
+ reason,
1868
+ pnl
1869
+ });
1870
+ }
1871
+ open = null;
1872
+ }
1873
+ for (let index = 0; index < normalizedTicks.length; index += 1) {
1874
+ const tick = normalizedTicks[index];
1875
+ history.push(tick);
1876
+ const currentDayKey = dayKeyUTC2(tick.time);
1877
+ if (dayKey === null || currentDayKey !== dayKey) {
1878
+ dayKey = currentDayKey;
1879
+ dayStartEquity = currentEquity;
1880
+ dayPnl = 0;
1881
+ dayTrades = 0;
1882
+ }
1883
+ if (open) {
1884
+ const { hit, px } = ocoExitCheck({
1885
+ side: open.side,
1886
+ stop: open.stop,
1887
+ tp: open.takeProfit,
1888
+ bar: tick,
1889
+ mode: "intrabar",
1890
+ tieBreak: ocoOptions.tieBreak
1891
+ });
1892
+ if (hit) {
1893
+ closePosition(tick, hit, px, hit === "TP" ? "limit" : "stop");
1894
+ }
1895
+ }
1896
+ if (!open && pending && index > pending.createdAtIndex) {
1897
+ if (pending.orderType === "market") {
1898
+ const rawSize = pending.fixedQty ?? calculatePositionSize({
1899
+ equity: currentEquity,
1900
+ entry: tick.close,
1901
+ stop: pending.stop,
1902
+ riskFraction: pending.riskFrac,
1903
+ qtyStep,
1904
+ minQty,
1905
+ maxLeverage
1906
+ });
1907
+ const size = roundStep2(rawSize, qtyStep);
1908
+ if (size >= minQty) {
1909
+ const { price, feeTotal } = applyFill(tick.close, pending.side, {
1910
+ slippageBps,
1911
+ feeBps,
1912
+ kind: "market",
1913
+ qty: size,
1914
+ costs
1915
+ });
1916
+ open = {
1917
+ symbol,
1918
+ id: ++tradeIdCounter,
1919
+ side: pending.side,
1920
+ entry: tick.close,
1921
+ stop: pending.stop,
1922
+ takeProfit: pending.takeProfit,
1923
+ size,
1924
+ openTime: tick.time,
1925
+ entryFill: price,
1926
+ entryFeeTotal: feeTotal,
1927
+ _initRisk: Math.abs(tick.close - pending.stop)
1928
+ };
1929
+ dayTrades += 1;
1930
+ if (collectReplay) {
1931
+ replayEvents.push({
1932
+ t: new Date(tick.time).toISOString(),
1933
+ price,
1934
+ type: "entry",
1935
+ side: open.side,
1936
+ size,
1937
+ tradeId: open.id
1938
+ });
1939
+ }
1940
+ }
1941
+ pending = null;
1942
+ } else {
1943
+ const touched = pending.side === "long" ? tick.low <= pending.entry : tick.high >= pending.entry;
1944
+ if (touched && deterministicFill(queueFillProbability, [
1945
+ symbol,
1946
+ tick.time,
1947
+ pending.entry,
1948
+ pending.stop,
1949
+ pending.side
1950
+ ])) {
1951
+ const rawSize = pending.fixedQty ?? calculatePositionSize({
1952
+ equity: currentEquity,
1953
+ entry: pending.entry,
1954
+ stop: pending.stop,
1955
+ riskFraction: pending.riskFrac,
1956
+ qtyStep,
1957
+ minQty,
1958
+ maxLeverage
1959
+ });
1960
+ const size = roundStep2(rawSize, qtyStep);
1961
+ if (size >= minQty) {
1962
+ const { price, feeTotal } = applyFill(pending.entry, pending.side, {
1963
+ slippageBps,
1964
+ feeBps,
1965
+ kind: "limit",
1966
+ qty: size,
1967
+ costs
1968
+ });
1969
+ open = {
1970
+ symbol,
1971
+ id: ++tradeIdCounter,
1972
+ side: pending.side,
1973
+ entry: pending.entry,
1974
+ stop: pending.stop,
1975
+ takeProfit: pending.takeProfit,
1976
+ size,
1977
+ openTime: tick.time,
1978
+ entryFill: price,
1979
+ entryFeeTotal: feeTotal,
1980
+ _initRisk: Math.abs(pending.entry - pending.stop)
1981
+ };
1982
+ dayTrades += 1;
1983
+ if (collectReplay) {
1984
+ replayEvents.push({
1985
+ t: new Date(tick.time).toISOString(),
1986
+ price,
1987
+ type: "entry",
1988
+ side: open.side,
1989
+ size,
1990
+ tradeId: open.id
1991
+ });
1992
+ }
1993
+ }
1994
+ pending = null;
1995
+ }
1996
+ }
1997
+ }
1998
+ const maxLossDollars = Math.abs(maxDailyLossPct) / 100 * dayStartEquity;
1999
+ const dailyLossHit = maxDailyLossPct > 0 && dayPnl <= -maxLossDollars;
2000
+ const dailyTradeCapHit = dailyMaxTrades > 0 && dayTrades >= dailyMaxTrades;
2001
+ if (!open && !pending && !dailyLossHit && !dailyTradeCapHit) {
2002
+ const nextSignal = normalizeSignal2(
2003
+ callSignalWithContext2({
2004
+ signal,
2005
+ context: {
2006
+ candles: history,
2007
+ index,
2008
+ bar: tick,
2009
+ equity: markedEquity(tick),
2010
+ openPosition: open,
2011
+ pendingOrder: pending
2012
+ },
2013
+ index,
2014
+ bar: tick,
2015
+ symbol
2016
+ }),
2017
+ tick,
2018
+ finalTP_R
2019
+ );
2020
+ if (nextSignal) {
2021
+ pending = {
2022
+ side: nextSignal.side,
2023
+ entry: nextSignal.entry,
2024
+ stop: nextSignal.stop,
2025
+ takeProfit: nextSignal.takeProfit,
2026
+ fixedQty: nextSignal.qty,
2027
+ riskFrac: Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : riskPct / 100,
2028
+ orderType: nextSignal.orderType,
2029
+ createdAtIndex: index
2030
+ };
2031
+ }
2032
+ }
2033
+ recordFrame(tick);
2034
+ }
2035
+ if (open) {
2036
+ const lastTick = normalizedTicks[normalizedTicks.length - 1];
2037
+ closePosition(lastTick, "EOT", lastTick.close, "market");
2038
+ recordFrame(lastTick);
2039
+ }
2040
+ const positions = trades;
1676
2041
  const metrics = buildMetrics({
1677
2042
  closed: trades,
1678
2043
  equityStart: equity,
1679
- equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : equity,
1680
- candles: orderedCandles,
1681
- estBarMs: estimateBarMs(orderedCandles),
2044
+ equityFinal: currentEquity,
2045
+ candles: normalizedTicks,
2046
+ estBarMs: normalizedTicks.length > 1 ? Math.max(1, normalizedTicks[1].time - normalizedTicks[0].time) : 1,
1682
2047
  eqSeries
1683
2048
  });
1684
2049
  return {
1685
- symbol: "PORTFOLIO",
1686
- interval: void 0,
1687
- range: void 0,
2050
+ symbol,
2051
+ interval,
2052
+ range,
1688
2053
  trades,
1689
2054
  positions,
2055
+ openPositions: [],
1690
2056
  metrics,
1691
2057
  eqSeries,
1692
- replay,
1693
- systems: systemRuns.map((run) => ({
1694
- symbol: run.symbol,
1695
- weight: run.weight / totalWeight,
1696
- equity: run.allocationEquity,
1697
- result: run.result
2058
+ replay: {
2059
+ frames: replayFrames,
2060
+ events: replayEvents
2061
+ }
2062
+ };
2063
+ }
2064
+
2065
+ // src/engine/barSystemRunner.js
2066
+ function asNumber3(value) {
2067
+ const numeric = Number(value);
2068
+ return Number.isFinite(numeric) ? numeric : null;
2069
+ }
2070
+ function equityPoint3(time, equity, extra = {}) {
2071
+ return { time, timestamp: time, equity, ...extra };
2072
+ }
2073
+ function isArrayIndexKey2(property) {
2074
+ if (typeof property !== "string") return false;
2075
+ const numeric = Number(property);
2076
+ return Number.isInteger(numeric) && numeric >= 0;
2077
+ }
2078
+ function strictHistoryView2(candles, currentIndex) {
2079
+ return new Proxy(candles, {
2080
+ get(target, property, receiver) {
2081
+ if (isArrayIndexKey2(property) && Number(property) >= target.length) {
2082
+ throw new Error(
2083
+ `strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
2084
+ );
2085
+ }
2086
+ return Reflect.get(target, property, receiver);
2087
+ }
2088
+ });
2089
+ }
2090
+ function describeValue3(value) {
2091
+ if (Array.isArray(value)) return `array(length=${value.length})`;
2092
+ if (value === null) return "null";
2093
+ return typeof value;
2094
+ }
2095
+ function formatIsoTime3(time) {
2096
+ return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
2097
+ }
2098
+ function callSignalWithContext3({ signal, context, index, bar, symbol }) {
2099
+ try {
2100
+ return signal(context);
2101
+ } catch (error) {
2102
+ const cause = error instanceof Error ? error.message : String(error);
2103
+ throw new Error(
2104
+ `signal() threw at index=${index}, time=${formatIsoTime3(bar?.time)}, symbol=${symbol}: ${cause}`
2105
+ );
2106
+ }
2107
+ }
2108
+ function snapshotOpenPosition2(open, markPrice) {
2109
+ if (!open) return null;
2110
+ const entryPrice = open.entryFill ?? open.entry;
2111
+ const direction = open.side === "long" ? 1 : -1;
2112
+ const unrealizedPnl = (markPrice - entryPrice) * direction * open.size;
2113
+ return {
2114
+ id: open.id,
2115
+ symbol: open.symbol,
2116
+ side: open.side,
2117
+ size: open.size,
2118
+ entry: open.entry,
2119
+ entryFill: open.entryFill,
2120
+ stop: open.stop,
2121
+ takeProfit: open.takeProfit,
2122
+ openTime: open.openTime,
2123
+ markPrice,
2124
+ unrealizedPnl,
2125
+ _initRisk: open._initRisk
2126
+ };
2127
+ }
2128
+ function normalizeSide3(value) {
2129
+ if (value === "long" || value === "buy") return "long";
2130
+ if (value === "short" || value === "sell") return "short";
2131
+ return null;
2132
+ }
2133
+ function normalizeSignal3(signal, bar, fallbackR) {
2134
+ if (!signal) return null;
2135
+ const side = normalizeSide3(signal.side ?? signal.direction ?? signal.action);
2136
+ if (!side) return null;
2137
+ const entry = asNumber3(signal.entry ?? signal.limit ?? signal.price) ?? asNumber3(bar?.close);
2138
+ const stop = asNumber3(signal.stop ?? signal.stopLoss ?? signal.sl);
2139
+ if (entry === null || stop === null) return null;
2140
+ const risk = Math.abs(entry - stop);
2141
+ if (!(risk > 0)) return null;
2142
+ let takeProfit = asNumber3(signal.takeProfit ?? signal.target ?? signal.tp);
2143
+ const rrHint = asNumber3(signal._rr ?? signal.rr);
2144
+ const targetR = rrHint ?? fallbackR;
2145
+ if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
2146
+ takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
2147
+ }
2148
+ if (takeProfit === null) return null;
2149
+ return {
2150
+ ...signal,
2151
+ side,
2152
+ entry,
2153
+ stop,
2154
+ takeProfit,
2155
+ qty: asNumber3(signal.qty ?? signal.size),
2156
+ riskPct: asNumber3(signal.riskPct),
2157
+ riskFraction: asNumber3(signal.riskFraction),
2158
+ _rr: rrHint ?? signal._rr,
2159
+ _initRisk: asNumber3(signal._initRisk) ?? signal._initRisk
2160
+ };
2161
+ }
2162
+ function mergeOptions2(options) {
2163
+ const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
2164
+ return {
2165
+ candles: normalizeCandles(options.candles ?? []),
2166
+ symbol: options.symbol ?? "UNKNOWN",
2167
+ equity: options.equity ?? 1e4,
2168
+ riskPct: normalizedRiskPct ?? 1,
2169
+ signal: options.signal,
2170
+ interval: options.interval,
2171
+ range: options.range,
2172
+ warmupBars: options.warmupBars ?? 200,
2173
+ slippageBps: options.slippageBps ?? 1,
2174
+ feeBps: options.feeBps ?? 0,
2175
+ costs: options.costs ?? null,
2176
+ scaleOutAtR: options.scaleOutAtR ?? 1,
2177
+ scaleOutFrac: options.scaleOutFrac ?? 0.5,
2178
+ finalTP_R: options.finalTP_R ?? 3,
2179
+ maxDailyLossPct: options.maxDailyLossPct ?? 2,
2180
+ atrTrailMult: options.atrTrailMult ?? 0,
2181
+ atrTrailPeriod: options.atrTrailPeriod ?? 14,
2182
+ oco: {
2183
+ mode: "intrabar",
2184
+ tieBreak: "pessimistic",
2185
+ clampStops: true,
2186
+ clampEpsBps: 0.25,
2187
+ ...options.oco || {}
2188
+ },
2189
+ triggerMode: options.triggerMode,
2190
+ flattenAtClose: options.flattenAtClose ?? true,
2191
+ dailyMaxTrades: options.dailyMaxTrades ?? 0,
2192
+ postLossCooldownBars: options.postLossCooldownBars ?? 0,
2193
+ mfeTrail: {
2194
+ enabled: false,
2195
+ armR: 1,
2196
+ givebackR: 0.5,
2197
+ ...options.mfeTrail || {}
2198
+ },
2199
+ pyramiding: {
2200
+ enabled: false,
2201
+ addAtR: 1,
2202
+ addFrac: 0.25,
2203
+ maxAdds: 1,
2204
+ onlyAfterBreakEven: true,
2205
+ ...options.pyramiding || {}
2206
+ },
2207
+ volScale: {
2208
+ enabled: false,
2209
+ atrPeriod: options.atrTrailPeriod ?? 14,
2210
+ cutIfAtrX: 1.3,
2211
+ cutFrac: 0.33,
2212
+ noCutAboveR: 1.5,
2213
+ ...options.volScale || {}
2214
+ },
2215
+ qtyStep: options.qtyStep ?? 1e-3,
2216
+ minQty: options.minQty ?? 1e-3,
2217
+ maxLeverage: options.maxLeverage ?? 2,
2218
+ entryChase: {
2219
+ enabled: true,
2220
+ afterBars: 2,
2221
+ maxSlipR: 0.2,
2222
+ convertOnExpiry: false,
2223
+ ...options.entryChase || {}
2224
+ },
2225
+ reanchorStopOnFill: options.reanchorStopOnFill ?? true,
2226
+ maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
2227
+ collectEqSeries: options.collectEqSeries ?? true,
2228
+ collectReplay: options.collectReplay ?? true,
2229
+ strict: options.strict ?? false
2230
+ };
2231
+ }
2232
+ function capitalForSize(entryPrice, size, maxLeverage) {
2233
+ const leverage = Math.max(1, Number(maxLeverage) || 1);
2234
+ return Math.abs(entryPrice) * Math.max(0, size) / leverage;
2235
+ }
2236
+ var BarSystemRunner = class {
2237
+ constructor(rawOptions = {}) {
2238
+ this.options = mergeOptions2(rawOptions);
2239
+ const { candles, signal } = this.options;
2240
+ if (!Array.isArray(candles) || candles.length === 0) {
2241
+ throw new Error(
2242
+ `backtestPortfolio() requires each system to include non-empty candles, got ${describeValue3(
2243
+ candles
2244
+ )} for ${this.options.symbol}`
2245
+ );
2246
+ }
2247
+ if (typeof signal !== "function") {
2248
+ throw new Error(
2249
+ `backtestPortfolio() requires each system to include a signal function, got ${describeValue3(
2250
+ signal
2251
+ )} for ${this.options.symbol}`
2252
+ );
2253
+ }
2254
+ this.symbol = this.options.symbol;
2255
+ this.candles = candles;
2256
+ this.closed = [];
2257
+ this.currentEquity = this.options.equity;
2258
+ this.open = null;
2259
+ this.cooldown = 0;
2260
+ this.pending = null;
2261
+ this.currentDay = null;
2262
+ this.dayPnl = 0;
2263
+ this.dayTrades = 0;
2264
+ this.dayEquityStart = this.options.equity;
2265
+ this.tradeIdCounter = 0;
2266
+ this.estimatedBarMs = estimateBarMs(candles);
2267
+ const atrSourcePeriod = this.options.volScale.enabled ? this.options.volScale.atrPeriod : this.options.atrTrailPeriod;
2268
+ const needAtr = this.options.atrTrailMult > 0 || this.options.volScale.enabled;
2269
+ this.atrValues = needAtr ? atr(candles, atrSourcePeriod) : null;
2270
+ this.wantEqSeries = Boolean(this.options.collectEqSeries);
2271
+ this.wantReplay = Boolean(this.options.collectReplay);
2272
+ this.eqSeries = this.wantEqSeries ? [equityPoint3(candles[0].time, this.currentEquity)] : [];
2273
+ this.replayFrames = this.wantReplay ? [] : [];
2274
+ this.replayEvents = this.wantReplay ? [] : [];
2275
+ this.startIndex = Math.min(Math.max(1, this.options.warmupBars), candles.length);
2276
+ this.history = candles.slice(0, this.startIndex);
2277
+ this.index = this.startIndex;
2278
+ this.lastBar = this.history.length ? this.history[this.history.length - 1] : null;
2279
+ }
2280
+ hasNext() {
2281
+ return this.index < this.candles.length;
2282
+ }
2283
+ peekTime() {
2284
+ return this.hasNext() ? this.candles[this.index].time : Infinity;
2285
+ }
2286
+ getLockedCapital() {
2287
+ if (!this.open) return 0;
2288
+ return capitalForSize(
2289
+ this.open.entryFill ?? this.open.entry,
2290
+ this.open.size,
2291
+ this.options.maxLeverage
2292
+ );
2293
+ }
2294
+ getMarkPrice() {
2295
+ return this.lastBar?.close ?? null;
2296
+ }
2297
+ getMarkedEquity() {
2298
+ if (!this.open || !this.lastBar) return this.currentEquity;
2299
+ const direction = this.open.side === "long" ? 1 : -1;
2300
+ const markPnl = (this.lastBar.close - (this.open.entryFill ?? this.open.entry)) * direction * this.open.size;
2301
+ return this.currentEquity + markPnl;
2302
+ }
2303
+ recordFrame(bar, extraFrame = {}) {
2304
+ if (this.wantEqSeries) {
2305
+ this.eqSeries.push(equityPoint3(bar.time, this.currentEquity));
2306
+ }
2307
+ if (this.wantReplay) {
2308
+ this.replayFrames.push({
2309
+ t: new Date(bar.time).toISOString(),
2310
+ price: bar.close,
2311
+ equity: this.currentEquity,
2312
+ posSide: this.open ? this.open.side : null,
2313
+ posSize: this.open ? this.open.size : 0,
2314
+ ...extraFrame
2315
+ });
2316
+ }
2317
+ }
2318
+ closeLeg({ openPos, qty, exitPx, exitFeeTotal = 0, time, reason }) {
2319
+ const direction = openPos.side === "long" ? 1 : -1;
2320
+ const entryFill = openPos.entryFill;
2321
+ const grossPnl = (exitPx - entryFill) * direction * qty;
2322
+ const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
2323
+ const pnl = grossPnl - entryFeePortion - exitFeeTotal;
2324
+ this.currentEquity += pnl;
2325
+ this.dayPnl += pnl;
2326
+ if (this.wantEqSeries) {
2327
+ this.eqSeries.push(equityPoint3(time, this.currentEquity));
2328
+ }
2329
+ const remaining = openPos.size - qty;
2330
+ const eventType = reason === "SCALE" ? "scale-out" : reason === "TP" ? "tp" : reason === "SL" ? "sl" : reason === "EOD" ? "eod" : remaining <= 0 ? "exit" : "scale-out";
2331
+ if (this.wantReplay) {
2332
+ this.replayEvents.push({
2333
+ t: new Date(time).toISOString(),
2334
+ price: exitPx,
2335
+ type: eventType,
2336
+ side: openPos.side,
2337
+ size: qty,
2338
+ tradeId: openPos.id,
2339
+ reason,
2340
+ pnl,
2341
+ symbol: this.symbol
2342
+ });
2343
+ }
2344
+ const record = {
2345
+ ...openPos,
2346
+ size: qty,
2347
+ exit: {
2348
+ price: exitPx,
2349
+ time,
2350
+ reason,
2351
+ pnl,
2352
+ exitATR: openPos._lastATR ?? void 0
2353
+ },
2354
+ mfeR: openPos._mfeR ?? 0,
2355
+ maeR: openPos._maeR ?? 0,
2356
+ adds: openPos._adds ?? 0
2357
+ };
2358
+ this.closed.push(record);
2359
+ openPos.size -= qty;
2360
+ openPos._realized = (openPos._realized || 0) + pnl;
2361
+ return record;
2362
+ }
2363
+ tightenStopToNetBreakeven(openPos, lastClose) {
2364
+ if (!openPos || openPos.size <= 0) return;
2365
+ const realized = openPos._realized || 0;
2366
+ if (realized <= 0) return;
2367
+ const direction = openPos.side === "long" ? 1 : -1;
2368
+ const breakevenDelta = Math.abs(realized / openPos.size);
2369
+ const breakevenPrice = direction === 1 ? openPos.entryFill - breakevenDelta : openPos.entryFill + breakevenDelta;
2370
+ const tightened = direction === 1 ? Math.max(openPos.stop, breakevenPrice) : Math.min(openPos.stop, breakevenPrice);
2371
+ openPos.stop = this.options.oco.clampStops ? clampStop(lastClose, tightened, openPos.side, this.options.oco) : tightened;
2372
+ }
2373
+ forceExit(reason, bar, overridePrice = null) {
2374
+ if (!this.open || !bar) return;
2375
+ const exitSide = this.open.side === "long" ? "short" : "long";
2376
+ const exitBasePrice = overridePrice ?? bar.close;
2377
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(exitBasePrice, exitSide, {
2378
+ slippageBps: this.options.slippageBps,
2379
+ feeBps: this.options.feeBps,
2380
+ kind: "market",
2381
+ qty: this.open.size,
2382
+ costs: this.options.costs
2383
+ });
2384
+ this.closeLeg({
2385
+ openPos: this.open,
2386
+ qty: this.open.size,
2387
+ exitPx: filled,
2388
+ exitFeeTotal,
2389
+ time: bar.time,
2390
+ reason
2391
+ });
2392
+ this.cooldown = this.open?._cooldownBars || 0;
2393
+ this.open = null;
2394
+ }
2395
+ cancelPending() {
2396
+ this.pending = null;
2397
+ }
2398
+ openFromPending(bar, signalEquity, entryPrice, fillKind = "limit", resolveEntrySize) {
2399
+ if (!this.pending) return false;
2400
+ const plannedRisk = Math.max(
2401
+ 1e-8,
2402
+ this.pending.plannedRiskAbs ?? Math.abs(this.pending.entry - this.pending.stop)
2403
+ );
2404
+ const slipR = Math.abs(entryPrice - this.pending.entry) / plannedRisk;
2405
+ if (slipR > this.options.maxSlipROnFill) return false;
2406
+ let stopPrice = this.pending.stop;
2407
+ if (this.options.reanchorStopOnFill) {
2408
+ const direction = this.pending.side === "long" ? 1 : -1;
2409
+ stopPrice = direction === 1 ? entryPrice - plannedRisk : entryPrice + plannedRisk;
2410
+ }
2411
+ let takeProfit = this.pending.tp;
2412
+ const immediateRisk = Math.abs(entryPrice - stopPrice) || 1e-8;
2413
+ const rrHint = this.pending.meta?._rr;
2414
+ if (this.options.reanchorStopOnFill && Number.isFinite(rrHint)) {
2415
+ const plannedTarget = this.pending.side === "long" ? this.pending.entry + rrHint * plannedRisk : this.pending.entry - rrHint * plannedRisk;
2416
+ const closeEnough = Math.abs((this.pending.tp ?? plannedTarget) - plannedTarget) <= Math.max(1e-8, plannedRisk * 1e-6);
2417
+ if (closeEnough) {
2418
+ takeProfit = this.pending.side === "long" ? entryPrice + rrHint * immediateRisk : entryPrice - rrHint * immediateRisk;
2419
+ }
2420
+ }
2421
+ const desiredSize = this.pending.fixedQty ?? calculatePositionSize({
2422
+ equity: signalEquity,
2423
+ entry: entryPrice,
2424
+ stop: stopPrice,
2425
+ riskFraction: this.pending.riskFrac,
2426
+ qtyStep: this.options.qtyStep,
2427
+ minQty: this.options.minQty,
2428
+ maxLeverage: this.options.maxLeverage
2429
+ });
2430
+ const approvedSize = typeof resolveEntrySize === "function" ? resolveEntrySize({
2431
+ runner: this,
2432
+ desiredSize,
2433
+ entryPrice,
2434
+ stopPrice,
2435
+ pending: this.pending,
2436
+ fillKind
2437
+ }) : desiredSize;
2438
+ const size = roundStep2(approvedSize, this.options.qtyStep);
2439
+ if (size < this.options.minQty) return false;
2440
+ const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, this.pending.side, {
2441
+ slippageBps: this.options.slippageBps,
2442
+ feeBps: this.options.feeBps,
2443
+ kind: fillKind,
2444
+ qty: size,
2445
+ costs: this.options.costs
2446
+ });
2447
+ this.open = {
2448
+ symbol: this.symbol,
2449
+ ...this.pending.meta,
2450
+ id: ++this.tradeIdCounter,
2451
+ side: this.pending.side,
2452
+ entry: entryPrice,
2453
+ stop: stopPrice,
2454
+ takeProfit,
2455
+ size,
2456
+ openTime: bar.time,
2457
+ entryFill,
2458
+ entryFeeTotal,
2459
+ initSize: size,
2460
+ baseSize: size,
2461
+ _mfeR: 0,
2462
+ _maeR: 0,
2463
+ _adds: 0,
2464
+ _initRisk: Math.abs(entryPrice - stopPrice) || 1e-8
2465
+ };
2466
+ if (this.atrValues && this.atrValues[this.index] !== void 0) {
2467
+ this.open.entryATR = this.atrValues[this.index];
2468
+ this.open._lastATR = this.atrValues[this.index];
2469
+ }
2470
+ this.dayTrades += 1;
2471
+ this.pending = null;
2472
+ if (this.wantReplay) {
2473
+ this.replayEvents.push({
2474
+ t: new Date(bar.time).toISOString(),
2475
+ price: entryFill,
2476
+ type: "entry",
2477
+ side: this.open.side,
2478
+ size,
2479
+ tradeId: this.open.id,
2480
+ symbol: this.symbol
2481
+ });
2482
+ }
2483
+ return true;
2484
+ }
2485
+ buildSignalContext(index, bar, signalEquity) {
2486
+ if (this.options.strict && this.history.length !== index + 1) {
2487
+ throw new Error(
2488
+ `strict mode: signal() received ${this.history.length} candles at index ${index}`
2489
+ );
2490
+ }
2491
+ return {
2492
+ candles: this.options.strict ? strictHistoryView2(this.history, index) : this.history,
2493
+ index,
2494
+ bar,
2495
+ equity: signalEquity,
2496
+ openPosition: this.open,
2497
+ pendingOrder: this.pending
2498
+ };
2499
+ }
2500
+ step({ signalEquity, canTrade = true, resolveEntrySize } = {}) {
2501
+ if (!this.hasNext()) return null;
2502
+ const bar = this.candles[this.index];
2503
+ this.history.push(bar);
2504
+ this.lastBar = bar;
2505
+ const trigger = this.options.triggerMode || this.options.oco.mode || "intrabar";
2506
+ const dayKey = this.options.flattenAtClose || trigger === "close" ? dayKeyET(bar.time) : dayKeyUTC2(bar.time);
2507
+ if (this.currentDay === null || dayKey !== this.currentDay) {
2508
+ this.currentDay = dayKey;
2509
+ this.dayPnl = 0;
2510
+ this.dayTrades = 0;
2511
+ this.dayEquityStart = this.currentEquity;
2512
+ }
2513
+ if (this.open && this.open._maxBarsInTrade > 0) {
2514
+ const barsHeld = Math.max(
2515
+ 1,
2516
+ Math.round((bar.time - this.open.openTime) / this.estimatedBarMs)
2517
+ );
2518
+ if (barsHeld >= this.open._maxBarsInTrade) {
2519
+ this.forceExit("TIME", bar);
2520
+ }
2521
+ }
2522
+ if (this.open && Number.isFinite(this.open._maxHoldMin) && this.open._maxHoldMin > 0) {
2523
+ const heldMinutes = (bar.time - this.open.openTime) / 6e4;
2524
+ if (heldMinutes >= this.open._maxHoldMin) {
2525
+ this.forceExit("TIME", bar);
2526
+ }
2527
+ }
2528
+ if (this.options.flattenAtClose && this.open && isEODBar(bar.time)) {
2529
+ this.forceExit("EOD", bar);
2530
+ }
2531
+ if (this.open) {
2532
+ const risk = this.open._initRisk || 1e-8;
2533
+ const highR = this.open.side === "long" ? (bar.high - this.open.entry) / risk : (this.open.entry - bar.low) / risk;
2534
+ const lowR = this.open.side === "long" ? (bar.low - this.open.entry) / risk : (this.open.entry - bar.high) / risk;
2535
+ const markR = this.open.side === "long" ? (bar.close - this.open.entry) / risk : (this.open.entry - bar.close) / risk;
2536
+ if (this.atrValues && this.atrValues[this.index] !== void 0) {
2537
+ this.open._lastATR = this.atrValues[this.index];
2538
+ }
2539
+ this.open._mfeR = Math.max(this.open._mfeR ?? -Infinity, highR);
2540
+ this.open._maeR = Math.min(this.open._maeR ?? Infinity, lowR);
2541
+ if (this.open._breakevenAtR > 0 && highR >= this.open._breakevenAtR && !this.open._beArmed) {
2542
+ const tightened = this.open.side === "long" ? Math.max(this.open.stop, this.open.entry) : Math.min(this.open.stop, this.open.entry);
2543
+ this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
2544
+ this.open._beArmed = true;
2545
+ }
2546
+ if (this.open._trailAfterR > 0 && highR >= this.open._trailAfterR) {
2547
+ const candidate = this.open.side === "long" ? bar.close - risk : bar.close + risk;
2548
+ const tightened = this.open.side === "long" ? Math.max(this.open.stop, candidate) : Math.min(this.open.stop, candidate);
2549
+ this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
2550
+ }
2551
+ if (this.options.mfeTrail.enabled && this.open._mfeR >= this.options.mfeTrail.armR) {
2552
+ const targetR = Math.max(0, this.open._mfeR - Math.max(0, this.options.mfeTrail.givebackR));
2553
+ const candidate = this.open.side === "long" ? this.open.entry + targetR * risk : this.open.entry - targetR * risk;
2554
+ const tightened = this.open.side === "long" ? Math.max(this.open.stop, candidate) : Math.min(this.open.stop, candidate);
2555
+ this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
2556
+ }
2557
+ if (this.options.atrTrailMult > 0 && this.atrValues && this.atrValues[this.index] !== void 0) {
2558
+ const trailDistance = this.atrValues[this.index] * this.options.atrTrailMult;
2559
+ const candidate = this.open.side === "long" ? bar.close - trailDistance : bar.close + trailDistance;
2560
+ const tightened = this.open.side === "long" ? Math.max(this.open.stop, candidate) : Math.min(this.open.stop, candidate);
2561
+ this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
2562
+ }
2563
+ if (this.options.volScale.enabled && this.open.entryATR && this.open.size > this.options.minQty && this.atrValues && this.atrValues[this.index] !== void 0) {
2564
+ const ratio = this.atrValues[this.index] / Math.max(1e-12, this.open.entryATR);
2565
+ const shouldCut = ratio >= this.options.volScale.cutIfAtrX && markR < this.options.volScale.noCutAboveR && !this.open._volCutDone;
2566
+ if (shouldCut) {
2567
+ const cutQty = roundStep2(
2568
+ this.open.size * this.options.volScale.cutFrac,
2569
+ this.options.qtyStep
2570
+ );
2571
+ if (cutQty >= this.options.minQty && cutQty < this.open.size) {
2572
+ const exitSide2 = this.open.side === "long" ? "short" : "long";
2573
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide2, {
2574
+ slippageBps: this.options.slippageBps,
2575
+ feeBps: this.options.feeBps,
2576
+ kind: "market",
2577
+ qty: cutQty,
2578
+ costs: this.options.costs
2579
+ });
2580
+ this.closeLeg({
2581
+ openPos: this.open,
2582
+ qty: cutQty,
2583
+ exitPx: filled,
2584
+ exitFeeTotal,
2585
+ time: bar.time,
2586
+ reason: "SCALE"
2587
+ });
2588
+ this.tightenStopToNetBreakeven(this.open, bar.close);
2589
+ this.open._volCutDone = true;
2590
+ }
2591
+ }
2592
+ }
2593
+ let addedThisBar = false;
2594
+ if (this.options.pyramiding.enabled && (this.open._adds ?? 0) < this.options.pyramiding.maxAdds) {
2595
+ const addNumber = (this.open._adds || 0) + 1;
2596
+ const triggerR = this.options.pyramiding.addAtR * addNumber;
2597
+ const triggerPrice = this.open.side === "long" ? this.open.entry + triggerR * risk : this.open.entry - triggerR * risk;
2598
+ const breakEvenSatisfied = !this.options.pyramiding.onlyAfterBreakEven || this.open.side === "long" && this.open.stop >= this.open.entry || this.open.side === "short" && this.open.stop <= this.open.entry;
2599
+ const touched = this.open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
2600
+ if (breakEvenSatisfied && touched) {
2601
+ const baseSize = this.open.baseSize || this.open.initSize;
2602
+ const requestedQty = roundStep2(
2603
+ baseSize * this.options.pyramiding.addFrac,
2604
+ this.options.qtyStep
2605
+ );
2606
+ const addQty = typeof resolveEntrySize === "function" ? roundStep2(
2607
+ resolveEntrySize({
2608
+ runner: this,
2609
+ desiredSize: requestedQty,
2610
+ entryPrice: triggerPrice,
2611
+ stopPrice: this.open.stop,
2612
+ pending: {
2613
+ side: this.open.side,
2614
+ meta: this.open,
2615
+ riskFrac: this.options.riskPct / 100
2616
+ },
2617
+ fillKind: "limit"
2618
+ }),
2619
+ this.options.qtyStep
2620
+ ) : requestedQty;
2621
+ if (addQty >= this.options.minQty) {
2622
+ const { price: addFill, feeTotal: addFeeTotal } = applyFill(
2623
+ triggerPrice,
2624
+ this.open.side,
2625
+ {
2626
+ slippageBps: this.options.slippageBps,
2627
+ feeBps: this.options.feeBps,
2628
+ kind: "limit",
2629
+ qty: addQty,
2630
+ costs: this.options.costs
2631
+ }
2632
+ );
2633
+ const newSize = this.open.size + addQty;
2634
+ this.open.entryFeeTotal += addFeeTotal;
2635
+ this.open.entryFill = (this.open.entryFill * this.open.size + addFill * addQty) / newSize;
2636
+ this.open.size = newSize;
2637
+ this.open.initSize += addQty;
2638
+ if (!this.open.baseSize) this.open.baseSize = baseSize;
2639
+ this.open._adds = addNumber;
2640
+ addedThisBar = true;
2641
+ }
2642
+ }
2643
+ }
2644
+ if (!addedThisBar && !this.open._scaled && this.options.scaleOutAtR > 0) {
2645
+ const triggerPrice = this.open.side === "long" ? this.open.entry + this.options.scaleOutAtR * risk : this.open.entry - this.options.scaleOutAtR * risk;
2646
+ const touched = this.open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
2647
+ if (touched) {
2648
+ const exitSide2 = this.open.side === "long" ? "short" : "long";
2649
+ const qty = roundStep2(this.open.size * this.options.scaleOutFrac, this.options.qtyStep);
2650
+ if (qty >= this.options.minQty && qty < this.open.size) {
2651
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(triggerPrice, exitSide2, {
2652
+ slippageBps: this.options.slippageBps,
2653
+ feeBps: this.options.feeBps,
2654
+ kind: "limit",
2655
+ qty,
2656
+ costs: this.options.costs
2657
+ });
2658
+ this.closeLeg({
2659
+ openPos: this.open,
2660
+ qty,
2661
+ exitPx: filled,
2662
+ exitFeeTotal,
2663
+ time: bar.time,
2664
+ reason: "SCALE"
2665
+ });
2666
+ this.open._scaled = true;
2667
+ this.open.takeProfit = this.open.side === "long" ? this.open.entry + this.options.finalTP_R * risk : this.open.entry - this.options.finalTP_R * risk;
2668
+ this.tightenStopToNetBreakeven(this.open, bar.close);
2669
+ this.open._beArmed = true;
2670
+ }
2671
+ }
2672
+ }
2673
+ const exitSide = this.open.side === "long" ? "short" : "long";
2674
+ const { hit, px } = ocoExitCheck({
2675
+ side: this.open.side,
2676
+ stop: this.open.stop,
2677
+ tp: this.open.takeProfit,
2678
+ bar,
2679
+ mode: this.options.oco.mode,
2680
+ tieBreak: this.options.oco.tieBreak
2681
+ });
2682
+ if (hit) {
2683
+ const exitKind = hit === "TP" ? "limit" : "stop";
2684
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(px, exitSide, {
2685
+ slippageBps: this.options.slippageBps,
2686
+ feeBps: this.options.feeBps,
2687
+ kind: exitKind,
2688
+ qty: this.open.size,
2689
+ costs: this.options.costs
2690
+ });
2691
+ const localCooldown = this.open._cooldownBars || 0;
2692
+ this.closeLeg({
2693
+ openPos: this.open,
2694
+ qty: this.open.size,
2695
+ exitPx: filled,
2696
+ exitFeeTotal,
2697
+ time: bar.time,
2698
+ reason: hit
2699
+ });
2700
+ this.cooldown = (hit === "SL" ? Math.max(this.cooldown, this.options.postLossCooldownBars) : this.cooldown) || localCooldown;
2701
+ this.open = null;
2702
+ }
2703
+ }
2704
+ const maxLossDollars = this.options.maxDailyLossPct / 100 * this.dayEquityStart;
2705
+ const dailyLossHit = this.dayPnl <= -Math.abs(maxLossDollars);
2706
+ const dailyTradeCapHit = this.options.dailyMaxTrades > 0 && this.dayTrades >= this.options.dailyMaxTrades;
2707
+ if (!this.open && this.pending) {
2708
+ if (!canTrade) {
2709
+ this.pending = null;
2710
+ } else if (this.index > this.pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
2711
+ if (this.options.entryChase.enabled && this.options.entryChase.convertOnExpiry) {
2712
+ const riskAtEdge = Math.abs(
2713
+ this.pending.meta._initRisk ?? this.pending.entry - this.pending.stop
2714
+ );
2715
+ const priceNow = bar.close;
2716
+ const direction = this.pending.side === "long" ? 1 : -1;
2717
+ const slippedR = Math.max(
2718
+ 0,
2719
+ direction === 1 ? priceNow - this.pending.entry : this.pending.entry - priceNow
2720
+ ) / Math.max(1e-8, riskAtEdge);
2721
+ if (slippedR > this.options.maxSlipROnFill) {
2722
+ this.pending = null;
2723
+ } else if (!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)) {
2724
+ this.pending = null;
2725
+ }
2726
+ } else {
2727
+ this.pending = null;
2728
+ }
2729
+ } else if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
2730
+ if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
2731
+ this.pending = null;
2732
+ }
2733
+ } else if (this.options.entryChase.enabled) {
2734
+ const elapsedBars = this.index - (this.pending.startedAtIndex ?? this.index);
2735
+ const midpoint = asNumber3(this.pending.meta?._imb?.mid);
2736
+ if (!this.pending._chasedCE && midpoint !== null && elapsedBars >= Math.max(1, this.options.entryChase.afterBars)) {
2737
+ this.pending.entry = midpoint;
2738
+ this.pending._chasedCE = true;
2739
+ }
2740
+ if (this.pending._chasedCE) {
2741
+ const riskRef = Math.abs(
2742
+ this.pending.meta?._initRisk ?? this.pending.entry - this.pending.stop
2743
+ );
2744
+ const priceNow = bar.close;
2745
+ const direction = this.pending.side === "long" ? 1 : -1;
2746
+ const slippedR = Math.max(
2747
+ 0,
2748
+ direction === 1 ? priceNow - this.pending.entry : this.pending.entry - priceNow
2749
+ ) / Math.max(1e-8, riskRef);
2750
+ if (slippedR > this.options.maxSlipROnFill) {
2751
+ this.pending = null;
2752
+ } else if (slippedR > 0 && slippedR <= this.options.entryChase.maxSlipR) {
2753
+ if (!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)) {
2754
+ this.pending = null;
2755
+ }
2756
+ }
2757
+ }
2758
+ }
2759
+ }
2760
+ if (this.open || this.cooldown > 0) {
2761
+ if (this.cooldown > 0) this.cooldown -= 1;
2762
+ this.recordFrame(bar);
2763
+ this.index += 1;
2764
+ return bar;
2765
+ }
2766
+ if (!canTrade || dailyLossHit || dailyTradeCapHit) {
2767
+ this.pending = null;
2768
+ this.recordFrame(bar);
2769
+ this.index += 1;
2770
+ return bar;
2771
+ }
2772
+ if (!this.pending) {
2773
+ const rawSignal = callSignalWithContext3({
2774
+ signal: this.options.signal,
2775
+ context: this.buildSignalContext(this.index, bar, signalEquity),
2776
+ index: this.index,
2777
+ bar,
2778
+ symbol: this.symbol
2779
+ });
2780
+ const nextSignal = normalizeSignal3(rawSignal, bar, this.options.finalTP_R);
2781
+ if (nextSignal) {
2782
+ const signalRiskFraction = Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : this.options.riskPct / 100;
2783
+ const expiryBars = nextSignal._entryExpiryBars ?? 5;
2784
+ this.pending = {
2785
+ side: nextSignal.side,
2786
+ entry: nextSignal.entry,
2787
+ stop: nextSignal.stop,
2788
+ tp: nextSignal.takeProfit,
2789
+ riskFrac: signalRiskFraction,
2790
+ fixedQty: nextSignal.qty,
2791
+ expiresAt: this.index + Math.max(1, expiryBars),
2792
+ startedAtIndex: this.index,
2793
+ meta: nextSignal,
2794
+ plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
2795
+ };
2796
+ if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
2797
+ if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
2798
+ this.pending = null;
2799
+ }
2800
+ }
2801
+ }
2802
+ }
2803
+ this.recordFrame(bar);
2804
+ this.index += 1;
2805
+ return bar;
2806
+ }
2807
+ buildResult() {
2808
+ const metrics = buildMetrics({
2809
+ closed: this.closed,
2810
+ equityStart: this.options.equity,
2811
+ equityFinal: this.currentEquity,
2812
+ candles: this.candles,
2813
+ estBarMs: this.estimatedBarMs,
2814
+ eqSeries: this.eqSeries
2815
+ });
2816
+ const positions = this.closed.filter((trade) => trade.exit.reason !== "SCALE");
2817
+ const lastPrice = asNumber3(this.candles[this.candles.length - 1]?.close);
2818
+ const openPositions = this.open ? [snapshotOpenPosition2(this.open, lastPrice ?? this.open.entryFill ?? this.open.entry)] : [];
2819
+ return {
2820
+ symbol: this.options.symbol,
2821
+ interval: this.options.interval,
2822
+ range: this.options.range,
2823
+ trades: this.closed,
2824
+ positions,
2825
+ openPositions,
2826
+ metrics,
2827
+ eqSeries: this.eqSeries,
2828
+ replay: {
2829
+ frames: this.replayFrames,
2830
+ events: this.replayEvents
2831
+ }
2832
+ };
2833
+ }
2834
+ };
2835
+ function defaultSystemCap(totalEquity, capPct, maxAllocation, maxAllocationPct) {
2836
+ const limits = [];
2837
+ if (Number.isFinite(capPct) && capPct > 0) limits.push(totalEquity * capPct);
2838
+ if (Number.isFinite(maxAllocation) && maxAllocation > 0) limits.push(maxAllocation);
2839
+ if (Number.isFinite(maxAllocationPct) && maxAllocationPct > 0) {
2840
+ limits.push(totalEquity * maxAllocationPct);
2841
+ }
2842
+ return limits.length ? Math.min(...limits) : Math.max(0, totalEquity);
2843
+ }
2844
+
2845
+ // src/engine/portfolio.js
2846
+ function asWeight(value) {
2847
+ return Number.isFinite(value) && value > 0 ? value : 0;
2848
+ }
2849
+ function describeValue4(value) {
2850
+ if (Array.isArray(value)) return `array(length=${value.length})`;
2851
+ if (value === null) return "null";
2852
+ return typeof value;
2853
+ }
2854
+ function buildPortfolioPoint(time, equity, lockedCapital, availableCapital) {
2855
+ return {
2856
+ time,
2857
+ timestamp: time,
2858
+ equity,
2859
+ lockedCapital,
2860
+ availableCapital
2861
+ };
2862
+ }
2863
+ function stableSystemOrder(left, right) {
2864
+ return left.index - right.index;
2865
+ }
2866
+ function hashedOrderScore(index, time, seed) {
2867
+ let value = (Number(time) ^ Math.imul(index + 1, 2654435761) ^ (seed | 0)) >>> 0;
2868
+ value = Math.imul(value ^ value >>> 16, 2246822507) >>> 0;
2869
+ value = Math.imul(value ^ value >>> 13, 3266489909) >>> 0;
2870
+ return (value ^ value >>> 16) >>> 0;
2871
+ }
2872
+ function orderActiveSystems(active, nextTime, processingOrder, shuffleSeed) {
2873
+ if (processingOrder !== "shuffle") {
2874
+ active.sort(stableSystemOrder);
2875
+ return;
2876
+ }
2877
+ active.sort((left, right) => {
2878
+ const leftScore = hashedOrderScore(left.index, nextTime, shuffleSeed);
2879
+ const rightScore = hashedOrderScore(right.index, nextTime, shuffleSeed);
2880
+ if (leftScore !== rightScore) return leftScore - rightScore;
2881
+ return stableSystemOrder(left, right);
2882
+ });
2883
+ }
2884
+ function combineReplay(systemResults, eqSeries, collectReplay) {
2885
+ if (!collectReplay) {
2886
+ return { frames: [], events: [] };
2887
+ }
2888
+ const events = systemResults.flatMap(
2889
+ (entry) => (entry.result.replay?.events || []).map((event) => ({
2890
+ ...event,
2891
+ symbol: event.symbol || entry.symbol
1698
2892
  }))
2893
+ ).sort((left, right) => new Date(left.t).getTime() - new Date(right.t).getTime());
2894
+ const frames = eqSeries.map((point) => ({
2895
+ t: new Date(point.time).toISOString(),
2896
+ price: 0,
2897
+ equity: point.equity,
2898
+ posSide: null,
2899
+ posSize: 0,
2900
+ lockedCapital: point.lockedCapital,
2901
+ availableCapital: point.availableCapital
2902
+ }));
2903
+ return { frames, events };
2904
+ }
2905
+ function portfolioState(runners, initialEquity) {
2906
+ let markedEquity = initialEquity;
2907
+ let lockedCapital = 0;
2908
+ for (const { runner, initialReferenceEquity } of runners) {
2909
+ markedEquity += runner.getMarkedEquity() - initialReferenceEquity;
2910
+ lockedCapital += runner.getLockedCapital();
2911
+ }
2912
+ return {
2913
+ markedEquity,
2914
+ lockedCapital,
2915
+ availableCapital: markedEquity - lockedCapital
2916
+ };
2917
+ }
2918
+ function findNextTimeAndActive(runners) {
2919
+ let nextTime = Infinity;
2920
+ const active = [];
2921
+ for (const entry of runners) {
2922
+ const time = entry.runner.peekTime();
2923
+ if (time < nextTime) {
2924
+ nextTime = time;
2925
+ active.length = 0;
2926
+ active.push(entry);
2927
+ continue;
2928
+ }
2929
+ if (time === nextTime) {
2930
+ active.push(entry);
2931
+ }
2932
+ }
2933
+ return { nextTime, active };
2934
+ }
2935
+ function initialPortfolioTime(runners) {
2936
+ let time = Infinity;
2937
+ for (const { runner } of runners) {
2938
+ const next = runner.candles[0]?.time ?? Infinity;
2939
+ if (next < time) time = next;
2940
+ }
2941
+ return Number.isFinite(time) ? time : 0;
2942
+ }
2943
+ function resolveSystemCap(systemEntry, totalEquity) {
2944
+ return defaultSystemCap(
2945
+ Math.max(0, totalEquity),
2946
+ systemEntry.defaultCapPct,
2947
+ systemEntry.system.maxAllocation,
2948
+ systemEntry.system.maxAllocationPct
2949
+ );
2950
+ }
2951
+ function forceExitAll(runners, time) {
2952
+ for (const { runner } of runners) {
2953
+ if (!runner.open) continue;
2954
+ const price = runner.getMarkPrice();
2955
+ if (!Number.isFinite(price)) continue;
2956
+ runner.forceExit("PORTFOLIO_DAILY_LOSS", { time, close: price }, price);
2957
+ }
2958
+ }
2959
+ function backtestPortfolio({
2960
+ systems = [],
2961
+ equity = 1e4,
2962
+ allocation = "equal",
2963
+ collectEqSeries = true,
2964
+ collectReplay = false,
2965
+ maxDailyLossPct = 0,
2966
+ processingOrder = "sequential",
2967
+ shuffleSeed = 0
2968
+ } = {}) {
2969
+ if (!Array.isArray(systems) || systems.length === 0) {
2970
+ throw new Error(
2971
+ `backtestPortfolio() requires a non-empty systems array, got ${describeValue4(systems)}`
2972
+ );
2973
+ }
2974
+ if (processingOrder !== "sequential" && processingOrder !== "shuffle") {
2975
+ throw new Error(
2976
+ `backtestPortfolio() processingOrder must be "sequential" or "shuffle", got ${processingOrder}`
2977
+ );
2978
+ }
2979
+ const weights = allocation === "equal" ? systems.map(() => 1) : systems.map((system) => asWeight(system.weight || 0));
2980
+ const totalWeight = weights.reduce((sumValue, weight) => sumValue + weight, 0);
2981
+ if (!(totalWeight > 0)) {
2982
+ throw new Error(
2983
+ `backtestPortfolio() requires positive allocation weights, got allocation=${allocation}`
2984
+ );
2985
+ }
2986
+ const runners = systems.map((system, index) => {
2987
+ const defaultCapPct = weights[index] / totalWeight;
2988
+ const initialReferenceEquity = equity * defaultCapPct;
2989
+ return {
2990
+ index,
2991
+ symbol: system.symbol ?? `system-${index + 1}`,
2992
+ system,
2993
+ defaultCapPct,
2994
+ initialReferenceEquity,
2995
+ runner: new BarSystemRunner({
2996
+ ...system,
2997
+ symbol: system.symbol ?? `system-${index + 1}`,
2998
+ equity: initialReferenceEquity,
2999
+ collectEqSeries,
3000
+ collectReplay
3001
+ })
3002
+ };
3003
+ });
3004
+ const eqSeries = collectEqSeries ? [] : [];
3005
+ let state = portfolioState(runners, equity);
3006
+ if (collectEqSeries) {
3007
+ eqSeries.push(
3008
+ buildPortfolioPoint(
3009
+ initialPortfolioTime(runners),
3010
+ state.markedEquity,
3011
+ state.lockedCapital,
3012
+ state.availableCapital
3013
+ )
3014
+ );
3015
+ }
3016
+ let currentDay = null;
3017
+ let dayStartEquity = equity;
3018
+ let portfolioHalted = false;
3019
+ while (true) {
3020
+ const { nextTime, active } = findNextTimeAndActive(runners);
3021
+ if (!Number.isFinite(nextTime)) break;
3022
+ orderActiveSystems(active, nextTime, processingOrder, shuffleSeed);
3023
+ const dayKey = dayKeyET(nextTime);
3024
+ if (currentDay === null || dayKey !== currentDay) {
3025
+ currentDay = dayKey;
3026
+ state = portfolioState(runners, equity);
3027
+ dayStartEquity = state.markedEquity;
3028
+ portfolioHalted = false;
3029
+ }
3030
+ for (const systemEntry of active) {
3031
+ state = portfolioState(runners, equity);
3032
+ const totalEquity = state.markedEquity;
3033
+ const availableCapital = Math.max(0, state.availableCapital);
3034
+ const systemLocked = systemEntry.runner.getLockedCapital();
3035
+ const systemCap = resolveSystemCap(systemEntry, totalEquity);
3036
+ const systemRemainingCapital = Math.max(0, systemCap - systemLocked);
3037
+ systemEntry.runner.step({
3038
+ signalEquity: totalEquity,
3039
+ canTrade: !portfolioHalted,
3040
+ resolveEntrySize({ desiredSize, entryPrice }) {
3041
+ const maxLeverage = Math.max(1, systemEntry.runner.options.maxLeverage || 1);
3042
+ const byAvailable = availableCapital * maxLeverage / Math.max(1e-12, Math.abs(entryPrice));
3043
+ const bySystemCap = systemRemainingCapital * maxLeverage / Math.max(1e-12, Math.abs(entryPrice));
3044
+ return Math.min(desiredSize, byAvailable, bySystemCap);
3045
+ }
3046
+ });
3047
+ state = portfolioState(runners, equity);
3048
+ if (!portfolioHalted && maxDailyLossPct > 0 && state.markedEquity <= dayStartEquity * (1 - Math.abs(maxDailyLossPct) / 100)) {
3049
+ portfolioHalted = true;
3050
+ for (const { runner } of runners) runner.cancelPending();
3051
+ forceExitAll(runners, nextTime);
3052
+ state = portfolioState(runners, equity);
3053
+ }
3054
+ }
3055
+ if (collectEqSeries) {
3056
+ eqSeries.push(
3057
+ buildPortfolioPoint(
3058
+ nextTime,
3059
+ state.markedEquity,
3060
+ state.lockedCapital,
3061
+ state.availableCapital
3062
+ )
3063
+ );
3064
+ }
3065
+ }
3066
+ const systemResults = runners.map((entry) => ({
3067
+ symbol: entry.symbol,
3068
+ weight: entry.defaultCapPct,
3069
+ equity: entry.initialReferenceEquity,
3070
+ allocationCapPct: entry.defaultCapPct,
3071
+ allocationCap: resolveSystemCap(entry, equity),
3072
+ result: entry.runner.buildResult()
3073
+ }));
3074
+ const trades = systemResults.flatMap(
3075
+ (run) => run.result.trades.map((trade) => ({
3076
+ ...trade,
3077
+ symbol: trade.symbol || run.symbol
3078
+ }))
3079
+ ).sort((left, right) => left.exit.time - right.exit.time);
3080
+ const positions = systemResults.flatMap(
3081
+ (run) => run.result.positions.map((trade) => ({
3082
+ ...trade,
3083
+ symbol: trade.symbol || run.symbol
3084
+ }))
3085
+ ).sort((left, right) => left.exit.time - right.exit.time);
3086
+ const openPositions = systemResults.flatMap(
3087
+ (run) => (run.result.openPositions || []).map((position) => ({
3088
+ ...position,
3089
+ symbol: position.symbol || run.symbol
3090
+ }))
3091
+ );
3092
+ const replay = combineReplay(systemResults, eqSeries, collectReplay);
3093
+ const allCandles = systems.flatMap((system) => system.candles || []);
3094
+ const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
3095
+ const metrics = buildMetrics({
3096
+ closed: trades,
3097
+ equityStart: equity,
3098
+ equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : equity,
3099
+ candles: orderedCandles,
3100
+ estBarMs: estimateBarMs(orderedCandles),
3101
+ eqSeries
3102
+ });
3103
+ return {
3104
+ symbol: "PORTFOLIO",
3105
+ interval: void 0,
3106
+ range: void 0,
3107
+ trades,
3108
+ positions,
3109
+ openPositions,
3110
+ metrics,
3111
+ eqSeries,
3112
+ replay,
3113
+ systems: systemResults
1699
3114
  };
1700
3115
  }
1701
3116
 
@@ -1714,6 +3129,61 @@ function stitchEquitySeries(target, source) {
1714
3129
  const nextPoints = source.filter((point) => point.time > lastTime);
1715
3130
  target.push(...nextPoints);
1716
3131
  }
3132
+ function canonicalParams(params) {
3133
+ const entries = Object.entries(params || {}).sort(([left], [right]) => left.localeCompare(right));
3134
+ return JSON.stringify(Object.fromEntries(entries));
3135
+ }
3136
+ function describeValue5(value) {
3137
+ if (Array.isArray(value)) return `array(length=${value.length})`;
3138
+ if (value === null) return "null";
3139
+ return typeof value;
3140
+ }
3141
+ function buildWindowRanges(length, trainBars, testBars, stepBars, mode) {
3142
+ const ranges = [];
3143
+ for (let start = 0; start + trainBars + testBars <= length; start += stepBars) {
3144
+ const trainStart = mode === "anchored" ? 0 : start;
3145
+ const trainEnd = mode === "anchored" ? trainBars + start : start + trainBars;
3146
+ const testStart = trainEnd;
3147
+ const testEnd = testStart + testBars;
3148
+ if (testEnd > length) break;
3149
+ ranges.push({ trainStart, trainEnd, testStart, testEnd });
3150
+ }
3151
+ return ranges;
3152
+ }
3153
+ function summarizeBestParams(windows) {
3154
+ const summaryBySignature = /* @__PURE__ */ new Map();
3155
+ let adjacentRepeats = 0;
3156
+ windows.forEach((window, index) => {
3157
+ const signature = window.bestParamsSignature ?? canonicalParams(window.bestParams);
3158
+ const current = summaryBySignature.get(signature) || {
3159
+ params: window.bestParams,
3160
+ wins: 0,
3161
+ profitableWindows: 0,
3162
+ oosTrades: 0
3163
+ };
3164
+ current.wins += 1;
3165
+ current.profitableWindows += window.profitable ? 1 : 0;
3166
+ current.oosTrades += window.oosTrades;
3167
+ summaryBySignature.set(signature, current);
3168
+ if (index > 0 && (windows[index - 1].bestParamsSignature ?? canonicalParams(windows[index - 1].bestParams)) === signature) {
3169
+ adjacentRepeats += 1;
3170
+ }
3171
+ });
3172
+ const byFrequency = [...summaryBySignature.values()].sort((left, right) => {
3173
+ if (right.wins !== left.wins) return right.wins - left.wins;
3174
+ return right.profitableWindows - left.profitableWindows;
3175
+ });
3176
+ const adjacentPairs = Math.max(0, windows.length - 1);
3177
+ return {
3178
+ winners: windows.map((window) => window.bestParams),
3179
+ stability: {
3180
+ adjacentRepeatRate: adjacentPairs ? adjacentRepeats / adjacentPairs : 0,
3181
+ uniqueWinnerCount: summaryBySignature.size,
3182
+ dominant: byFrequency[0] || null,
3183
+ leaderboard: byFrequency
3184
+ }
3185
+ };
3186
+ }
1717
3187
  function walkForwardOptimize({
1718
3188
  candles = [],
1719
3189
  signalFactory,
@@ -1721,33 +3191,63 @@ function walkForwardOptimize({
1721
3191
  trainBars,
1722
3192
  testBars,
1723
3193
  stepBars = testBars,
3194
+ mode = "rolling",
1724
3195
  scoreBy = "profitFactor",
1725
3196
  backtestOptions = {}
1726
3197
  } = {}) {
1727
3198
  if (!Array.isArray(candles) || candles.length === 0) {
1728
- throw new Error("walkForwardOptimize() requires a non-empty candles array");
3199
+ throw new Error(
3200
+ `walkForwardOptimize() requires a non-empty candles array, got ${describeValue5(candles)}`
3201
+ );
1729
3202
  }
1730
3203
  if (typeof signalFactory !== "function") {
1731
- throw new Error("walkForwardOptimize() requires a signalFactory function");
3204
+ throw new Error(
3205
+ `walkForwardOptimize() requires a signalFactory function, got ${describeValue5(signalFactory)}`
3206
+ );
1732
3207
  }
1733
3208
  if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
1734
- throw new Error("walkForwardOptimize() requires parameterSets");
3209
+ throw new Error(
3210
+ `walkForwardOptimize() requires parameterSets, got ${describeValue5(parameterSets)}`
3211
+ );
1735
3212
  }
1736
3213
  if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
1737
3214
  throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
1738
3215
  }
3216
+ if (mode !== "rolling" && mode !== "anchored") {
3217
+ throw new Error('walkForwardOptimize() mode must be "rolling" or "anchored"');
3218
+ }
1739
3219
  const windows = [];
1740
3220
  const allTrades = [];
1741
3221
  const allPositions = [];
1742
3222
  const eqSeries = [];
1743
3223
  let rollingEquity = backtestOptions.equity ?? 1e4;
1744
- for (let start = 0; start + trainBars + testBars <= candles.length; start += stepBars) {
1745
- const trainSlice = candles.slice(start, start + trainBars);
1746
- const testSlice = candles.slice(start + trainBars, start + trainBars + testBars);
3224
+ const ranges = buildWindowRanges(candles.length, trainBars, testBars, stepBars, mode);
3225
+ if (!ranges.length) {
3226
+ const required = trainBars + testBars;
3227
+ throw new Error(
3228
+ `walkForwardOptimize() produced zero windows: need at least ${required} candles (trainBars=${trainBars} + testBars=${testBars}) but got ${candles.length}. Try reducing trainBars/testBars or adding more historical data.`
3229
+ );
3230
+ }
3231
+ const trainBacktestOptions = {
3232
+ ...backtestOptions,
3233
+ collectEqSeries: false,
3234
+ collectReplay: false
3235
+ };
3236
+ const testBacktestOptions = { ...backtestOptions };
3237
+ for (const range of ranges) {
3238
+ const trainSlice = candles.slice(range.trainStart, range.trainEnd);
3239
+ const testSlice = candles.slice(range.testStart, range.testEnd);
3240
+ if (!trainSlice.length || !testSlice.length) {
3241
+ throw new Error(
3242
+ `walkForwardOptimize() generated an empty window (train=${trainSlice.length}, test=${testSlice.length}, range=${JSON.stringify(
3243
+ range
3244
+ )})`
3245
+ );
3246
+ }
1747
3247
  let best = null;
1748
3248
  for (const params of parameterSets) {
1749
3249
  const trainResult = backtest({
1750
- ...backtestOptions,
3250
+ ...trainBacktestOptions,
1751
3251
  candles: trainSlice,
1752
3252
  equity: rollingEquity,
1753
3253
  signal: signalFactory(params)
@@ -1758,11 +3258,12 @@ function walkForwardOptimize({
1758
3258
  }
1759
3259
  }
1760
3260
  const testResult = backtest({
1761
- ...backtestOptions,
3261
+ ...testBacktestOptions,
1762
3262
  candles: testSlice,
1763
3263
  equity: rollingEquity,
1764
3264
  signal: signalFactory(best.params)
1765
3265
  });
3266
+ const bestParamsSignature = canonicalParams(best.params);
1766
3267
  rollingEquity = testResult.metrics.finalEquity;
1767
3268
  allTrades.push(...testResult.trades);
1768
3269
  allPositions.push(...testResult.positions);
@@ -1780,9 +3281,25 @@ function walkForwardOptimize({
1780
3281
  trainScore: best.score,
1781
3282
  trainMetrics: best.metrics,
1782
3283
  testMetrics: testResult.metrics,
3284
+ oosTrades: testResult.metrics.trades,
3285
+ profitable: testResult.metrics.totalPnL > 0,
3286
+ stabilityScore: 0,
3287
+ bestParamsSignature,
1783
3288
  result: testResult
1784
3289
  });
1785
3290
  }
3291
+ for (let index = 0; index < windows.length; index += 1) {
3292
+ const currentSignature = windows[index].bestParamsSignature;
3293
+ const adjacent = [];
3294
+ if (index > 0) {
3295
+ adjacent.push(windows[index - 1].bestParamsSignature === currentSignature ? 1 : 0);
3296
+ }
3297
+ if (index + 1 < windows.length) {
3298
+ adjacent.push(windows[index + 1].bestParamsSignature === currentSignature ? 1 : 0);
3299
+ }
3300
+ windows[index].stabilityScore = adjacent.length ? adjacent.reduce((total, value) => total + value, 0) / adjacent.length : 1;
3301
+ delete windows[index].bestParamsSignature;
3302
+ }
1786
3303
  const metrics = buildMetrics({
1787
3304
  closed: allTrades,
1788
3305
  equityStart: backtestOptions.equity ?? 1e4,
@@ -1791,14 +3308,20 @@ function walkForwardOptimize({
1791
3308
  estBarMs: estimateBarMs(candles),
1792
3309
  eqSeries
1793
3310
  });
3311
+ const bestParamsSummary = summarizeBestParams(windows);
1794
3312
  return {
1795
3313
  windows,
1796
3314
  trades: allTrades,
1797
3315
  positions: allPositions,
3316
+ openPositions: [],
1798
3317
  metrics,
1799
3318
  eqSeries,
1800
3319
  replay: { frames: [], events: [] },
1801
- bestParams: windows.map((window) => window.bestParams)
3320
+ bestParams: Object.assign(
3321
+ windows.map((window) => window.bestParams),
3322
+ bestParamsSummary
3323
+ ),
3324
+ bestParamsSummary: bestParamsSummary.stability
1802
3325
  };
1803
3326
  }
1804
3327
 
@@ -1808,7 +3331,6 @@ var import_path2 = __toESM(require("path"), 1);
1808
3331
  // src/data/yahoo.js
1809
3332
  var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1810
3333
  var DAY_MS = 24 * 60 * 60 * 1e3;
1811
- var DAY_SEC = 24 * 60 * 60;
1812
3334
  var requestQueue = {
1813
3335
  lastRequestAt: 0,
1814
3336
  minDelayMs: 400
@@ -1963,13 +3485,7 @@ async function fetchYahooChartWithRetry(symbol, params, period, maxRetries = 3)
1963
3485
  }
1964
3486
  }
1965
3487
  throw new Error(
1966
- formatYahooFailureMessage(
1967
- symbol,
1968
- params.interval,
1969
- period,
1970
- lastError,
1971
- maxRetries
1972
- )
3488
+ formatYahooFailureMessage(symbol, params.interval, period, lastError, maxRetries)
1973
3489
  );
1974
3490
  }
1975
3491
  async function fetchHistorical(symbol, interval = "5m", period = "60d", options = {}) {
@@ -2009,9 +3525,9 @@ async function fetchHistorical(symbol, interval = "5m", period = "60d", options
2009
3525
  period
2010
3526
  );
2011
3527
  chunks.push(...candles);
2012
- chunkEndMs = chunkStartMs - 1e3;
2013
3528
  remainingMs -= takeMs;
2014
- if (chunks.length > 2e6) break;
3529
+ chunkEndMs = chunkStartMs - 1e3;
3530
+ if (chunkEndMs <= 0 || chunks.length > 2e6) break;
2015
3531
  }
2016
3532
  return sanitizeBars(chunks);
2017
3533
  }
@@ -2087,11 +3603,7 @@ async function getHistoricalCandles(options = {}) {
2087
3603
  }
2088
3604
  return candles;
2089
3605
  }
2090
- async function backtestHistorical({
2091
- backtestOptions = {},
2092
- data,
2093
- ...legacy
2094
- } = {}) {
3606
+ async function backtestHistorical({ backtestOptions = {}, data, ...legacy } = {}) {
2095
3607
  const candles = await getHistoricalCandles(data || legacy);
2096
3608
  return backtest({
2097
3609
  candles,
@@ -2119,7 +3631,8 @@ function candidateRoots() {
2119
3631
  return [...new Set(roots)];
2120
3632
  }
2121
3633
  function readTemplate(relativePath) {
2122
- for (const root of candidateRoots()) {
3634
+ const roots = candidateRoots();
3635
+ for (const root of roots) {
2123
3636
  const absolutePath = import_path3.default.join(root, relativePath);
2124
3637
  if (!import_fs2.default.existsSync(absolutePath)) continue;
2125
3638
  if (!templateCache.has(absolutePath)) {
@@ -2127,7 +3640,9 @@ function readTemplate(relativePath) {
2127
3640
  }
2128
3641
  return templateCache.get(absolutePath);
2129
3642
  }
2130
- throw new Error(`Could not locate template asset: ${relativePath}`);
3643
+ throw new Error(
3644
+ `Could not locate template asset: ${relativePath} (searched ${roots.length} roots starting from ${roots[0]})`
3645
+ );
2131
3646
  }
2132
3647
  function fmt(value, digits = 2) {
2133
3648
  if (value === void 0 || value === null || Number.isNaN(value)) return "\u2014";
@@ -2208,9 +3723,7 @@ function renderPositionRows(positions) {
2208
3723
  <td>${escapeHtml(fmt(exit.price, 4))}</td>
2209
3724
  <td>${escapeHtml(exit.reason ?? "\u2014")}</td>
2210
3725
  <td>${escapeHtml(fmt(exit.pnl, 2))}</td>
2211
- <td>${escapeHtml(fmt(trade.mfeR ?? 0, 2))} / ${escapeHtml(
2212
- fmt(trade.maeR ?? 0, 2)
2213
- )}</td>
3726
+ <td>${escapeHtml(fmt(trade.mfeR ?? 0, 2))} / ${escapeHtml(fmt(trade.maeR ?? 0, 2))}</td>
2214
3727
  </tr>
2215
3728
  `;
2216
3729
  }).join("");
@@ -2315,10 +3828,7 @@ function renderHtmlReport({
2315
3828
  ["R p50 / p90", `${fmt(metrics.rDist?.p50 ?? 0, 2)} / ${fmt(metrics.rDist?.p90 ?? 0, 2)}`],
2316
3829
  [
2317
3830
  "Hold p50 / p90",
2318
- `${fmt(metrics.holdDistMin?.p50 ?? 0, 1)} / ${fmt(
2319
- metrics.holdDistMin?.p90 ?? 0,
2320
- 1
2321
- )} min`
3831
+ `${fmt(metrics.holdDistMin?.p50 ?? 0, 1)} / ${fmt(metrics.holdDistMin?.p90 ?? 0, 1)} min`
2322
3832
  ]
2323
3833
  ]);
2324
3834
  return renderTemplate(template, {
@@ -2359,10 +3869,7 @@ function exportHtmlReport({
2359
3869
  const safeSymbol = String(symbol).replace(/[^a-zA-Z0-9_.-]+/g, "_");
2360
3870
  const safeInterval = String(interval).replace(/[^a-zA-Z0-9_.-]+/g, "_");
2361
3871
  const safeRange = String(range).replace(/[^a-zA-Z0-9_.-]+/g, "_");
2362
- const outputPath = import_path3.default.join(
2363
- outDir,
2364
- `report-${safeSymbol}-${safeInterval}-${safeRange}.html`
2365
- );
3872
+ const outputPath = import_path3.default.join(outDir, `report-${safeSymbol}-${safeInterval}-${safeRange}.html`);
2366
3873
  const html = renderHtmlReport({
2367
3874
  symbol,
2368
3875
  interval,
@@ -2522,6 +4029,7 @@ function exportBacktestArtifacts({
2522
4029
  backtest,
2523
4030
  backtestHistorical,
2524
4031
  backtestPortfolio,
4032
+ backtestTicks,
2525
4033
  bpsOf,
2526
4034
  buildMetrics,
2527
4035
  cachedCandlesPath,