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
package/dist/cjs/data.cjs CHANGED
@@ -1,3 +1,4 @@
1
+ "use strict";
1
2
  var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -87,6 +88,14 @@ function atr(bars, period = 14) {
87
88
  function roundStep(value, step) {
88
89
  return Math.floor(value / step) * step;
89
90
  }
91
+ var warnedNonPositiveEquity = false;
92
+ function warnNonPositiveEquity(equity) {
93
+ if (warnedNonPositiveEquity) return;
94
+ warnedNonPositiveEquity = true;
95
+ console.warn(
96
+ `[tradelab] calculatePositionSize() received non-positive equity (${equity}); returning size 0`
97
+ );
98
+ }
90
99
  function calculatePositionSize({
91
100
  equity,
92
101
  entry,
@@ -96,6 +105,10 @@ function calculatePositionSize({
96
105
  minQty = 1e-3,
97
106
  maxLeverage = 2
98
107
  }) {
108
+ if (!Number.isFinite(equity) || equity <= 0) {
109
+ warnNonPositiveEquity(equity);
110
+ return 0;
111
+ }
99
112
  const riskPerUnit = Math.abs(entry - stop);
100
113
  if (!Number.isFinite(riskPerUnit) || riskPerUnit <= 0) return 0;
101
114
  const maxRiskDollars = Math.max(0, equity * riskFraction);
@@ -207,14 +220,14 @@ function percentile(values, percentileRank) {
207
220
  const index = Math.floor((sorted.length - 1) * percentileRank);
208
221
  return sorted[index];
209
222
  }
210
- function buildMetrics({
211
- closed,
212
- equityStart,
213
- equityFinal,
214
- candles,
215
- estBarMs,
216
- eqSeries
217
- }) {
223
+ var PROFIT_FACTOR_CAP = 1e6;
224
+ function finiteProfitFactor(grossProfit, grossLoss) {
225
+ if (grossLoss === 0) {
226
+ return grossProfit > 0 ? PROFIT_FACTOR_CAP : 0;
227
+ }
228
+ return grossProfit / grossLoss;
229
+ }
230
+ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqSeries }) {
218
231
  const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
219
232
  const completedTrades = [];
220
233
  const tradeRs = [];
@@ -290,8 +303,8 @@ function buildMetrics({
290
303
  const tradeReturnStd = stddev(tradeReturns);
291
304
  const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
292
305
  const sortinoPerTrade = sortino(tradeReturns);
293
- const profitFactorPositions = grossLossPositions === 0 ? grossProfitPositions > 0 ? Infinity : 0 : grossProfitPositions / grossLossPositions;
294
- const profitFactorLegs = grossLossLegs === 0 ? grossProfitLegs > 0 ? Infinity : 0 : grossProfitLegs / grossLossLegs;
306
+ const profitFactorPositions = finiteProfitFactor(grossProfitPositions, grossLossPositions);
307
+ const profitFactorLegs = finiteProfitFactor(grossProfitLegs, grossLossLegs);
295
308
  const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
296
309
  const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
297
310
  const totalBars = Math.max(1, candles.length);
@@ -460,7 +473,7 @@ function normalizeDateBoundary(value, fallback) {
460
473
  }
461
474
  function normalizeCandles(candles) {
462
475
  if (!Array.isArray(candles)) return [];
463
- const normalized = candles.map((bar) => {
476
+ const parsed = candles.map((bar) => {
464
477
  try {
465
478
  const time = resolveDate(bar?.time ?? bar?.timestamp ?? bar?.date);
466
479
  const open = Number(bar?.open ?? bar?.o);
@@ -482,7 +495,16 @@ function normalizeCandles(candles) {
482
495
  } catch {
483
496
  return null;
484
497
  }
485
- }).filter(Boolean).sort((left, right) => left.time - right.time);
498
+ }).filter(Boolean);
499
+ let reordered = false;
500
+ let duplicateCount = 0;
501
+ for (let index = 1; index < parsed.length; index += 1) {
502
+ const prev = parsed[index - 1].time;
503
+ const current = parsed[index].time;
504
+ if (current < prev) reordered = true;
505
+ if (current === prev) duplicateCount += 1;
506
+ }
507
+ const normalized = parsed.sort((left, right) => left.time - right.time);
486
508
  const deduped = [];
487
509
  let lastTime = null;
488
510
  for (const candle of normalized) {
@@ -490,6 +512,12 @@ function normalizeCandles(candles) {
490
512
  deduped.push(candle);
491
513
  lastTime = candle.time;
492
514
  }
515
+ const removedDuplicates = normalized.length - deduped.length;
516
+ if (reordered || removedDuplicates > 0 || duplicateCount > 0) {
517
+ console.warn(
518
+ `[tradelab] normalizeCandles() reordered or deduplicated candles (input=${candles.length}, valid=${parsed.length}, output=${deduped.length})`
519
+ );
520
+ }
493
521
  return deduped;
494
522
  }
495
523
  function loadCandlesFromCSV(filePath, options = {}) {
@@ -532,9 +560,7 @@ function loadCandlesFromCSV(filePath, options = {}) {
532
560
  const closeIdx = resolveColumn(closeCol, headerIndex, ["c", "adj close"]);
533
561
  const volumeIdx = resolveColumn(volumeCol, headerIndex, ["v", "vol", "quantity"]);
534
562
  if (timeIdx < 0 || openIdx < 0 || highIdx < 0 || lowIdx < 0 || closeIdx < 0) {
535
- throw new Error(
536
- `Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`
537
- );
563
+ throw new Error(`Could not resolve required CSV columns in ${import_path.default.basename(filePath)}`);
538
564
  }
539
565
  const minTime = normalizeDateBoundary(startDate, -Infinity);
540
566
  const maxTime = normalizeDateBoundary(endDate, Infinity);
@@ -649,18 +675,12 @@ function usDstBoundsUTC(year) {
649
675
  if (sundaysSeen === 2) break;
650
676
  marchCursor = new Date(marchCursor.getTime() + 24 * 60 * 60 * 1e3);
651
677
  }
652
- const dstStart = new Date(
653
- Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0)
654
- );
678
+ const dstStart = new Date(Date.UTC(year, 2, marchCursor.getUTCDate(), 7, 0, 0));
655
679
  let novemberCursor = new Date(Date.UTC(year, 10, 1, 6, 0, 0));
656
680
  while (novemberCursor.getUTCDay() !== 0) {
657
- novemberCursor = new Date(
658
- novemberCursor.getTime() + 24 * 60 * 60 * 1e3
659
- );
681
+ novemberCursor = new Date(novemberCursor.getTime() + 24 * 60 * 60 * 1e3);
660
682
  }
661
- const dstEnd = new Date(
662
- Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0)
663
- );
683
+ const dstEnd = new Date(Date.UTC(year, 10, novemberCursor.getUTCDate(), 6, 0, 0));
664
684
  return { dstStart, dstEnd };
665
685
  }
666
686
  function isUsEasternDST(timeMs) {
@@ -691,11 +711,7 @@ function applyFill(price, side, { slippageBps = 0, feeBps = 0, kind = "market",
691
711
  const model = costs || {};
692
712
  const modelSlippageBps = Number.isFinite(model.slippageBps) ? model.slippageBps : slippageBps;
693
713
  const modelFeeBps = Number.isFinite(model.commissionBps) ? model.commissionBps : feeBps;
694
- const effectiveSlippageBps = resolveSlippageBps(
695
- kind,
696
- modelSlippageBps,
697
- model.slippageByKind
698
- );
714
+ const effectiveSlippageBps = resolveSlippageBps(kind, modelSlippageBps, model.slippageByKind);
699
715
  const halfSpreadBps = Number.isFinite(model.spreadBps) ? model.spreadBps / 2 : 0;
700
716
  const slippage = (effectiveSlippageBps + halfSpreadBps) / 1e4 * price;
701
717
  const filledPrice = side === "long" ? price + slippage : price - slippage;
@@ -722,14 +738,7 @@ function touchedLimit(side, limitPrice, bar, mode = "intrabar") {
722
738
  }
723
739
  return side === "long" ? bar.low <= limitPrice : bar.high >= limitPrice;
724
740
  }
725
- function ocoExitCheck({
726
- side,
727
- stop,
728
- tp,
729
- bar,
730
- mode = "intrabar",
731
- tieBreak = "pessimistic"
732
- }) {
741
+ function ocoExitCheck({ side, stop, tp, bar, mode = "intrabar", tieBreak = "pessimistic" }) {
733
742
  if (mode === "close") {
734
743
  const close = bar.close;
735
744
  if (side === "long") {
@@ -810,13 +819,51 @@ function strictHistoryView(candles, currentIndex) {
810
819
  get(target, property, receiver) {
811
820
  if (isArrayIndexKey(property) && Number(property) >= target.length) {
812
821
  throw new Error(
813
- `strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
822
+ `strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
814
823
  );
815
824
  }
816
825
  return Reflect.get(target, property, receiver);
817
826
  }
818
827
  });
819
828
  }
829
+ function describeValue(value) {
830
+ if (Array.isArray(value)) return `array(length=${value.length})`;
831
+ if (value === null) return "null";
832
+ return typeof value;
833
+ }
834
+ function formatIsoTime(time) {
835
+ return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
836
+ }
837
+ function callSignalWithContext({ signal, context, index, bar, symbol }) {
838
+ try {
839
+ return signal(context);
840
+ } catch (error) {
841
+ const cause = error instanceof Error ? error.message : String(error);
842
+ throw new Error(
843
+ `signal() threw at index=${index}, time=${formatIsoTime(bar?.time)}, symbol=${symbol}: ${cause}`
844
+ );
845
+ }
846
+ }
847
+ function snapshotOpenPosition(open, markPrice) {
848
+ if (!open) return null;
849
+ const entryPrice = open.entryFill ?? open.entry;
850
+ const direction = open.side === "long" ? 1 : -1;
851
+ const unrealizedPnl = (markPrice - entryPrice) * direction * open.size;
852
+ return {
853
+ id: open.id,
854
+ symbol: open.symbol,
855
+ side: open.side,
856
+ size: open.size,
857
+ entry: open.entry,
858
+ entryFill: open.entryFill,
859
+ stop: open.stop,
860
+ takeProfit: open.takeProfit,
861
+ openTime: open.openTime,
862
+ markPrice,
863
+ unrealizedPnl,
864
+ _initRisk: open._initRisk
865
+ };
866
+ }
820
867
  function mergeOptions(options) {
821
868
  const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
822
869
  return {
@@ -958,10 +1005,10 @@ function backtest(rawOptions) {
958
1005
  strict
959
1006
  } = options;
960
1007
  if (!Array.isArray(candles) || candles.length === 0) {
961
- throw new Error("backtest() requires a non-empty candles array");
1008
+ throw new Error(`backtest() requires a non-empty candles array, got ${describeValue(candles)}`);
962
1009
  }
963
1010
  if (typeof signal !== "function") {
964
- throw new Error("backtest() requires a signal function");
1011
+ throw new Error(`backtest() requires a signal function, got ${describeValue(signal)}`);
965
1012
  }
966
1013
  const closed = [];
967
1014
  let currentEquity = equity;
@@ -1110,17 +1157,13 @@ function backtest(rawOptions) {
1110
1157
  });
1111
1158
  const size = roundStep2(rawSize, qtyStep);
1112
1159
  if (size < minQty) return false;
1113
- const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
1114
- entryPrice,
1115
- pending.side,
1116
- {
1117
- slippageBps,
1118
- feeBps,
1119
- kind: fillKind,
1120
- qty: size,
1121
- costs
1122
- }
1123
- );
1160
+ const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, pending.side, {
1161
+ slippageBps,
1162
+ feeBps,
1163
+ kind: fillKind,
1164
+ qty: size,
1165
+ costs
1166
+ });
1124
1167
  open = {
1125
1168
  symbol,
1126
1169
  ...pending.meta,
@@ -1171,10 +1214,7 @@ function backtest(rawOptions) {
1171
1214
  dayEquityStart = currentEquity;
1172
1215
  }
1173
1216
  if (open && open._maxBarsInTrade > 0) {
1174
- const barsHeld = Math.max(
1175
- 1,
1176
- Math.round((bar.time - open.openTime) / estimatedBarMs)
1177
- );
1217
+ const barsHeld = Math.max(1, Math.round((bar.time - open.openTime) / estimatedBarMs));
1178
1218
  if (barsHeld >= open._maxBarsInTrade) {
1179
1219
  forceExit("TIME", bar);
1180
1220
  }
@@ -1228,11 +1268,13 @@ function backtest(rawOptions) {
1228
1268
  const cutQty = roundStep2(open.size * volScale.cutFrac, qtyStep);
1229
1269
  if (cutQty >= minQty && cutQty < open.size) {
1230
1270
  const exitSide2 = open.side === "long" ? "short" : "long";
1231
- const { price: filled, feeTotal: exitFeeTotal } = applyFill(
1232
- bar.close,
1233
- exitSide2,
1234
- { slippageBps, feeBps, kind: "market", qty: cutQty, costs }
1235
- );
1271
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide2, {
1272
+ slippageBps,
1273
+ feeBps,
1274
+ kind: "market",
1275
+ qty: cutQty,
1276
+ costs
1277
+ });
1236
1278
  closeLeg({
1237
1279
  openPos: open,
1238
1280
  qty: cutQty,
@@ -1257,11 +1299,13 @@ function backtest(rawOptions) {
1257
1299
  const baseSize = open.baseSize || open.initSize;
1258
1300
  const addQty = roundStep2(baseSize * pyramiding.addFrac, qtyStep);
1259
1301
  if (addQty >= minQty) {
1260
- const { price: addFill, feeTotal: addFeeTotal } = applyFill(
1261
- triggerPrice,
1262
- open.side,
1263
- { slippageBps, feeBps, kind: "limit", qty: addQty, costs }
1264
- );
1302
+ const { price: addFill, feeTotal: addFeeTotal } = applyFill(triggerPrice, open.side, {
1303
+ slippageBps,
1304
+ feeBps,
1305
+ kind: "limit",
1306
+ qty: addQty,
1307
+ costs
1308
+ });
1265
1309
  const newSize = open.size + addQty;
1266
1310
  open.entryFeeTotal += addFeeTotal;
1267
1311
  open.entryFill = (open.entryFill * open.size + addFill * addQty) / newSize;
@@ -1339,15 +1383,10 @@ function backtest(rawOptions) {
1339
1383
  if (!open && pending) {
1340
1384
  if (index > pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
1341
1385
  if (entryChase.enabled && entryChase.convertOnExpiry) {
1342
- const riskAtEdge = Math.abs(
1343
- pending.meta._initRisk ?? pending.entry - pending.stop
1344
- );
1386
+ const riskAtEdge = Math.abs(pending.meta._initRisk ?? pending.entry - pending.stop);
1345
1387
  const priceNow = bar.close;
1346
1388
  const direction = pending.side === "long" ? 1 : -1;
1347
- const slippedR = Math.max(
1348
- 0,
1349
- direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
1350
- ) / Math.max(1e-8, riskAtEdge);
1389
+ const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskAtEdge);
1351
1390
  if (slippedR > maxSlipROnFill) {
1352
1391
  pending = null;
1353
1392
  } else if (!openFromPending(bar, index, priceNow, "market")) {
@@ -1362,21 +1401,16 @@ function backtest(rawOptions) {
1362
1401
  }
1363
1402
  } else if (entryChase.enabled) {
1364
1403
  const elapsedBars = index - (pending.startedAtIndex ?? index);
1365
- const midpoint = pending.meta?._imb?.mid;
1366
- if (!pending._chasedCE && midpoint !== void 0 && elapsedBars >= Math.max(1, entryChase.afterBars)) {
1404
+ const midpoint = asNumber(pending.meta?._imb?.mid);
1405
+ if (!pending._chasedCE && midpoint !== null && elapsedBars >= Math.max(1, entryChase.afterBars)) {
1367
1406
  pending.entry = midpoint;
1368
1407
  pending._chasedCE = true;
1369
1408
  }
1370
1409
  if (pending._chasedCE) {
1371
- const riskRef = Math.abs(
1372
- pending.meta?._initRisk ?? pending.entry - pending.stop
1373
- );
1410
+ const riskRef = Math.abs(pending.meta?._initRisk ?? pending.entry - pending.stop);
1374
1411
  const priceNow = bar.close;
1375
1412
  const direction = pending.side === "long" ? 1 : -1;
1376
- const slippedR = Math.max(
1377
- 0,
1378
- direction === 1 ? priceNow - pending.entry : pending.entry - priceNow
1379
- ) / Math.max(1e-8, riskRef);
1413
+ const slippedR = Math.max(0, direction === 1 ? priceNow - pending.entry : pending.entry - priceNow) / Math.max(1e-8, riskRef);
1380
1414
  if (slippedR > maxSlipROnFill) {
1381
1415
  pending = null;
1382
1416
  } else if (slippedR > 0 && slippedR <= entryChase.maxSlipR) {
@@ -1404,13 +1438,19 @@ function backtest(rawOptions) {
1404
1438
  );
1405
1439
  }
1406
1440
  const signalCandles = strict ? strictHistoryView(history, index) : history;
1407
- const rawSignal = signal({
1408
- candles: signalCandles,
1441
+ const rawSignal = callSignalWithContext({
1442
+ signal,
1443
+ context: {
1444
+ candles: signalCandles,
1445
+ index,
1446
+ bar,
1447
+ equity: currentEquity,
1448
+ openPosition: open,
1449
+ pendingOrder: pending
1450
+ },
1409
1451
  index,
1410
1452
  bar,
1411
- equity: currentEquity,
1412
- openPosition: open,
1413
- pendingOrder: pending
1453
+ symbol
1414
1454
  });
1415
1455
  const nextSignal = normalizeSignal(rawSignal, bar, finalTP_R);
1416
1456
  if (nextSignal) {
@@ -1426,9 +1466,7 @@ function backtest(rawOptions) {
1426
1466
  expiresAt: index + Math.max(1, expiryBars),
1427
1467
  startedAtIndex: index,
1428
1468
  meta: nextSignal,
1429
- plannedRiskAbs: Math.abs(
1430
- nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop
1431
- )
1469
+ plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
1432
1470
  };
1433
1471
  if (touchedLimit(pending.side, pending.entry, bar, trigger)) {
1434
1472
  if (!openFromPending(bar, index, pending.entry, "limit")) {
@@ -1448,12 +1486,15 @@ function backtest(rawOptions) {
1448
1486
  eqSeries
1449
1487
  });
1450
1488
  const positions = closed.filter((trade) => trade.exit.reason !== "SCALE");
1489
+ const lastPrice = asNumber(candles[candles.length - 1]?.close);
1490
+ const openPositions = open ? [snapshotOpenPosition(open, lastPrice ?? open.entryFill ?? open.entry)] : [];
1451
1491
  return {
1452
1492
  symbol: options.symbol,
1453
1493
  interval: options.interval,
1454
1494
  range: options.range,
1455
1495
  trades: closed,
1456
1496
  positions,
1497
+ openPositions,
1457
1498
  metrics,
1458
1499
  eqSeries,
1459
1500
  replay: {
@@ -1466,7 +1507,6 @@ function backtest(rawOptions) {
1466
1507
  // src/data/yahoo.js
1467
1508
  var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1468
1509
  var DAY_MS = 24 * 60 * 60 * 1e3;
1469
- var DAY_SEC = 24 * 60 * 60;
1470
1510
  var requestQueue = {
1471
1511
  lastRequestAt: 0,
1472
1512
  minDelayMs: 400
@@ -1621,13 +1661,7 @@ async function fetchYahooChartWithRetry(symbol, params, period, maxRetries = 3)
1621
1661
  }
1622
1662
  }
1623
1663
  throw new Error(
1624
- formatYahooFailureMessage(
1625
- symbol,
1626
- params.interval,
1627
- period,
1628
- lastError,
1629
- maxRetries
1630
- )
1664
+ formatYahooFailureMessage(symbol, params.interval, period, lastError, maxRetries)
1631
1665
  );
1632
1666
  }
1633
1667
  async function fetchHistorical(symbol, interval = "5m", period = "60d", options = {}) {
@@ -1667,9 +1701,9 @@ async function fetchHistorical(symbol, interval = "5m", period = "60d", options
1667
1701
  period
1668
1702
  );
1669
1703
  chunks.push(...candles);
1670
- chunkEndMs = chunkStartMs - 1e3;
1671
1704
  remainingMs -= takeMs;
1672
- if (chunks.length > 2e6) break;
1705
+ chunkEndMs = chunkStartMs - 1e3;
1706
+ if (chunkEndMs <= 0 || chunks.length > 2e6) break;
1673
1707
  }
1674
1708
  return sanitizeBars(chunks);
1675
1709
  }
@@ -1745,11 +1779,7 @@ async function getHistoricalCandles(options = {}) {
1745
1779
  }
1746
1780
  return candles;
1747
1781
  }
1748
- async function backtestHistorical({
1749
- backtestOptions = {},
1750
- data,
1751
- ...legacy
1752
- } = {}) {
1782
+ async function backtestHistorical({ backtestOptions = {}, data, ...legacy } = {}) {
1753
1783
  const candles = await getHistoricalCandles(data || legacy);
1754
1784
  return backtest({
1755
1785
  candles,