tradelab 0.5.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +89 -41
  2. package/bin/tradelab.js +276 -30
  3. package/dist/cjs/data.cjs +134 -104
  4. package/dist/cjs/index.cjs +378 -177
  5. package/dist/cjs/live.cjs +3350 -0
  6. package/docs/README.md +21 -9
  7. package/docs/api-reference.md +87 -29
  8. package/docs/backtest-engine.md +37 -53
  9. package/docs/data-reporting-cli.md +60 -34
  10. package/docs/examples.md +6 -12
  11. package/docs/live-trading.md +186 -0
  12. package/examples/yahooEmaCross.js +1 -6
  13. package/package.json +18 -3
  14. package/src/data/csv.js +24 -14
  15. package/src/data/index.js +1 -5
  16. package/src/data/yahoo.js +6 -19
  17. package/src/engine/backtest.js +137 -144
  18. package/src/engine/backtestTicks.js +89 -37
  19. package/src/engine/barSystemRunner.js +182 -118
  20. package/src/engine/execution.js +11 -39
  21. package/src/engine/portfolio.js +54 -6
  22. package/src/engine/walkForward.js +37 -14
  23. package/src/index.js +2 -11
  24. package/src/live/broker/alpaca.js +254 -0
  25. package/src/live/broker/binance.js +351 -0
  26. package/src/live/broker/coinbase.js +339 -0
  27. package/src/live/broker/interactiveBrokers.js +123 -0
  28. package/src/live/broker/interface.js +74 -0
  29. package/src/live/clock.js +56 -0
  30. package/src/live/engine/candleAggregator.js +154 -0
  31. package/src/live/engine/liveEngine.js +694 -0
  32. package/src/live/engine/paperEngine.js +453 -0
  33. package/src/live/engine/riskManager.js +185 -0
  34. package/src/live/engine/stateManager.js +112 -0
  35. package/src/live/events.js +48 -0
  36. package/src/live/feed/brokerFeed.js +35 -0
  37. package/src/live/feed/interface.js +28 -0
  38. package/src/live/feed/pollingFeed.js +105 -0
  39. package/src/live/index.js +27 -0
  40. package/src/live/logger.js +82 -0
  41. package/src/live/orchestrator.js +133 -0
  42. package/src/live/storage/interface.js +36 -0
  43. package/src/live/storage/jsonFileStorage.js +112 -0
  44. package/src/metrics/buildMetrics.js +18 -41
  45. package/src/reporting/exportBacktestArtifacts.js +1 -4
  46. package/src/reporting/exportTradesCsv.js +2 -7
  47. package/src/reporting/renderHtmlReport.js +8 -13
  48. package/src/utils/indicators.js +1 -2
  49. package/src/utils/positionSizing.js +16 -2
  50. package/src/utils/time.js +4 -12
  51. package/templates/report.html +23 -9
  52. package/templates/report.js +83 -69
  53. package/types/index.d.ts +21 -3
  54. package/types/live.d.ts +382 -0
@@ -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;
@@ -189,6 +190,14 @@ var pct = (a, b) => (a - b) / b;
189
190
  function roundStep(value, step) {
190
191
  return Math.floor(value / step) * step;
191
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
+ }
192
201
  function calculatePositionSize({
193
202
  equity,
194
203
  entry,
@@ -198,6 +207,10 @@ function calculatePositionSize({
198
207
  minQty = 1e-3,
199
208
  maxLeverage = 2
200
209
  }) {
210
+ if (!Number.isFinite(equity) || equity <= 0) {
211
+ warnNonPositiveEquity(equity);
212
+ return 0;
213
+ }
201
214
  const riskPerUnit = Math.abs(entry - stop);
202
215
  if (!Number.isFinite(riskPerUnit) || riskPerUnit <= 0) return 0;
203
216
  const maxRiskDollars = Math.max(0, equity * riskFraction);
@@ -309,14 +322,14 @@ function percentile(values, percentileRank) {
309
322
  const index = Math.floor((sorted.length - 1) * percentileRank);
310
323
  return sorted[index];
311
324
  }
312
- function buildMetrics({
313
- closed,
314
- equityStart,
315
- equityFinal,
316
- candles,
317
- estBarMs,
318
- eqSeries
319
- }) {
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 }) {
320
333
  const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
321
334
  const completedTrades = [];
322
335
  const tradeRs = [];
@@ -392,8 +405,8 @@ function buildMetrics({
392
405
  const tradeReturnStd = stddev(tradeReturns);
393
406
  const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
394
407
  const sortinoPerTrade = sortino(tradeReturns);
395
- const profitFactorPositions = grossLossPositions === 0 ? grossProfitPositions > 0 ? Infinity : 0 : grossProfitPositions / grossLossPositions;
396
- const profitFactorLegs = grossLossLegs === 0 ? grossProfitLegs > 0 ? Infinity : 0 : grossProfitLegs / grossLossLegs;
408
+ const profitFactorPositions = finiteProfitFactor(grossProfitPositions, grossLossPositions);
409
+ const profitFactorLegs = finiteProfitFactor(grossProfitLegs, grossLossLegs);
397
410
  const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
398
411
  const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
399
412
  const totalBars = Math.max(1, candles.length);
@@ -562,7 +575,7 @@ function normalizeDateBoundary(value, fallback) {
562
575
  }
563
576
  function normalizeCandles(candles) {
564
577
  if (!Array.isArray(candles)) return [];
565
- const normalized = candles.map((bar) => {
578
+ const parsed = candles.map((bar) => {
566
579
  try {
567
580
  const time = resolveDate(bar?.time ?? bar?.timestamp ?? bar?.date);
568
581
  const open = Number(bar?.open ?? bar?.o);
@@ -584,7 +597,16 @@ function normalizeCandles(candles) {
584
597
  } catch {
585
598
  return null;
586
599
  }
587
- }).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);
588
610
  const deduped = [];
589
611
  let lastTime = null;
590
612
  for (const candle of normalized) {
@@ -592,6 +614,12 @@ function normalizeCandles(candles) {
592
614
  deduped.push(candle);
593
615
  lastTime = candle.time;
594
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
+ }
595
623
  return deduped;
596
624
  }
597
625
  function loadCandlesFromCSV(filePath, options = {}) {
@@ -634,9 +662,7 @@ function loadCandlesFromCSV(filePath, options = {}) {
634
662
  const closeIdx = resolveColumn(closeCol, headerIndex, ["c", "adj close"]);
635
663
  const volumeIdx = resolveColumn(volumeCol, headerIndex, ["v", "vol", "quantity"]);
636
664
  if (timeIdx < 0 || openIdx < 0 || highIdx < 0 || lowIdx < 0 || closeIdx < 0) {
637
- throw new Error(
638
- `Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`
639
- );
665
+ throw new Error(`Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`);
640
666
  }
641
667
  const minTime = normalizeDateBoundary(startDate, -Infinity);
642
668
  const maxTime = normalizeDateBoundary(endDate, Infinity);
@@ -751,18 +777,12 @@ function usDstBoundsUTC(year) {
751
777
  if (sundaysSeen === 2) break;
752
778
  marchCursor = new Date(marchCursor.getTime() + 24 * 60 * 60 * 1e3);
753
779
  }
754
- const dstStart = new Date(
755
- Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0)
756
- );
780
+ const dstStart = new Date(Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0));
757
781
  let novemberCursor = new Date(Date.UTC(year, 10, 1, 6, 0, 0));
758
782
  while (novemberCursor.getUTCDay() !== 0) {
759
- novemberCursor = new Date(
760
- novemberCursor.getTime() + 24 * 60 * 60 * 1e3
761
- );
783
+ novemberCursor = new Date(novemberCursor.getTime() + 24 * 60 * 60 * 1e3);
762
784
  }
763
- const dstEnd = new Date(
764
- Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0)
765
- );
785
+ const dstEnd = new Date(Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0));
766
786
  return { dstStart, dstEnd };
767
787
  }
768
788
  function isUsEasternDST(timeMs) {
@@ -830,11 +850,7 @@ function applyFill(price, side, { slippageBps = 0, feeBps = 0, kind = "market",
830
850
  const model = costs || {};
831
851
  const modelSlippageBps = Number.isFinite(model.slippageBps) ? model.slippageBps : slippageBps;
832
852
  const modelFeeBps = Number.isFinite(model.commissionBps) ? model.commissionBps : feeBps;
833
- const effectiveSlippageBps = resolveSlippageBps(
834
- kind,
835
- modelSlippageBps,
836
- model.slippageByKind
837
- );
853
+ const effectiveSlippageBps = resolveSlippageBps(kind, modelSlippageBps, model.slippageByKind);
838
854
  const halfSpreadBps = Number.isFinite(model.spreadBps) ? model.spreadBps / 2 : 0;
839
855
  const slippage = (effectiveSlippageBps + halfSpreadBps) / 1e4 * price;
840
856
  const filledPrice = side === "long" ? price + slippage : price - slippage;
@@ -861,14 +877,7 @@ function touchedLimit(side, limitPrice, bar, mode = "intrabar") {
861
877
  }
862
878
  return side === "long" ? bar.low <= limitPrice : bar.high >= limitPrice;
863
879
  }
864
- function ocoExitCheck({
865
- side,
866
- stop,
867
- tp,
868
- bar,
869
- mode = "intrabar",
870
- tieBreak = "pessimistic"
871
- }) {
880
+ function ocoExitCheck({ side, stop, tp, bar, mode = "intrabar", tieBreak = "pessimistic" }) {
872
881
  if (mode === "close") {
873
882
  const close = bar.close;
874
883
  if (side === "long") {
@@ -949,13 +958,51 @@ function strictHistoryView(candles, currentIndex) {
949
958
  get(target, property, receiver) {
950
959
  if (isArrayIndexKey(property) && Number(property) >= target.length) {
951
960
  throw new Error(
952
- `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}`
953
962
  );
954
963
  }
955
964
  return Reflect.get(target, property, receiver);
956
965
  }
957
966
  });
958
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
+ }
959
1006
  function mergeOptions(options) {
960
1007
  const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
961
1008
  return {
@@ -1097,10 +1144,10 @@ function backtest(rawOptions) {
1097
1144
  strict
1098
1145
  } = options;
1099
1146
  if (!Array.isArray(candles) || candles.length === 0) {
1100
- throw new Error("backtest() requires a non-empty candles array");
1147
+ throw new Error(`backtest() requires a non-empty candles array, got ${describeValue(candles)}`);
1101
1148
  }
1102
1149
  if (typeof signal !== "function") {
1103
- throw new Error("backtest() requires a signal function");
1150
+ throw new Error(`backtest() requires a signal function, got ${describeValue(signal)}`);
1104
1151
  }
1105
1152
  const closed = [];
1106
1153
  let currentEquity = equity;
@@ -1249,17 +1296,13 @@ function backtest(rawOptions) {
1249
1296
  });
1250
1297
  const size = roundStep2(rawSize, qtyStep);
1251
1298
  if (size < minQty) return false;
1252
- const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
1253
- entryPrice,
1254
- pending.side,
1255
- {
1256
- slippageBps,
1257
- feeBps,
1258
- kind: fillKind,
1259
- qty: size,
1260
- costs
1261
- }
1262
- );
1299
+ const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, pending.side, {
1300
+ slippageBps,
1301
+ feeBps,
1302
+ kind: fillKind,
1303
+ qty: size,
1304
+ costs
1305
+ });
1263
1306
  open = {
1264
1307
  symbol,
1265
1308
  ...pending.meta,
@@ -1310,10 +1353,7 @@ function backtest(rawOptions) {
1310
1353
  dayEquityStart = currentEquity;
1311
1354
  }
1312
1355
  if (open && open._maxBarsInTrade > 0) {
1313
- const barsHeld = Math.max(
1314
- 1,
1315
- Math.round((bar.time - open.openTime) / estimatedBarMs)
1316
- );
1356
+ const barsHeld = Math.max(1, Math.round((bar.time - open.openTime) / estimatedBarMs));
1317
1357
  if (barsHeld >= open._maxBarsInTrade) {
1318
1358
  forceExit("TIME", bar);
1319
1359
  }
@@ -1367,11 +1407,13 @@ function backtest(rawOptions) {
1367
1407
  const cutQty = roundStep2(open.size * volScale.cutFrac, qtyStep);
1368
1408
  if (cutQty >= minQty && cutQty < open.size) {
1369
1409
  const exitSide2 = open.side === "long" ? "short" : "long";
1370
- const { price: filled, feeTotal: exitFeeTotal } = applyFill(
1371
- bar.close,
1372
- exitSide2,
1373
- { slippageBps, feeBps, kind: "market", qty: cutQty, costs }
1374
- );
1410
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide2, {
1411
+ slippageBps,
1412
+ feeBps,
1413
+ kind: "market",
1414
+ qty: cutQty,
1415
+ costs
1416
+ });
1375
1417
  closeLeg({
1376
1418
  openPos: open,
1377
1419
  qty: cutQty,
@@ -1396,11 +1438,13 @@ function backtest(rawOptions) {
1396
1438
  const baseSize = open.baseSize || open.initSize;
1397
1439
  const addQty = roundStep2(baseSize * pyramiding.addFrac, qtyStep);
1398
1440
  if (addQty >= minQty) {
1399
- const { price: addFill, feeTotal: addFeeTotal } = applyFill(
1400
- triggerPrice,
1401
- open.side,
1402
- { slippageBps, feeBps, kind: "limit", qty: addQty, costs }
1403
- );
1441
+ const { price: addFill, feeTotal: addFeeTotal } = applyFill(triggerPrice, open.side, {
1442
+ slippageBps,
1443
+ feeBps,
1444
+ kind: "limit",
1445
+ qty: addQty,
1446
+ costs
1447
+ });
1404
1448
  const newSize = open.size + addQty;
1405
1449
  open.entryFeeTotal += addFeeTotal;
1406
1450
  open.entryFill = (open.entryFill * open.size + addFill * addQty) / newSize;
@@ -1478,15 +1522,10 @@ function backtest(rawOptions) {
1478
1522
  if (!open && pending) {
1479
1523
  if (index > pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
1480
1524
  if (entryChase.enabled && entryChase.convertOnExpiry) {
1481
- const riskAtEdge = Math.abs(
1482
- pending.meta._initRisk ?? pending.entry - pending.stop
1483
- );
1525
+ const riskAtEdge = Math.abs(pending.meta._initRisk ?? pending.entry - pending.stop);
1484
1526
  const priceNow = bar.close;
1485
1527
  const direction = pending.side === "long" ? 1 : -1;
1486
- const slippedR = Math.max(
1487
- 0,
1488
- direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
1489
- ) / Math.max(1e-8, riskAtEdge);
1528
+ const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskAtEdge);
1490
1529
  if (slippedR > maxSlipROnFill) {
1491
1530
  pending = null;
1492
1531
  } else if (!openFromPending(bar, index, priceNow, "market")) {
@@ -1501,21 +1540,16 @@ function backtest(rawOptions) {
1501
1540
  }
1502
1541
  } else if (entryChase.enabled) {
1503
1542
  const elapsedBars = index - (pending.startedAtIndex ?? index);
1504
- const midpoint = pending.meta?._imb?.mid;
1505
- 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)) {
1506
1545
  pending.entry = midpoint;
1507
1546
  pending._chasedCE = true;
1508
1547
  }
1509
1548
  if (pending._chasedCE) {
1510
- const riskRef = Math.abs(
1511
- pending.meta?._initRisk ?? pending.entry - pending.stop
1512
- );
1549
+ const riskRef = Math.abs(pending.meta?._initRisk ?? pending.entry - pending.stop);
1513
1550
  const priceNow = bar.close;
1514
1551
  const direction = pending.side === "long" ? 1 : -1;
1515
- const slippedR = Math.max(
1516
- 0,
1517
- direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
1518
- ) / Math.max(1e-8, riskRef);
1552
+ const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskRef);
1519
1553
  if (slippedR > maxSlipROnFill) {
1520
1554
  pending = null;
1521
1555
  } else if (slippedR > 0 && slippedR <= entryChase.maxSlipR) {
@@ -1543,13 +1577,19 @@ function backtest(rawOptions) {
1543
1577
  );
1544
1578
  }
1545
1579
  const signalCandles = strict ? strictHistoryView(history, index) : history;
1546
- const rawSignal = signal({
1547
- 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
+ },
1548
1590
  index,
1549
1591
  bar,
1550
- equity: currentEquity,
1551
- openPosition: open,
1552
- pendingOrder: pending
1592
+ symbol
1553
1593
  });
1554
1594
  const nextSignal = normalizeSignal(rawSignal, bar, finalTP_R);
1555
1595
  if (nextSignal) {
@@ -1565,9 +1605,7 @@ function backtest(rawOptions) {
1565
1605
  expiresAt: index + Math.max(1, expiryBars),
1566
1606
  startedAtIndex: index,
1567
1607
  meta: nextSignal,
1568
- plannedRiskAbs: Math.abs(
1569
- nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop
1570
- )
1608
+ plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
1571
1609
  };
1572
1610
  if (touchedLimit(pending.side, pending.entry, bar, trigger)) {
1573
1611
  if (!openFromPending(bar, index, pending.entry, "limit")) {
@@ -1587,12 +1625,15 @@ function backtest(rawOptions) {
1587
1625
  eqSeries
1588
1626
  });
1589
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)] : [];
1590
1630
  return {
1591
1631
  symbol: options.symbol,
1592
1632
  interval: options.interval,
1593
1633
  range: options.range,
1594
1634
  trades: closed,
1595
1635
  positions,
1636
+ openPositions,
1596
1637
  metrics,
1597
1638
  eqSeries,
1598
1639
  replay: {
@@ -1607,6 +1648,24 @@ function asNumber2(value) {
1607
1648
  const numeric = Number(value);
1608
1649
  return Number.isFinite(numeric) ? numeric : null;
1609
1650
  }
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
+ );
1667
+ }
1668
+ }
1610
1669
  function normalizeSide2(value) {
1611
1670
  if (value === "long" || value === "buy") return "long";
1612
1671
  if (value === "short" || value === "sell") return "short";
@@ -1666,16 +1725,36 @@ function normalizeSignal2(signal, bar, fallbackR) {
1666
1725
  function equityPoint2(time, equity) {
1667
1726
  return { time, timestamp: time, equity };
1668
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;
1733
+ }
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
+ };
1739
+ }
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
+ }
1669
1754
  function deterministicFill(probability, seedParts) {
1670
1755
  if (probability >= 1) return true;
1671
1756
  if (probability <= 0) return false;
1672
- let hash = 2166136261;
1673
- const seed = seedParts.join("|");
1674
- for (let index = 0; index < seed.length; index += 1) {
1675
- hash ^= seed.charCodeAt(index);
1676
- hash = Math.imul(hash, 16777619);
1677
- }
1678
- const normalized = (hash >>> 0) / 4294967295;
1757
+ const normalized = seededUnitInterval(seedParts);
1679
1758
  return normalized <= probability;
1680
1759
  }
1681
1760
  function backtestTicks({
@@ -1701,14 +1780,18 @@ function backtestTicks({
1701
1780
  oco = {}
1702
1781
  } = {}) {
1703
1782
  if (!Array.isArray(ticks) || ticks.length === 0) {
1704
- throw new Error("backtestTicks() requires a non-empty ticks array");
1783
+ throw new Error(
1784
+ `backtestTicks() requires a non-empty ticks array, got ${describeValue2(ticks)}`
1785
+ );
1705
1786
  }
1706
1787
  if (typeof signal !== "function") {
1707
- throw new Error("backtestTicks() requires a signal function");
1788
+ throw new Error(`backtestTicks() requires a signal function, got ${describeValue2(signal)}`);
1708
1789
  }
1709
1790
  const normalizedTicks = ticks.map(normalizeTick).filter(Boolean);
1710
1791
  if (!normalizedTicks.length) {
1711
- throw new Error("backtestTicks() could not normalize any ticks");
1792
+ throw new Error(
1793
+ `backtestTicks() could not normalize any ticks from ${ticks.length} input rows`
1794
+ );
1712
1795
  }
1713
1796
  const ocoOptions = {
1714
1797
  mode: "intrabar",
@@ -1917,13 +2000,19 @@ function backtestTicks({
1917
2000
  const dailyTradeCapHit = dailyMaxTrades > 0 && dayTrades >= dailyMaxTrades;
1918
2001
  if (!open && !pending && !dailyLossHit && !dailyTradeCapHit) {
1919
2002
  const nextSignal = normalizeSignal2(
1920
- signal({
1921
- candles: history,
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
+ },
1922
2013
  index,
1923
2014
  bar: tick,
1924
- equity: markedEquity(tick),
1925
- openPosition: open,
1926
- pendingOrder: pending
2015
+ symbol
1927
2016
  }),
1928
2017
  tick,
1929
2018
  finalTP_R
@@ -1963,6 +2052,7 @@ function backtestTicks({
1963
2052
  range,
1964
2053
  trades,
1965
2054
  positions,
2055
+ openPositions: [],
1966
2056
  metrics,
1967
2057
  eqSeries,
1968
2058
  replay: {
@@ -1990,13 +2080,51 @@ function strictHistoryView2(candles, currentIndex) {
1990
2080
  get(target, property, receiver) {
1991
2081
  if (isArrayIndexKey2(property) && Number(property) >= target.length) {
1992
2082
  throw new Error(
1993
- `strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
2083
+ `strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
1994
2084
  );
1995
2085
  }
1996
2086
  return Reflect.get(target, property, receiver);
1997
2087
  }
1998
2088
  });
1999
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
+ }
2000
2128
  function normalizeSide3(value) {
2001
2129
  if (value === "long" || value === "buy") return "long";
2002
2130
  if (value === "short" || value === "sell") return "short";
@@ -2110,10 +2238,18 @@ var BarSystemRunner = class {
2110
2238
  this.options = mergeOptions2(rawOptions);
2111
2239
  const { candles, signal } = this.options;
2112
2240
  if (!Array.isArray(candles) || candles.length === 0) {
2113
- throw new Error("backtestPortfolio() requires each system to include non-empty candles");
2241
+ throw new Error(
2242
+ `backtestPortfolio() requires each system to include non-empty candles, got ${describeValue3(
2243
+ candles
2244
+ )} for ${this.options.symbol}`
2245
+ );
2114
2246
  }
2115
2247
  if (typeof signal !== "function") {
2116
- throw new Error("backtestPortfolio() requires each system to include a 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
+ );
2117
2253
  }
2118
2254
  this.symbol = this.options.symbol;
2119
2255
  this.candles = candles;
@@ -2149,7 +2285,11 @@ var BarSystemRunner = class {
2149
2285
  }
2150
2286
  getLockedCapital() {
2151
2287
  if (!this.open) return 0;
2152
- return capitalForSize(this.open.entryFill ?? this.open.entry, this.open.size, this.options.maxLeverage);
2288
+ return capitalForSize(
2289
+ this.open.entryFill ?? this.open.entry,
2290
+ this.open.size,
2291
+ this.options.maxLeverage
2292
+ );
2153
2293
  }
2154
2294
  getMarkPrice() {
2155
2295
  return this.lastBar?.close ?? null;
@@ -2297,17 +2437,13 @@ var BarSystemRunner = class {
2297
2437
  }) : desiredSize;
2298
2438
  const size = roundStep2(approvedSize, this.options.qtyStep);
2299
2439
  if (size < this.options.minQty) return false;
2300
- const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
2301
- entryPrice,
2302
- this.pending.side,
2303
- {
2304
- slippageBps: this.options.slippageBps,
2305
- feeBps: this.options.feeBps,
2306
- kind: fillKind,
2307
- qty: size,
2308
- costs: this.options.costs
2309
- }
2310
- );
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
+ });
2311
2447
  this.open = {
2312
2448
  symbol: this.symbol,
2313
2449
  ...this.pending.meta,
@@ -2413,10 +2549,7 @@ var BarSystemRunner = class {
2413
2549
  this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
2414
2550
  }
2415
2551
  if (this.options.mfeTrail.enabled && this.open._mfeR >= this.options.mfeTrail.armR) {
2416
- const targetR = Math.max(
2417
- 0,
2418
- this.open._mfeR - Math.max(0, this.options.mfeTrail.givebackR)
2419
- );
2552
+ const targetR = Math.max(0, this.open._mfeR - Math.max(0, this.options.mfeTrail.givebackR));
2420
2553
  const candidate = this.open.side === "long" ? this.open.entry + targetR * risk : this.open.entry - targetR * risk;
2421
2554
  const tightened = this.open.side === "long" ? Math.max(this.open.stop, candidate) : Math.min(this.open.stop, candidate);
2422
2555
  this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
@@ -2431,7 +2564,10 @@ var BarSystemRunner = class {
2431
2564
  const ratio = this.atrValues[this.index] / Math.max(1e-12, this.open.entryATR);
2432
2565
  const shouldCut = ratio >= this.options.volScale.cutIfAtrX && markR < this.options.volScale.noCutAboveR && !this.open._volCutDone;
2433
2566
  if (shouldCut) {
2434
- const cutQty = roundStep2(this.open.size * this.options.volScale.cutFrac, this.options.qtyStep);
2567
+ const cutQty = roundStep2(
2568
+ this.open.size * this.options.volScale.cutFrac,
2569
+ this.options.qtyStep
2570
+ );
2435
2571
  if (cutQty >= this.options.minQty && cutQty < this.open.size) {
2436
2572
  const exitSide2 = this.open.side === "long" ? "short" : "long";
2437
2573
  const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide2, {
@@ -2463,7 +2599,10 @@ var BarSystemRunner = class {
2463
2599
  const touched = this.open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
2464
2600
  if (breakEvenSatisfied && touched) {
2465
2601
  const baseSize = this.open.baseSize || this.open.initSize;
2466
- const requestedQty = roundStep2(baseSize * this.options.pyramiding.addFrac, this.options.qtyStep);
2602
+ const requestedQty = roundStep2(
2603
+ baseSize * this.options.pyramiding.addFrac,
2604
+ this.options.qtyStep
2605
+ );
2467
2606
  const addQty = typeof resolveEntrySize === "function" ? roundStep2(
2468
2607
  resolveEntrySize({
2469
2608
  runner: this,
@@ -2480,13 +2619,17 @@ var BarSystemRunner = class {
2480
2619
  this.options.qtyStep
2481
2620
  ) : requestedQty;
2482
2621
  if (addQty >= this.options.minQty) {
2483
- const { price: addFill, feeTotal: addFeeTotal } = applyFill(triggerPrice, this.open.side, {
2484
- slippageBps: this.options.slippageBps,
2485
- feeBps: this.options.feeBps,
2486
- kind: "limit",
2487
- qty: addQty,
2488
- costs: this.options.costs
2489
- });
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
+ );
2490
2633
  const newSize = this.open.size + addQty;
2491
2634
  this.open.entryFeeTotal += addFeeTotal;
2492
2635
  this.open.entryFill = (this.open.entryFill * this.open.size + addFill * addQty) / newSize;
@@ -2589,8 +2732,8 @@ var BarSystemRunner = class {
2589
2732
  }
2590
2733
  } else if (this.options.entryChase.enabled) {
2591
2734
  const elapsedBars = this.index - (this.pending.startedAtIndex ?? this.index);
2592
- const midpoint = this.pending.meta?._imb?.mid;
2593
- if (!this.pending._chasedCE && midpoint !== void 0 && elapsedBars >= Math.max(1, this.options.entryChase.afterBars)) {
2735
+ const midpoint = asNumber3(this.pending.meta?._imb?.mid);
2736
+ if (!this.pending._chasedCE && midpoint !== null && elapsedBars >= Math.max(1, this.options.entryChase.afterBars)) {
2594
2737
  this.pending.entry = midpoint;
2595
2738
  this.pending._chasedCE = true;
2596
2739
  }
@@ -2627,7 +2770,13 @@ var BarSystemRunner = class {
2627
2770
  return bar;
2628
2771
  }
2629
2772
  if (!this.pending) {
2630
- const rawSignal = this.options.signal(this.buildSignalContext(this.index, bar, signalEquity));
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
+ });
2631
2780
  const nextSignal = normalizeSignal3(rawSignal, bar, this.options.finalTP_R);
2632
2781
  if (nextSignal) {
2633
2782
  const signalRiskFraction = Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : this.options.riskPct / 100;
@@ -2642,9 +2791,7 @@ var BarSystemRunner = class {
2642
2791
  expiresAt: this.index + Math.max(1, expiryBars),
2643
2792
  startedAtIndex: this.index,
2644
2793
  meta: nextSignal,
2645
- plannedRiskAbs: Math.abs(
2646
- nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop
2647
- )
2794
+ plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
2648
2795
  };
2649
2796
  if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
2650
2797
  if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
@@ -2667,12 +2814,15 @@ var BarSystemRunner = class {
2667
2814
  eqSeries: this.eqSeries
2668
2815
  });
2669
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)] : [];
2670
2819
  return {
2671
2820
  symbol: this.options.symbol,
2672
2821
  interval: this.options.interval,
2673
2822
  range: this.options.range,
2674
2823
  trades: this.closed,
2675
2824
  positions,
2825
+ openPositions,
2676
2826
  metrics,
2677
2827
  eqSeries: this.eqSeries,
2678
2828
  replay: {
@@ -2696,6 +2846,11 @@ function defaultSystemCap(totalEquity, capPct, maxAllocation, maxAllocationPct)
2696
2846
  function asWeight(value) {
2697
2847
  return Number.isFinite(value) && value > 0 ? value : 0;
2698
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
+ }
2699
2854
  function buildPortfolioPoint(time, equity, lockedCapital, availableCapital) {
2700
2855
  return {
2701
2856
  time,
@@ -2708,6 +2863,24 @@ function buildPortfolioPoint(time, equity, lockedCapital, availableCapital) {
2708
2863
  function stableSystemOrder(left, right) {
2709
2864
  return left.index - right.index;
2710
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
+ }
2711
2884
  function combineReplay(systemResults, eqSeries, collectReplay) {
2712
2885
  if (!collectReplay) {
2713
2886
  return { frames: [], events: [] };
@@ -2789,15 +2962,26 @@ function backtestPortfolio({
2789
2962
  allocation = "equal",
2790
2963
  collectEqSeries = true,
2791
2964
  collectReplay = false,
2792
- maxDailyLossPct = 0
2965
+ maxDailyLossPct = 0,
2966
+ processingOrder = "sequential",
2967
+ shuffleSeed = 0
2793
2968
  } = {}) {
2794
2969
  if (!Array.isArray(systems) || systems.length === 0) {
2795
- throw new Error("backtestPortfolio() requires a non-empty systems array");
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
+ );
2796
2978
  }
2797
2979
  const weights = allocation === "equal" ? systems.map(() => 1) : systems.map((system) => asWeight(system.weight || 0));
2798
2980
  const totalWeight = weights.reduce((sumValue, weight) => sumValue + weight, 0);
2799
2981
  if (!(totalWeight > 0)) {
2800
- throw new Error("backtestPortfolio() requires positive allocation weights");
2982
+ throw new Error(
2983
+ `backtestPortfolio() requires positive allocation weights, got allocation=${allocation}`
2984
+ );
2801
2985
  }
2802
2986
  const runners = systems.map((system, index) => {
2803
2987
  const defaultCapPct = weights[index] / totalWeight;
@@ -2835,7 +3019,7 @@ function backtestPortfolio({
2835
3019
  while (true) {
2836
3020
  const { nextTime, active } = findNextTimeAndActive(runners);
2837
3021
  if (!Number.isFinite(nextTime)) break;
2838
- active.sort(stableSystemOrder);
3022
+ orderActiveSystems(active, nextTime, processingOrder, shuffleSeed);
2839
3023
  const dayKey = dayKeyET(nextTime);
2840
3024
  if (currentDay === null || dayKey !== currentDay) {
2841
3025
  currentDay = dayKey;
@@ -2899,6 +3083,12 @@ function backtestPortfolio({
2899
3083
  symbol: trade.symbol || run.symbol
2900
3084
  }))
2901
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
+ );
2902
3092
  const replay = combineReplay(systemResults, eqSeries, collectReplay);
2903
3093
  const allCandles = systems.flatMap((system) => system.candles || []);
2904
3094
  const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
@@ -2916,6 +3106,7 @@ function backtestPortfolio({
2916
3106
  range: void 0,
2917
3107
  trades,
2918
3108
  positions,
3109
+ openPositions,
2919
3110
  metrics,
2920
3111
  eqSeries,
2921
3112
  replay,
@@ -2939,11 +3130,14 @@ function stitchEquitySeries(target, source) {
2939
3130
  target.push(...nextPoints);
2940
3131
  }
2941
3132
  function canonicalParams(params) {
2942
- const entries = Object.entries(params || {}).sort(
2943
- ([left], [right]) => left.localeCompare(right)
2944
- );
3133
+ const entries = Object.entries(params || {}).sort(([left], [right]) => left.localeCompare(right));
2945
3134
  return JSON.stringify(Object.fromEntries(entries));
2946
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
+ }
2947
3141
  function buildWindowRanges(length, trainBars, testBars, stepBars, mode) {
2948
3142
  const ranges = [];
2949
3143
  for (let start = 0; start + trainBars + testBars <= length; start += stepBars) {
@@ -3002,13 +3196,19 @@ function walkForwardOptimize({
3002
3196
  backtestOptions = {}
3003
3197
  } = {}) {
3004
3198
  if (!Array.isArray(candles) || candles.length === 0) {
3005
- 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
+ );
3006
3202
  }
3007
3203
  if (typeof signalFactory !== "function") {
3008
- throw new Error("walkForwardOptimize() requires a signalFactory function");
3204
+ throw new Error(
3205
+ `walkForwardOptimize() requires a signalFactory function, got ${describeValue5(signalFactory)}`
3206
+ );
3009
3207
  }
3010
3208
  if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
3011
- throw new Error("walkForwardOptimize() requires parameterSets");
3209
+ throw new Error(
3210
+ `walkForwardOptimize() requires parameterSets, got ${describeValue5(parameterSets)}`
3211
+ );
3012
3212
  }
3013
3213
  if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
3014
3214
  throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
@@ -3022,6 +3222,12 @@ function walkForwardOptimize({
3022
3222
  const eqSeries = [];
3023
3223
  let rollingEquity = backtestOptions.equity ?? 1e4;
3024
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
+ }
3025
3231
  const trainBacktestOptions = {
3026
3232
  ...backtestOptions,
3027
3233
  collectEqSeries: false,
@@ -3031,6 +3237,13 @@ function walkForwardOptimize({
3031
3237
  for (const range of ranges) {
3032
3238
  const trainSlice = candles.slice(range.trainStart, range.trainEnd);
3033
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
+ }
3034
3247
  let best = null;
3035
3248
  for (const params of parameterSets) {
3036
3249
  const trainResult = backtest({
@@ -3100,10 +3313,14 @@ function walkForwardOptimize({
3100
3313
  windows,
3101
3314
  trades: allTrades,
3102
3315
  positions: allPositions,
3316
+ openPositions: [],
3103
3317
  metrics,
3104
3318
  eqSeries,
3105
3319
  replay: { frames: [], events: [] },
3106
- bestParams: Object.assign(windows.map((window) => window.bestParams), bestParamsSummary),
3320
+ bestParams: Object.assign(
3321
+ windows.map((window) => window.bestParams),
3322
+ bestParamsSummary
3323
+ ),
3107
3324
  bestParamsSummary: bestParamsSummary.stability
3108
3325
  };
3109
3326
  }
@@ -3114,7 +3331,6 @@ var import_path2 = __toESM(require("path"), 1);
3114
3331
  // src/data/yahoo.js
3115
3332
  var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3116
3333
  var DAY_MS = 24 * 60 * 60 * 1e3;
3117
- var DAY_SEC = 24 * 60 * 60;
3118
3334
  var requestQueue = {
3119
3335
  lastRequestAt: 0,
3120
3336
  minDelayMs: 400
@@ -3269,13 +3485,7 @@ async function fetchYahooChartWithRetry(symbol, params, period, maxRetries = 3)
3269
3485
  }
3270
3486
  }
3271
3487
  throw new Error(
3272
- formatYahooFailureMessage(
3273
- symbol,
3274
- params.interval,
3275
- period,
3276
- lastError,
3277
- maxRetries
3278
- )
3488
+ formatYahooFailureMessage(symbol, params.interval, period, lastError, maxRetries)
3279
3489
  );
3280
3490
  }
3281
3491
  async function fetchHistorical(symbol, interval = "5m", period = "60d", options = {}) {
@@ -3315,9 +3525,9 @@ async function fetchHistorical(symbol, interval = "5m", period = "60d", options
3315
3525
  period
3316
3526
  );
3317
3527
  chunks.push(...candles);
3318
- chunkEndMs = chunkStartMs - 1e3;
3319
3528
  remainingMs -= takeMs;
3320
- if (chunks.length > 2e6) break;
3529
+ chunkEndMs = chunkStartMs - 1e3;
3530
+ if (chunkEndMs <= 0 || chunks.length > 2e6) break;
3321
3531
  }
3322
3532
  return sanitizeBars(chunks);
3323
3533
  }
@@ -3393,11 +3603,7 @@ async function getHistoricalCandles(options = {}) {
3393
3603
  }
3394
3604
  return candles;
3395
3605
  }
3396
- async function backtestHistorical({
3397
- backtestOptions = {},
3398
- data,
3399
- ...legacy
3400
- } = {}) {
3606
+ async function backtestHistorical({ backtestOptions = {}, data, ...legacy } = {}) {
3401
3607
  const candles = await getHistoricalCandles(data || legacy);
3402
3608
  return backtest({
3403
3609
  candles,
@@ -3425,7 +3631,8 @@ function candidateRoots() {
3425
3631
  return [...new Set(roots)];
3426
3632
  }
3427
3633
  function readTemplate(relativePath) {
3428
- for (const root of candidateRoots()) {
3634
+ const roots = candidateRoots();
3635
+ for (const root of roots) {
3429
3636
  const absolutePath = import_path3.default.join(root, relativePath);
3430
3637
  if (!import_fs2.default.existsSync(absolutePath)) continue;
3431
3638
  if (!templateCache.has(absolutePath)) {
@@ -3433,7 +3640,9 @@ function readTemplate(relativePath) {
3433
3640
  }
3434
3641
  return templateCache.get(absolutePath);
3435
3642
  }
3436
- 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
+ );
3437
3646
  }
3438
3647
  function fmt(value, digits = 2) {
3439
3648
  if (value === void 0 || value === null || Number.isNaN(value)) return "\u2014";
@@ -3514,9 +3723,7 @@ function renderPositionRows(positions) {
3514
3723
  <td>${escapeHtml(fmt(exit.price, 4))}</td>
3515
3724
  <td>${escapeHtml(exit.reason ?? "\u2014")}</td>
3516
3725
  <td>${escapeHtml(fmt(exit.pnl, 2))}</td>
3517
- <td>${escapeHtml(fmt(trade.mfeR ?? 0, 2))} / ${escapeHtml(
3518
- fmt(trade.maeR ?? 0, 2)
3519
- )}</td>
3726
+ <td>${escapeHtml(fmt(trade.mfeR ?? 0, 2))} / ${escapeHtml(fmt(trade.maeR ?? 0, 2))}</td>
3520
3727
  </tr>
3521
3728
  `;
3522
3729
  }).join("");
@@ -3621,10 +3828,7 @@ function renderHtmlReport({
3621
3828
  ["R p50 / p90", `${fmt(metrics.rDist?.p50 ?? 0, 2)} / ${fmt(metrics.rDist?.p90 ?? 0, 2)}`],
3622
3829
  [
3623
3830
  "Hold p50 / p90",
3624
- `${fmt(metrics.holdDistMin?.p50 ?? 0, 1)} / ${fmt(
3625
- metrics.holdDistMin?.p90 ?? 0,
3626
- 1
3627
- )} min`
3831
+ `${fmt(metrics.holdDistMin?.p50 ?? 0, 1)} / ${fmt(metrics.holdDistMin?.p90 ?? 0, 1)} min`
3628
3832
  ]
3629
3833
  ]);
3630
3834
  return renderTemplate(template, {
@@ -3665,10 +3869,7 @@ function exportHtmlReport({
3665
3869
  const safeSymbol = String(symbol).replace(/[^a-zA-Z0-9_.-]+/g, "_");
3666
3870
  const safeInterval = String(interval).replace(/[^a-zA-Z0-9_.-]+/g, "_");
3667
3871
  const safeRange = String(range).replace(/[^a-zA-Z0-9_.-]+/g, "_");
3668
- const outputPath = import_path3.default.join(
3669
- outDir,
3670
- `report-${safeSymbol}-${safeInterval}-${safeRange}.html`
3671
- );
3872
+ const outputPath = import_path3.default.join(outDir, `report-${safeSymbol}-${safeInterval}-${safeRange}.html`);
3672
3873
  const html = renderHtmlReport({
3673
3874
  symbol,
3674
3875
  interval,