tradelab 0.4.0 → 0.5.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.
@@ -33,6 +33,7 @@ __export(index_exports, {
33
33
  backtest: () => backtest,
34
34
  backtestHistorical: () => backtestHistorical,
35
35
  backtestPortfolio: () => backtestPortfolio,
36
+ backtestTicks: () => backtestTicks,
36
37
  bpsOf: () => bpsOf,
37
38
  buildMetrics: () => buildMetrics,
38
39
  cachedCandlesPath: () => cachedCandlesPath,
@@ -316,56 +317,87 @@ function buildMetrics({
316
317
  estBarMs,
317
318
  eqSeries
318
319
  }) {
319
- const completedTrades = closed.filter((trade) => trade.exit.reason !== "SCALE");
320
- const winningTrades = completedTrades.filter((trade) => trade.exit.pnl > 0);
321
- const losingTrades = completedTrades.filter((trade) => trade.exit.pnl < 0);
322
- const tradeRs = completedTrades.map(tradeRMultiple);
323
- const totalR = sum(tradeRs);
324
- const avgR = mean(tradeRs);
325
- const labels = completedTrades.map(
326
- (trade) => trade.exit.pnl > 0 ? "win" : trade.exit.pnl < 0 ? "loss" : "flat"
327
- );
328
- const { maxWin, maxLoss } = streaks(labels);
329
- const tradePnls = completedTrades.map((trade) => trade.exit.pnl);
330
- const expectancy = mean(tradePnls);
331
- const tradeReturns = completedTrades.map(
332
- (trade) => trade.exit.pnl / Math.max(1e-12, equityStart)
333
- );
334
- const tradeReturnStd = stddev(tradeReturns);
335
- const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
336
- const sortinoPerTrade = sortino(tradeReturns);
337
- const grossProfitPositions = sum(winningTrades.map((trade) => trade.exit.pnl));
338
- const grossLossPositions = Math.abs(
339
- sum(losingTrades.map((trade) => trade.exit.pnl))
340
- );
341
- const profitFactorPositions = grossLossPositions === 0 ? grossProfitPositions > 0 ? Infinity : 0 : grossProfitPositions / grossLossPositions;
342
320
  const legs = [...closed].sort((left, right) => left.exit.time - right.exit.time);
343
- const winningLegs = legs.filter((trade) => trade.exit.pnl > 0);
344
- const losingLegs = legs.filter((trade) => trade.exit.pnl < 0);
345
- const grossProfitLegs = sum(winningLegs.map((trade) => trade.exit.pnl));
346
- const grossLossLegs = Math.abs(sum(losingLegs.map((trade) => trade.exit.pnl)));
347
- const profitFactorLegs = grossLossLegs === 0 ? grossProfitLegs > 0 ? Infinity : 0 : grossProfitLegs / grossLossLegs;
321
+ const completedTrades = [];
322
+ const tradeRs = [];
323
+ const tradePnls = [];
324
+ const tradeReturns = [];
325
+ const holdDurationsMinutes = [];
326
+ const labels = [];
327
+ const longRs = [];
328
+ const shortRs = [];
329
+ let totalR = 0;
330
+ let realizedPnL = 0;
331
+ let winningTradeCount = 0;
332
+ let grossProfitPositions = 0;
333
+ let grossLossPositions = 0;
334
+ let grossProfitLegs = 0;
335
+ let grossLossLegs = 0;
336
+ let winningLegCount = 0;
337
+ let openBars = 0;
338
+ let longTradesCount = 0;
339
+ let longTradeWins = 0;
340
+ let longPnLSum = 0;
341
+ let shortTradesCount = 0;
342
+ let shortTradeWins = 0;
343
+ let shortPnLSum = 0;
348
344
  let peakEquity = equityStart;
349
345
  let currentEquity = equityStart;
350
346
  let maxDrawdown = 0;
351
- for (const leg of legs) {
352
- currentEquity += leg.exit.pnl;
347
+ for (const trade of legs) {
348
+ const pnl = trade.exit.pnl;
349
+ realizedPnL += pnl;
350
+ if (pnl > 0) {
351
+ grossProfitLegs += pnl;
352
+ winningLegCount += 1;
353
+ } else if (pnl < 0) {
354
+ grossLossLegs += Math.abs(pnl);
355
+ }
356
+ currentEquity += pnl;
353
357
  if (currentEquity > peakEquity) peakEquity = currentEquity;
354
358
  const drawdown = (peakEquity - currentEquity) / Math.max(1e-12, peakEquity);
355
359
  if (drawdown > maxDrawdown) maxDrawdown = drawdown;
360
+ if (trade.exit.reason === "SCALE") continue;
361
+ completedTrades.push(trade);
362
+ tradePnls.push(pnl);
363
+ tradeReturns.push(pnl / Math.max(1e-12, equityStart));
364
+ const tradeR = tradeRMultiple(trade);
365
+ tradeRs.push(tradeR);
366
+ totalR += tradeR;
367
+ labels.push(pnl > 0 ? "win" : pnl < 0 ? "loss" : "flat");
368
+ const holdMinutes = (trade.exit.time - trade.openTime) / (1e3 * 60);
369
+ holdDurationsMinutes.push(holdMinutes);
370
+ openBars += Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
371
+ if (pnl > 0) {
372
+ winningTradeCount += 1;
373
+ grossProfitPositions += pnl;
374
+ } else if (pnl < 0) {
375
+ grossLossPositions += Math.abs(pnl);
376
+ }
377
+ if (trade.side === "long") {
378
+ longTradesCount += 1;
379
+ longPnLSum += pnl;
380
+ longRs.push(tradeR);
381
+ if (pnl > 0) longTradeWins += 1;
382
+ } else if (trade.side === "short") {
383
+ shortTradesCount += 1;
384
+ shortPnLSum += pnl;
385
+ shortRs.push(tradeR);
386
+ if (pnl > 0) shortTradeWins += 1;
387
+ }
356
388
  }
357
- const realizedPnL = sum(closed.map((trade) => trade.exit.pnl));
389
+ const avgR = mean(tradeRs);
390
+ const { maxWin, maxLoss } = streaks(labels);
391
+ const expectancy = mean(tradePnls);
392
+ const tradeReturnStd = stddev(tradeReturns);
393
+ const sharpePerTrade = tradeReturnStd === 0 ? tradeReturns.length ? Infinity : 0 : mean(tradeReturns) / tradeReturnStd;
394
+ const sortinoPerTrade = sortino(tradeReturns);
395
+ const profitFactorPositions = grossLossPositions === 0 ? grossProfitPositions > 0 ? Infinity : 0 : grossProfitPositions / grossLossPositions;
396
+ const profitFactorLegs = grossLossLegs === 0 ? grossProfitLegs > 0 ? Infinity : 0 : grossProfitLegs / grossLossLegs;
358
397
  const returnPct = (equityFinal - equityStart) / Math.max(1e-12, equityStart);
359
398
  const calmar = maxDrawdown === 0 ? returnPct > 0 ? Infinity : 0 : returnPct / maxDrawdown;
360
399
  const totalBars = Math.max(1, candles.length);
361
- const openBars = completedTrades.reduce((total, trade) => {
362
- const barsHeld = Math.max(1, Math.round((trade.exit.time - trade.openTime) / estBarMs));
363
- return total + barsHeld;
364
- }, 0);
365
400
  const exposurePct = openBars / totalBars;
366
- const holdDurationsMinutes = completedTrades.map(
367
- (trade) => (trade.exit.time - trade.openTime) / (1e3 * 60)
368
- );
369
401
  const avgHoldMin = mean(holdDurationsMinutes);
370
402
  const equitySeries = eqSeries && eqSeries.length ? eqSeries : buildEquitySeriesFromLegs({ legs, equityStart });
371
403
  const dailyReturnsSeries = dailyReturns(equitySeries);
@@ -373,12 +405,6 @@ function buildMetrics({
373
405
  const sharpeDaily = dailyStd === 0 ? dailyReturnsSeries.length ? Infinity : 0 : mean(dailyReturnsSeries) / dailyStd;
374
406
  const sortinoDaily = sortino(dailyReturnsSeries);
375
407
  const dailyWinRate = dailyReturnsSeries.length ? dailyReturnsSeries.filter((value) => value > 0).length / dailyReturnsSeries.length : 0;
376
- const longTrades = completedTrades.filter((trade) => trade.side === "long");
377
- const shortTrades = completedTrades.filter((trade) => trade.side === "short");
378
- const longRs = longTrades.map(tradeRMultiple);
379
- const shortRs = shortTrades.map(tradeRMultiple);
380
- const longPnls = longTrades.map((trade) => trade.exit.pnl);
381
- const shortPnls = shortTrades.map((trade) => trade.exit.pnl);
382
408
  const rDistribution = {
383
409
  p10: percentile(tradeRs, 0.1),
384
410
  p25: percentile(tradeRs, 0.25),
@@ -395,21 +421,21 @@ function buildMetrics({
395
421
  };
396
422
  const sideBreakdown = {
397
423
  long: {
398
- trades: longTrades.length,
399
- winRate: longTrades.length ? longTrades.filter((trade) => trade.exit.pnl > 0).length / longTrades.length : 0,
400
- avgPnL: mean(longPnls),
424
+ trades: longTradesCount,
425
+ winRate: longTradesCount ? longTradeWins / longTradesCount : 0,
426
+ avgPnL: longTradesCount ? longPnLSum / longTradesCount : 0,
401
427
  avgR: mean(longRs)
402
428
  },
403
429
  short: {
404
- trades: shortTrades.length,
405
- winRate: shortTrades.length ? shortTrades.filter((trade) => trade.exit.pnl > 0).length / shortTrades.length : 0,
406
- avgPnL: mean(shortPnls),
430
+ trades: shortTradesCount,
431
+ winRate: shortTradesCount ? shortTradeWins / shortTradesCount : 0,
432
+ avgPnL: shortTradesCount ? shortPnLSum / shortTradesCount : 0,
407
433
  avgR: mean(shortRs)
408
434
  }
409
435
  };
410
436
  return {
411
437
  trades: completedTrades.length,
412
- winRate: completedTrades.length ? winningTrades.length / completedTrades.length : 0,
438
+ winRate: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
413
439
  profitFactor: profitFactorPositions,
414
440
  expectancy,
415
441
  totalR,
@@ -431,8 +457,8 @@ function buildMetrics({
431
457
  startEquity: equityStart,
432
458
  profitFactor_pos: profitFactorPositions,
433
459
  profitFactor_leg: profitFactorLegs,
434
- winRate_pos: completedTrades.length ? winningTrades.length / completedTrades.length : 0,
435
- winRate_leg: legs.length ? winningLegs.length / legs.length : 0,
460
+ winRate_pos: completedTrades.length ? winningTradeCount / completedTrades.length : 0,
461
+ winRate_leg: legs.length ? winningLegCount / legs.length : 0,
436
462
  sharpeDaily,
437
463
  sortinoDaily,
438
464
  sideBreakdown,
@@ -1576,193 +1602,1455 @@ function backtest(rawOptions) {
1576
1602
  };
1577
1603
  }
1578
1604
 
1579
- // src/engine/portfolio.js
1580
- function asWeight(value) {
1581
- return Number.isFinite(value) && value > 0 ? value : 0;
1605
+ // src/engine/backtestTicks.js
1606
+ function asNumber2(value) {
1607
+ const numeric = Number(value);
1608
+ return Number.isFinite(numeric) ? numeric : null;
1582
1609
  }
1583
- function combineEquitySeries(systemRuns, totalEquity) {
1584
- const timeline = /* @__PURE__ */ new Set();
1585
- for (const run of systemRuns) {
1586
- for (const point of run.result.eqSeries || []) {
1587
- timeline.add(point.time);
1588
- }
1589
- }
1590
- const times = [...timeline].sort((left, right) => left - right);
1591
- if (!times.length) {
1592
- return [{ time: 0, timestamp: 0, equity: totalEquity }];
1593
- }
1594
- const states = systemRuns.map((run) => ({
1595
- points: run.result.eqSeries || [],
1596
- index: 0,
1597
- lastEquity: run.allocationEquity
1598
- }));
1599
- return times.map((time) => {
1600
- let equity = 0;
1601
- states.forEach((state) => {
1602
- while (state.index < state.points.length && state.points[state.index].time <= time) {
1603
- state.lastEquity = state.points[state.index].equity;
1604
- state.index += 1;
1605
- }
1606
- equity += state.lastEquity;
1607
- });
1608
- return { time, timestamp: time, equity };
1609
- });
1610
+ function normalizeSide2(value) {
1611
+ if (value === "long" || value === "buy") return "long";
1612
+ if (value === "short" || value === "sell") return "short";
1613
+ return null;
1610
1614
  }
1611
- function combineReplay(systemRuns, eqSeries, collectReplay) {
1612
- if (!collectReplay) {
1613
- return { frames: [], events: [] };
1615
+ function normalizeTick(tick) {
1616
+ const time = Number(tick?.time);
1617
+ const bid = asNumber2(tick?.bid);
1618
+ const ask = asNumber2(tick?.ask);
1619
+ const last = asNumber2(tick?.price ?? tick?.last ?? tick?.close);
1620
+ const mid = bid !== null && ask !== null ? (bid + ask) / 2 : last ?? bid ?? ask;
1621
+ if (!Number.isFinite(time) || !Number.isFinite(mid)) return null;
1622
+ const prices = [asNumber2(tick?.low), asNumber2(tick?.high), bid, ask, last, mid].filter(
1623
+ Number.isFinite
1624
+ );
1625
+ const low = prices.length ? Math.min(...prices) : mid;
1626
+ const high = prices.length ? Math.max(...prices) : mid;
1627
+ return {
1628
+ ...tick,
1629
+ time,
1630
+ open: mid,
1631
+ high,
1632
+ low,
1633
+ close: mid,
1634
+ volume: asNumber2(tick?.size ?? tick?.volume) ?? void 0
1635
+ };
1636
+ }
1637
+ function normalizeSignal2(signal, bar, fallbackR) {
1638
+ if (!signal) return null;
1639
+ const side = normalizeSide2(signal.side ?? signal.direction ?? signal.action);
1640
+ if (!side) return null;
1641
+ const hasExplicitEntry = signal.entry !== void 0 || signal.limit !== void 0 || signal.price !== void 0;
1642
+ const entry = asNumber2(signal.entry ?? signal.limit ?? signal.price) ?? asNumber2(bar?.close);
1643
+ const stop = asNumber2(signal.stop ?? signal.stopLoss ?? signal.sl);
1644
+ if (entry === null || stop === null) return null;
1645
+ const risk = Math.abs(entry - stop);
1646
+ if (!(risk > 0)) return null;
1647
+ let takeProfit = asNumber2(signal.takeProfit ?? signal.target ?? signal.tp);
1648
+ const rrHint = asNumber2(signal._rr ?? signal.rr);
1649
+ const targetR = rrHint ?? fallbackR;
1650
+ if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
1651
+ takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
1614
1652
  }
1615
- const events = systemRuns.flatMap(
1616
- (run) => (run.result.replay?.events || []).map((event) => ({
1617
- ...event,
1618
- symbol: event.symbol || run.symbol
1619
- }))
1620
- ).sort((left, right) => new Date(left.t).getTime() - new Date(right.t).getTime());
1621
- const frames = eqSeries.map((point) => ({
1622
- t: new Date(point.time).toISOString(),
1623
- price: 0,
1624
- equity: point.equity,
1625
- posSide: null,
1626
- posSize: 0
1627
- }));
1628
- return { frames, events };
1653
+ if (takeProfit === null) return null;
1654
+ return {
1655
+ ...signal,
1656
+ side,
1657
+ entry,
1658
+ stop,
1659
+ takeProfit,
1660
+ qty: asNumber2(signal.qty ?? signal.size),
1661
+ riskPct: asNumber2(signal.riskPct),
1662
+ riskFraction: asNumber2(signal.riskFraction),
1663
+ orderType: hasExplicitEntry ? "limit" : "market"
1664
+ };
1629
1665
  }
1630
- function backtestPortfolio({
1631
- systems = [],
1666
+ function equityPoint2(time, equity) {
1667
+ return { time, timestamp: time, equity };
1668
+ }
1669
+ function deterministicFill(probability, seedParts) {
1670
+ if (probability >= 1) return true;
1671
+ if (probability <= 0) return false;
1672
+ let hash = 2166136261;
1673
+ const seed = seedParts.join("|");
1674
+ for (let index = 0; index < seed.length; index += 1) {
1675
+ hash ^= seed.charCodeAt(index);
1676
+ hash = Math.imul(hash, 16777619);
1677
+ }
1678
+ const normalized = (hash >>> 0) / 4294967295;
1679
+ return normalized <= probability;
1680
+ }
1681
+ function backtestTicks({
1682
+ ticks = [],
1683
+ symbol = "UNKNOWN",
1632
1684
  equity = 1e4,
1633
- allocation = "equal",
1685
+ riskPct = 1,
1686
+ signal,
1687
+ interval,
1688
+ range,
1689
+ slippageBps = 1,
1690
+ feeBps = 0,
1691
+ costs = null,
1692
+ finalTP_R = 3,
1693
+ maxDailyLossPct = 0,
1694
+ dailyMaxTrades = 0,
1695
+ qtyStep = 1e-3,
1696
+ minQty = 1e-3,
1697
+ maxLeverage = 2,
1634
1698
  collectEqSeries = true,
1635
- collectReplay = false
1699
+ collectReplay = true,
1700
+ queueFillProbability = 1,
1701
+ oco = {}
1636
1702
  } = {}) {
1637
- if (!Array.isArray(systems) || systems.length === 0) {
1638
- throw new Error("backtestPortfolio() requires a non-empty systems array");
1703
+ if (!Array.isArray(ticks) || ticks.length === 0) {
1704
+ throw new Error("backtestTicks() requires a non-empty ticks array");
1639
1705
  }
1640
- const weights = allocation === "equal" ? systems.map(() => 1) : systems.map((system) => asWeight(system.weight || 0));
1641
- const totalWeight = weights.reduce((sum2, weight) => sum2 + weight, 0);
1642
- if (!(totalWeight > 0)) {
1643
- throw new Error("backtestPortfolio() requires positive allocation weights");
1706
+ if (typeof signal !== "function") {
1707
+ throw new Error("backtestTicks() requires a signal function");
1708
+ }
1709
+ const normalizedTicks = ticks.map(normalizeTick).filter(Boolean);
1710
+ if (!normalizedTicks.length) {
1711
+ throw new Error("backtestTicks() could not normalize any ticks");
1712
+ }
1713
+ const ocoOptions = {
1714
+ mode: "intrabar",
1715
+ tieBreak: "pessimistic",
1716
+ ...oco
1717
+ };
1718
+ const trades = [];
1719
+ const eqSeries = collectEqSeries ? [equityPoint2(normalizedTicks[0].time, equity)] : [];
1720
+ const replayFrames = collectReplay ? [] : [];
1721
+ const replayEvents = collectReplay ? [] : [];
1722
+ const history = [];
1723
+ let open = null;
1724
+ let pending = null;
1725
+ let currentEquity = equity;
1726
+ let dayKey = null;
1727
+ let dayStartEquity = equity;
1728
+ let dayPnl = 0;
1729
+ let dayTrades = 0;
1730
+ let tradeIdCounter = 0;
1731
+ function markedEquity(tick) {
1732
+ if (!open) return currentEquity;
1733
+ const direction = open.side === "long" ? 1 : -1;
1734
+ return currentEquity + (tick.close - open.entryFill) * direction * open.size;
1735
+ }
1736
+ function recordFrame(tick) {
1737
+ const equityNow = markedEquity(tick);
1738
+ if (collectEqSeries) {
1739
+ eqSeries.push(equityPoint2(tick.time, equityNow));
1740
+ }
1741
+ if (collectReplay) {
1742
+ replayFrames.push({
1743
+ t: new Date(tick.time).toISOString(),
1744
+ price: tick.close,
1745
+ equity: equityNow,
1746
+ posSide: open?.side ?? null,
1747
+ posSize: open?.size ?? 0
1748
+ });
1749
+ }
1644
1750
  }
1645
- const systemRuns = systems.map((system, index) => {
1646
- const allocationEquity = equity * (weights[index] / totalWeight);
1647
- const result = backtest({
1648
- ...system,
1649
- equity: allocationEquity,
1650
- collectEqSeries,
1651
- collectReplay
1751
+ function closePosition(tick, reason, rawPrice, fillKind) {
1752
+ if (!open) return;
1753
+ const exitSide = open.side === "long" ? "short" : "long";
1754
+ const { price, feeTotal } = applyFill(rawPrice, exitSide, {
1755
+ slippageBps,
1756
+ feeBps,
1757
+ kind: fillKind,
1758
+ qty: open.size,
1759
+ costs
1652
1760
  });
1653
- return {
1654
- symbol: system.symbol ?? result.symbol ?? `system-${index + 1}`,
1655
- weight: weights[index],
1656
- allocationEquity,
1657
- result
1761
+ const direction = open.side === "long" ? 1 : -1;
1762
+ const grossPnl = (price - open.entryFill) * direction * open.size;
1763
+ const pnl = grossPnl - (open.entryFeeTotal || 0) - feeTotal;
1764
+ currentEquity += pnl;
1765
+ dayPnl += pnl;
1766
+ const trade = {
1767
+ ...open,
1768
+ exit: {
1769
+ price,
1770
+ time: tick.time,
1771
+ reason,
1772
+ pnl
1773
+ }
1658
1774
  };
1659
- });
1660
- const trades = systemRuns.flatMap(
1661
- (run) => run.result.trades.map((trade) => ({
1662
- ...trade,
1663
- symbol: trade.symbol || run.symbol
1664
- }))
1665
- ).sort((left, right) => left.exit.time - right.exit.time);
1666
- const positions = systemRuns.flatMap(
1667
- (run) => run.result.positions.map((trade) => ({
1668
- ...trade,
1669
- symbol: trade.symbol || run.symbol
1670
- }))
1671
- ).sort((left, right) => left.exit.time - right.exit.time);
1672
- const eqSeries = collectEqSeries ? combineEquitySeries(systemRuns, equity) : [];
1673
- const replay = combineReplay(systemRuns, eqSeries, collectReplay);
1674
- const allCandles = systems.flatMap((system) => system.candles || []);
1675
- const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
1775
+ trades.push(trade);
1776
+ if (collectReplay) {
1777
+ replayEvents.push({
1778
+ t: new Date(tick.time).toISOString(),
1779
+ price,
1780
+ type: reason === "TP" ? "tp" : reason === "SL" ? "sl" : "exit",
1781
+ side: open.side,
1782
+ size: open.size,
1783
+ tradeId: open.id,
1784
+ reason,
1785
+ pnl
1786
+ });
1787
+ }
1788
+ open = null;
1789
+ }
1790
+ for (let index = 0; index < normalizedTicks.length; index += 1) {
1791
+ const tick = normalizedTicks[index];
1792
+ history.push(tick);
1793
+ const currentDayKey = dayKeyUTC2(tick.time);
1794
+ if (dayKey === null || currentDayKey !== dayKey) {
1795
+ dayKey = currentDayKey;
1796
+ dayStartEquity = currentEquity;
1797
+ dayPnl = 0;
1798
+ dayTrades = 0;
1799
+ }
1800
+ if (open) {
1801
+ const { hit, px } = ocoExitCheck({
1802
+ side: open.side,
1803
+ stop: open.stop,
1804
+ tp: open.takeProfit,
1805
+ bar: tick,
1806
+ mode: "intrabar",
1807
+ tieBreak: ocoOptions.tieBreak
1808
+ });
1809
+ if (hit) {
1810
+ closePosition(tick, hit, px, hit === "TP" ? "limit" : "stop");
1811
+ }
1812
+ }
1813
+ if (!open && pending && index > pending.createdAtIndex) {
1814
+ if (pending.orderType === "market") {
1815
+ const rawSize = pending.fixedQty ?? calculatePositionSize({
1816
+ equity: currentEquity,
1817
+ entry: tick.close,
1818
+ stop: pending.stop,
1819
+ riskFraction: pending.riskFrac,
1820
+ qtyStep,
1821
+ minQty,
1822
+ maxLeverage
1823
+ });
1824
+ const size = roundStep2(rawSize, qtyStep);
1825
+ if (size >= minQty) {
1826
+ const { price, feeTotal } = applyFill(tick.close, pending.side, {
1827
+ slippageBps,
1828
+ feeBps,
1829
+ kind: "market",
1830
+ qty: size,
1831
+ costs
1832
+ });
1833
+ open = {
1834
+ symbol,
1835
+ id: ++tradeIdCounter,
1836
+ side: pending.side,
1837
+ entry: tick.close,
1838
+ stop: pending.stop,
1839
+ takeProfit: pending.takeProfit,
1840
+ size,
1841
+ openTime: tick.time,
1842
+ entryFill: price,
1843
+ entryFeeTotal: feeTotal,
1844
+ _initRisk: Math.abs(tick.close - pending.stop)
1845
+ };
1846
+ dayTrades += 1;
1847
+ if (collectReplay) {
1848
+ replayEvents.push({
1849
+ t: new Date(tick.time).toISOString(),
1850
+ price,
1851
+ type: "entry",
1852
+ side: open.side,
1853
+ size,
1854
+ tradeId: open.id
1855
+ });
1856
+ }
1857
+ }
1858
+ pending = null;
1859
+ } else {
1860
+ const touched = pending.side === "long" ? tick.low <= pending.entry : tick.high >= pending.entry;
1861
+ if (touched && deterministicFill(queueFillProbability, [
1862
+ symbol,
1863
+ tick.time,
1864
+ pending.entry,
1865
+ pending.stop,
1866
+ pending.side
1867
+ ])) {
1868
+ const rawSize = pending.fixedQty ?? calculatePositionSize({
1869
+ equity: currentEquity,
1870
+ entry: pending.entry,
1871
+ stop: pending.stop,
1872
+ riskFraction: pending.riskFrac,
1873
+ qtyStep,
1874
+ minQty,
1875
+ maxLeverage
1876
+ });
1877
+ const size = roundStep2(rawSize, qtyStep);
1878
+ if (size >= minQty) {
1879
+ const { price, feeTotal } = applyFill(pending.entry, pending.side, {
1880
+ slippageBps,
1881
+ feeBps,
1882
+ kind: "limit",
1883
+ qty: size,
1884
+ costs
1885
+ });
1886
+ open = {
1887
+ symbol,
1888
+ id: ++tradeIdCounter,
1889
+ side: pending.side,
1890
+ entry: pending.entry,
1891
+ stop: pending.stop,
1892
+ takeProfit: pending.takeProfit,
1893
+ size,
1894
+ openTime: tick.time,
1895
+ entryFill: price,
1896
+ entryFeeTotal: feeTotal,
1897
+ _initRisk: Math.abs(pending.entry - pending.stop)
1898
+ };
1899
+ dayTrades += 1;
1900
+ if (collectReplay) {
1901
+ replayEvents.push({
1902
+ t: new Date(tick.time).toISOString(),
1903
+ price,
1904
+ type: "entry",
1905
+ side: open.side,
1906
+ size,
1907
+ tradeId: open.id
1908
+ });
1909
+ }
1910
+ }
1911
+ pending = null;
1912
+ }
1913
+ }
1914
+ }
1915
+ const maxLossDollars = Math.abs(maxDailyLossPct) / 100 * dayStartEquity;
1916
+ const dailyLossHit = maxDailyLossPct > 0 && dayPnl <= -maxLossDollars;
1917
+ const dailyTradeCapHit = dailyMaxTrades > 0 && dayTrades >= dailyMaxTrades;
1918
+ if (!open && !pending && !dailyLossHit && !dailyTradeCapHit) {
1919
+ const nextSignal = normalizeSignal2(
1920
+ signal({
1921
+ candles: history,
1922
+ index,
1923
+ bar: tick,
1924
+ equity: markedEquity(tick),
1925
+ openPosition: open,
1926
+ pendingOrder: pending
1927
+ }),
1928
+ tick,
1929
+ finalTP_R
1930
+ );
1931
+ if (nextSignal) {
1932
+ pending = {
1933
+ side: nextSignal.side,
1934
+ entry: nextSignal.entry,
1935
+ stop: nextSignal.stop,
1936
+ takeProfit: nextSignal.takeProfit,
1937
+ fixedQty: nextSignal.qty,
1938
+ riskFrac: Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : riskPct / 100,
1939
+ orderType: nextSignal.orderType,
1940
+ createdAtIndex: index
1941
+ };
1942
+ }
1943
+ }
1944
+ recordFrame(tick);
1945
+ }
1946
+ if (open) {
1947
+ const lastTick = normalizedTicks[normalizedTicks.length - 1];
1948
+ closePosition(lastTick, "EOT", lastTick.close, "market");
1949
+ recordFrame(lastTick);
1950
+ }
1951
+ const positions = trades;
1676
1952
  const metrics = buildMetrics({
1677
1953
  closed: trades,
1678
1954
  equityStart: equity,
1679
- equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : equity,
1680
- candles: orderedCandles,
1681
- estBarMs: estimateBarMs(orderedCandles),
1955
+ equityFinal: currentEquity,
1956
+ candles: normalizedTicks,
1957
+ estBarMs: normalizedTicks.length > 1 ? Math.max(1, normalizedTicks[1].time - normalizedTicks[0].time) : 1,
1682
1958
  eqSeries
1683
1959
  });
1684
1960
  return {
1685
- symbol: "PORTFOLIO",
1686
- interval: void 0,
1687
- range: void 0,
1961
+ symbol,
1962
+ interval,
1963
+ range,
1688
1964
  trades,
1689
1965
  positions,
1690
1966
  metrics,
1691
1967
  eqSeries,
1692
- replay,
1693
- systems: systemRuns.map((run) => ({
1694
- symbol: run.symbol,
1695
- weight: run.weight / totalWeight,
1696
- equity: run.allocationEquity,
1697
- result: run.result
1698
- }))
1968
+ replay: {
1969
+ frames: replayFrames,
1970
+ events: replayEvents
1971
+ }
1699
1972
  };
1700
1973
  }
1701
1974
 
1702
- // src/engine/walkForward.js
1703
- function scoreOf(metrics, scoreBy) {
1704
- const value = metrics?.[scoreBy];
1705
- return Number.isFinite(value) ? value : -Infinity;
1975
+ // src/engine/barSystemRunner.js
1976
+ function asNumber3(value) {
1977
+ const numeric = Number(value);
1978
+ return Number.isFinite(numeric) ? numeric : null;
1706
1979
  }
1707
- function stitchEquitySeries(target, source) {
1708
- if (!source?.length) return;
1709
- if (!target.length) {
1710
- target.push(...source);
1711
- return;
1712
- }
1713
- const lastTime = target[target.length - 1].time;
1714
- const nextPoints = source.filter((point) => point.time > lastTime);
1715
- target.push(...nextPoints);
1980
+ function equityPoint3(time, equity, extra = {}) {
1981
+ return { time, timestamp: time, equity, ...extra };
1716
1982
  }
1717
- function walkForwardOptimize({
1718
- candles = [],
1719
- signalFactory,
1720
- parameterSets = [],
1721
- trainBars,
1722
- testBars,
1723
- stepBars = testBars,
1724
- scoreBy = "profitFactor",
1725
- backtestOptions = {}
1726
- } = {}) {
1727
- if (!Array.isArray(candles) || candles.length === 0) {
1728
- throw new Error("walkForwardOptimize() requires a non-empty candles array");
1729
- }
1730
- if (typeof signalFactory !== "function") {
1731
- throw new Error("walkForwardOptimize() requires a signalFactory function");
1732
- }
1733
- if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
1734
- throw new Error("walkForwardOptimize() requires parameterSets");
1735
- }
1736
- if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
1737
- throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
1738
- }
1739
- const windows = [];
1740
- const allTrades = [];
1741
- const allPositions = [];
1742
- const eqSeries = [];
1743
- let rollingEquity = backtestOptions.equity ?? 1e4;
1744
- for (let start = 0; start + trainBars + testBars <= candles.length; start += stepBars) {
1745
- const trainSlice = candles.slice(start, start + trainBars);
1746
- const testSlice = candles.slice(start + trainBars, start + trainBars + testBars);
1747
- let best = null;
1748
- for (const params of parameterSets) {
1749
- const trainResult = backtest({
1750
- ...backtestOptions,
1751
- candles: trainSlice,
1752
- equity: rollingEquity,
1753
- signal: signalFactory(params)
1754
- });
1755
- const score = scoreOf(trainResult.metrics, scoreBy);
1756
- if (!best || score > best.score) {
1757
- best = { params, score, metrics: trainResult.metrics };
1983
+ function isArrayIndexKey2(property) {
1984
+ if (typeof property !== "string") return false;
1985
+ const numeric = Number(property);
1986
+ return Number.isInteger(numeric) && numeric >= 0;
1987
+ }
1988
+ function strictHistoryView2(candles, currentIndex) {
1989
+ return new Proxy(candles, {
1990
+ get(target, property, receiver) {
1991
+ if (isArrayIndexKey2(property) && Number(property) >= target.length) {
1992
+ throw new Error(
1993
+ `strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
1994
+ );
1758
1995
  }
1996
+ return Reflect.get(target, property, receiver);
1759
1997
  }
1760
- const testResult = backtest({
1761
- ...backtestOptions,
1762
- candles: testSlice,
1763
- equity: rollingEquity,
1998
+ });
1999
+ }
2000
+ function normalizeSide3(value) {
2001
+ if (value === "long" || value === "buy") return "long";
2002
+ if (value === "short" || value === "sell") return "short";
2003
+ return null;
2004
+ }
2005
+ function normalizeSignal3(signal, bar, fallbackR) {
2006
+ if (!signal) return null;
2007
+ const side = normalizeSide3(signal.side ?? signal.direction ?? signal.action);
2008
+ if (!side) return null;
2009
+ const entry = asNumber3(signal.entry ?? signal.limit ?? signal.price) ?? asNumber3(bar?.close);
2010
+ const stop = asNumber3(signal.stop ?? signal.stopLoss ?? signal.sl);
2011
+ if (entry === null || stop === null) return null;
2012
+ const risk = Math.abs(entry - stop);
2013
+ if (!(risk > 0)) return null;
2014
+ let takeProfit = asNumber3(signal.takeProfit ?? signal.target ?? signal.tp);
2015
+ const rrHint = asNumber3(signal._rr ?? signal.rr);
2016
+ const targetR = rrHint ?? fallbackR;
2017
+ if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
2018
+ takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
2019
+ }
2020
+ if (takeProfit === null) return null;
2021
+ return {
2022
+ ...signal,
2023
+ side,
2024
+ entry,
2025
+ stop,
2026
+ takeProfit,
2027
+ qty: asNumber3(signal.qty ?? signal.size),
2028
+ riskPct: asNumber3(signal.riskPct),
2029
+ riskFraction: asNumber3(signal.riskFraction),
2030
+ _rr: rrHint ?? signal._rr,
2031
+ _initRisk: asNumber3(signal._initRisk) ?? signal._initRisk
2032
+ };
2033
+ }
2034
+ function mergeOptions2(options) {
2035
+ const normalizedRiskPct = Number.isFinite(options.riskFraction) ? options.riskFraction * 100 : options.riskPct;
2036
+ return {
2037
+ candles: normalizeCandles(options.candles ?? []),
2038
+ symbol: options.symbol ?? "UNKNOWN",
2039
+ equity: options.equity ?? 1e4,
2040
+ riskPct: normalizedRiskPct ?? 1,
2041
+ signal: options.signal,
2042
+ interval: options.interval,
2043
+ range: options.range,
2044
+ warmupBars: options.warmupBars ?? 200,
2045
+ slippageBps: options.slippageBps ?? 1,
2046
+ feeBps: options.feeBps ?? 0,
2047
+ costs: options.costs ?? null,
2048
+ scaleOutAtR: options.scaleOutAtR ?? 1,
2049
+ scaleOutFrac: options.scaleOutFrac ?? 0.5,
2050
+ finalTP_R: options.finalTP_R ?? 3,
2051
+ maxDailyLossPct: options.maxDailyLossPct ?? 2,
2052
+ atrTrailMult: options.atrTrailMult ?? 0,
2053
+ atrTrailPeriod: options.atrTrailPeriod ?? 14,
2054
+ oco: {
2055
+ mode: "intrabar",
2056
+ tieBreak: "pessimistic",
2057
+ clampStops: true,
2058
+ clampEpsBps: 0.25,
2059
+ ...options.oco || {}
2060
+ },
2061
+ triggerMode: options.triggerMode,
2062
+ flattenAtClose: options.flattenAtClose ?? true,
2063
+ dailyMaxTrades: options.dailyMaxTrades ?? 0,
2064
+ postLossCooldownBars: options.postLossCooldownBars ?? 0,
2065
+ mfeTrail: {
2066
+ enabled: false,
2067
+ armR: 1,
2068
+ givebackR: 0.5,
2069
+ ...options.mfeTrail || {}
2070
+ },
2071
+ pyramiding: {
2072
+ enabled: false,
2073
+ addAtR: 1,
2074
+ addFrac: 0.25,
2075
+ maxAdds: 1,
2076
+ onlyAfterBreakEven: true,
2077
+ ...options.pyramiding || {}
2078
+ },
2079
+ volScale: {
2080
+ enabled: false,
2081
+ atrPeriod: options.atrTrailPeriod ?? 14,
2082
+ cutIfAtrX: 1.3,
2083
+ cutFrac: 0.33,
2084
+ noCutAboveR: 1.5,
2085
+ ...options.volScale || {}
2086
+ },
2087
+ qtyStep: options.qtyStep ?? 1e-3,
2088
+ minQty: options.minQty ?? 1e-3,
2089
+ maxLeverage: options.maxLeverage ?? 2,
2090
+ entryChase: {
2091
+ enabled: true,
2092
+ afterBars: 2,
2093
+ maxSlipR: 0.2,
2094
+ convertOnExpiry: false,
2095
+ ...options.entryChase || {}
2096
+ },
2097
+ reanchorStopOnFill: options.reanchorStopOnFill ?? true,
2098
+ maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
2099
+ collectEqSeries: options.collectEqSeries ?? true,
2100
+ collectReplay: options.collectReplay ?? true,
2101
+ strict: options.strict ?? false
2102
+ };
2103
+ }
2104
+ function capitalForSize(entryPrice, size, maxLeverage) {
2105
+ const leverage = Math.max(1, Number(maxLeverage) || 1);
2106
+ return Math.abs(entryPrice) * Math.max(0, size) / leverage;
2107
+ }
2108
+ var BarSystemRunner = class {
2109
+ constructor(rawOptions = {}) {
2110
+ this.options = mergeOptions2(rawOptions);
2111
+ const { candles, signal } = this.options;
2112
+ if (!Array.isArray(candles) || candles.length === 0) {
2113
+ throw new Error("backtestPortfolio() requires each system to include non-empty candles");
2114
+ }
2115
+ if (typeof signal !== "function") {
2116
+ throw new Error("backtestPortfolio() requires each system to include a signal function");
2117
+ }
2118
+ this.symbol = this.options.symbol;
2119
+ this.candles = candles;
2120
+ this.closed = [];
2121
+ this.currentEquity = this.options.equity;
2122
+ this.open = null;
2123
+ this.cooldown = 0;
2124
+ this.pending = null;
2125
+ this.currentDay = null;
2126
+ this.dayPnl = 0;
2127
+ this.dayTrades = 0;
2128
+ this.dayEquityStart = this.options.equity;
2129
+ this.tradeIdCounter = 0;
2130
+ this.estimatedBarMs = estimateBarMs(candles);
2131
+ const atrSourcePeriod = this.options.volScale.enabled ? this.options.volScale.atrPeriod : this.options.atrTrailPeriod;
2132
+ const needAtr = this.options.atrTrailMult > 0 || this.options.volScale.enabled;
2133
+ this.atrValues = needAtr ? atr(candles, atrSourcePeriod) : null;
2134
+ this.wantEqSeries = Boolean(this.options.collectEqSeries);
2135
+ this.wantReplay = Boolean(this.options.collectReplay);
2136
+ this.eqSeries = this.wantEqSeries ? [equityPoint3(candles[0].time, this.currentEquity)] : [];
2137
+ this.replayFrames = this.wantReplay ? [] : [];
2138
+ this.replayEvents = this.wantReplay ? [] : [];
2139
+ this.startIndex = Math.min(Math.max(1, this.options.warmupBars), candles.length);
2140
+ this.history = candles.slice(0, this.startIndex);
2141
+ this.index = this.startIndex;
2142
+ this.lastBar = this.history.length ? this.history[this.history.length - 1] : null;
2143
+ }
2144
+ hasNext() {
2145
+ return this.index < this.candles.length;
2146
+ }
2147
+ peekTime() {
2148
+ return this.hasNext() ? this.candles[this.index].time : Infinity;
2149
+ }
2150
+ getLockedCapital() {
2151
+ if (!this.open) return 0;
2152
+ return capitalForSize(this.open.entryFill ?? this.open.entry, this.open.size, this.options.maxLeverage);
2153
+ }
2154
+ getMarkPrice() {
2155
+ return this.lastBar?.close ?? null;
2156
+ }
2157
+ getMarkedEquity() {
2158
+ if (!this.open || !this.lastBar) return this.currentEquity;
2159
+ const direction = this.open.side === "long" ? 1 : -1;
2160
+ const markPnl = (this.lastBar.close - (this.open.entryFill ?? this.open.entry)) * direction * this.open.size;
2161
+ return this.currentEquity + markPnl;
2162
+ }
2163
+ recordFrame(bar, extraFrame = {}) {
2164
+ if (this.wantEqSeries) {
2165
+ this.eqSeries.push(equityPoint3(bar.time, this.currentEquity));
2166
+ }
2167
+ if (this.wantReplay) {
2168
+ this.replayFrames.push({
2169
+ t: new Date(bar.time).toISOString(),
2170
+ price: bar.close,
2171
+ equity: this.currentEquity,
2172
+ posSide: this.open ? this.open.side : null,
2173
+ posSize: this.open ? this.open.size : 0,
2174
+ ...extraFrame
2175
+ });
2176
+ }
2177
+ }
2178
+ closeLeg({ openPos, qty, exitPx, exitFeeTotal = 0, time, reason }) {
2179
+ const direction = openPos.side === "long" ? 1 : -1;
2180
+ const entryFill = openPos.entryFill;
2181
+ const grossPnl = (exitPx - entryFill) * direction * qty;
2182
+ const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
2183
+ const pnl = grossPnl - entryFeePortion - exitFeeTotal;
2184
+ this.currentEquity += pnl;
2185
+ this.dayPnl += pnl;
2186
+ if (this.wantEqSeries) {
2187
+ this.eqSeries.push(equityPoint3(time, this.currentEquity));
2188
+ }
2189
+ const remaining = openPos.size - qty;
2190
+ const eventType = reason === "SCALE" ? "scale-out" : reason === "TP" ? "tp" : reason === "SL" ? "sl" : reason === "EOD" ? "eod" : remaining <= 0 ? "exit" : "scale-out";
2191
+ if (this.wantReplay) {
2192
+ this.replayEvents.push({
2193
+ t: new Date(time).toISOString(),
2194
+ price: exitPx,
2195
+ type: eventType,
2196
+ side: openPos.side,
2197
+ size: qty,
2198
+ tradeId: openPos.id,
2199
+ reason,
2200
+ pnl,
2201
+ symbol: this.symbol
2202
+ });
2203
+ }
2204
+ const record = {
2205
+ ...openPos,
2206
+ size: qty,
2207
+ exit: {
2208
+ price: exitPx,
2209
+ time,
2210
+ reason,
2211
+ pnl,
2212
+ exitATR: openPos._lastATR ?? void 0
2213
+ },
2214
+ mfeR: openPos._mfeR ?? 0,
2215
+ maeR: openPos._maeR ?? 0,
2216
+ adds: openPos._adds ?? 0
2217
+ };
2218
+ this.closed.push(record);
2219
+ openPos.size -= qty;
2220
+ openPos._realized = (openPos._realized || 0) + pnl;
2221
+ return record;
2222
+ }
2223
+ tightenStopToNetBreakeven(openPos, lastClose) {
2224
+ if (!openPos || openPos.size <= 0) return;
2225
+ const realized = openPos._realized || 0;
2226
+ if (realized <= 0) return;
2227
+ const direction = openPos.side === "long" ? 1 : -1;
2228
+ const breakevenDelta = Math.abs(realized / openPos.size);
2229
+ const breakevenPrice = direction === 1 ? openPos.entryFill - breakevenDelta : openPos.entryFill + breakevenDelta;
2230
+ const tightened = direction === 1 ? Math.max(openPos.stop, breakevenPrice) : Math.min(openPos.stop, breakevenPrice);
2231
+ openPos.stop = this.options.oco.clampStops ? clampStop(lastClose, tightened, openPos.side, this.options.oco) : tightened;
2232
+ }
2233
+ forceExit(reason, bar, overridePrice = null) {
2234
+ if (!this.open || !bar) return;
2235
+ const exitSide = this.open.side === "long" ? "short" : "long";
2236
+ const exitBasePrice = overridePrice ?? bar.close;
2237
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(exitBasePrice, exitSide, {
2238
+ slippageBps: this.options.slippageBps,
2239
+ feeBps: this.options.feeBps,
2240
+ kind: "market",
2241
+ qty: this.open.size,
2242
+ costs: this.options.costs
2243
+ });
2244
+ this.closeLeg({
2245
+ openPos: this.open,
2246
+ qty: this.open.size,
2247
+ exitPx: filled,
2248
+ exitFeeTotal,
2249
+ time: bar.time,
2250
+ reason
2251
+ });
2252
+ this.cooldown = this.open?._cooldownBars || 0;
2253
+ this.open = null;
2254
+ }
2255
+ cancelPending() {
2256
+ this.pending = null;
2257
+ }
2258
+ openFromPending(bar, signalEquity, entryPrice, fillKind = "limit", resolveEntrySize) {
2259
+ if (!this.pending) return false;
2260
+ const plannedRisk = Math.max(
2261
+ 1e-8,
2262
+ this.pending.plannedRiskAbs ?? Math.abs(this.pending.entry - this.pending.stop)
2263
+ );
2264
+ const slipR = Math.abs(entryPrice - this.pending.entry) / plannedRisk;
2265
+ if (slipR > this.options.maxSlipROnFill) return false;
2266
+ let stopPrice = this.pending.stop;
2267
+ if (this.options.reanchorStopOnFill) {
2268
+ const direction = this.pending.side === "long" ? 1 : -1;
2269
+ stopPrice = direction === 1 ? entryPrice - plannedRisk : entryPrice + plannedRisk;
2270
+ }
2271
+ let takeProfit = this.pending.tp;
2272
+ const immediateRisk = Math.abs(entryPrice - stopPrice) || 1e-8;
2273
+ const rrHint = this.pending.meta?._rr;
2274
+ if (this.options.reanchorStopOnFill && Number.isFinite(rrHint)) {
2275
+ const plannedTarget = this.pending.side === "long" ? this.pending.entry + rrHint * plannedRisk : this.pending.entry - rrHint * plannedRisk;
2276
+ const closeEnough = Math.abs((this.pending.tp ?? plannedTarget) - plannedTarget) <= Math.max(1e-8, plannedRisk * 1e-6);
2277
+ if (closeEnough) {
2278
+ takeProfit = this.pending.side === "long" ? entryPrice + rrHint * immediateRisk : entryPrice - rrHint * immediateRisk;
2279
+ }
2280
+ }
2281
+ const desiredSize = this.pending.fixedQty ?? calculatePositionSize({
2282
+ equity: signalEquity,
2283
+ entry: entryPrice,
2284
+ stop: stopPrice,
2285
+ riskFraction: this.pending.riskFrac,
2286
+ qtyStep: this.options.qtyStep,
2287
+ minQty: this.options.minQty,
2288
+ maxLeverage: this.options.maxLeverage
2289
+ });
2290
+ const approvedSize = typeof resolveEntrySize === "function" ? resolveEntrySize({
2291
+ runner: this,
2292
+ desiredSize,
2293
+ entryPrice,
2294
+ stopPrice,
2295
+ pending: this.pending,
2296
+ fillKind
2297
+ }) : desiredSize;
2298
+ const size = roundStep2(approvedSize, this.options.qtyStep);
2299
+ if (size < this.options.minQty) return false;
2300
+ const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
2301
+ entryPrice,
2302
+ this.pending.side,
2303
+ {
2304
+ slippageBps: this.options.slippageBps,
2305
+ feeBps: this.options.feeBps,
2306
+ kind: fillKind,
2307
+ qty: size,
2308
+ costs: this.options.costs
2309
+ }
2310
+ );
2311
+ this.open = {
2312
+ symbol: this.symbol,
2313
+ ...this.pending.meta,
2314
+ id: ++this.tradeIdCounter,
2315
+ side: this.pending.side,
2316
+ entry: entryPrice,
2317
+ stop: stopPrice,
2318
+ takeProfit,
2319
+ size,
2320
+ openTime: bar.time,
2321
+ entryFill,
2322
+ entryFeeTotal,
2323
+ initSize: size,
2324
+ baseSize: size,
2325
+ _mfeR: 0,
2326
+ _maeR: 0,
2327
+ _adds: 0,
2328
+ _initRisk: Math.abs(entryPrice - stopPrice) || 1e-8
2329
+ };
2330
+ if (this.atrValues && this.atrValues[this.index] !== void 0) {
2331
+ this.open.entryATR = this.atrValues[this.index];
2332
+ this.open._lastATR = this.atrValues[this.index];
2333
+ }
2334
+ this.dayTrades += 1;
2335
+ this.pending = null;
2336
+ if (this.wantReplay) {
2337
+ this.replayEvents.push({
2338
+ t: new Date(bar.time).toISOString(),
2339
+ price: entryFill,
2340
+ type: "entry",
2341
+ side: this.open.side,
2342
+ size,
2343
+ tradeId: this.open.id,
2344
+ symbol: this.symbol
2345
+ });
2346
+ }
2347
+ return true;
2348
+ }
2349
+ buildSignalContext(index, bar, signalEquity) {
2350
+ if (this.options.strict && this.history.length !== index + 1) {
2351
+ throw new Error(
2352
+ `strict mode: signal() received ${this.history.length} candles at index ${index}`
2353
+ );
2354
+ }
2355
+ return {
2356
+ candles: this.options.strict ? strictHistoryView2(this.history, index) : this.history,
2357
+ index,
2358
+ bar,
2359
+ equity: signalEquity,
2360
+ openPosition: this.open,
2361
+ pendingOrder: this.pending
2362
+ };
2363
+ }
2364
+ step({ signalEquity, canTrade = true, resolveEntrySize } = {}) {
2365
+ if (!this.hasNext()) return null;
2366
+ const bar = this.candles[this.index];
2367
+ this.history.push(bar);
2368
+ this.lastBar = bar;
2369
+ const trigger = this.options.triggerMode || this.options.oco.mode || "intrabar";
2370
+ const dayKey = this.options.flattenAtClose || trigger === "close" ? dayKeyET(bar.time) : dayKeyUTC2(bar.time);
2371
+ if (this.currentDay === null || dayKey !== this.currentDay) {
2372
+ this.currentDay = dayKey;
2373
+ this.dayPnl = 0;
2374
+ this.dayTrades = 0;
2375
+ this.dayEquityStart = this.currentEquity;
2376
+ }
2377
+ if (this.open && this.open._maxBarsInTrade > 0) {
2378
+ const barsHeld = Math.max(
2379
+ 1,
2380
+ Math.round((bar.time - this.open.openTime) / this.estimatedBarMs)
2381
+ );
2382
+ if (barsHeld >= this.open._maxBarsInTrade) {
2383
+ this.forceExit("TIME", bar);
2384
+ }
2385
+ }
2386
+ if (this.open && Number.isFinite(this.open._maxHoldMin) && this.open._maxHoldMin > 0) {
2387
+ const heldMinutes = (bar.time - this.open.openTime) / 6e4;
2388
+ if (heldMinutes >= this.open._maxHoldMin) {
2389
+ this.forceExit("TIME", bar);
2390
+ }
2391
+ }
2392
+ if (this.options.flattenAtClose && this.open && isEODBar(bar.time)) {
2393
+ this.forceExit("EOD", bar);
2394
+ }
2395
+ if (this.open) {
2396
+ const risk = this.open._initRisk || 1e-8;
2397
+ const highR = this.open.side === "long" ? (bar.high - this.open.entry) / risk : (this.open.entry - bar.low) / risk;
2398
+ const lowR = this.open.side === "long" ? (bar.low - this.open.entry) / risk : (this.open.entry - bar.high) / risk;
2399
+ const markR = this.open.side === "long" ? (bar.close - this.open.entry) / risk : (this.open.entry - bar.close) / risk;
2400
+ if (this.atrValues && this.atrValues[this.index] !== void 0) {
2401
+ this.open._lastATR = this.atrValues[this.index];
2402
+ }
2403
+ this.open._mfeR = Math.max(this.open._mfeR ?? -Infinity, highR);
2404
+ this.open._maeR = Math.min(this.open._maeR ?? Infinity, lowR);
2405
+ if (this.open._breakevenAtR > 0 && highR >= this.open._breakevenAtR && !this.open._beArmed) {
2406
+ const tightened = this.open.side === "long" ? Math.max(this.open.stop, this.open.entry) : Math.min(this.open.stop, this.open.entry);
2407
+ this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
2408
+ this.open._beArmed = true;
2409
+ }
2410
+ if (this.open._trailAfterR > 0 && highR >= this.open._trailAfterR) {
2411
+ const candidate = this.open.side === "long" ? bar.close - risk : bar.close + risk;
2412
+ const tightened = this.open.side === "long" ? Math.max(this.open.stop, candidate) : Math.min(this.open.stop, candidate);
2413
+ this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
2414
+ }
2415
+ if (this.options.mfeTrail.enabled && this.open._mfeR >= this.options.mfeTrail.armR) {
2416
+ const targetR = Math.max(
2417
+ 0,
2418
+ this.open._mfeR - Math.max(0, this.options.mfeTrail.givebackR)
2419
+ );
2420
+ const candidate = this.open.side === "long" ? this.open.entry + targetR * risk : this.open.entry - targetR * risk;
2421
+ const tightened = this.open.side === "long" ? Math.max(this.open.stop, candidate) : Math.min(this.open.stop, candidate);
2422
+ this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
2423
+ }
2424
+ if (this.options.atrTrailMult > 0 && this.atrValues && this.atrValues[this.index] !== void 0) {
2425
+ const trailDistance = this.atrValues[this.index] * this.options.atrTrailMult;
2426
+ const candidate = this.open.side === "long" ? bar.close - trailDistance : bar.close + trailDistance;
2427
+ const tightened = this.open.side === "long" ? Math.max(this.open.stop, candidate) : Math.min(this.open.stop, candidate);
2428
+ this.open.stop = this.options.oco.clampStops ? clampStop(bar.close, tightened, this.open.side, this.options.oco) : tightened;
2429
+ }
2430
+ if (this.options.volScale.enabled && this.open.entryATR && this.open.size > this.options.minQty && this.atrValues && this.atrValues[this.index] !== void 0) {
2431
+ const ratio = this.atrValues[this.index] / Math.max(1e-12, this.open.entryATR);
2432
+ const shouldCut = ratio >= this.options.volScale.cutIfAtrX && markR < this.options.volScale.noCutAboveR && !this.open._volCutDone;
2433
+ if (shouldCut) {
2434
+ const cutQty = roundStep2(this.open.size * this.options.volScale.cutFrac, this.options.qtyStep);
2435
+ if (cutQty >= this.options.minQty && cutQty < this.open.size) {
2436
+ const exitSide2 = this.open.side === "long" ? "short" : "long";
2437
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide2, {
2438
+ slippageBps: this.options.slippageBps,
2439
+ feeBps: this.options.feeBps,
2440
+ kind: "market",
2441
+ qty: cutQty,
2442
+ costs: this.options.costs
2443
+ });
2444
+ this.closeLeg({
2445
+ openPos: this.open,
2446
+ qty: cutQty,
2447
+ exitPx: filled,
2448
+ exitFeeTotal,
2449
+ time: bar.time,
2450
+ reason: "SCALE"
2451
+ });
2452
+ this.tightenStopToNetBreakeven(this.open, bar.close);
2453
+ this.open._volCutDone = true;
2454
+ }
2455
+ }
2456
+ }
2457
+ let addedThisBar = false;
2458
+ if (this.options.pyramiding.enabled && (this.open._adds ?? 0) < this.options.pyramiding.maxAdds) {
2459
+ const addNumber = (this.open._adds || 0) + 1;
2460
+ const triggerR = this.options.pyramiding.addAtR * addNumber;
2461
+ const triggerPrice = this.open.side === "long" ? this.open.entry + triggerR * risk : this.open.entry - triggerR * risk;
2462
+ const breakEvenSatisfied = !this.options.pyramiding.onlyAfterBreakEven || this.open.side === "long" && this.open.stop >= this.open.entry || this.open.side === "short" && this.open.stop <= this.open.entry;
2463
+ const touched = this.open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
2464
+ if (breakEvenSatisfied && touched) {
2465
+ const baseSize = this.open.baseSize || this.open.initSize;
2466
+ const requestedQty = roundStep2(baseSize * this.options.pyramiding.addFrac, this.options.qtyStep);
2467
+ const addQty = typeof resolveEntrySize === "function" ? roundStep2(
2468
+ resolveEntrySize({
2469
+ runner: this,
2470
+ desiredSize: requestedQty,
2471
+ entryPrice: triggerPrice,
2472
+ stopPrice: this.open.stop,
2473
+ pending: {
2474
+ side: this.open.side,
2475
+ meta: this.open,
2476
+ riskFrac: this.options.riskPct / 100
2477
+ },
2478
+ fillKind: "limit"
2479
+ }),
2480
+ this.options.qtyStep
2481
+ ) : requestedQty;
2482
+ if (addQty >= this.options.minQty) {
2483
+ const { price: addFill, feeTotal: addFeeTotal } = applyFill(triggerPrice, this.open.side, {
2484
+ slippageBps: this.options.slippageBps,
2485
+ feeBps: this.options.feeBps,
2486
+ kind: "limit",
2487
+ qty: addQty,
2488
+ costs: this.options.costs
2489
+ });
2490
+ const newSize = this.open.size + addQty;
2491
+ this.open.entryFeeTotal += addFeeTotal;
2492
+ this.open.entryFill = (this.open.entryFill * this.open.size + addFill * addQty) / newSize;
2493
+ this.open.size = newSize;
2494
+ this.open.initSize += addQty;
2495
+ if (!this.open.baseSize) this.open.baseSize = baseSize;
2496
+ this.open._adds = addNumber;
2497
+ addedThisBar = true;
2498
+ }
2499
+ }
2500
+ }
2501
+ if (!addedThisBar && !this.open._scaled && this.options.scaleOutAtR > 0) {
2502
+ const triggerPrice = this.open.side === "long" ? this.open.entry + this.options.scaleOutAtR * risk : this.open.entry - this.options.scaleOutAtR * risk;
2503
+ const touched = this.open.side === "long" ? trigger === "intrabar" ? bar.high >= triggerPrice : bar.close >= triggerPrice : trigger === "intrabar" ? bar.low <= triggerPrice : bar.close <= triggerPrice;
2504
+ if (touched) {
2505
+ const exitSide2 = this.open.side === "long" ? "short" : "long";
2506
+ const qty = roundStep2(this.open.size * this.options.scaleOutFrac, this.options.qtyStep);
2507
+ if (qty >= this.options.minQty && qty < this.open.size) {
2508
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(triggerPrice, exitSide2, {
2509
+ slippageBps: this.options.slippageBps,
2510
+ feeBps: this.options.feeBps,
2511
+ kind: "limit",
2512
+ qty,
2513
+ costs: this.options.costs
2514
+ });
2515
+ this.closeLeg({
2516
+ openPos: this.open,
2517
+ qty,
2518
+ exitPx: filled,
2519
+ exitFeeTotal,
2520
+ time: bar.time,
2521
+ reason: "SCALE"
2522
+ });
2523
+ this.open._scaled = true;
2524
+ this.open.takeProfit = this.open.side === "long" ? this.open.entry + this.options.finalTP_R * risk : this.open.entry - this.options.finalTP_R * risk;
2525
+ this.tightenStopToNetBreakeven(this.open, bar.close);
2526
+ this.open._beArmed = true;
2527
+ }
2528
+ }
2529
+ }
2530
+ const exitSide = this.open.side === "long" ? "short" : "long";
2531
+ const { hit, px } = ocoExitCheck({
2532
+ side: this.open.side,
2533
+ stop: this.open.stop,
2534
+ tp: this.open.takeProfit,
2535
+ bar,
2536
+ mode: this.options.oco.mode,
2537
+ tieBreak: this.options.oco.tieBreak
2538
+ });
2539
+ if (hit) {
2540
+ const exitKind = hit === "TP" ? "limit" : "stop";
2541
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(px, exitSide, {
2542
+ slippageBps: this.options.slippageBps,
2543
+ feeBps: this.options.feeBps,
2544
+ kind: exitKind,
2545
+ qty: this.open.size,
2546
+ costs: this.options.costs
2547
+ });
2548
+ const localCooldown = this.open._cooldownBars || 0;
2549
+ this.closeLeg({
2550
+ openPos: this.open,
2551
+ qty: this.open.size,
2552
+ exitPx: filled,
2553
+ exitFeeTotal,
2554
+ time: bar.time,
2555
+ reason: hit
2556
+ });
2557
+ this.cooldown = (hit === "SL" ? Math.max(this.cooldown, this.options.postLossCooldownBars) : this.cooldown) || localCooldown;
2558
+ this.open = null;
2559
+ }
2560
+ }
2561
+ const maxLossDollars = this.options.maxDailyLossPct / 100 * this.dayEquityStart;
2562
+ const dailyLossHit = this.dayPnl <= -Math.abs(maxLossDollars);
2563
+ const dailyTradeCapHit = this.options.dailyMaxTrades > 0 && this.dayTrades >= this.options.dailyMaxTrades;
2564
+ if (!this.open && this.pending) {
2565
+ if (!canTrade) {
2566
+ this.pending = null;
2567
+ } else if (this.index > this.pending.expiresAt || dailyLossHit || dailyTradeCapHit) {
2568
+ if (this.options.entryChase.enabled && this.options.entryChase.convertOnExpiry) {
2569
+ const riskAtEdge = Math.abs(
2570
+ this.pending.meta._initRisk ?? this.pending.entry - this.pending.stop
2571
+ );
2572
+ const priceNow = bar.close;
2573
+ const direction = this.pending.side === "long" ? 1 : -1;
2574
+ const slippedR = Math.max(
2575
+ 0,
2576
+ direction === 1 ? priceNow - this.pending.entry : this.pending.entry - priceNow
2577
+ ) / Math.max(1e-8, riskAtEdge);
2578
+ if (slippedR > this.options.maxSlipROnFill) {
2579
+ this.pending = null;
2580
+ } else if (!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)) {
2581
+ this.pending = null;
2582
+ }
2583
+ } else {
2584
+ this.pending = null;
2585
+ }
2586
+ } else if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
2587
+ if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
2588
+ this.pending = null;
2589
+ }
2590
+ } else if (this.options.entryChase.enabled) {
2591
+ const elapsedBars = this.index - (this.pending.startedAtIndex ?? this.index);
2592
+ const midpoint = this.pending.meta?._imb?.mid;
2593
+ if (!this.pending._chasedCE && midpoint !== void 0 && elapsedBars >= Math.max(1, this.options.entryChase.afterBars)) {
2594
+ this.pending.entry = midpoint;
2595
+ this.pending._chasedCE = true;
2596
+ }
2597
+ if (this.pending._chasedCE) {
2598
+ const riskRef = Math.abs(
2599
+ this.pending.meta?._initRisk ?? this.pending.entry - this.pending.stop
2600
+ );
2601
+ const priceNow = bar.close;
2602
+ const direction = this.pending.side === "long" ? 1 : -1;
2603
+ const slippedR = Math.max(
2604
+ 0,
2605
+ direction === 1 ? priceNow - this.pending.entry : this.pending.entry - priceNow
2606
+ ) / Math.max(1e-8, riskRef);
2607
+ if (slippedR > this.options.maxSlipROnFill) {
2608
+ this.pending = null;
2609
+ } else if (slippedR > 0 && slippedR <= this.options.entryChase.maxSlipR) {
2610
+ if (!this.openFromPending(bar, signalEquity, priceNow, "market", resolveEntrySize)) {
2611
+ this.pending = null;
2612
+ }
2613
+ }
2614
+ }
2615
+ }
2616
+ }
2617
+ if (this.open || this.cooldown > 0) {
2618
+ if (this.cooldown > 0) this.cooldown -= 1;
2619
+ this.recordFrame(bar);
2620
+ this.index += 1;
2621
+ return bar;
2622
+ }
2623
+ if (!canTrade || dailyLossHit || dailyTradeCapHit) {
2624
+ this.pending = null;
2625
+ this.recordFrame(bar);
2626
+ this.index += 1;
2627
+ return bar;
2628
+ }
2629
+ if (!this.pending) {
2630
+ const rawSignal = this.options.signal(this.buildSignalContext(this.index, bar, signalEquity));
2631
+ const nextSignal = normalizeSignal3(rawSignal, bar, this.options.finalTP_R);
2632
+ if (nextSignal) {
2633
+ const signalRiskFraction = Number.isFinite(nextSignal.riskFraction) ? nextSignal.riskFraction : Number.isFinite(nextSignal.riskPct) ? nextSignal.riskPct / 100 : this.options.riskPct / 100;
2634
+ const expiryBars = nextSignal._entryExpiryBars ?? 5;
2635
+ this.pending = {
2636
+ side: nextSignal.side,
2637
+ entry: nextSignal.entry,
2638
+ stop: nextSignal.stop,
2639
+ tp: nextSignal.takeProfit,
2640
+ riskFrac: signalRiskFraction,
2641
+ fixedQty: nextSignal.qty,
2642
+ expiresAt: this.index + Math.max(1, expiryBars),
2643
+ startedAtIndex: this.index,
2644
+ meta: nextSignal,
2645
+ plannedRiskAbs: Math.abs(
2646
+ nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop
2647
+ )
2648
+ };
2649
+ if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
2650
+ if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
2651
+ this.pending = null;
2652
+ }
2653
+ }
2654
+ }
2655
+ }
2656
+ this.recordFrame(bar);
2657
+ this.index += 1;
2658
+ return bar;
2659
+ }
2660
+ buildResult() {
2661
+ const metrics = buildMetrics({
2662
+ closed: this.closed,
2663
+ equityStart: this.options.equity,
2664
+ equityFinal: this.currentEquity,
2665
+ candles: this.candles,
2666
+ estBarMs: this.estimatedBarMs,
2667
+ eqSeries: this.eqSeries
2668
+ });
2669
+ const positions = this.closed.filter((trade) => trade.exit.reason !== "SCALE");
2670
+ return {
2671
+ symbol: this.options.symbol,
2672
+ interval: this.options.interval,
2673
+ range: this.options.range,
2674
+ trades: this.closed,
2675
+ positions,
2676
+ metrics,
2677
+ eqSeries: this.eqSeries,
2678
+ replay: {
2679
+ frames: this.replayFrames,
2680
+ events: this.replayEvents
2681
+ }
2682
+ };
2683
+ }
2684
+ };
2685
+ function defaultSystemCap(totalEquity, capPct, maxAllocation, maxAllocationPct) {
2686
+ const limits = [];
2687
+ if (Number.isFinite(capPct) && capPct > 0) limits.push(totalEquity * capPct);
2688
+ if (Number.isFinite(maxAllocation) && maxAllocation > 0) limits.push(maxAllocation);
2689
+ if (Number.isFinite(maxAllocationPct) && maxAllocationPct > 0) {
2690
+ limits.push(totalEquity * maxAllocationPct);
2691
+ }
2692
+ return limits.length ? Math.min(...limits) : Math.max(0, totalEquity);
2693
+ }
2694
+
2695
+ // src/engine/portfolio.js
2696
+ function asWeight(value) {
2697
+ return Number.isFinite(value) && value > 0 ? value : 0;
2698
+ }
2699
+ function buildPortfolioPoint(time, equity, lockedCapital, availableCapital) {
2700
+ return {
2701
+ time,
2702
+ timestamp: time,
2703
+ equity,
2704
+ lockedCapital,
2705
+ availableCapital
2706
+ };
2707
+ }
2708
+ function stableSystemOrder(left, right) {
2709
+ return left.index - right.index;
2710
+ }
2711
+ function combineReplay(systemResults, eqSeries, collectReplay) {
2712
+ if (!collectReplay) {
2713
+ return { frames: [], events: [] };
2714
+ }
2715
+ const events = systemResults.flatMap(
2716
+ (entry) => (entry.result.replay?.events || []).map((event) => ({
2717
+ ...event,
2718
+ symbol: event.symbol || entry.symbol
2719
+ }))
2720
+ ).sort((left, right) => new Date(left.t).getTime() - new Date(right.t).getTime());
2721
+ const frames = eqSeries.map((point) => ({
2722
+ t: new Date(point.time).toISOString(),
2723
+ price: 0,
2724
+ equity: point.equity,
2725
+ posSide: null,
2726
+ posSize: 0,
2727
+ lockedCapital: point.lockedCapital,
2728
+ availableCapital: point.availableCapital
2729
+ }));
2730
+ return { frames, events };
2731
+ }
2732
+ function portfolioState(runners, initialEquity) {
2733
+ let markedEquity = initialEquity;
2734
+ let lockedCapital = 0;
2735
+ for (const { runner, initialReferenceEquity } of runners) {
2736
+ markedEquity += runner.getMarkedEquity() - initialReferenceEquity;
2737
+ lockedCapital += runner.getLockedCapital();
2738
+ }
2739
+ return {
2740
+ markedEquity,
2741
+ lockedCapital,
2742
+ availableCapital: markedEquity - lockedCapital
2743
+ };
2744
+ }
2745
+ function findNextTimeAndActive(runners) {
2746
+ let nextTime = Infinity;
2747
+ const active = [];
2748
+ for (const entry of runners) {
2749
+ const time = entry.runner.peekTime();
2750
+ if (time < nextTime) {
2751
+ nextTime = time;
2752
+ active.length = 0;
2753
+ active.push(entry);
2754
+ continue;
2755
+ }
2756
+ if (time === nextTime) {
2757
+ active.push(entry);
2758
+ }
2759
+ }
2760
+ return { nextTime, active };
2761
+ }
2762
+ function initialPortfolioTime(runners) {
2763
+ let time = Infinity;
2764
+ for (const { runner } of runners) {
2765
+ const next = runner.candles[0]?.time ?? Infinity;
2766
+ if (next < time) time = next;
2767
+ }
2768
+ return Number.isFinite(time) ? time : 0;
2769
+ }
2770
+ function resolveSystemCap(systemEntry, totalEquity) {
2771
+ return defaultSystemCap(
2772
+ Math.max(0, totalEquity),
2773
+ systemEntry.defaultCapPct,
2774
+ systemEntry.system.maxAllocation,
2775
+ systemEntry.system.maxAllocationPct
2776
+ );
2777
+ }
2778
+ function forceExitAll(runners, time) {
2779
+ for (const { runner } of runners) {
2780
+ if (!runner.open) continue;
2781
+ const price = runner.getMarkPrice();
2782
+ if (!Number.isFinite(price)) continue;
2783
+ runner.forceExit("PORTFOLIO_DAILY_LOSS", { time, close: price }, price);
2784
+ }
2785
+ }
2786
+ function backtestPortfolio({
2787
+ systems = [],
2788
+ equity = 1e4,
2789
+ allocation = "equal",
2790
+ collectEqSeries = true,
2791
+ collectReplay = false,
2792
+ maxDailyLossPct = 0
2793
+ } = {}) {
2794
+ if (!Array.isArray(systems) || systems.length === 0) {
2795
+ throw new Error("backtestPortfolio() requires a non-empty systems array");
2796
+ }
2797
+ const weights = allocation === "equal" ? systems.map(() => 1) : systems.map((system) => asWeight(system.weight || 0));
2798
+ const totalWeight = weights.reduce((sumValue, weight) => sumValue + weight, 0);
2799
+ if (!(totalWeight > 0)) {
2800
+ throw new Error("backtestPortfolio() requires positive allocation weights");
2801
+ }
2802
+ const runners = systems.map((system, index) => {
2803
+ const defaultCapPct = weights[index] / totalWeight;
2804
+ const initialReferenceEquity = equity * defaultCapPct;
2805
+ return {
2806
+ index,
2807
+ symbol: system.symbol ?? `system-${index + 1}`,
2808
+ system,
2809
+ defaultCapPct,
2810
+ initialReferenceEquity,
2811
+ runner: new BarSystemRunner({
2812
+ ...system,
2813
+ symbol: system.symbol ?? `system-${index + 1}`,
2814
+ equity: initialReferenceEquity,
2815
+ collectEqSeries,
2816
+ collectReplay
2817
+ })
2818
+ };
2819
+ });
2820
+ const eqSeries = collectEqSeries ? [] : [];
2821
+ let state = portfolioState(runners, equity);
2822
+ if (collectEqSeries) {
2823
+ eqSeries.push(
2824
+ buildPortfolioPoint(
2825
+ initialPortfolioTime(runners),
2826
+ state.markedEquity,
2827
+ state.lockedCapital,
2828
+ state.availableCapital
2829
+ )
2830
+ );
2831
+ }
2832
+ let currentDay = null;
2833
+ let dayStartEquity = equity;
2834
+ let portfolioHalted = false;
2835
+ while (true) {
2836
+ const { nextTime, active } = findNextTimeAndActive(runners);
2837
+ if (!Number.isFinite(nextTime)) break;
2838
+ active.sort(stableSystemOrder);
2839
+ const dayKey = dayKeyET(nextTime);
2840
+ if (currentDay === null || dayKey !== currentDay) {
2841
+ currentDay = dayKey;
2842
+ state = portfolioState(runners, equity);
2843
+ dayStartEquity = state.markedEquity;
2844
+ portfolioHalted = false;
2845
+ }
2846
+ for (const systemEntry of active) {
2847
+ state = portfolioState(runners, equity);
2848
+ const totalEquity = state.markedEquity;
2849
+ const availableCapital = Math.max(0, state.availableCapital);
2850
+ const systemLocked = systemEntry.runner.getLockedCapital();
2851
+ const systemCap = resolveSystemCap(systemEntry, totalEquity);
2852
+ const systemRemainingCapital = Math.max(0, systemCap - systemLocked);
2853
+ systemEntry.runner.step({
2854
+ signalEquity: totalEquity,
2855
+ canTrade: !portfolioHalted,
2856
+ resolveEntrySize({ desiredSize, entryPrice }) {
2857
+ const maxLeverage = Math.max(1, systemEntry.runner.options.maxLeverage || 1);
2858
+ const byAvailable = availableCapital * maxLeverage / Math.max(1e-12, Math.abs(entryPrice));
2859
+ const bySystemCap = systemRemainingCapital * maxLeverage / Math.max(1e-12, Math.abs(entryPrice));
2860
+ return Math.min(desiredSize, byAvailable, bySystemCap);
2861
+ }
2862
+ });
2863
+ state = portfolioState(runners, equity);
2864
+ if (!portfolioHalted && maxDailyLossPct > 0 && state.markedEquity <= dayStartEquity * (1 - Math.abs(maxDailyLossPct) / 100)) {
2865
+ portfolioHalted = true;
2866
+ for (const { runner } of runners) runner.cancelPending();
2867
+ forceExitAll(runners, nextTime);
2868
+ state = portfolioState(runners, equity);
2869
+ }
2870
+ }
2871
+ if (collectEqSeries) {
2872
+ eqSeries.push(
2873
+ buildPortfolioPoint(
2874
+ nextTime,
2875
+ state.markedEquity,
2876
+ state.lockedCapital,
2877
+ state.availableCapital
2878
+ )
2879
+ );
2880
+ }
2881
+ }
2882
+ const systemResults = runners.map((entry) => ({
2883
+ symbol: entry.symbol,
2884
+ weight: entry.defaultCapPct,
2885
+ equity: entry.initialReferenceEquity,
2886
+ allocationCapPct: entry.defaultCapPct,
2887
+ allocationCap: resolveSystemCap(entry, equity),
2888
+ result: entry.runner.buildResult()
2889
+ }));
2890
+ const trades = systemResults.flatMap(
2891
+ (run) => run.result.trades.map((trade) => ({
2892
+ ...trade,
2893
+ symbol: trade.symbol || run.symbol
2894
+ }))
2895
+ ).sort((left, right) => left.exit.time - right.exit.time);
2896
+ const positions = systemResults.flatMap(
2897
+ (run) => run.result.positions.map((trade) => ({
2898
+ ...trade,
2899
+ symbol: trade.symbol || run.symbol
2900
+ }))
2901
+ ).sort((left, right) => left.exit.time - right.exit.time);
2902
+ const replay = combineReplay(systemResults, eqSeries, collectReplay);
2903
+ const allCandles = systems.flatMap((system) => system.candles || []);
2904
+ const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
2905
+ const metrics = buildMetrics({
2906
+ closed: trades,
2907
+ equityStart: equity,
2908
+ equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : equity,
2909
+ candles: orderedCandles,
2910
+ estBarMs: estimateBarMs(orderedCandles),
2911
+ eqSeries
2912
+ });
2913
+ return {
2914
+ symbol: "PORTFOLIO",
2915
+ interval: void 0,
2916
+ range: void 0,
2917
+ trades,
2918
+ positions,
2919
+ metrics,
2920
+ eqSeries,
2921
+ replay,
2922
+ systems: systemResults
2923
+ };
2924
+ }
2925
+
2926
+ // src/engine/walkForward.js
2927
+ function scoreOf(metrics, scoreBy) {
2928
+ const value = metrics?.[scoreBy];
2929
+ return Number.isFinite(value) ? value : -Infinity;
2930
+ }
2931
+ function stitchEquitySeries(target, source) {
2932
+ if (!source?.length) return;
2933
+ if (!target.length) {
2934
+ target.push(...source);
2935
+ return;
2936
+ }
2937
+ const lastTime = target[target.length - 1].time;
2938
+ const nextPoints = source.filter((point) => point.time > lastTime);
2939
+ target.push(...nextPoints);
2940
+ }
2941
+ function canonicalParams(params) {
2942
+ const entries = Object.entries(params || {}).sort(
2943
+ ([left], [right]) => left.localeCompare(right)
2944
+ );
2945
+ return JSON.stringify(Object.fromEntries(entries));
2946
+ }
2947
+ function buildWindowRanges(length, trainBars, testBars, stepBars, mode) {
2948
+ const ranges = [];
2949
+ for (let start = 0; start + trainBars + testBars <= length; start += stepBars) {
2950
+ const trainStart = mode === "anchored" ? 0 : start;
2951
+ const trainEnd = mode === "anchored" ? trainBars + start : start + trainBars;
2952
+ const testStart = trainEnd;
2953
+ const testEnd = testStart + testBars;
2954
+ if (testEnd > length) break;
2955
+ ranges.push({ trainStart, trainEnd, testStart, testEnd });
2956
+ }
2957
+ return ranges;
2958
+ }
2959
+ function summarizeBestParams(windows) {
2960
+ const summaryBySignature = /* @__PURE__ */ new Map();
2961
+ let adjacentRepeats = 0;
2962
+ windows.forEach((window, index) => {
2963
+ const signature = window.bestParamsSignature ?? canonicalParams(window.bestParams);
2964
+ const current = summaryBySignature.get(signature) || {
2965
+ params: window.bestParams,
2966
+ wins: 0,
2967
+ profitableWindows: 0,
2968
+ oosTrades: 0
2969
+ };
2970
+ current.wins += 1;
2971
+ current.profitableWindows += window.profitable ? 1 : 0;
2972
+ current.oosTrades += window.oosTrades;
2973
+ summaryBySignature.set(signature, current);
2974
+ if (index > 0 && (windows[index - 1].bestParamsSignature ?? canonicalParams(windows[index - 1].bestParams)) === signature) {
2975
+ adjacentRepeats += 1;
2976
+ }
2977
+ });
2978
+ const byFrequency = [...summaryBySignature.values()].sort((left, right) => {
2979
+ if (right.wins !== left.wins) return right.wins - left.wins;
2980
+ return right.profitableWindows - left.profitableWindows;
2981
+ });
2982
+ const adjacentPairs = Math.max(0, windows.length - 1);
2983
+ return {
2984
+ winners: windows.map((window) => window.bestParams),
2985
+ stability: {
2986
+ adjacentRepeatRate: adjacentPairs ? adjacentRepeats / adjacentPairs : 0,
2987
+ uniqueWinnerCount: summaryBySignature.size,
2988
+ dominant: byFrequency[0] || null,
2989
+ leaderboard: byFrequency
2990
+ }
2991
+ };
2992
+ }
2993
+ function walkForwardOptimize({
2994
+ candles = [],
2995
+ signalFactory,
2996
+ parameterSets = [],
2997
+ trainBars,
2998
+ testBars,
2999
+ stepBars = testBars,
3000
+ mode = "rolling",
3001
+ scoreBy = "profitFactor",
3002
+ backtestOptions = {}
3003
+ } = {}) {
3004
+ if (!Array.isArray(candles) || candles.length === 0) {
3005
+ throw new Error("walkForwardOptimize() requires a non-empty candles array");
3006
+ }
3007
+ if (typeof signalFactory !== "function") {
3008
+ throw new Error("walkForwardOptimize() requires a signalFactory function");
3009
+ }
3010
+ if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
3011
+ throw new Error("walkForwardOptimize() requires parameterSets");
3012
+ }
3013
+ if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
3014
+ throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
3015
+ }
3016
+ if (mode !== "rolling" && mode !== "anchored") {
3017
+ throw new Error('walkForwardOptimize() mode must be "rolling" or "anchored"');
3018
+ }
3019
+ const windows = [];
3020
+ const allTrades = [];
3021
+ const allPositions = [];
3022
+ const eqSeries = [];
3023
+ let rollingEquity = backtestOptions.equity ?? 1e4;
3024
+ const ranges = buildWindowRanges(candles.length, trainBars, testBars, stepBars, mode);
3025
+ const trainBacktestOptions = {
3026
+ ...backtestOptions,
3027
+ collectEqSeries: false,
3028
+ collectReplay: false
3029
+ };
3030
+ const testBacktestOptions = { ...backtestOptions };
3031
+ for (const range of ranges) {
3032
+ const trainSlice = candles.slice(range.trainStart, range.trainEnd);
3033
+ const testSlice = candles.slice(range.testStart, range.testEnd);
3034
+ let best = null;
3035
+ for (const params of parameterSets) {
3036
+ const trainResult = backtest({
3037
+ ...trainBacktestOptions,
3038
+ candles: trainSlice,
3039
+ equity: rollingEquity,
3040
+ signal: signalFactory(params)
3041
+ });
3042
+ const score = scoreOf(trainResult.metrics, scoreBy);
3043
+ if (!best || score > best.score) {
3044
+ best = { params, score, metrics: trainResult.metrics };
3045
+ }
3046
+ }
3047
+ const testResult = backtest({
3048
+ ...testBacktestOptions,
3049
+ candles: testSlice,
3050
+ equity: rollingEquity,
1764
3051
  signal: signalFactory(best.params)
1765
3052
  });
3053
+ const bestParamsSignature = canonicalParams(best.params);
1766
3054
  rollingEquity = testResult.metrics.finalEquity;
1767
3055
  allTrades.push(...testResult.trades);
1768
3056
  allPositions.push(...testResult.positions);
@@ -1780,9 +3068,25 @@ function walkForwardOptimize({
1780
3068
  trainScore: best.score,
1781
3069
  trainMetrics: best.metrics,
1782
3070
  testMetrics: testResult.metrics,
3071
+ oosTrades: testResult.metrics.trades,
3072
+ profitable: testResult.metrics.totalPnL > 0,
3073
+ stabilityScore: 0,
3074
+ bestParamsSignature,
1783
3075
  result: testResult
1784
3076
  });
1785
3077
  }
3078
+ for (let index = 0; index < windows.length; index += 1) {
3079
+ const currentSignature = windows[index].bestParamsSignature;
3080
+ const adjacent = [];
3081
+ if (index > 0) {
3082
+ adjacent.push(windows[index - 1].bestParamsSignature === currentSignature ? 1 : 0);
3083
+ }
3084
+ if (index + 1 < windows.length) {
3085
+ adjacent.push(windows[index + 1].bestParamsSignature === currentSignature ? 1 : 0);
3086
+ }
3087
+ windows[index].stabilityScore = adjacent.length ? adjacent.reduce((total, value) => total + value, 0) / adjacent.length : 1;
3088
+ delete windows[index].bestParamsSignature;
3089
+ }
1786
3090
  const metrics = buildMetrics({
1787
3091
  closed: allTrades,
1788
3092
  equityStart: backtestOptions.equity ?? 1e4,
@@ -1791,6 +3095,7 @@ function walkForwardOptimize({
1791
3095
  estBarMs: estimateBarMs(candles),
1792
3096
  eqSeries
1793
3097
  });
3098
+ const bestParamsSummary = summarizeBestParams(windows);
1794
3099
  return {
1795
3100
  windows,
1796
3101
  trades: allTrades,
@@ -1798,7 +3103,8 @@ function walkForwardOptimize({
1798
3103
  metrics,
1799
3104
  eqSeries,
1800
3105
  replay: { frames: [], events: [] },
1801
- bestParams: windows.map((window) => window.bestParams)
3106
+ bestParams: Object.assign(windows.map((window) => window.bestParams), bestParamsSummary),
3107
+ bestParamsSummary: bestParamsSummary.stability
1802
3108
  };
1803
3109
  }
1804
3110
 
@@ -2522,6 +3828,7 @@ function exportBacktestArtifacts({
2522
3828
  backtest,
2523
3829
  backtestHistorical,
2524
3830
  backtestPortfolio,
3831
+ backtestTicks,
2525
3832
  bpsOf,
2526
3833
  buildMetrics,
2527
3834
  cachedCandlesPath,