tradelab 1.0.0 → 1.1.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 (67) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +75 -12
  3. package/bin/tradelab-mcp.js +7 -0
  4. package/bin/tradelab.js +29 -0
  5. package/dist/cjs/data.cjs +149 -26
  6. package/dist/cjs/index.cjs +1893 -1003
  7. package/dist/cjs/live.cjs +134 -25
  8. package/dist/cjs/ta.cjs +339 -0
  9. package/docs/api-reference.md +46 -0
  10. package/docs/backtest-engine.md +112 -0
  11. package/docs/live-trading.md +51 -0
  12. package/docs/mcp.md +64 -0
  13. package/docs/research.md +103 -0
  14. package/docs/superpowers/plans/2026-00-overview.md +101 -0
  15. package/docs/superpowers/plans/2026-01-metrics-correctness.md +873 -0
  16. package/docs/superpowers/plans/2026-02-indicator-library.md +677 -0
  17. package/docs/superpowers/plans/2026-03-overfitting-toolkit.md +882 -0
  18. package/docs/superpowers/plans/2026-04-async-signals-seeding.md +981 -0
  19. package/docs/superpowers/plans/2026-05-mcp-server.md +758 -0
  20. package/docs/superpowers/plans/2026-06-parallel-param-sweep.md +508 -0
  21. package/docs/superpowers/plans/2026-07-funding-carry-costs.md +535 -0
  22. package/docs/superpowers/plans/2026-08-live-dashboard.md +547 -0
  23. package/docs/superpowers/plans/HANDOFF.md +88 -0
  24. package/examples/liveDashboard.js +33 -0
  25. package/examples/llmSignal.js +33 -0
  26. package/examples/optimize.js +25 -0
  27. package/package.json +16 -2
  28. package/src/engine/asyncSignal.js +28 -0
  29. package/src/engine/backtest.js +13 -1
  30. package/src/engine/backtestAsync.js +27 -0
  31. package/src/engine/backtestTicks.js +13 -2
  32. package/src/engine/barSystemRunner.js +96 -41
  33. package/src/engine/execution.js +39 -0
  34. package/src/engine/grid.js +15 -0
  35. package/src/engine/llmSignal.js +84 -0
  36. package/src/engine/optimize.js +86 -0
  37. package/src/engine/optimizeWorker.js +67 -0
  38. package/src/engine/walkForward.js +1 -0
  39. package/src/index.js +9 -0
  40. package/src/live/dashboard/server.js +120 -0
  41. package/src/live/engine/liveEngine.js +2 -2
  42. package/src/live/index.js +1 -0
  43. package/src/mcp/schemas.js +48 -0
  44. package/src/mcp/server.js +31 -0
  45. package/src/mcp/tools.js +142 -0
  46. package/src/metrics/annualize.js +32 -0
  47. package/src/metrics/benchmark.js +55 -0
  48. package/src/metrics/buildMetrics.js +34 -13
  49. package/src/metrics/finite.js +17 -0
  50. package/src/research/combinations.js +18 -0
  51. package/src/research/cpcv.js +47 -0
  52. package/src/research/deflatedSharpe.js +35 -0
  53. package/src/research/index.js +6 -0
  54. package/src/research/monteCarlo.js +88 -0
  55. package/src/research/pbo.js +69 -0
  56. package/src/research/stats.js +78 -0
  57. package/src/strategies/builtins.js +96 -0
  58. package/src/strategies/index.js +30 -0
  59. package/src/ta/channels.js +67 -0
  60. package/src/ta/index.js +16 -0
  61. package/src/ta/oscillators.js +70 -0
  62. package/src/ta/trend.js +78 -0
  63. package/src/utils/random.js +33 -0
  64. package/templates/dashboard.html +174 -0
  65. package/types/index.d.ts +154 -0
  66. package/types/live.d.ts +15 -0
  67. package/types/ta.d.ts +45 -0
@@ -30,16 +30,21 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.js
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ BIG_NUMBER: () => BIG_NUMBER,
34
+ LlmSignal: () => LlmSignal,
33
35
  atr: () => atr,
34
36
  backtest: () => backtest,
37
+ backtestAsync: () => backtestAsync,
35
38
  backtestHistorical: () => backtestHistorical,
36
39
  backtestPortfolio: () => backtestPortfolio,
37
40
  backtestTicks: () => backtestTicks,
41
+ benchmarkStats: () => benchmarkStats,
38
42
  bpsOf: () => bpsOf,
39
43
  buildMetrics: () => buildMetrics,
40
44
  cachedCandlesPath: () => cachedCandlesPath,
41
45
  calculatePositionSize: () => calculatePositionSize,
42
46
  candleStats: () => candleStats,
47
+ clampFinite: () => clampFinite,
43
48
  detectFVG: () => detectFVG,
44
49
  ema: () => ema,
45
50
  exportBacktestArtifacts: () => exportBacktestArtifacts,
@@ -49,18 +54,25 @@ __export(index_exports, {
49
54
  fetchHistorical: () => fetchHistorical,
50
55
  fetchLatestCandle: () => fetchLatestCandle,
51
56
  getHistoricalCandles: () => getHistoricalCandles,
57
+ getStrategy: () => getStrategy,
58
+ grid: () => grid,
52
59
  inWindowsET: () => inWindowsET,
53
60
  isSession: () => isSession,
54
61
  lastSwing: () => lastSwing,
62
+ listStrategies: () => listStrategies,
55
63
  loadCandlesFromCSV: () => loadCandlesFromCSV,
56
64
  loadCandlesFromCache: () => loadCandlesFromCache,
57
65
  mergeCandles: () => mergeCandles,
58
66
  minutesET: () => minutesET,
59
67
  normalizeCandles: () => normalizeCandles,
60
68
  offsetET: () => offsetET,
69
+ optimize: () => optimize,
61
70
  parseWindowsCSV: () => parseWindowsCSV,
62
71
  pct: () => pct,
72
+ periodsPerYear: () => periodsPerYear,
73
+ registerStrategy: () => registerStrategy,
63
74
  renderHtmlReport: () => renderHtmlReport,
75
+ research: () => research_exports,
64
76
  saveCandlesToCache: () => saveCandlesToCache,
65
77
  structureState: () => structureState,
66
78
  swingHigh: () => swingHigh,
@@ -221,22 +233,93 @@ function calculatePositionSize({
221
233
  return quantity >= minQty ? quantity : 0;
222
234
  }
223
235
 
236
+ // src/metrics/finite.js
237
+ var BIG_NUMBER = 1e9;
238
+ function clampFinite(value, fallback = 0) {
239
+ if (value === Infinity) return BIG_NUMBER;
240
+ if (value === -Infinity) return -BIG_NUMBER;
241
+ if (typeof value === "number" && Number.isFinite(value)) return value;
242
+ return fallback;
243
+ }
244
+
245
+ // src/metrics/annualize.js
246
+ var TRADING_DAYS = 252;
247
+ var RTH_HOURS = 6.5;
248
+ var MS_PER_YEAR = 365 * 24 * 60 * 60 * 1e3;
249
+ var INTERVAL_PERIODS = {
250
+ "1m": TRADING_DAYS * RTH_HOURS * 60,
251
+ "2m": TRADING_DAYS * RTH_HOURS * 30,
252
+ "5m": TRADING_DAYS * RTH_HOURS * 12,
253
+ "15m": TRADING_DAYS * RTH_HOURS * 4,
254
+ "30m": TRADING_DAYS * RTH_HOURS * 2,
255
+ "1h": TRADING_DAYS * RTH_HOURS,
256
+ "60m": TRADING_DAYS * RTH_HOURS,
257
+ "1d": TRADING_DAYS,
258
+ "1wk": 52,
259
+ "1mo": 12
260
+ };
261
+ function periodsPerYear(interval, estBarMs) {
262
+ if (interval && INTERVAL_PERIODS[interval]) return INTERVAL_PERIODS[interval];
263
+ if (Number.isFinite(estBarMs) && estBarMs > 0) {
264
+ return Math.round(MS_PER_YEAR / estBarMs);
265
+ }
266
+ return TRADING_DAYS;
267
+ }
268
+
269
+ // src/metrics/benchmark.js
270
+ function mean(xs) {
271
+ return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
272
+ }
273
+ function benchmarkStats(strategyReturns, benchmarkReturns) {
274
+ const nullStats = {
275
+ alpha: null,
276
+ beta: null,
277
+ correlation: null,
278
+ informationRatio: null,
279
+ trackingError: null
280
+ };
281
+ if (!Array.isArray(strategyReturns) || !Array.isArray(benchmarkReturns) || strategyReturns.length === 0 || strategyReturns.length !== benchmarkReturns.length) {
282
+ return nullStats;
283
+ }
284
+ const meanStrat = mean(strategyReturns);
285
+ const meanBench = mean(benchmarkReturns);
286
+ let covar = 0;
287
+ let varBench = 0;
288
+ let varStrat = 0;
289
+ for (let i = 0; i < strategyReturns.length; i += 1) {
290
+ const ds = strategyReturns[i] - meanStrat;
291
+ const db = benchmarkReturns[i] - meanBench;
292
+ covar += ds * db;
293
+ varBench += db * db;
294
+ varStrat += ds * ds;
295
+ }
296
+ const beta = varBench === 0 ? 0 : covar / varBench;
297
+ const alpha = meanStrat - beta * meanBench;
298
+ const denom = Math.sqrt(varStrat * varBench);
299
+ const correlation = denom === 0 ? 0 : covar / denom;
300
+ const active = strategyReturns.map((r, i) => r - benchmarkReturns[i]);
301
+ const meanActive = mean(active);
302
+ const trackingError = Math.sqrt(mean(active.map((a) => (a - meanActive) ** 2)));
303
+ const informationRatio = trackingError === 0 ? 0 : meanActive / trackingError;
304
+ return { alpha, beta, correlation, informationRatio, trackingError };
305
+ }
306
+
224
307
  // src/metrics/buildMetrics.js
225
308
  function sum(values) {
226
309
  return values.reduce((total, value) => total + value, 0);
227
310
  }
228
- function mean(values) {
311
+ function mean2(values) {
229
312
  return values.length ? sum(values) / values.length : 0;
230
313
  }
231
314
  function stddev(values) {
232
315
  if (values.length <= 1) return 0;
233
- const avg = mean(values);
234
- return Math.sqrt(mean(values.map((value) => (value - avg) ** 2)));
316
+ const avg = mean2(values);
317
+ return Math.sqrt(mean2(values.map((value) => (value - avg) ** 2)));
235
318
  }
236
319
  function sortino(values) {
237
320
  const losses = values.filter((value) => value < 0);
238
321
  const downsideDeviation = stddev(losses.length ? losses : [0]);
239
- const avg = mean(values);
322
+ const avg = mean2(values);
240
323
  return downsideDeviation === 0 ? avg > 0 ? Infinity : 0 : avg / downsideDeviation;
241
324
  }
242
325
  function dayKeyUTC(timeMs) {
@@ -322,14 +405,22 @@ function percentile(values, percentileRank) {
322
405
  const index = Math.floor((sorted.length - 1) * percentileRank);
323
406
  return sorted[index];
324
407
  }
325
- var PROFIT_FACTOR_CAP = 1e6;
326
408
  function finiteProfitFactor(grossProfit, grossLoss) {
327
409
  if (grossLoss === 0) {
328
- return grossProfit > 0 ? PROFIT_FACTOR_CAP : 0;
410
+ return grossProfit > 0 ? BIG_NUMBER : 0;
329
411
  }
330
412
  return grossProfit / grossLoss;
331
413
  }
332
- function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqSeries }) {
414
+ function buildMetrics({
415
+ closed,
416
+ equityStart,
417
+ equityFinal,
418
+ candles,
419
+ estBarMs,
420
+ eqSeries,
421
+ interval,
422
+ benchmarkReturns
423
+ }) {
333
424
  const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
334
425
  const completedTrades = [];
335
426
  const tradeRs = [];
@@ -399,11 +490,11 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
399
490
  if (pnl > 0) shortTradeWins += 1;
400
491
  }
401
492
  }
402
- const avgR = mean(tradeRs);
493
+ const avgR = mean2(tradeRs);
403
494
  const { maxWin, maxLoss } = streaks(labels);
404
- const expectancy = mean(tradePnls);
495
+ const expectancy = mean2(tradePnls);
405
496
  const tradeReturnStd = stddev(tradeReturns);
406
- const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
497
+ const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean2(tradeReturns) / tradeReturnStd;
407
498
  const sortinoPerTrade = sortino(tradeReturns);
408
499
  const profitFactorPositions = finiteProfitFactor(grossProfitPositions, grossLossPositions);
409
500
  const profitFactorLegs = finiteProfitFactor(grossProfitLegs, grossLossLegs);
@@ -411,11 +502,11 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
411
502
  const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
412
503
  const totalBars = Math.max(1, candles.length);
413
504
  const exposurePct = openBars / totalBars;
414
- const avgHoldMin = mean(holdDurationsMinutes);
505
+ const avgHoldMin = mean2(holdDurationsMinutes);
415
506
  const equitySeries = eqSeries && eqSeries.length ? eqSeries : buildEquitySeriesFromLegs({ legs, equityStart });
416
507
  const dailyReturnsSeries = dailyReturns(equitySeries);
417
508
  const dailyStd = stddev(dailyReturnsSeries);
418
- const sharpeDaily = dailyStd === 0 ? dailyReturnsSeries.length ? Infinity : 0 : mean(dailyReturnsSeries) / dailyStd;
509
+ const sharpeDaily = dailyStd === 0 ? dailyReturnsSeries.length ? Infinity : 0 : mean2(dailyReturnsSeries) / dailyStd;
419
510
  const sortinoDaily = sortino(dailyReturnsSeries);
420
511
  const dailyWinRate = dailyReturnsSeries.length ? dailyReturnsSeries.filter((value) => value > 0).length / dailyReturnsSeries.length : 0;
421
512
  const rDistribution = {
@@ -437,28 +528,36 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
437
528
  trades: longTradesCount,
438
529
  winRate: longTradesCount ? longTradeWins / longTradesCount : 0,
439
530
  avgPnL: longTradesCount ? longPnLSum / longTradesCount : 0,
440
- avgR: mean(longRs)
531
+ avgR: mean2(longRs)
441
532
  },
442
533
  short: {
443
534
  trades: shortTradesCount,
444
535
  winRate: shortTradesCount ? shortTradeWins / shortTradesCount : 0,
445
536
  avgPnL: shortTradesCount ? shortPnLSum / shortTradesCount : 0,
446
- avgR: mean(shortRs)
537
+ avgR: mean2(shortRs)
447
538
  }
448
539
  };
540
+ const periods = periodsPerYear(interval, estBarMs);
541
+ const sqrtPeriods = Math.sqrt(periods);
542
+ const sharpeAnnualized = clampFinite(clampFinite(sharpeDaily) * sqrtPeriods);
543
+ const sortinoAnnualized = clampFinite(clampFinite(sortinoDaily) * sqrtPeriods);
544
+ const benchmark = benchmarkStats(dailyReturnsSeries, benchmarkReturns ?? []);
449
545
  return {
450
546
  trades: completedTrades.length,
451
547
  winRate: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
452
- profitFactor: profitFactorPositions,
548
+ profitFactor: clampFinite(profitFactorPositions),
453
549
  expectancy,
454
550
  totalR,
455
551
  avgR,
456
- sharpe: sharpeDaily,
457
- sharpePerTrade,
458
- sortinoPerTrade,
552
+ sharpe: clampFinite(sharpeDaily),
553
+ sharpeAnnualized,
554
+ sortinoAnnualized,
555
+ sharpePerTrade: clampFinite(sharpePerTrade),
556
+ sortinoPerTrade: clampFinite(sortinoPerTrade),
557
+ annualizationPeriods: periods,
459
558
  maxDrawdown,
460
559
  maxDrawdownPct: maxDrawdown,
461
- calmar,
560
+ calmar: clampFinite(calmar),
462
561
  maxConsecWins: maxWin,
463
562
  maxConsecLosses: maxLoss,
464
563
  avgHold: avgHoldMin,
@@ -468,12 +567,13 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
468
567
  returnPct,
469
568
  finalEquity: equityFinal,
470
569
  startEquity: equityStart,
471
- profitFactor_pos: profitFactorPositions,
472
- profitFactor_leg: profitFactorLegs,
570
+ profitFactor_pos: clampFinite(profitFactorPositions),
571
+ profitFactor_leg: clampFinite(profitFactorLegs),
473
572
  winRate_pos: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
474
573
  winRate_leg: legs.length ? winningLegCount / legs.length : 0,
475
- sharpeDaily,
476
- sortinoDaily,
574
+ sharpeDaily: clampFinite(sharpeDaily),
575
+ sortinoDaily: clampFinite(sortinoDaily),
576
+ benchmark,
477
577
  sideBreakdown,
478
578
  long: sideBreakdown.long,
479
579
  short: sideBreakdown.short,
@@ -482,7 +582,7 @@ function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqS
482
582
  daily: {
483
583
  count: dailyReturnsSeries.length,
484
584
  winRate: dailyWinRate,
485
- avgReturn: mean(dailyReturnsSeries)
585
+ avgReturn: mean2(dailyReturnsSeries)
486
586
  }
487
587
  };
488
588
  }
@@ -939,6 +1039,30 @@ function dayKeyET(timeMs) {
939
1039
  const pseudoEtTime = anchor.getTime() + hoursET * 60 * 60 * 1e3 + minutesETDay * 60 * 1e3;
940
1040
  return dayKeyUTC2(pseudoEtTime);
941
1041
  }
1042
+ var MS_PER_YEAR2 = 365 * 24 * 60 * 60 * 1e3;
1043
+ function fundingEvents(fromMs, toMs, intervalMs, anchorMs = 0) {
1044
+ if (!(intervalMs > 0) || toMs <= fromMs) return 0;
1045
+ const firstK = Math.floor((fromMs - anchorMs) / intervalMs) + 1;
1046
+ const lastK = Math.floor((toMs - anchorMs) / intervalMs);
1047
+ return Math.max(0, lastK - firstK + 1);
1048
+ }
1049
+ function financingCost({ side, notional, fromMs, toMs, costs }) {
1050
+ const model = costs || {};
1051
+ const absNotional = Math.abs(notional);
1052
+ let cost = 0;
1053
+ if (model.carry) {
1054
+ const annualBps = side === "long" ? model.carry.longAnnualBps ?? 0 : model.carry.shortAnnualBps ?? 0;
1055
+ const years = Math.max(0, toMs - fromMs) / MS_PER_YEAR2;
1056
+ cost += absNotional * (annualBps / 1e4) * years;
1057
+ }
1058
+ const funding = model.funding;
1059
+ if (funding && funding.intervalMs > 0 && Number.isFinite(funding.rateBps)) {
1060
+ const count = fundingEvents(fromMs, toMs, funding.intervalMs, funding.anchorMs ?? 0);
1061
+ const perEvent = absNotional * (funding.rateBps / 1e4);
1062
+ cost += (side === "long" ? 1 : -1) * perEvent * count;
1063
+ }
1064
+ return cost;
1065
+ }
942
1066
 
943
1067
  // src/engine/backtest.js
944
1068
  function asNumber(value) {
@@ -1070,6 +1194,7 @@ function mergeOptions(options) {
1070
1194
  maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
1071
1195
  collectEqSeries: options.collectEqSeries ?? true,
1072
1196
  collectReplay: options.collectReplay ?? true,
1197
+ benchmarkReturns: Array.isArray(options.benchmarkReturns) ? options.benchmarkReturns : null,
1073
1198
  strict: options.strict ?? false
1074
1199
  };
1075
1200
  }
@@ -1192,7 +1317,14 @@ function backtest(rawOptions) {
1192
1317
  const entryFill = openPos.entryFill;
1193
1318
  const grossPnl = (exitPx - entryFill) * direction * qty;
1194
1319
  const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
1195
- const pnl = grossPnl - entryFeePortion - exitFeeTotal;
1320
+ const financing = financingCost({
1321
+ side: openPos.side,
1322
+ notional: entryFill * qty,
1323
+ fromMs: openPos.openTime,
1324
+ toMs: time,
1325
+ costs
1326
+ });
1327
+ const pnl = grossPnl - entryFeePortion - exitFeeTotal - financing;
1196
1328
  currentEquity += pnl;
1197
1329
  dayPnl += pnl;
1198
1330
  if (wantEqSeries) {
@@ -1220,6 +1352,7 @@ function backtest(rawOptions) {
1220
1352
  time,
1221
1353
  reason,
1222
1354
  pnl,
1355
+ financing,
1223
1356
  exitATR: openPos._lastATR ?? void 0
1224
1357
  },
1225
1358
  mfeR: openPos._mfeR ?? 0,
@@ -1622,7 +1755,9 @@ function backtest(rawOptions) {
1622
1755
  equityFinal: currentEquity,
1623
1756
  candles,
1624
1757
  estBarMs: estimatedBarMs,
1625
- eqSeries
1758
+ eqSeries,
1759
+ interval: options.interval,
1760
+ benchmarkReturns: options.benchmarkReturns
1626
1761
  });
1627
1762
  const positions = closed.filter((trade) => trade.exit.reason !== "SCALE");
1628
1763
  const lastPrice = asNumber(candles[candles.length - 1]?.close);
@@ -1643,11 +1778,31 @@ function backtest(rawOptions) {
1643
1778
  };
1644
1779
  }
1645
1780
 
1646
- // src/engine/backtestTicks.js
1781
+ // src/engine/barSystemRunner.js
1647
1782
  function asNumber2(value) {
1648
1783
  const numeric = Number(value);
1649
1784
  return Number.isFinite(numeric) ? numeric : null;
1650
1785
  }
1786
+ function equityPoint2(time, equity, extra = {}) {
1787
+ return { time, timestamp: time, equity, ...extra };
1788
+ }
1789
+ function isArrayIndexKey2(property) {
1790
+ if (typeof property !== "string") return false;
1791
+ const numeric = Number(property);
1792
+ return Number.isInteger(numeric) && numeric >= 0;
1793
+ }
1794
+ function strictHistoryView2(candles, currentIndex) {
1795
+ return new Proxy(candles, {
1796
+ get(target, property, receiver) {
1797
+ if (isArrayIndexKey2(property) && Number(property) >= target.length) {
1798
+ throw new Error(
1799
+ `strict mode: signal() tried to access candles[${String(property)}] beyond current index ${currentIndex}`
1800
+ );
1801
+ }
1802
+ return Reflect.get(target, property, receiver);
1803
+ }
1804
+ });
1805
+ }
1651
1806
  function describeValue2(value) {
1652
1807
  if (Array.isArray(value)) return `array(length=${value.length})`;
1653
1808
  if (value === null) return "null";
@@ -1666,38 +1821,45 @@ function callSignalWithContext2({ signal, context, index, bar, symbol }) {
1666
1821
  );
1667
1822
  }
1668
1823
  }
1824
+ async function callSignalWithContextAsync({ signal, context, index, bar, symbol }) {
1825
+ try {
1826
+ return await signal(context);
1827
+ } catch (error) {
1828
+ const cause = error instanceof Error ? error.message : String(error);
1829
+ throw new Error(
1830
+ `signal() threw at index=${index}, time=${formatIsoTime2(bar?.time)}, symbol=${symbol}: ${cause}`
1831
+ );
1832
+ }
1833
+ }
1834
+ function snapshotOpenPosition2(open, markPrice) {
1835
+ if (!open) return null;
1836
+ const entryPrice = open.entryFill ?? open.entry;
1837
+ const direction = open.side === "long" ? 1 : -1;
1838
+ const unrealizedPnl = (markPrice - entryPrice) * direction * open.size;
1839
+ return {
1840
+ id: open.id,
1841
+ symbol: open.symbol,
1842
+ side: open.side,
1843
+ size: open.size,
1844
+ entry: open.entry,
1845
+ entryFill: open.entryFill,
1846
+ stop: open.stop,
1847
+ takeProfit: open.takeProfit,
1848
+ openTime: open.openTime,
1849
+ markPrice,
1850
+ unrealizedPnl,
1851
+ _initRisk: open._initRisk
1852
+ };
1853
+ }
1669
1854
  function normalizeSide2(value) {
1670
1855
  if (value === "long" || value === "buy") return "long";
1671
1856
  if (value === "short" || value === "sell") return "short";
1672
1857
  return null;
1673
1858
  }
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
1859
  function normalizeSignal2(signal, bar, fallbackR) {
1697
1860
  if (!signal) return null;
1698
1861
  const side = normalizeSide2(signal.side ?? signal.direction ?? signal.action);
1699
1862
  if (!side) return null;
1700
- const hasExplicitEntry = signal.entry !== void 0 || signal.limit !== void 0 || signal.price !== void 0;
1701
1863
  const entry = asNumber2(signal.entry ?? signal.limit ?? signal.price) ?? asNumber2(bar?.close);
1702
1864
  const stop = asNumber2(signal.stop ?? signal.stopLoss ?? signal.sl);
1703
1865
  if (entry === null || stop === null) return null;
@@ -1719,804 +1881,376 @@ function normalizeSignal2(signal, bar, fallbackR) {
1719
1881
  qty: asNumber2(signal.qty ?? signal.size),
1720
1882
  riskPct: asNumber2(signal.riskPct),
1721
1883
  riskFraction: asNumber2(signal.riskFraction),
1722
- orderType: hasExplicitEntry ? "limit" : "market"
1723
- };
1724
- }
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;
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;
1884
+ _rr: rrHint ?? signal._rr,
1885
+ _initRisk: asNumber2(signal._initRisk) ?? signal._initRisk
1738
1886
  };
1739
1887
  }
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;
1888
+ function mergeOptions2(options) {
1889
+ const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
1890
+ return {
1891
+ candles: normalizeCandles(options.candles ?? []),
1892
+ symbol: options.symbol ?? "UNKNOWN",
1893
+ equity: options.equity ?? 1e4,
1894
+ riskPct: normalizedRiskPct ?? 1,
1895
+ signal: options.signal,
1896
+ interval: options.interval,
1897
+ range: options.range,
1898
+ warmupBars: options.warmupBars ?? 200,
1899
+ slippageBps: options.slippageBps ?? 1,
1900
+ feeBps: options.feeBps ?? 0,
1901
+ costs: options.costs ?? null,
1902
+ scaleOutAtR: options.scaleOutAtR ?? 1,
1903
+ scaleOutFrac: options.scaleOutFrac ?? 0.5,
1904
+ finalTP_R: options.finalTP_R ?? 3,
1905
+ maxDailyLossPct: options.maxDailyLossPct ?? 2,
1906
+ atrTrailMult: options.atrTrailMult ?? 0,
1907
+ atrTrailPeriod: options.atrTrailPeriod ?? 14,
1908
+ oco: {
1909
+ mode: "intrabar",
1910
+ tieBreak: "pessimistic",
1911
+ clampStops: true,
1912
+ clampEpsBps: 0.25,
1913
+ ...options.oco || {}
1914
+ },
1915
+ triggerMode: options.triggerMode,
1916
+ flattenAtClose: options.flattenAtClose ?? true,
1917
+ dailyMaxTrades: options.dailyMaxTrades ?? 0,
1918
+ postLossCooldownBars: options.postLossCooldownBars ?? 0,
1919
+ mfeTrail: {
1920
+ enabled: false,
1921
+ armR: 1,
1922
+ givebackR: 0.5,
1923
+ ...options.mfeTrail || {}
1924
+ },
1925
+ pyramiding: {
1926
+ enabled: false,
1927
+ addAtR: 1,
1928
+ addFrac: 0.25,
1929
+ maxAdds: 1,
1930
+ onlyAfterBreakEven: true,
1931
+ ...options.pyramiding || {}
1932
+ },
1933
+ volScale: {
1934
+ enabled: false,
1935
+ atrPeriod: options.atrTrailPeriod ?? 14,
1936
+ cutIfAtrX: 1.3,
1937
+ cutFrac: 0.33,
1938
+ noCutAboveR: 1.5,
1939
+ ...options.volScale || {}
1940
+ },
1941
+ qtyStep: options.qtyStep ?? 1e-3,
1942
+ minQty: options.minQty ?? 1e-3,
1943
+ maxLeverage: options.maxLeverage ?? 2,
1944
+ entryChase: {
1945
+ enabled: true,
1946
+ afterBars: 2,
1947
+ maxSlipR: 0.2,
1948
+ convertOnExpiry: false,
1949
+ ...options.entryChase || {}
1950
+ },
1951
+ reanchorStopOnFill: options.reanchorStopOnFill ?? true,
1952
+ maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
1953
+ collectEqSeries: options.collectEqSeries ?? true,
1954
+ collectReplay: options.collectReplay ?? true,
1955
+ strict: options.strict ?? false
1747
1956
  };
1748
1957
  }
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;
1958
+ function capitalForSize(entryPrice, size, maxLeverage) {
1959
+ const leverage = Math.max(1, Number(maxLeverage) || 1);
1960
+ return Math.abs(entryPrice) * Math.max(0, size) / leverage;
1759
1961
  }
1760
- function backtestTicks({
1761
- ticks = [],
1762
- symbol = "UNKNOWN",
1763
- equity = 1e4,
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,
1777
- collectEqSeries = true,
1778
- collectReplay = true,
1779
- queueFillProbability = 1,
1780
- oco = {}
1781
- } = {}) {
1782
- if (!Array.isArray(ticks) || ticks.length === 0) {
1783
- throw new Error(
1784
- `backtestTicks() requires a non-empty ticks array, got ${describeValue2(ticks)}`
1785
- );
1786
- }
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
1962
+ var BarSystemRunner = class {
1963
+ constructor(rawOptions = {}) {
1964
+ this.options = mergeOptions2(rawOptions);
1965
+ const { candles, signal } = this.options;
1966
+ if (!Array.isArray(candles) || candles.length === 0) {
1967
+ throw new Error(
1968
+ `backtestPortfolio() requires each system to include non-empty candles, got ${describeValue2(
1969
+ candles
1970
+ )} for ${this.options.symbol}`
1971
+ );
1972
+ }
1973
+ if (typeof signal !== "function") {
1974
+ throw new Error(
1975
+ `backtestPortfolio() requires each system to include a signal function, got ${describeValue2(
1976
+ signal
1977
+ )} for ${this.options.symbol}`
1978
+ );
1979
+ }
1980
+ this.symbol = this.options.symbol;
1981
+ this.candles = candles;
1982
+ this.closed = [];
1983
+ this.currentEquity = this.options.equity;
1984
+ this.open = null;
1985
+ this.cooldown = 0;
1986
+ this.pending = null;
1987
+ this.currentDay = null;
1988
+ this.dayPnl = 0;
1989
+ this.dayTrades = 0;
1990
+ this.dayEquityStart = this.options.equity;
1991
+ this.tradeIdCounter = 0;
1992
+ this.estimatedBarMs = estimateBarMs(candles);
1993
+ const atrSourcePeriod = this.options.volScale.enabled ? this.options.volScale.atrPeriod : this.options.atrTrailPeriod;
1994
+ const needAtr = this.options.atrTrailMult > 0 || this.options.volScale.enabled;
1995
+ this.atrValues = needAtr ? atr(candles, atrSourcePeriod) : null;
1996
+ this.wantEqSeries = Boolean(this.options.collectEqSeries);
1997
+ this.wantReplay = Boolean(this.options.collectReplay);
1998
+ this.eqSeries = this.wantEqSeries ? [equityPoint2(candles[0].time, this.currentEquity)] : [];
1999
+ this.replayFrames = this.wantReplay ? [] : [];
2000
+ this.replayEvents = this.wantReplay ? [] : [];
2001
+ this.startIndex = Math.min(Math.max(1, this.options.warmupBars), candles.length);
2002
+ this.history = candles.slice(0, this.startIndex);
2003
+ this.index = this.startIndex;
2004
+ this.lastBar = this.history.length ? this.history[this.history.length - 1] : null;
2005
+ }
2006
+ hasNext() {
2007
+ return this.index < this.candles.length;
2008
+ }
2009
+ peekTime() {
2010
+ return this.hasNext() ? this.candles[this.index].time : Infinity;
2011
+ }
2012
+ getLockedCapital() {
2013
+ if (!this.open) return 0;
2014
+ return capitalForSize(
2015
+ this.open.entryFill ?? this.open.entry,
2016
+ this.open.size,
2017
+ this.options.maxLeverage
2018
+ );
2019
+ }
2020
+ getMarkPrice() {
2021
+ return this.lastBar?.close ?? null;
2022
+ }
2023
+ getMarkedEquity() {
2024
+ if (!this.open || !this.lastBar) return this.currentEquity;
2025
+ const direction = this.open.side === "long" ? 1 : -1;
2026
+ const markPnl = (this.lastBar.close - (this.open.entryFill ?? this.open.entry)) * direction * this.open.size;
2027
+ return this.currentEquity + markPnl;
2028
+ }
2029
+ recordFrame(bar, extraFrame = {}) {
2030
+ if (this.wantEqSeries) {
2031
+ this.eqSeries.push(equityPoint2(bar.time, this.currentEquity));
2032
+ }
2033
+ if (this.wantReplay) {
2034
+ this.replayFrames.push({
2035
+ t: new Date(bar.time).toISOString(),
2036
+ price: bar.close,
2037
+ equity: this.currentEquity,
2038
+ posSide: this.open ? this.open.side : null,
2039
+ posSize: this.open ? this.open.size : 0,
2040
+ ...extraFrame
1831
2041
  });
1832
2042
  }
1833
2043
  }
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
2044
+ closeLeg({ openPos, qty, exitPx, exitFeeTotal = 0, time, reason }) {
2045
+ const direction = openPos.side === "long" ? 1 : -1;
2046
+ const entryFill = openPos.entryFill;
2047
+ const grossPnl = (exitPx - entryFill) * direction * qty;
2048
+ const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
2049
+ const financing = financingCost({
2050
+ side: openPos.side,
2051
+ notional: entryFill * qty,
2052
+ fromMs: openPos.openTime,
2053
+ toMs: time,
2054
+ costs: this.options.costs
1843
2055
  });
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
- }
1857
- };
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,
2056
+ const pnl = grossPnl - entryFeePortion - exitFeeTotal - financing;
2057
+ this.currentEquity += pnl;
2058
+ this.dayPnl += pnl;
2059
+ if (this.wantEqSeries) {
2060
+ this.eqSeries.push(equityPoint2(time, this.currentEquity));
2061
+ }
2062
+ const remaining = openPos.size - qty;
2063
+ const eventType = reason === "SCALE" ? "scale-out" : reason === "TP" ? "tp" : reason === "SL" ? "sl" : reason === "EOD" ? "eod" : remaining <= 0 ? "exit" : "scale-out";
2064
+ if (this.wantReplay) {
2065
+ this.replayEvents.push({
2066
+ t: new Date(time).toISOString(),
2067
+ price: exitPx,
2068
+ type: eventType,
2069
+ side: openPos.side,
2070
+ size: qty,
2071
+ tradeId: openPos.id,
1867
2072
  reason,
1868
- pnl
2073
+ pnl,
2074
+ symbol: this.symbol
1869
2075
  });
1870
2076
  }
1871
- open = null;
2077
+ const record = {
2078
+ ...openPos,
2079
+ size: qty,
2080
+ exit: {
2081
+ price: exitPx,
2082
+ time,
2083
+ reason,
2084
+ pnl,
2085
+ financing,
2086
+ exitATR: openPos._lastATR ?? void 0
2087
+ },
2088
+ mfeR: openPos._mfeR ?? 0,
2089
+ maeR: openPos._maeR ?? 0,
2090
+ adds: openPos._adds ?? 0
2091
+ };
2092
+ this.closed.push(record);
2093
+ openPos.size -= qty;
2094
+ openPos._realized = (openPos._realized || 0) + pnl;
2095
+ return record;
1872
2096
  }
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;
2097
+ tightenStopToNetBreakeven(openPos, lastClose) {
2098
+ if (!openPos || openPos.size <= 0) return;
2099
+ const realized = openPos._realized || 0;
2100
+ if (realized <= 0) return;
2101
+ const direction = openPos.side === "long" ? 1 : -1;
2102
+ const breakevenDelta = Math.abs(realized / openPos.size);
2103
+ const breakevenPrice = direction === 1 ? openPos.entryFill - breakevenDelta : openPos.entryFill + breakevenDelta;
2104
+ const tightened = direction === 1 ? Math.max(openPos.stop, breakevenPrice) : Math.min(openPos.stop, breakevenPrice);
2105
+ openPos.stop = this.options.oco.clampStops ? clampStop(lastClose, tightened, openPos.side, this.options.oco) : tightened;
2106
+ }
2107
+ forceExit(reason, bar, overridePrice = null) {
2108
+ if (!this.open || !bar) return;
2109
+ const exitSide = this.open.side === "long" ? "short" : "long";
2110
+ const exitBasePrice = overridePrice ?? bar.close;
2111
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(exitBasePrice, exitSide, {
2112
+ slippageBps: this.options.slippageBps,
2113
+ feeBps: this.options.feeBps,
2114
+ kind: "market",
2115
+ qty: this.open.size,
2116
+ costs: this.options.costs
2117
+ });
2118
+ this.closeLeg({
2119
+ openPos: this.open,
2120
+ qty: this.open.size,
2121
+ exitPx: filled,
2122
+ exitFeeTotal,
2123
+ time: bar.time,
2124
+ reason
2125
+ });
2126
+ this.cooldown = this.open?._cooldownBars || 0;
2127
+ this.open = null;
2128
+ }
2129
+ cancelPending() {
2130
+ this.pending = null;
2131
+ }
2132
+ openFromPending(bar, signalEquity, entryPrice, fillKind = "limit", resolveEntrySize) {
2133
+ if (!this.pending) return false;
2134
+ const plannedRisk = Math.max(
2135
+ 1e-8,
2136
+ this.pending.plannedRiskAbs ?? Math.abs(this.pending.entry - this.pending.stop)
2137
+ );
2138
+ const slipR = Math.abs(entryPrice - this.pending.entry) / plannedRisk;
2139
+ if (slipR > this.options.maxSlipROnFill) return false;
2140
+ let stopPrice = this.pending.stop;
2141
+ if (this.options.reanchorStopOnFill) {
2142
+ const direction = this.pending.side === "long" ? 1 : -1;
2143
+ stopPrice = direction === 1 ? entryPrice - plannedRisk : entryPrice + plannedRisk;
1882
2144
  }
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");
2145
+ let takeProfit = this.pending.tp;
2146
+ const immediateRisk = Math.abs(entryPrice - stopPrice) || 1e-8;
2147
+ const rrHint = this.pending.meta?._rr;
2148
+ if (this.options.reanchorStopOnFill && Number.isFinite(rrHint)) {
2149
+ const plannedTarget = this.pending.side === "long" ? this.pending.entry + rrHint * plannedRisk : this.pending.entry - rrHint * plannedRisk;
2150
+ const closeEnough = Math.abs((this.pending.tp ?? plannedTarget) - plannedTarget) <= Math.max(1e-8, plannedRisk * 1e-6);
2151
+ if (closeEnough) {
2152
+ takeProfit = this.pending.side === "long" ? entryPrice + rrHint * immediateRisk : entryPrice - rrHint * immediateRisk;
1894
2153
  }
1895
2154
  }
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
- }
2155
+ const desiredSize = this.pending.fixedQty ?? calculatePositionSize({
2156
+ equity: signalEquity,
2157
+ entry: entryPrice,
2158
+ stop: stopPrice,
2159
+ riskFraction: this.pending.riskFrac,
2160
+ qtyStep: this.options.qtyStep,
2161
+ minQty: this.options.minQty,
2162
+ maxLeverage: this.options.maxLeverage
2163
+ });
2164
+ const approvedSize = typeof resolveEntrySize === "function" ? resolveEntrySize({
2165
+ runner: this,
2166
+ desiredSize,
2167
+ entryPrice,
2168
+ stopPrice,
2169
+ pending: this.pending,
2170
+ fillKind
2171
+ }) : desiredSize;
2172
+ const size = roundStep2(approvedSize, this.options.qtyStep);
2173
+ if (size < this.options.minQty) return false;
2174
+ const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(entryPrice, this.pending.side, {
2175
+ slippageBps: this.options.slippageBps,
2176
+ feeBps: this.options.feeBps,
2177
+ kind: fillKind,
2178
+ qty: size,
2179
+ costs: this.options.costs
2180
+ });
2181
+ this.open = {
2182
+ symbol: this.symbol,
2183
+ ...this.pending.meta,
2184
+ id: ++this.tradeIdCounter,
2185
+ side: this.pending.side,
2186
+ entry: entryPrice,
2187
+ stop: stopPrice,
2188
+ takeProfit,
2189
+ size,
2190
+ openTime: bar.time,
2191
+ entryFill,
2192
+ entryFeeTotal,
2193
+ initSize: size,
2194
+ baseSize: size,
2195
+ _mfeR: 0,
2196
+ _maeR: 0,
2197
+ _adds: 0,
2198
+ _initRisk: Math.abs(entryPrice - stopPrice) || 1e-8
2199
+ };
2200
+ if (this.atrValues && this.atrValues[this.index] !== void 0) {
2201
+ this.open.entryATR = this.atrValues[this.index];
2202
+ this.open._lastATR = this.atrValues[this.index];
1997
2203
  }
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
- }
2204
+ this.dayTrades += 1;
2205
+ this.pending = null;
2206
+ if (this.wantReplay) {
2207
+ this.replayEvents.push({
2208
+ t: new Date(bar.time).toISOString(),
2209
+ price: entryFill,
2210
+ type: "entry",
2211
+ side: this.open.side,
2212
+ size,
2213
+ tradeId: this.open.id,
2214
+ symbol: this.symbol
2215
+ });
2032
2216
  }
2033
- recordFrame(tick);
2217
+ return true;
2034
2218
  }
2035
- if (open) {
2036
- const lastTick = normalizedTicks[normalizedTicks.length - 1];
2037
- closePosition(lastTick, "EOT", lastTick.close, "market");
2038
- recordFrame(lastTick);
2219
+ buildSignalContext(index, bar, signalEquity) {
2220
+ if (this.options.strict && this.history.length !== index + 1) {
2221
+ throw new Error(
2222
+ `strict mode: signal() received ${this.history.length} candles at index ${index}`
2223
+ );
2224
+ }
2225
+ return {
2226
+ candles: this.options.strict ? strictHistoryView2(this.history, index) : this.history,
2227
+ index,
2228
+ bar,
2229
+ equity: signalEquity,
2230
+ openPosition: this.open,
2231
+ pendingOrder: this.pending
2232
+ };
2039
2233
  }
2040
- const positions = trades;
2041
- const metrics = buildMetrics({
2042
- closed: trades,
2043
- equityStart: equity,
2044
- equityFinal: currentEquity,
2045
- candles: normalizedTicks,
2046
- estBarMs: normalizedTicks.length > 1 ? Math.max(1, normalizedTicks[1].time - normalizedTicks[0].time) : 1,
2047
- eqSeries
2048
- });
2049
- return {
2050
- symbol,
2051
- interval,
2052
- range,
2053
- trades,
2054
- positions,
2055
- openPositions: [],
2056
- metrics,
2057
- eqSeries,
2058
- replay: {
2059
- frames: replayFrames,
2060
- events: replayEvents
2234
+ _preSignal({ signalEquity, canTrade = true, resolveEntrySize } = {}) {
2235
+ if (!this.hasNext()) return { handled: true, bar: null };
2236
+ const bar = this.candles[this.index];
2237
+ this.history.push(bar);
2238
+ this.lastBar = bar;
2239
+ const trigger = this.options.triggerMode || this.options.oco.mode || "intrabar";
2240
+ const dayKey = this.options.flattenAtClose || trigger === "close" ? dayKeyET(bar.time) : dayKeyUTC2(bar.time);
2241
+ if (this.currentDay === null || dayKey !== this.currentDay) {
2242
+ this.currentDay = dayKey;
2243
+ this.dayPnl = 0;
2244
+ this.dayTrades = 0;
2245
+ this.dayEquityStart = this.currentEquity;
2061
2246
  }
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);
2247
+ if (this.open && this.open._maxBarsInTrade > 0) {
2248
+ const barsHeld = Math.max(
2249
+ 1,
2250
+ Math.round((bar.time - this.open.openTime) / this.estimatedBarMs)
2251
+ );
2252
+ if (barsHeld >= this.open._maxBarsInTrade) {
2253
+ this.forceExit("TIME", bar);
2520
2254
  }
2521
2255
  }
2522
2256
  if (this.open && Number.isFinite(this.open._maxHoldMin) && this.open._maxHoldMin > 0) {
@@ -2641,205 +2375,706 @@ var BarSystemRunner = class {
2641
2375
  }
2642
2376
  }
2643
2377
  }
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
- }
2378
+ if (!addedThisBar && !this.open._scaled && this.options.scaleOutAtR > 0) {
2379
+ const triggerPrice = this.open.side === "long" ? this.open.entry + this.options.scaleOutAtR * risk : this.open.entry - this.options.scaleOutAtR * risk;
2380
+ const touched = this.open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
2381
+ if (touched) {
2382
+ const exitSide2 = this.open.side === "long" ? "short" : "long";
2383
+ const qty = roundStep2(this.open.size * this.options.scaleOutFrac, this.options.qtyStep);
2384
+ if (qty >= this.options.minQty && qty < this.open.size) {
2385
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(triggerPrice, exitSide2, {
2386
+ slippageBps: this.options.slippageBps,
2387
+ feeBps: this.options.feeBps,
2388
+ kind: "limit",
2389
+ qty,
2390
+ costs: this.options.costs
2391
+ });
2392
+ this.closeLeg({
2393
+ openPos: this.open,
2394
+ qty,
2395
+ exitPx: filled,
2396
+ exitFeeTotal,
2397
+ time: bar.time,
2398
+ reason: "SCALE"
2399
+ });
2400
+ this.open._scaled = true;
2401
+ this.open.takeProfit = this.open.side === "long" ? this.open.entry + this.options.finalTP_R * risk : this.open.entry - this.options.finalTP_R * risk;
2402
+ this.tightenStopToNetBreakeven(this.open, bar.close);
2403
+ this.open._beArmed = true;
2404
+ }
2405
+ }
2406
+ }
2407
+ const exitSide = this.open.side === "long" ? "short" : "long";
2408
+ const { hit, px } = ocoExitCheck({
2409
+ side: this.open.side,
2410
+ stop: this.open.stop,
2411
+ tp: this.open.takeProfit,
2412
+ bar,
2413
+ mode: this.options.oco.mode,
2414
+ tieBreak: this.options.oco.tieBreak
2415
+ });
2416
+ if (hit) {
2417
+ const exitKind = hit === "TP" ? "limit" : "stop";
2418
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(px, exitSide, {
2419
+ slippageBps: this.options.slippageBps,
2420
+ feeBps: this.options.feeBps,
2421
+ kind: exitKind,
2422
+ qty: this.open.size,
2423
+ costs: this.options.costs
2424
+ });
2425
+ const localCooldown = this.open._cooldownBars || 0;
2426
+ this.closeLeg({
2427
+ openPos: this.open,
2428
+ qty: this.open.size,
2429
+ exitPx: filled,
2430
+ exitFeeTotal,
2431
+ time: bar.time,
2432
+ reason: hit
2433
+ });
2434
+ this.cooldown = (hit === "SL" ? Math.max(this.cooldown, this.options.postLossCooldownBars) : this.cooldown) || localCooldown;
2435
+ this.open = null;
2436
+ }
2437
+ }
2438
+ const maxLossDollars = this.options.maxDailyLossPct / 100 * this.dayEquityStart;
2439
+ const dailyLossHit = this.dayPnl <= -Math.abs(maxLossDollars);
2440
+ const dailyTradeCapHit = this.options.dailyMaxTrades > 0 && this.dayTrades >= this.options.dailyMaxTrades;
2441
+ if (!this.open && this.pending) {
2442
+ if (!canTrade) {
2443
+ this.pending = null;
2444
+ } else if (this.index > this.pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
2445
+ if (this.options.entryChase.enabled && this.options.entryChase.convertOnExpiry) {
2446
+ const riskAtEdge = Math.abs(
2447
+ this.pending.meta._initRisk ?? this.pending.entry - this.pending.stop
2448
+ );
2449
+ const priceNow = bar.close;
2450
+ const direction = this.pending.side === "long" ? 1 : -1;
2451
+ const slippedR = Math.max(
2452
+ 0,
2453
+ direction === 1 ? priceNow - this.pending.entry : this.pending.entry - priceNow
2454
+ ) / Math.max(1e-8, riskAtEdge);
2455
+ if (slippedR > this.options.maxSlipROnFill) {
2456
+ this.pending = null;
2457
+ } else if (!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)) {
2458
+ this.pending = null;
2459
+ }
2460
+ } else {
2461
+ this.pending = null;
2462
+ }
2463
+ } else if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
2464
+ if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
2465
+ this.pending = null;
2466
+ }
2467
+ } else if (this.options.entryChase.enabled) {
2468
+ const elapsedBars = this.index - (this.pending.startedAtIndex ?? this.index);
2469
+ const midpoint = asNumber2(this.pending.meta?._imb?.mid);
2470
+ if (!this.pending._chasedCE && midpoint !== null && elapsedBars >= Math.max(1, this.options.entryChase.afterBars)) {
2471
+ this.pending.entry = midpoint;
2472
+ this.pending._chasedCE = true;
2473
+ }
2474
+ if (this.pending._chasedCE) {
2475
+ const riskRef = Math.abs(
2476
+ this.pending.meta?._initRisk ?? this.pending.entry - this.pending.stop
2477
+ );
2478
+ const priceNow = bar.close;
2479
+ const direction = this.pending.side === "long" ? 1 : -1;
2480
+ const slippedR = Math.max(
2481
+ 0,
2482
+ direction === 1 ? priceNow - this.pending.entry : this.pending.entry - priceNow
2483
+ ) / Math.max(1e-8, riskRef);
2484
+ if (slippedR > this.options.maxSlipROnFill) {
2485
+ this.pending = null;
2486
+ } else if (slippedR > 0 && slippedR <= this.options.entryChase.maxSlipR) {
2487
+ if (!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)) {
2488
+ this.pending = null;
2489
+ }
2490
+ }
2491
+ }
2492
+ }
2493
+ }
2494
+ if (this.open || this.cooldown > 0) {
2495
+ if (this.cooldown > 0) this.cooldown -= 1;
2496
+ this.recordFrame(bar);
2497
+ this.index += 1;
2498
+ return { handled: true, bar };
2499
+ }
2500
+ if (!canTrade || dailyLossHit || dailyTradeCapHit) {
2501
+ this.pending = null;
2502
+ this.recordFrame(bar);
2503
+ this.index += 1;
2504
+ return { handled: true, bar };
2505
+ }
2506
+ if (this.pending) {
2507
+ this.recordFrame(bar);
2508
+ this.index += 1;
2509
+ return { handled: true, bar };
2510
+ }
2511
+ return {
2512
+ handled: false,
2513
+ bar,
2514
+ trigger,
2515
+ signalEquity,
2516
+ resolveEntrySize
2517
+ };
2518
+ }
2519
+ _applyRawSignal(rawSignal, pre) {
2520
+ const { bar, trigger, signalEquity, resolveEntrySize } = pre;
2521
+ const nextSignal = normalizeSignal2(rawSignal, bar, this.options.finalTP_R);
2522
+ if (nextSignal) {
2523
+ const signalRiskFraction = Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : this.options.riskPct / 100;
2524
+ const expiryBars = nextSignal._entryExpiryBars ?? 5;
2525
+ this.pending = {
2526
+ side: nextSignal.side,
2527
+ entry: nextSignal.entry,
2528
+ stop: nextSignal.stop,
2529
+ tp: nextSignal.takeProfit,
2530
+ riskFrac: signalRiskFraction,
2531
+ fixedQty: nextSignal.qty,
2532
+ expiresAt: this.index + Math.max(1, expiryBars),
2533
+ startedAtIndex: this.index,
2534
+ meta: nextSignal,
2535
+ plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop)
2536
+ };
2537
+ if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
2538
+ if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
2539
+ this.pending = null;
2671
2540
  }
2672
2541
  }
2673
- const exitSide = this.open.side === "long" ? "short" : "long";
2542
+ }
2543
+ this.recordFrame(bar);
2544
+ this.index += 1;
2545
+ return bar;
2546
+ }
2547
+ step(options = {}) {
2548
+ const pre = this._preSignal(options);
2549
+ if (pre.handled) return pre.bar;
2550
+ const rawSignal = callSignalWithContext2({
2551
+ signal: this.options.signal,
2552
+ context: this.buildSignalContext(this.index, pre.bar, pre.signalEquity),
2553
+ index: this.index,
2554
+ bar: pre.bar,
2555
+ symbol: this.symbol
2556
+ });
2557
+ return this._applyRawSignal(rawSignal, pre);
2558
+ }
2559
+ async stepAsync(options = {}) {
2560
+ const pre = this._preSignal(options);
2561
+ if (pre.handled) return pre.bar;
2562
+ const rawSignal = await callSignalWithContextAsync({
2563
+ signal: this.options.signal,
2564
+ context: this.buildSignalContext(this.index, pre.bar, pre.signalEquity),
2565
+ index: this.index,
2566
+ bar: pre.bar,
2567
+ symbol: this.symbol
2568
+ });
2569
+ return this._applyRawSignal(rawSignal, pre);
2570
+ }
2571
+ buildResult() {
2572
+ const metrics = buildMetrics({
2573
+ closed: this.closed,
2574
+ equityStart: this.options.equity,
2575
+ equityFinal: this.currentEquity,
2576
+ candles: this.candles,
2577
+ estBarMs: this.estimatedBarMs,
2578
+ eqSeries: this.eqSeries,
2579
+ interval: this.options.interval
2580
+ });
2581
+ const positions = this.closed.filter((trade) => trade.exit.reason !== "SCALE");
2582
+ const lastPrice = asNumber2(this.candles[this.candles.length - 1]?.close);
2583
+ const openPositions = this.open ? [snapshotOpenPosition2(this.open, lastPrice ?? this.open.entryFill ?? this.open.entry)] : [];
2584
+ return {
2585
+ symbol: this.options.symbol,
2586
+ interval: this.options.interval,
2587
+ range: this.options.range,
2588
+ trades: this.closed,
2589
+ positions,
2590
+ openPositions,
2591
+ metrics,
2592
+ eqSeries: this.eqSeries,
2593
+ replay: {
2594
+ frames: this.replayFrames,
2595
+ events: this.replayEvents
2596
+ }
2597
+ };
2598
+ }
2599
+ };
2600
+ function defaultSystemCap(totalEquity, capPct, maxAllocation, maxAllocationPct) {
2601
+ const limits = [];
2602
+ if (Number.isFinite(capPct) && capPct > 0) limits.push(totalEquity * capPct);
2603
+ if (Number.isFinite(maxAllocation) && maxAllocation > 0) limits.push(maxAllocation);
2604
+ if (Number.isFinite(maxAllocationPct) && maxAllocationPct > 0) {
2605
+ limits.push(totalEquity * maxAllocationPct);
2606
+ }
2607
+ return limits.length ? Math.min(...limits) : Math.max(0, totalEquity);
2608
+ }
2609
+
2610
+ // src/engine/asyncSignal.js
2611
+ var BudgetExceededError = class extends Error {
2612
+ constructor(ms) {
2613
+ super(`signal() exceeded its ${ms}ms per-bar budget`);
2614
+ this.name = "BudgetExceededError";
2615
+ this.budgetMs = ms;
2616
+ }
2617
+ };
2618
+ function withBudget(promise, budgetMs) {
2619
+ if (!budgetMs || budgetMs <= 0) return Promise.resolve(promise);
2620
+ return new Promise((resolve, reject) => {
2621
+ const timer = setTimeout(() => reject(new BudgetExceededError(budgetMs)), budgetMs);
2622
+ Promise.resolve(promise).then(
2623
+ (value) => {
2624
+ clearTimeout(timer);
2625
+ resolve(value);
2626
+ },
2627
+ (err) => {
2628
+ clearTimeout(timer);
2629
+ reject(err);
2630
+ }
2631
+ );
2632
+ });
2633
+ }
2634
+
2635
+ // src/engine/backtestAsync.js
2636
+ async function backtestAsync(rawOptions = {}) {
2637
+ const budgetMs = rawOptions.signalBudgetMs ?? 0;
2638
+ const userSignal = rawOptions.signal;
2639
+ const budgetedSignal = (context) => withBudget(
2640
+ Promise.resolve().then(() => userSignal(context)),
2641
+ budgetMs
2642
+ );
2643
+ const runner = new BarSystemRunner({ ...rawOptions, signal: budgetedSignal });
2644
+ while (runner.hasNext()) {
2645
+ await runner.stepAsync({ signalEquity: runner.getMarkedEquity() });
2646
+ }
2647
+ return runner.buildResult();
2648
+ }
2649
+
2650
+ // src/engine/backtestTicks.js
2651
+ function asNumber3(value) {
2652
+ const numeric = Number(value);
2653
+ return Number.isFinite(numeric) ? numeric : null;
2654
+ }
2655
+ function describeValue3(value) {
2656
+ if (Array.isArray(value)) return `array(length=${value.length})`;
2657
+ if (value === null) return "null";
2658
+ return typeof value;
2659
+ }
2660
+ function formatIsoTime3(time) {
2661
+ return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
2662
+ }
2663
+ function callSignalWithContext3({ signal, context, index, bar, symbol }) {
2664
+ try {
2665
+ return signal(context);
2666
+ } catch (error) {
2667
+ const cause = error instanceof Error ? error.message : String(error);
2668
+ throw new Error(
2669
+ `signal() threw at index=${index}, time=${formatIsoTime3(bar?.time)}, symbol=${symbol}: ${cause}`
2670
+ );
2671
+ }
2672
+ }
2673
+ function normalizeSide3(value) {
2674
+ if (value === "long" || value === "buy") return "long";
2675
+ if (value === "short" || value === "sell") return "short";
2676
+ return null;
2677
+ }
2678
+ function normalizeTick(tick) {
2679
+ const time = Number(tick?.time);
2680
+ const bid = asNumber3(tick?.bid);
2681
+ const ask = asNumber3(tick?.ask);
2682
+ const last = asNumber3(tick?.price ?? tick?.last ?? tick?.close);
2683
+ const mid = bid !== null && ask !== null ? (bid + ask) / 2 : last ?? bid ?? ask;
2684
+ if (!Number.isFinite(time) || !Number.isFinite(mid)) return null;
2685
+ const prices = [asNumber3(tick?.low), asNumber3(tick?.high), bid, ask, last, mid].filter(
2686
+ Number.isFinite
2687
+ );
2688
+ const low = prices.length ? Math.min(...prices) : mid;
2689
+ const high = prices.length ? Math.max(...prices) : mid;
2690
+ return {
2691
+ ...tick,
2692
+ time,
2693
+ open: mid,
2694
+ high,
2695
+ low,
2696
+ close: mid,
2697
+ volume: asNumber3(tick?.size ?? tick?.volume) ?? void 0
2698
+ };
2699
+ }
2700
+ function normalizeSignal3(signal, bar, fallbackR) {
2701
+ if (!signal) return null;
2702
+ const side = normalizeSide3(signal.side ?? signal.direction ?? signal.action);
2703
+ if (!side) return null;
2704
+ const hasExplicitEntry = signal.entry !== void 0 || signal.limit !== void 0 || signal.price !== void 0;
2705
+ const entry = asNumber3(signal.entry ?? signal.limit ?? signal.price) ?? asNumber3(bar?.close);
2706
+ const stop = asNumber3(signal.stop ?? signal.stopLoss ?? signal.sl);
2707
+ if (entry === null || stop === null) return null;
2708
+ const risk = Math.abs(entry - stop);
2709
+ if (!(risk > 0)) return null;
2710
+ let takeProfit = asNumber3(signal.takeProfit ?? signal.target ?? signal.tp);
2711
+ const rrHint = asNumber3(signal._rr ?? signal.rr);
2712
+ const targetR = rrHint ?? fallbackR;
2713
+ if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
2714
+ takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
2715
+ }
2716
+ if (takeProfit === null) return null;
2717
+ return {
2718
+ ...signal,
2719
+ side,
2720
+ entry,
2721
+ stop,
2722
+ takeProfit,
2723
+ qty: asNumber3(signal.qty ?? signal.size),
2724
+ riskPct: asNumber3(signal.riskPct),
2725
+ riskFraction: asNumber3(signal.riskFraction),
2726
+ orderType: hasExplicitEntry ? "limit" : "market"
2727
+ };
2728
+ }
2729
+ function equityPoint3(time, equity) {
2730
+ return { time, timestamp: time, equity };
2731
+ }
2732
+ function xmur3(seed) {
2733
+ let hash = 1779033703 ^ seed.length;
2734
+ for (let index = 0; index < seed.length; index += 1) {
2735
+ hash = Math.imul(hash ^ seed.charCodeAt(index), 3432918353);
2736
+ hash = hash << 13 | hash >>> 19;
2737
+ }
2738
+ return () => {
2739
+ hash = Math.imul(hash ^ hash >>> 16, 2246822507);
2740
+ hash = Math.imul(hash ^ hash >>> 13, 3266489909);
2741
+ return (hash ^= hash >>> 16) >>> 0;
2742
+ };
2743
+ }
2744
+ function mulberry32(seed) {
2745
+ let state = seed >>> 0;
2746
+ return () => {
2747
+ state = state + 1831565813 >>> 0;
2748
+ let value = Math.imul(state ^ state >>> 15, state | 1);
2749
+ value ^= value + Math.imul(value ^ value >>> 7, value | 61);
2750
+ return ((value ^ value >>> 14) >>> 0) / 4294967296;
2751
+ };
2752
+ }
2753
+ function seededUnitInterval(seedParts) {
2754
+ const seed = seedParts.map((part) => String(part)).join("|");
2755
+ const seedFn = xmur3(seed);
2756
+ return mulberry32(seedFn())();
2757
+ }
2758
+ function deterministicFill(probability, seedParts) {
2759
+ if (probability >= 1) return true;
2760
+ if (probability <= 0) return false;
2761
+ const normalized = seededUnitInterval(seedParts);
2762
+ return normalized <= probability;
2763
+ }
2764
+ function backtestTicks({
2765
+ ticks = [],
2766
+ symbol = "UNKNOWN",
2767
+ equity = 1e4,
2768
+ riskPct = 1,
2769
+ signal,
2770
+ interval,
2771
+ range,
2772
+ slippageBps = 1,
2773
+ feeBps = 0,
2774
+ costs = null,
2775
+ finalTP_R = 3,
2776
+ maxDailyLossPct = 0,
2777
+ dailyMaxTrades = 0,
2778
+ qtyStep = 1e-3,
2779
+ minQty = 1e-3,
2780
+ maxLeverage = 2,
2781
+ collectEqSeries = true,
2782
+ collectReplay = true,
2783
+ queueFillProbability = 1,
2784
+ seed = "tradelab-ticks",
2785
+ oco = {}
2786
+ } = {}) {
2787
+ if (!Array.isArray(ticks) || ticks.length === 0) {
2788
+ throw new Error(
2789
+ `backtestTicks() requires a non-empty ticks array, got ${describeValue3(ticks)}`
2790
+ );
2791
+ }
2792
+ if (typeof signal !== "function") {
2793
+ throw new Error(`backtestTicks() requires a signal function, got ${describeValue3(signal)}`);
2794
+ }
2795
+ const normalizedTicks = ticks.map(normalizeTick).filter(Boolean);
2796
+ if (!normalizedTicks.length) {
2797
+ throw new Error(
2798
+ `backtestTicks() could not normalize any ticks from ${ticks.length} input rows`
2799
+ );
2800
+ }
2801
+ const ocoOptions = {
2802
+ mode: "intrabar",
2803
+ tieBreak: "pessimistic",
2804
+ ...oco
2805
+ };
2806
+ const trades = [];
2807
+ const eqSeries = collectEqSeries ? [equityPoint3(normalizedTicks[0].time, equity)] : [];
2808
+ const replayFrames = collectReplay ? [] : [];
2809
+ const replayEvents = collectReplay ? [] : [];
2810
+ const history = [];
2811
+ let open = null;
2812
+ let pending = null;
2813
+ let currentEquity = equity;
2814
+ let dayKey = null;
2815
+ let dayStartEquity = equity;
2816
+ let dayPnl = 0;
2817
+ let dayTrades = 0;
2818
+ let tradeIdCounter = 0;
2819
+ function markedEquity(tick) {
2820
+ if (!open) return currentEquity;
2821
+ const direction = open.side === "long" ? 1 : -1;
2822
+ return currentEquity + (tick.close - open.entryFill) * direction * open.size;
2823
+ }
2824
+ function recordFrame(tick) {
2825
+ const equityNow = markedEquity(tick);
2826
+ if (collectEqSeries) {
2827
+ eqSeries.push(equityPoint3(tick.time, equityNow));
2828
+ }
2829
+ if (collectReplay) {
2830
+ replayFrames.push({
2831
+ t: new Date(tick.time).toISOString(),
2832
+ price: tick.close,
2833
+ equity: equityNow,
2834
+ posSide: open?.side ?? null,
2835
+ posSize: open?.size ?? 0
2836
+ });
2837
+ }
2838
+ }
2839
+ function closePosition(tick, reason, rawPrice, fillKind) {
2840
+ if (!open) return;
2841
+ const exitSide = open.side === "long" ? "short" : "long";
2842
+ const { price, feeTotal } = applyFill(rawPrice, exitSide, {
2843
+ slippageBps,
2844
+ feeBps,
2845
+ kind: fillKind,
2846
+ qty: open.size,
2847
+ costs
2848
+ });
2849
+ const direction = open.side === "long" ? 1 : -1;
2850
+ const grossPnl = (price - open.entryFill) * direction * open.size;
2851
+ const financing = financingCost({
2852
+ side: open.side,
2853
+ notional: open.entryFill * open.size,
2854
+ fromMs: open.openTime,
2855
+ toMs: tick.time,
2856
+ costs
2857
+ });
2858
+ const pnl = grossPnl - (open.entryFeeTotal || 0) - feeTotal - financing;
2859
+ currentEquity += pnl;
2860
+ dayPnl += pnl;
2861
+ const trade = {
2862
+ ...open,
2863
+ exit: {
2864
+ price,
2865
+ time: tick.time,
2866
+ reason,
2867
+ pnl,
2868
+ financing
2869
+ }
2870
+ };
2871
+ trades.push(trade);
2872
+ if (collectReplay) {
2873
+ replayEvents.push({
2874
+ t: new Date(tick.time).toISOString(),
2875
+ price,
2876
+ type: reason === "TP" ? "tp" : reason === "SL" ? "sl" : "exit",
2877
+ side: open.side,
2878
+ size: open.size,
2879
+ tradeId: open.id,
2880
+ reason,
2881
+ pnl
2882
+ });
2883
+ }
2884
+ open = null;
2885
+ }
2886
+ for (let index = 0; index < normalizedTicks.length; index += 1) {
2887
+ const tick = normalizedTicks[index];
2888
+ history.push(tick);
2889
+ const currentDayKey = dayKeyUTC2(tick.time);
2890
+ if (dayKey === null || currentDayKey !== dayKey) {
2891
+ dayKey = currentDayKey;
2892
+ dayStartEquity = currentEquity;
2893
+ dayPnl = 0;
2894
+ dayTrades = 0;
2895
+ }
2896
+ if (open) {
2674
2897
  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
2898
+ side: open.side,
2899
+ stop: open.stop,
2900
+ tp: open.takeProfit,
2901
+ bar: tick,
2902
+ mode: "intrabar",
2903
+ tieBreak: ocoOptions.tieBreak
2681
2904
  });
2682
2905
  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;
2906
+ closePosition(tick, hit, px, hit === "TP" ? "limit" : "stop");
2702
2907
  }
2703
2908
  }
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;
2909
+ if (!open && pending && index > pending.createdAtIndex) {
2910
+ if (pending.orderType === "market") {
2911
+ const rawSize = pending.fixedQty ?? calculatePositionSize({
2912
+ equity: currentEquity,
2913
+ entry: tick.close,
2914
+ stop: pending.stop,
2915
+ riskFraction: pending.riskFrac,
2916
+ qtyStep,
2917
+ minQty,
2918
+ maxLeverage
2919
+ });
2920
+ const size = roundStep2(rawSize, qtyStep);
2921
+ if (size >= minQty) {
2922
+ const { price, feeTotal } = applyFill(tick.close, pending.side, {
2923
+ slippageBps,
2924
+ feeBps,
2925
+ kind: "market",
2926
+ qty: size,
2927
+ costs
2928
+ });
2929
+ open = {
2930
+ symbol,
2931
+ id: ++tradeIdCounter,
2932
+ side: pending.side,
2933
+ entry: tick.close,
2934
+ stop: pending.stop,
2935
+ takeProfit: pending.takeProfit,
2936
+ size,
2937
+ openTime: tick.time,
2938
+ entryFill: price,
2939
+ entryFeeTotal: feeTotal,
2940
+ _initRisk: Math.abs(tick.close - pending.stop)
2941
+ };
2942
+ dayTrades += 1;
2943
+ if (collectReplay) {
2944
+ replayEvents.push({
2945
+ t: new Date(tick.time).toISOString(),
2946
+ price,
2947
+ type: "entry",
2948
+ side: open.side,
2949
+ size,
2950
+ tradeId: open.id
2951
+ });
2725
2952
  }
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
2953
  }
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;
2954
+ pending = null;
2955
+ } else {
2956
+ const touched = pending.side === "long" ? tick.low <= pending.entry : tick.high >= pending.entry;
2957
+ if (touched && deterministicFill(queueFillProbability, [
2958
+ seed,
2959
+ symbol,
2960
+ tick.time,
2961
+ pending.entry,
2962
+ pending.stop,
2963
+ pending.side
2964
+ ])) {
2965
+ const rawSize = pending.fixedQty ?? calculatePositionSize({
2966
+ equity: currentEquity,
2967
+ entry: pending.entry,
2968
+ stop: pending.stop,
2969
+ riskFraction: pending.riskFrac,
2970
+ qtyStep,
2971
+ minQty,
2972
+ maxLeverage
2973
+ });
2974
+ const size = roundStep2(rawSize, qtyStep);
2975
+ if (size >= minQty) {
2976
+ const { price, feeTotal } = applyFill(pending.entry, pending.side, {
2977
+ slippageBps,
2978
+ feeBps,
2979
+ kind: "limit",
2980
+ qty: size,
2981
+ costs
2982
+ });
2983
+ open = {
2984
+ symbol,
2985
+ id: ++tradeIdCounter,
2986
+ side: pending.side,
2987
+ entry: pending.entry,
2988
+ stop: pending.stop,
2989
+ takeProfit: pending.takeProfit,
2990
+ size,
2991
+ openTime: tick.time,
2992
+ entryFill: price,
2993
+ entryFeeTotal: feeTotal,
2994
+ _initRisk: Math.abs(pending.entry - pending.stop)
2995
+ };
2996
+ dayTrades += 1;
2997
+ if (collectReplay) {
2998
+ replayEvents.push({
2999
+ t: new Date(tick.time).toISOString(),
3000
+ price,
3001
+ type: "entry",
3002
+ side: open.side,
3003
+ size,
3004
+ tradeId: open.id
3005
+ });
2755
3006
  }
2756
3007
  }
3008
+ pending = null;
2757
3009
  }
2758
3010
  }
2759
3011
  }
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);
3012
+ const maxLossDollars = Math.abs(maxDailyLossPct) / 100 * dayStartEquity;
3013
+ const dailyLossHit = maxDailyLossPct > 0 && dayPnl <= -maxLossDollars;
3014
+ const dailyTradeCapHit = dailyMaxTrades > 0 && dayTrades >= dailyMaxTrades;
3015
+ if (!open && !pending && !dailyLossHit && !dailyTradeCapHit) {
3016
+ const nextSignal = normalizeSignal3(
3017
+ callSignalWithContext3({
3018
+ signal,
3019
+ context: {
3020
+ candles: history,
3021
+ index,
3022
+ bar: tick,
3023
+ equity: markedEquity(tick),
3024
+ openPosition: open,
3025
+ pendingOrder: pending
3026
+ },
3027
+ index,
3028
+ bar: tick,
3029
+ symbol
3030
+ }),
3031
+ tick,
3032
+ finalTP_R
3033
+ );
2781
3034
  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 = {
3035
+ pending = {
2785
3036
  side: nextSignal.side,
2786
3037
  entry: nextSignal.entry,
2787
3038
  stop: nextSignal.stop,
2788
- tp: nextSignal.takeProfit,
2789
- riskFrac: signalRiskFraction,
3039
+ takeProfit: nextSignal.takeProfit,
2790
3040
  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)
3041
+ riskFrac: Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : riskPct / 100,
3042
+ orderType: nextSignal.orderType,
3043
+ createdAtIndex: index
2795
3044
  };
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
3045
  }
2802
3046
  }
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
- };
3047
+ recordFrame(tick);
2833
3048
  }
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);
3049
+ if (open) {
3050
+ const lastTick = normalizedTicks[normalizedTicks.length - 1];
3051
+ closePosition(lastTick, "EOT", lastTick.close, "market");
3052
+ recordFrame(lastTick);
2841
3053
  }
2842
- return limits.length ? Math.min(...limits) : Math.max(0, totalEquity);
3054
+ const positions = trades;
3055
+ const metrics = buildMetrics({
3056
+ closed: trades,
3057
+ equityStart: equity,
3058
+ equityFinal: currentEquity,
3059
+ candles: normalizedTicks,
3060
+ estBarMs: normalizedTicks.length > 1 ? Math.max(1, normalizedTicks[1].time - normalizedTicks[0].time) : 1,
3061
+ eqSeries,
3062
+ interval
3063
+ });
3064
+ return {
3065
+ symbol,
3066
+ interval,
3067
+ range,
3068
+ trades,
3069
+ positions,
3070
+ openPositions: [],
3071
+ metrics,
3072
+ eqSeries,
3073
+ replay: {
3074
+ frames: replayFrames,
3075
+ events: replayEvents
3076
+ }
3077
+ };
2843
3078
  }
2844
3079
 
2845
3080
  // src/engine/portfolio.js
@@ -3114,6 +3349,74 @@ function backtestPortfolio({
3114
3349
  };
3115
3350
  }
3116
3351
 
3352
+ // src/engine/llmSignal.js
3353
+ function isArrayIndexKey3(property) {
3354
+ if (typeof property !== "string") return false;
3355
+ const n = Number(property);
3356
+ return Number.isInteger(n) && n >= 0;
3357
+ }
3358
+ function noLookaheadView(candles, index) {
3359
+ return new Proxy(candles, {
3360
+ get(target, property, receiver) {
3361
+ if (isArrayIndexKey3(property) && Number(property) > index) {
3362
+ throw new Error(
3363
+ `LlmSignal: lookahead access to candles[${String(property)}] (current index ${index})`
3364
+ );
3365
+ }
3366
+ return Reflect.get(target, property, receiver);
3367
+ }
3368
+ });
3369
+ }
3370
+ var LlmSignal = class {
3371
+ constructor({ resolve, budgetMs = 0, onError = "skip" } = {}) {
3372
+ if (typeof resolve !== "function") {
3373
+ throw new Error("LlmSignal requires a resolve(context) function");
3374
+ }
3375
+ this.resolve = resolve;
3376
+ this.budgetMs = budgetMs;
3377
+ this.onError = onError;
3378
+ this.log = [];
3379
+ this._cache = /* @__PURE__ */ new Map();
3380
+ this.signal = this.signal.bind(this);
3381
+ }
3382
+ async signal(context) {
3383
+ const key = context.bar?.time ?? context.index;
3384
+ if (this._cache.has(key)) return this._cache.get(key);
3385
+ const safeContext = {
3386
+ ...context,
3387
+ candles: noLookaheadView(context.candles, context.index)
3388
+ };
3389
+ const startedAt = Date.now();
3390
+ try {
3391
+ const result = await withBudget(
3392
+ Promise.resolve().then(() => this.resolve(safeContext)),
3393
+ this.budgetMs
3394
+ );
3395
+ this._cache.set(key, result ?? null);
3396
+ this.log.push({
3397
+ index: context.index,
3398
+ time: context.bar?.time,
3399
+ close: context.bar?.close,
3400
+ latencyMs: Date.now() - startedAt,
3401
+ result: result ?? null
3402
+ });
3403
+ return result ?? null;
3404
+ } catch (error) {
3405
+ const message = error instanceof Error ? error.message : String(error);
3406
+ this.log.push({
3407
+ index: context.index,
3408
+ time: context.bar?.time,
3409
+ close: context.bar?.close,
3410
+ latencyMs: Date.now() - startedAt,
3411
+ error: message
3412
+ });
3413
+ this._cache.set(key, null);
3414
+ if (this.onError === "throw") throw error;
3415
+ return null;
3416
+ }
3417
+ }
3418
+ };
3419
+
3117
3420
  // src/engine/walkForward.js
3118
3421
  function scoreOf(metrics, scoreBy) {
3119
3422
  const value = metrics?.[scoreBy];
@@ -3306,7 +3609,8 @@ function walkForwardOptimize({
3306
3609
  equityFinal: rollingEquity,
3307
3610
  candles,
3308
3611
  estBarMs: estimateBarMs(candles),
3309
- eqSeries
3612
+ eqSeries,
3613
+ interval: backtestOptions.interval
3310
3614
  });
3311
3615
  const bestParamsSummary = summarizeBestParams(windows);
3312
3616
  return {
@@ -3325,6 +3629,580 @@ function walkForwardOptimize({
3325
3629
  };
3326
3630
  }
3327
3631
 
3632
+ // src/engine/optimize.js
3633
+ var import_node_worker_threads = require("node:worker_threads");
3634
+ var import_node_os = __toESM(require("node:os"), 1);
3635
+ var import_meta = {};
3636
+ function defaultConcurrency() {
3637
+ return Math.max(1, (import_node_os.default.cpus()?.length ?? 2) - 1);
3638
+ }
3639
+ function scoreValue(metrics, scoreBy) {
3640
+ const v = metrics?.[scoreBy];
3641
+ return Number.isFinite(v) ? v : -Infinity;
3642
+ }
3643
+ function optimize({
3644
+ candles,
3645
+ signalModulePath,
3646
+ parameterSets,
3647
+ interval,
3648
+ backtestOptions = {},
3649
+ concurrency,
3650
+ scoreBy = "profitFactor"
3651
+ }) {
3652
+ if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
3653
+ return Promise.resolve({ results: [], leaderboard: [], best: null });
3654
+ }
3655
+ return new Promise((resolve, reject) => {
3656
+ const poolSize = Math.min(concurrency || defaultConcurrency(), parameterSets.length);
3657
+ const results = new Array(parameterSets.length);
3658
+ const workers = [];
3659
+ let nextIndex = 0;
3660
+ let completed = 0;
3661
+ let settled = false;
3662
+ const finish = () => {
3663
+ if (settled) return;
3664
+ settled = true;
3665
+ for (const w of workers) w.terminate();
3666
+ const ranked = results.filter((r) => r && r.metrics).sort((a, b) => scoreValue(b.metrics, scoreBy) - scoreValue(a.metrics, scoreBy));
3667
+ resolve({ results, leaderboard: ranked, best: ranked[0] ?? null });
3668
+ };
3669
+ const fail = (error) => {
3670
+ if (settled) return;
3671
+ settled = true;
3672
+ for (const w of workers) w.terminate();
3673
+ reject(error);
3674
+ };
3675
+ const dispatch = (worker) => {
3676
+ if (nextIndex >= parameterSets.length) {
3677
+ worker.postMessage({ type: "stop" });
3678
+ return;
3679
+ }
3680
+ const index = nextIndex;
3681
+ nextIndex += 1;
3682
+ worker.postMessage({ type: "run", index, params: parameterSets[index] });
3683
+ };
3684
+ for (let i = 0; i < poolSize; i += 1) {
3685
+ const worker = new import_node_worker_threads.Worker(new URL("./optimizeWorker.js", import_meta.url), {
3686
+ workerData: { candles, signalModulePath, interval, backtestOptions }
3687
+ });
3688
+ workers.push(worker);
3689
+ worker.on("message", (msg) => {
3690
+ if (msg.type === "ready") {
3691
+ dispatch(worker);
3692
+ return;
3693
+ }
3694
+ if (msg.type === "result" || msg.type === "error") {
3695
+ results[msg.index] = msg.type === "result" ? { params: msg.params, metrics: msg.metrics } : { params: msg.params, error: msg.error };
3696
+ completed += 1;
3697
+ if (completed === parameterSets.length) finish();
3698
+ else dispatch(worker);
3699
+ }
3700
+ });
3701
+ worker.on("error", fail);
3702
+ }
3703
+ });
3704
+ }
3705
+
3706
+ // src/engine/grid.js
3707
+ function grid(spec = {}) {
3708
+ const keys = Object.keys(spec);
3709
+ if (!keys.length) return [{}];
3710
+ return keys.reduce(
3711
+ (acc, key) => {
3712
+ const values = Array.isArray(spec[key]) ? spec[key] : [spec[key]];
3713
+ return acc.flatMap((base) => values.map((v) => ({ ...base, [key]: v })));
3714
+ },
3715
+ [{}]
3716
+ );
3717
+ }
3718
+
3719
+ // src/ta/oscillators.js
3720
+ function rsi(closes, period = 14) {
3721
+ const out = new Array(closes.length).fill(void 0);
3722
+ if (closes.length <= period) return out;
3723
+ let gainSum = 0;
3724
+ let lossSum = 0;
3725
+ for (let i = 1; i <= period; i += 1) {
3726
+ const change = closes[i] - closes[i - 1];
3727
+ if (change >= 0) gainSum += change;
3728
+ else lossSum -= change;
3729
+ }
3730
+ let avgGain = gainSum / period;
3731
+ let avgLoss = lossSum / period;
3732
+ out[period] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
3733
+ for (let i = period + 1; i < closes.length; i += 1) {
3734
+ const change = closes[i] - closes[i - 1];
3735
+ const gain = change > 0 ? change : 0;
3736
+ const loss = change < 0 ? -change : 0;
3737
+ avgGain = (avgGain * (period - 1) + gain) / period;
3738
+ avgLoss = (avgLoss * (period - 1) + loss) / period;
3739
+ out[i] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
3740
+ }
3741
+ return out;
3742
+ }
3743
+
3744
+ // src/ta/channels.js
3745
+ function donchian(bars, period = 20) {
3746
+ const upper = new Array(bars.length).fill(void 0);
3747
+ const lower = new Array(bars.length).fill(void 0);
3748
+ const middle = new Array(bars.length).fill(void 0);
3749
+ for (let i = period - 1; i < bars.length; i += 1) {
3750
+ let hh = -Infinity;
3751
+ let ll = Infinity;
3752
+ for (let j = i - period + 1; j <= i; j += 1) {
3753
+ if (bars[j].high > hh) hh = bars[j].high;
3754
+ if (bars[j].low < ll) ll = bars[j].low;
3755
+ }
3756
+ upper[i] = hh;
3757
+ lower[i] = ll;
3758
+ middle[i] = (hh + ll) / 2;
3759
+ }
3760
+ return { upper, lower, middle };
3761
+ }
3762
+
3763
+ // src/strategies/builtins.js
3764
+ var BUILTINS = {
3765
+ "ema-cross": {
3766
+ description: "Long when fast EMA crosses above slow EMA; stop at recent swing low.",
3767
+ params: {
3768
+ fast: { type: "number", default: 10, description: "fast EMA period" },
3769
+ slow: { type: "number", default: 30, description: "slow EMA period" },
3770
+ rr: { type: "number", default: 2, description: "reward:risk target" },
3771
+ lookback: { type: "number", default: 15, description: "swing-low lookback for stop" }
3772
+ },
3773
+ factory({ fast = 10, slow = 30, rr = 2, lookback = 15 } = {}) {
3774
+ return ({ candles, bar }) => {
3775
+ if (candles.length < slow + 2) return null;
3776
+ const closes = candles.map((c) => c.close);
3777
+ const f = ema(closes, fast);
3778
+ const s = ema(closes, slow);
3779
+ const last = closes.length - 1;
3780
+ if (f[last - 1] <= s[last - 1] && f[last] > s[last]) {
3781
+ const stop = Math.min(...candles.slice(-lookback).map((c) => c.low));
3782
+ if (stop >= bar.close) return null;
3783
+ return { side: "long", entry: bar.close, stop, rr };
3784
+ }
3785
+ return null;
3786
+ };
3787
+ }
3788
+ },
3789
+ "rsi-reversion": {
3790
+ description: "Long when RSI dips below `oversold`; stop a fixed pct below entry.",
3791
+ params: {
3792
+ period: { type: "number", default: 14, description: "RSI period" },
3793
+ oversold: { type: "number", default: 30, description: "RSI entry threshold" },
3794
+ stopPct: { type: "number", default: 2, description: "stop distance in percent" },
3795
+ rr: { type: "number", default: 1.5, description: "reward:risk target" }
3796
+ },
3797
+ factory({ period = 14, oversold = 30, stopPct = 2, rr = 1.5 } = {}) {
3798
+ return ({ candles, bar }) => {
3799
+ if (candles.length < period + 2) return null;
3800
+ const values = rsi(
3801
+ candles.map((c) => c.close),
3802
+ period
3803
+ );
3804
+ const r = values[values.length - 1];
3805
+ if (r === void 0 || r > oversold) return null;
3806
+ return { side: "long", entry: bar.close, stop: bar.close * (1 - stopPct / 100), rr };
3807
+ };
3808
+ }
3809
+ },
3810
+ "donchian-breakout": {
3811
+ description: "Long on a close above the prior Donchian upper channel.",
3812
+ params: {
3813
+ period: { type: "number", default: 20, description: "channel lookback" },
3814
+ rr: { type: "number", default: 2, description: "reward:risk target" }
3815
+ },
3816
+ factory({ period = 20, rr = 2 } = {}) {
3817
+ return ({ candles, bar }) => {
3818
+ if (candles.length < period + 2) return null;
3819
+ const ch = donchian(candles, period);
3820
+ const i = candles.length - 1;
3821
+ const priorUpper = ch.upper[i - 1];
3822
+ const priorLower = ch.lower[i - 1];
3823
+ if (priorUpper === void 0) return null;
3824
+ if (bar.close > priorUpper) {
3825
+ return { side: "long", entry: bar.close, stop: priorLower, rr };
3826
+ }
3827
+ return null;
3828
+ };
3829
+ }
3830
+ },
3831
+ "buy-hold": {
3832
+ description: "Enter once at the first eligible bar and hold for `holdBars`.",
3833
+ params: {
3834
+ holdBars: { type: "number", default: 5, description: "bars to hold before exit" },
3835
+ stopPct: { type: "number", default: 10, description: "protective stop distance in percent" }
3836
+ },
3837
+ factory({ holdBars = 5, stopPct = 10 } = {}) {
3838
+ let entered = false;
3839
+ return ({ bar }) => {
3840
+ if (entered) return null;
3841
+ entered = true;
3842
+ return {
3843
+ side: "long",
3844
+ entry: bar.close,
3845
+ stop: bar.close * (1 - stopPct / 100),
3846
+ rr: 5,
3847
+ _maxBarsInTrade: holdBars
3848
+ };
3849
+ };
3850
+ }
3851
+ }
3852
+ };
3853
+
3854
+ // src/strategies/index.js
3855
+ var registry = new Map(Object.entries(BUILTINS));
3856
+ function registerStrategy(name, def) {
3857
+ if (typeof def?.factory !== "function") {
3858
+ throw new Error(`registerStrategy("${name}") requires a factory function`);
3859
+ }
3860
+ registry.set(name, def);
3861
+ }
3862
+ function listStrategies() {
3863
+ return [...registry.entries()].map(([name, def]) => ({
3864
+ name,
3865
+ description: def.description,
3866
+ params: def.params
3867
+ }));
3868
+ }
3869
+ function getStrategy(name) {
3870
+ const def = registry.get(name);
3871
+ if (!def) {
3872
+ const available = [...registry.keys()].join(", ");
3873
+ throw new Error(`Unknown strategy "${name}". Available: ${available}`);
3874
+ }
3875
+ return def.factory;
3876
+ }
3877
+
3878
+ // src/research/index.js
3879
+ var research_exports = {};
3880
+ __export(research_exports, {
3881
+ combinations: () => combinations,
3882
+ combinatorialPurgedSplits: () => combinatorialPurgedSplits,
3883
+ deflatedSharpe: () => deflatedSharpe,
3884
+ moments: () => moments,
3885
+ monteCarlo: () => monteCarlo,
3886
+ normalCdf: () => normalCdf,
3887
+ normalPpf: () => normalPpf,
3888
+ probabilityOfBacktestOverfitting: () => probabilityOfBacktestOverfitting,
3889
+ sweepHaircut: () => sweepHaircut
3890
+ });
3891
+
3892
+ // src/utils/random.js
3893
+ function xmur32(str) {
3894
+ let h = 1779033703 ^ str.length;
3895
+ for (let i = 0; i < str.length; i += 1) {
3896
+ h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
3897
+ h = h << 13 | h >>> 19;
3898
+ }
3899
+ return () => {
3900
+ h = Math.imul(h ^ h >>> 16, 2246822507);
3901
+ h = Math.imul(h ^ h >>> 13, 3266489909);
3902
+ return (h ^= h >>> 16) >>> 0;
3903
+ };
3904
+ }
3905
+ function mulberry322(seed) {
3906
+ let state = seed >>> 0;
3907
+ return () => {
3908
+ state = state + 1831565813 >>> 0;
3909
+ let t = Math.imul(state ^ state >>> 15, state | 1);
3910
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
3911
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
3912
+ };
3913
+ }
3914
+ function makeRng(seed = "tradelab") {
3915
+ const seedFn = xmur32(String(seed));
3916
+ return mulberry322(seedFn());
3917
+ }
3918
+ function randInt(rng, maxExclusive) {
3919
+ return Math.floor(rng() * maxExclusive);
3920
+ }
3921
+
3922
+ // src/research/monteCarlo.js
3923
+ function percentile2(sorted, p) {
3924
+ if (!sorted.length) return 0;
3925
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor((sorted.length - 1) * p)));
3926
+ return sorted[idx];
3927
+ }
3928
+ function maxDrawdownOf(equityPath) {
3929
+ let peak = equityPath[0];
3930
+ let maxDd = 0;
3931
+ for (const e of equityPath) {
3932
+ if (e > peak) peak = e;
3933
+ const dd = peak > 0 ? (peak - e) / peak : 0;
3934
+ if (dd > maxDd) maxDd = dd;
3935
+ }
3936
+ return maxDd;
3937
+ }
3938
+ function monteCarlo({
3939
+ tradePnls,
3940
+ equityStart = 1e4,
3941
+ iterations = 1e3,
3942
+ blockSize = 1,
3943
+ seed = "tradelab-mc"
3944
+ }) {
3945
+ if (!Array.isArray(tradePnls) || tradePnls.length === 0) {
3946
+ throw new Error("monteCarlo() requires a non-empty tradePnls array");
3947
+ }
3948
+ const rng = makeRng(seed);
3949
+ const n = tradePnls.length;
3950
+ const block = Math.max(1, Math.floor(blockSize));
3951
+ const finals = [];
3952
+ const drawdowns = [];
3953
+ const pathSamples = Array.from({ length: n + 1 }, () => []);
3954
+ for (let it = 0; it < iterations; it += 1) {
3955
+ const path6 = [equityStart];
3956
+ let equity = equityStart;
3957
+ let filled = 0;
3958
+ while (filled < n) {
3959
+ const start = randInt(rng, n);
3960
+ for (let k = 0; k < block && filled < n; k += 1) {
3961
+ equity += tradePnls[(start + k) % n];
3962
+ path6.push(equity);
3963
+ filled += 1;
3964
+ }
3965
+ }
3966
+ for (let step = 0; step < path6.length; step += 1) {
3967
+ pathSamples[step].push(path6[step]);
3968
+ }
3969
+ finals.push(equity);
3970
+ drawdowns.push(maxDrawdownOf(path6));
3971
+ }
3972
+ const sortedFinals = [...finals].sort((a, b) => a - b);
3973
+ const sortedDd = [...drawdowns].sort((a, b) => a - b);
3974
+ const pathBands = pathSamples.map((samples) => {
3975
+ const s = [...samples].sort((a, b) => a - b);
3976
+ return { p5: percentile2(s, 0.05), p50: percentile2(s, 0.5), p95: percentile2(s, 0.95) };
3977
+ });
3978
+ const bands = (sorted) => ({
3979
+ p5: percentile2(sorted, 0.05),
3980
+ p25: percentile2(sorted, 0.25),
3981
+ p50: percentile2(sorted, 0.5),
3982
+ p75: percentile2(sorted, 0.75),
3983
+ p95: percentile2(sorted, 0.95)
3984
+ });
3985
+ return {
3986
+ iterations,
3987
+ blockSize: block,
3988
+ finalEquity: bands(sortedFinals),
3989
+ maxDrawdown: bands(sortedDd),
3990
+ pathBands,
3991
+ probProfit: finals.filter((f) => f > equityStart).length / iterations
3992
+ };
3993
+ }
3994
+
3995
+ // src/research/stats.js
3996
+ function normalCdf(x) {
3997
+ const sign = x < 0 ? -1 : 1;
3998
+ const ax = Math.abs(x) / Math.SQRT2;
3999
+ const t = 1 / (1 + 0.3275911 * ax);
4000
+ const y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-ax * ax);
4001
+ return 0.5 * (1 + sign * y);
4002
+ }
4003
+ function normalPpf(p) {
4004
+ if (p <= 0) return -Infinity;
4005
+ if (p >= 1) return Infinity;
4006
+ const a = [
4007
+ -39.69683028665376,
4008
+ 220.9460984245205,
4009
+ -275.9285104469687,
4010
+ 138.357751867269,
4011
+ -30.66479806614716,
4012
+ 2.506628277459239
4013
+ ];
4014
+ const b = [
4015
+ -54.47609879822406,
4016
+ 161.5858368580409,
4017
+ -155.6989798598866,
4018
+ 66.80131188771972,
4019
+ -13.28068155288572
4020
+ ];
4021
+ const c = [
4022
+ -0.007784894002430293,
4023
+ -0.3223964580411365,
4024
+ -2.400758277161838,
4025
+ -2.549732539343734,
4026
+ 4.374664141464968,
4027
+ 2.938163982698783
4028
+ ];
4029
+ const d = [0.007784695709041462, 0.3224671290700398, 2.445134137142996, 3.754408661907416];
4030
+ const plow = 0.02425;
4031
+ const phigh = 1 - plow;
4032
+ let q;
4033
+ let r;
4034
+ if (p < plow) {
4035
+ q = Math.sqrt(-2 * Math.log(p));
4036
+ return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
4037
+ }
4038
+ if (p <= phigh) {
4039
+ q = p - 0.5;
4040
+ r = q * q;
4041
+ return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
4042
+ }
4043
+ q = Math.sqrt(-2 * Math.log(1 - p));
4044
+ return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
4045
+ }
4046
+ function moments(values) {
4047
+ const n = values.length;
4048
+ if (n < 2) return { mean: values[0] ?? 0, std: 0, skew: 0, kurtosis: 3 };
4049
+ const mean3 = values.reduce((a, b) => a + b, 0) / n;
4050
+ let m2 = 0;
4051
+ let m3 = 0;
4052
+ let m4 = 0;
4053
+ for (const v of values) {
4054
+ const d = v - mean3;
4055
+ m2 += d * d;
4056
+ m3 += d * d * d;
4057
+ m4 += d * d * d * d;
4058
+ }
4059
+ m2 /= n;
4060
+ m3 /= n;
4061
+ m4 /= n;
4062
+ const std = Math.sqrt(m2);
4063
+ const skew = std === 0 ? 0 : m3 / std ** 3;
4064
+ const kurtosis = m2 === 0 ? 3 : m4 / m2 ** 2;
4065
+ return { mean: mean3, std, skew, kurtosis };
4066
+ }
4067
+
4068
+ // src/research/deflatedSharpe.js
4069
+ var EULER_MASCHERONI = 0.5772156649015329;
4070
+ function sweepHaircut({ numTrials, sharpeStd }) {
4071
+ const N = Math.max(1, numTrials);
4072
+ const a = normalPpf(1 - 1 / N);
4073
+ const b = normalPpf(1 - 1 / (N * Math.E));
4074
+ const expectedMaxSharpe = sharpeStd * ((1 - EULER_MASCHERONI) * a + EULER_MASCHERONI * b);
4075
+ return { expectedMaxSharpe, numTrials: N };
4076
+ }
4077
+ function deflatedSharpe({
4078
+ sharpe,
4079
+ sampleSize,
4080
+ numTrials = 1,
4081
+ sharpeStd = 0,
4082
+ skew = 0,
4083
+ kurtosis = 3
4084
+ }) {
4085
+ const sr0 = sweepHaircut({ numTrials, sharpeStd }).expectedMaxSharpe;
4086
+ const denom = Math.sqrt(
4087
+ Math.max(1e-12, 1 - skew * sharpe + (kurtosis - 1) / 4 * sharpe * sharpe)
4088
+ );
4089
+ const z = (sharpe - sr0) * Math.sqrt(Math.max(1, sampleSize - 1)) / denom;
4090
+ return normalCdf(z);
4091
+ }
4092
+
4093
+ // src/research/combinations.js
4094
+ function combinations(n, k) {
4095
+ const result = [];
4096
+ const combo = [];
4097
+ function recurse(start) {
4098
+ if (combo.length === k) {
4099
+ result.push([...combo]);
4100
+ return;
4101
+ }
4102
+ for (let i = start; i < n; i += 1) {
4103
+ combo.push(i);
4104
+ recurse(i + 1);
4105
+ combo.pop();
4106
+ }
4107
+ }
4108
+ recurse(0);
4109
+ return result;
4110
+ }
4111
+
4112
+ // src/research/pbo.js
4113
+ function sharpeOf(returns) {
4114
+ const n = returns.length;
4115
+ if (n < 2) return 0;
4116
+ const mean3 = returns.reduce((a, b) => a + b, 0) / n;
4117
+ let variance = 0;
4118
+ for (const r of returns) variance += (r - mean3) ** 2;
4119
+ variance /= n - 1;
4120
+ const std = Math.sqrt(variance);
4121
+ if (std === 0) {
4122
+ if (mean3 > 0) return Infinity;
4123
+ if (mean3 < 0) return -Infinity;
4124
+ return 0;
4125
+ }
4126
+ return mean3 / std;
4127
+ }
4128
+ function probabilityOfBacktestOverfitting(performanceMatrix, { groups = 16 } = {}) {
4129
+ const nStrategies = performanceMatrix.length;
4130
+ if (nStrategies < 2) throw new Error("PBO needs at least 2 strategies");
4131
+ const nObs = performanceMatrix[0].length;
4132
+ const S = Math.min(groups, nObs);
4133
+ if (S % 2 !== 0) throw new Error("groups must be even");
4134
+ const groupIdx = Array.from({ length: S }, () => []);
4135
+ for (let i = 0; i < nObs; i += 1) groupIdx[Math.floor(i * S / nObs)].push(i);
4136
+ const isCombos = combinations(S, S / 2);
4137
+ const logits = [];
4138
+ let overfitCount = 0;
4139
+ for (const isGroups of isCombos) {
4140
+ const isSet = new Set(isGroups);
4141
+ const isIndices = [];
4142
+ const osIndices = [];
4143
+ for (let g = 0; g < S; g += 1) {
4144
+ (isSet.has(g) ? isIndices : osIndices).push(...groupIdx[g]);
4145
+ }
4146
+ const isScores = performanceMatrix.map((row) => sharpeOf(isIndices.map((i) => row[i])));
4147
+ const osScores = performanceMatrix.map((row) => sharpeOf(osIndices.map((i) => row[i])));
4148
+ let bestStrategy = 0;
4149
+ for (let s = 1; s < nStrategies; s += 1) {
4150
+ if (isScores[s] > isScores[bestStrategy]) bestStrategy = s;
4151
+ }
4152
+ const winnerOs = osScores[bestStrategy];
4153
+ let rank = 1;
4154
+ for (let s = 0; s < nStrategies; s += 1) {
4155
+ if (s !== bestStrategy && osScores[s] < winnerOs) rank += 1;
4156
+ }
4157
+ const relativeRank = rank / (nStrategies + 1);
4158
+ const logit = Math.log(relativeRank / (1 - relativeRank));
4159
+ logits.push(logit);
4160
+ if (relativeRank <= 0.5) overfitCount += 1;
4161
+ }
4162
+ return {
4163
+ pbo: overfitCount / isCombos.length,
4164
+ combos: isCombos.length,
4165
+ medianLogit: [...logits].sort((a, b) => a - b)[Math.floor(logits.length / 2)]
4166
+ };
4167
+ }
4168
+
4169
+ // src/research/cpcv.js
4170
+ function combinatorialPurgedSplits({
4171
+ nObservations,
4172
+ nGroups = 6,
4173
+ nTestGroups = 2,
4174
+ embargo = 0
4175
+ }) {
4176
+ if (!(nObservations > 0)) throw new Error("nObservations must be positive");
4177
+ if (nTestGroups >= nGroups) throw new Error("nTestGroups must be < nGroups");
4178
+ const bounds = [];
4179
+ for (let g = 0; g < nGroups; g += 1) {
4180
+ bounds.push([
4181
+ Math.floor(g * nObservations / nGroups),
4182
+ Math.floor((g + 1) * nObservations / nGroups)
4183
+ ]);
4184
+ }
4185
+ const splits = [];
4186
+ for (const testGroups of combinations(nGroups, nTestGroups)) {
4187
+ const testSet = /* @__PURE__ */ new Set();
4188
+ const purgeZones = [];
4189
+ for (const g of testGroups) {
4190
+ const [start, end] = bounds[g];
4191
+ for (let i = start; i < end; i += 1) testSet.add(i);
4192
+ purgeZones.push([start - embargo, end + embargo]);
4193
+ }
4194
+ const inPurge = (i) => purgeZones.some(([lo, hi]) => i >= lo && i < hi);
4195
+ const train = [];
4196
+ const testIdx = [];
4197
+ for (let i = 0; i < nObservations; i += 1) {
4198
+ if (testSet.has(i)) testIdx.push(i);
4199
+ else if (!inPurge(i)) train.push(i);
4200
+ }
4201
+ splits.push({ train, test: testIdx, testGroups });
4202
+ }
4203
+ return splits;
4204
+ }
4205
+
3328
4206
  // src/data/index.js
3329
4207
  var import_path2 = __toESM(require("path"), 1);
3330
4208
 
@@ -4025,16 +4903,21 @@ function exportBacktestArtifacts({
4025
4903
  }
4026
4904
  // Annotate the CommonJS export names for ESM import in node:
4027
4905
  0 && (module.exports = {
4906
+ BIG_NUMBER,
4907
+ LlmSignal,
4028
4908
  atr,
4029
4909
  backtest,
4910
+ backtestAsync,
4030
4911
  backtestHistorical,
4031
4912
  backtestPortfolio,
4032
4913
  backtestTicks,
4914
+ benchmarkStats,
4033
4915
  bpsOf,
4034
4916
  buildMetrics,
4035
4917
  cachedCandlesPath,
4036
4918
  calculatePositionSize,
4037
4919
  candleStats,
4920
+ clampFinite,
4038
4921
  detectFVG,
4039
4922
  ema,
4040
4923
  exportBacktestArtifacts,
@@ -4044,18 +4927,25 @@ function exportBacktestArtifacts({
4044
4927
  fetchHistorical,
4045
4928
  fetchLatestCandle,
4046
4929
  getHistoricalCandles,
4930
+ getStrategy,
4931
+ grid,
4047
4932
  inWindowsET,
4048
4933
  isSession,
4049
4934
  lastSwing,
4935
+ listStrategies,
4050
4936
  loadCandlesFromCSV,
4051
4937
  loadCandlesFromCache,
4052
4938
  mergeCandles,
4053
4939
  minutesET,
4054
4940
  normalizeCandles,
4055
4941
  offsetET,
4942
+ optimize,
4056
4943
  parseWindowsCSV,
4057
4944
  pct,
4945
+ periodsPerYear,
4946
+ registerStrategy,
4058
4947
  renderHtmlReport,
4948
+ research,
4059
4949
  saveCandlesToCache,
4060
4950
  structureState,
4061
4951
  swingHigh,