backtest-kit 10.1.0 → 10.2.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.
package/build/index.cjs CHANGED
@@ -23726,7 +23726,7 @@ const CREATE_FILE_NAME_FN$c = (symbol, strategyName, exchangeName, frameName, ti
23726
23726
  * @param value - Value to check
23727
23727
  * @returns true if value is unsafe, false otherwise
23728
23728
  */
23729
- function isUnsafe$3(value) {
23729
+ function isUnsafe$4(value) {
23730
23730
  if (typeof value !== "number") {
23731
23731
  return true;
23732
23732
  }
@@ -23738,6 +23738,25 @@ function isUnsafe$3(value) {
23738
23738
  }
23739
23739
  return false;
23740
23740
  }
23741
+ /** Minimum closed signals required to annualize Sharpe / yearly returns / Calmar. */
23742
+ const MIN_SIGNALS_FOR_ANNUALIZATION$2 = 10;
23743
+ /** Minimum signals required for ANY ratio metric (Sharpe / Sortino / stdDev). Below this,
23744
+ * sample size is too small to estimate variance meaningfully. */
23745
+ const MIN_SIGNALS_FOR_RATIOS$2 = 10;
23746
+ /** Minimum calendar span (days) for trade-frequency extrapolation. */
23747
+ const MIN_CALENDAR_SPAN_DAYS$2 = 14;
23748
+ /** Hard cap on tradesPerYear — prevents absurd extrapolation from short windows / clustered trades. */
23749
+ const MAX_TRADES_PER_YEAR$2 = 365;
23750
+ /** Hard cap on |expectedYearlyReturns| percent. Compound interest on high avgPnl × frequency
23751
+ * blows up to mathematically correct but business-unrealistic values. ±100% = 2x equity —
23752
+ * anything above this we suspect is a noisy estimate, not a genuine edge. Above the cap → null. */
23753
+ const MAX_EXPECTED_YEARLY_RETURNS$2 = 100;
23754
+ /** Hard cap on |calmarRatio|. Prevents explosion when equityMaxDrawdown is near zero. */
23755
+ const MAX_CALMAR_RATIO$2 = 1000;
23756
+ /** Minimum stdDev required for Sharpe/Sortino computation. Identical-returns series produce
23757
+ * float-artifact stdDev (~1e-17) that's mathematically > 0 but spuriously inflates
23758
+ * sharpe to astronomical values. Treat any stdDev below this threshold as zero. */
23759
+ const STDDEV_EPSILON$2 = 1e-9;
23741
23760
  /**
23742
23761
  * Storage class for accumulating closed signals per strategy.
23743
23762
  * Maintains a list of all closed signals and provides methods to generate reports.
@@ -23791,65 +23810,190 @@ let ReportStorage$a = class ReportStorage {
23791
23810
  recoveryFactor: null,
23792
23811
  };
23793
23812
  }
23794
- const totalSignals = this._signalList.length;
23795
- const winCount = this._signalList.filter((s) => s.pnl.pnlPercentage > 0).length;
23796
- const lossCount = this._signalList.filter((s) => s.pnl.pnlPercentage < 0).length;
23797
- // Calculate basic statistics
23798
- const avgPnl = this._signalList.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0) / totalSignals;
23799
- const totalPnl = this._signalList.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0);
23800
- const winRate = (winCount / totalSignals) * 100;
23801
- // Calculate Sharpe Ratio (risk-free rate = 0)
23802
- const returns = this._signalList.map((s) => s.pnl.pnlPercentage);
23803
- const variance = returns.reduce((sum, r) => sum + Math.pow(r - avgPnl, 2), 0) / totalSignals;
23804
- const stdDev = Math.sqrt(variance);
23805
- const sharpeRatio = stdDev > 0 ? avgPnl / stdDev : 0;
23806
- const annualizedSharpeRatio = sharpeRatio * Math.sqrt(365);
23807
- // Calculate Certainty Ratio
23808
- const wins = this._signalList.filter((s) => s.pnl.pnlPercentage > 0);
23809
- const losses = this._signalList.filter((s) => s.pnl.pnlPercentage < 0);
23813
+ // Valid signal set — those with usable pendingAt AND closeTimestamp. Single source
23814
+ // of truth for EVERY metric in this method (counts, sums, span, equity curve,
23815
+ // ratios, annualization). If we used different subsets for different metrics, the
23816
+ // numerator of one ratio could be drawn from a different population than the
23817
+ // denominator of another and the report would silently lie. On clean data
23818
+ // validSignals === this._signalList; the filter only matters for corrupted runtime
23819
+ // data.
23820
+ const validSignals = this._signalList.filter((s) => typeof s.signal.pendingAt === "number" && s.signal.pendingAt > 0 &&
23821
+ typeof s.closeTimestamp === "number" && s.closeTimestamp > 0);
23822
+ const totalSignals = validSignals.length;
23823
+ const winCount = validSignals.filter((s) => s.pnl.pnlPercentage > 0).length;
23824
+ const lossCount = validSignals.filter((s) => s.pnl.pnlPercentage < 0).length;
23825
+ // Basic statistics guard against an empty validSignals (e.g. every signal had
23826
+ // corrupted timestamps) so we don't divide by zero.
23827
+ const avgPnl = totalSignals > 0
23828
+ ? validSignals.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0) / totalSignals
23829
+ : 0;
23830
+ const totalPnl = validSignals.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0);
23831
+ // Win rate excludes break-even trades from both numerator and denominator.
23832
+ const decisiveTrades = winCount + lossCount;
23833
+ const winRate = decisiveTrades > 0 ? (winCount / decisiveTrades) * 100 : 0;
23834
+ // Calendar span over the same validSignals set used for ratios.
23835
+ let firstPendingAt = Infinity;
23836
+ let lastCloseAt = -Infinity;
23837
+ for (const s of validSignals) {
23838
+ if (s.signal.pendingAt < firstPendingAt)
23839
+ firstPendingAt = s.signal.pendingAt;
23840
+ if (s.closeTimestamp > lastCloseAt)
23841
+ lastCloseAt = s.closeTimestamp;
23842
+ }
23843
+ const calendarSpanDays = isFinite(firstPendingAt) && isFinite(lastCloseAt)
23844
+ ? (lastCloseAt - firstPendingAt) / (1000 * 60 * 60 * 24)
23845
+ : 0;
23846
+ // tradesPerYear uses the RAW observed frequency — no clipping. Clipping would
23847
+ // silently understate Sharpe / Calmar / expectedYearlyReturns. Instead, if the
23848
+ // raw frequency exceeds MAX_TRADES_PER_YEAR we treat the sample as too clustered
23849
+ // for reliable annualization and surface every annualized metric as null.
23850
+ const rawTradesPerYear = totalSignals >= MIN_SIGNALS_FOR_ANNUALIZATION$2 &&
23851
+ calendarSpanDays >= MIN_CALENDAR_SPAN_DAYS$2
23852
+ ? (totalSignals / calendarSpanDays) * 365
23853
+ : 0;
23854
+ const canAnnualize = rawTradesPerYear > 0 && rawTradesPerYear <= MAX_TRADES_PER_YEAR$2;
23855
+ const tradesPerYear = canAnnualize ? rawTradesPerYear : 0;
23856
+ // Per-trade Sharpe Ratio (risk-free rate = 0). Sample stddev (N-1) for unbiased estimate.
23857
+ // Per-trade ratios are gated by MIN_SIGNALS_FOR_RATIOS — below that, variance estimates
23858
+ // are too noisy to publish (high chance of spurious ±Sharpe).
23859
+ const returns = validSignals.map((s) => s.pnl.pnlPercentage);
23860
+ const canComputeRatios = totalSignals >= MIN_SIGNALS_FOR_RATIOS$2;
23861
+ const stdDev = canComputeRatios
23862
+ ? Math.sqrt(returns.reduce((sum, r) => sum + Math.pow(r - avgPnl, 2), 0) / (totalSignals - 1))
23863
+ : 0;
23864
+ // Use STDDEV_EPSILON gate (not stdDev > 0) — identical-returns series produce
23865
+ // float-artifact stdDev (~1e-17) that's mathematically > 0 but spuriously
23866
+ // inflates sharpe to astronomical magnitudes (avgPnl / epsilon).
23867
+ const sharpeRatio = canComputeRatios && stdDev > STDDEV_EPSILON$2
23868
+ ? avgPnl / stdDev
23869
+ : null;
23870
+ // Annualize only when gate passes; otherwise null.
23871
+ const annualizedSharpeRatio = canAnnualize && sharpeRatio !== null
23872
+ ? sharpeRatio * Math.sqrt(tradesPerYear)
23873
+ : null;
23874
+ // Equity-curve max drawdown via compounded equity (multiplicative, not additive).
23875
+ // Returns are per-trade on cost basis — compounding assumes equal capital allocation
23876
+ // per trade ("as-if 100% allocation"). Walks validSignals in chronological order
23877
+ // (storage is newest-first, so iterate in reverse). Using validSignals (same set as
23878
+ // tradesPerYear) keeps equityFinal consistent with the annualization exponent.
23879
+ // If equity goes ≤ 0 (e.g. leveraged short with r < -100%) — account blown,
23880
+ // fix DD at 100% and stop walking the curve.
23881
+ let equity = 1;
23882
+ let peak = 1;
23883
+ let equityMaxDrawdown = 0;
23884
+ let blown = false;
23885
+ for (let i = validSignals.length - 1; i >= 0; i--) {
23886
+ equity *= 1 + validSignals[i].pnl.pnlPercentage / 100;
23887
+ if (equity <= 0) {
23888
+ equityMaxDrawdown = 100;
23889
+ blown = true;
23890
+ break;
23891
+ }
23892
+ if (equity > peak)
23893
+ peak = equity;
23894
+ const dd = (peak - equity) / peak * 100;
23895
+ if (dd > equityMaxDrawdown)
23896
+ equityMaxDrawdown = dd;
23897
+ }
23898
+ const equityFinal = blown ? 0 : equity;
23899
+ // Compounded yearly return via geometric mean of equity curve.
23900
+ // equityFinal^(tradesPerYear / N) - 1 — accounts for volatility drag that
23901
+ // arithmetic-mean compounding ((1+avgPnl)^N) misses. If account is blown, full loss.
23902
+ // If the raw value would exceed MAX_EXPECTED_YEARLY_RETURNS, return null rather than
23903
+ // showing the cap as a real figure — capped numbers mislead users into trusting them.
23904
+ const expectedYearlyReturns = canAnnualize
23905
+ ? blown
23906
+ ? -100
23907
+ : (() => {
23908
+ // Geometric annualization uses validSignals.length (same set that defined
23909
+ // tradesPerYear); using totalSignals here would mismatch numerator/denominator.
23910
+ const raw = (Math.pow(equityFinal, tradesPerYear / validSignals.length) - 1) * 100;
23911
+ return Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS$2 ? null : raw;
23912
+ })()
23913
+ : null;
23914
+ // Certainty Ratio — over validSignals so wins/losses come from the same set as
23915
+ // winCount/lossCount/avgPnl above.
23916
+ const wins = validSignals.filter((s) => s.pnl.pnlPercentage > 0);
23917
+ const losses = validSignals.filter((s) => s.pnl.pnlPercentage < 0);
23810
23918
  const avgWin = wins.length > 0
23811
23919
  ? wins.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0) / wins.length
23812
23920
  : 0;
23813
23921
  const avgLoss = losses.length > 0
23814
23922
  ? losses.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0) / losses.length
23815
23923
  : 0;
23816
- const certaintyRatio = avgLoss < 0 ? avgWin / Math.abs(avgLoss) : 0;
23817
- // Calculate Expected Yearly Returns
23818
- const avgDurationMs = this._signalList.reduce((sum, s) => sum + (s.closeTimestamp - s.signal.pendingAt), 0) / totalSignals;
23819
- const avgDurationDays = avgDurationMs / (1000 * 60 * 60 * 24);
23820
- const tradesPerYear = avgDurationDays > 0 ? 365 / avgDurationDays : 0;
23821
- const expectedYearlyReturns = avgPnl * tradesPerYear;
23822
- // Calculate average peak and fall PNL across all signals
23823
- const avgPeakPnl = this._signalList.reduce((sum, s) => sum + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / totalSignals;
23824
- const avgFallPnl = this._signalList.reduce((sum, s) => sum + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / totalSignals;
23825
- // Downside per signal: maxDrawdown.pnlPercentage captures the worst intra-trade dip
23826
- const fallReturns = this._signalList.map((s) => s.signal.maxDrawdown?.pnlPercentage ?? 0);
23827
- // Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
23828
- const fallVariance = fallReturns.reduce((sum, r) => sum + Math.pow(r, 2), 0) / totalSignals;
23829
- const fallDeviation = Math.sqrt(fallVariance);
23830
- const sortinoRatio = fallDeviation > 0 ? avgPnl / fallDeviation : 0;
23831
- // Max absolute drawdown across all signals — used as denominator for Calmar and Recovery
23832
- const maxAbsFall = fallReturns.reduce((max, r) => Math.max(max, Math.abs(r)), 0);
23833
- const calmarRatio = maxAbsFall > 0 ? expectedYearlyReturns / maxAbsFall : 0;
23834
- const recoveryFactor = maxAbsFall > 0 ? totalPnl / maxAbsFall : 0;
23924
+ // Null below MIN_SIGNALS_FOR_RATIOS on a handful of trades the win/loss
23925
+ // means are too noisy to publish a ratio (same sample-size gate as Sharpe/
23926
+ // Sortino, so the report doesn't surface certainty while withholding the rest).
23927
+ // Also null when no losing trades OR when |avgLoss| is below STDDEV_EPSILON
23928
+ // (float-artifact losses (-1e-15) would otherwise produce a spurious
23929
+ // astronomical certaintyRatio ≈1e14).
23930
+ const certaintyRatio = canComputeRatios && Math.abs(avgLoss) > STDDEV_EPSILON$2 && avgLoss < 0
23931
+ ? avgWin / Math.abs(avgLoss)
23932
+ : null;
23933
+ // Average peak/fall PNL over validSignals; only signals that actually have the
23934
+ // value contribute (no zero dilution from missing peakProfit/maxDrawdown).
23935
+ const peakValues = validSignals
23936
+ .map((s) => s.signal.peakProfit?.pnlPercentage)
23937
+ .filter((v) => typeof v === "number");
23938
+ const fallValues = validSignals
23939
+ .map((s) => s.signal.maxDrawdown?.pnlPercentage)
23940
+ .filter((v) => typeof v === "number");
23941
+ const avgPeakPnl = peakValues.length > 0
23942
+ ? peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length
23943
+ : null;
23944
+ const avgFallPnl = fallValues.length > 0
23945
+ ? fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length
23946
+ : null;
23947
+ // Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
23948
+ // downsideDev = √( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
23949
+ // so the numerator reduces to avgPnl and the squared term to r² for r < 0.
23950
+ // Dividing by N_total (not N_negative) properly penalises strategies with frequent
23951
+ // losses; the "modified" form (N_negative) hides frequency risk in catastrophic-tail
23952
+ // strategies.
23953
+ const negativeReturns = returns.filter((r) => r < 0);
23954
+ const sortinoRatio = (() => {
23955
+ if (!canComputeRatios)
23956
+ return null;
23957
+ if (negativeReturns.length === 0)
23958
+ return null;
23959
+ const downsideVariance = negativeReturns.reduce((sum, r) => sum + r * r, 0) / returns.length;
23960
+ const downsideDeviation = Math.sqrt(downsideVariance);
23961
+ // Same epsilon guard as Sharpe — protects against float-artifact downsideDev.
23962
+ return downsideDeviation > STDDEV_EPSILON$2 ? avgPnl / downsideDeviation : null;
23963
+ })();
23964
+ // Calmar — cap |value| at MAX_CALMAR_RATIO to prevent explosion when DD is near zero.
23965
+ const calmarRatio = equityMaxDrawdown > 0 && expectedYearlyReturns !== null
23966
+ ? Math.max(-MAX_CALMAR_RATIO$2, Math.min(MAX_CALMAR_RATIO$2, expectedYearlyReturns / equityMaxDrawdown))
23967
+ : null;
23968
+ // Recovery Factor: numerator must be the compounded total return (equityFinal − 1) × 100,
23969
+ // not the arithmetic totalPnl — denominator (equityMaxDrawdown) is from the compounded
23970
+ // curve, so mixing units would inflate Recovery on long winning streaks.
23971
+ // Null below MIN_SIGNALS_FOR_RATIOS — same sample-size gate as the other ratios,
23972
+ // so a 3-trade run doesn't surface a Recovery Factor while Sharpe/Calmar are N/A.
23973
+ // Null when account is blown — ratio is meaningless after total loss.
23974
+ // Same MAX_CALMAR_RATIO clamp as Calmar — both are compounded-profit/DD ratios
23975
+ // and explode the same way when DD is near zero.
23976
+ const recoveryFactor = !canComputeRatios || blown || equityMaxDrawdown <= 0
23977
+ ? null
23978
+ : Math.max(-MAX_CALMAR_RATIO$2, Math.min(MAX_CALMAR_RATIO$2, ((equityFinal - 1) * 100) / equityMaxDrawdown));
23835
23979
  return {
23836
23980
  signalList: this._signalList,
23837
23981
  totalSignals,
23838
23982
  winCount,
23839
23983
  lossCount,
23840
- winRate: isUnsafe$3(winRate) ? null : winRate,
23841
- avgPnl: isUnsafe$3(avgPnl) ? null : avgPnl,
23842
- totalPnl: isUnsafe$3(totalPnl) ? null : totalPnl,
23843
- stdDev: isUnsafe$3(stdDev) ? null : stdDev,
23844
- sharpeRatio: isUnsafe$3(sharpeRatio) ? null : sharpeRatio,
23845
- annualizedSharpeRatio: isUnsafe$3(annualizedSharpeRatio) ? null : annualizedSharpeRatio,
23846
- certaintyRatio: isUnsafe$3(certaintyRatio) ? null : certaintyRatio,
23847
- expectedYearlyReturns: isUnsafe$3(expectedYearlyReturns) ? null : expectedYearlyReturns,
23848
- avgPeakPnl: isUnsafe$3(avgPeakPnl) ? null : avgPeakPnl,
23849
- avgFallPnl: isUnsafe$3(avgFallPnl) ? null : avgFallPnl,
23850
- sortinoRatio: isUnsafe$3(sortinoRatio) ? null : sortinoRatio,
23851
- calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
23852
- recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
23984
+ winRate: isUnsafe$4(winRate) ? null : winRate,
23985
+ avgPnl: isUnsafe$4(avgPnl) ? null : avgPnl,
23986
+ totalPnl: isUnsafe$4(totalPnl) ? null : totalPnl,
23987
+ stdDev: isUnsafe$4(stdDev) ? null : stdDev,
23988
+ sharpeRatio: isUnsafe$4(sharpeRatio) ? null : sharpeRatio,
23989
+ annualizedSharpeRatio: isUnsafe$4(annualizedSharpeRatio) ? null : annualizedSharpeRatio,
23990
+ certaintyRatio: isUnsafe$4(certaintyRatio) ? null : certaintyRatio,
23991
+ expectedYearlyReturns: isUnsafe$4(expectedYearlyReturns) ? null : expectedYearlyReturns,
23992
+ avgPeakPnl: isUnsafe$4(avgPeakPnl) ? null : avgPeakPnl,
23993
+ avgFallPnl: isUnsafe$4(avgFallPnl) ? null : avgFallPnl,
23994
+ sortinoRatio: isUnsafe$4(sortinoRatio) ? null : sortinoRatio,
23995
+ calmarRatio: isUnsafe$4(calmarRatio) ? null : calmarRatio,
23996
+ recoveryFactor: isUnsafe$4(recoveryFactor) ? null : recoveryFactor,
23853
23997
  };
23854
23998
  }
23855
23999
  /**
@@ -23891,24 +24035,26 @@ let ReportStorage$a = class ReportStorage {
23891
24035
  `**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`,
23892
24036
  `**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`,
23893
24037
  `**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`,
23894
- `**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better, theoretical)`}`,
24038
+ `**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`,
23895
24039
  `**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`,
23896
- `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better, theoretical)`}`,
24040
+ `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
23897
24041
  `**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
23898
24042
  `**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
23899
24043
  `**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
23900
- `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better, theoretical)`}`,
24044
+ `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
23901
24045
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
23902
24046
  "",
23903
24047
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
23904
24048
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
23905
- `*Annualized Sharpe Ratio: theoretical maximum assuming continuous trading. Real-world value is lower due to idle periods.*`,
23906
- `*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
24049
+ `*Annualized Sharpe Ratio: per-trade Sharpe × √tradesPerYear; tradesPerYear = signals × 365 / calendarSpanDays. N/A unless ≥${MIN_SIGNALS_FOR_ANNUALIZATION$2} signals and span ≥${MIN_CALENDAR_SPAN_DAYS$2} days. Assumes returns are iid — autocorrelated strategies are overstated.*`,
24050
+ `*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals. N/A when no losing trades — Sortino is mathematically undefined (infinite) and we cannot distinguish "truly flawless" from "lucky streak so far".*`,
23907
24051
  `*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
23908
- `*Expected Yearly Returns: theoretical maximum assuming all capital is deployed continuously with no idle time.*`,
23909
- `*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Based on theoretical yearly returns.*`,
23910
- `*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good.*`,
23911
- `*All metrics require 100+ signals to be statistically reliable. Time period matters only for Annualized Sharpe Ratio and Expected Yearly Returns — they assume current market conditions hold year-round, which may not reflect reality.*`,
24052
+ `*Expected Yearly Returns: compounded geometric return from the equity curve, annualized by tradesPerYear. Same gating as Annualized Sharpe. Capped at ±${MAX_EXPECTED_YEARLY_RETURNS$2}% — values above the cap return N/A.*`,
24053
+ `*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is compounded equity-curve max drawdown. Capped at ±${MAX_CALMAR_RATIO$2}.*`,
24054
+ `*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good. Uses compounded total return as numerator.*`,
24055
+ `*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
24056
+ `*IMPORTANT: Equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per trade** (no sizing, no portfolio fraction). Per-trade pnlPercentage is treated as a return on full equity. If your strategy risks X% of capital per trade, the realized portfolio return / drawdown will be roughly X/100 of the reported figures. The framework does not track portfolio-level sizing, so these metrics represent a theoretical upper bound under full allocation.*`,
24057
+ `*Negative values for Sharpe / Sortino / Calmar / Recovery / Expected Yearly Returns indicate a losing strategy (avgPnl < 0 or totalPnl < 0). "Higher is better" still applies — closer to zero is less bad, positive is profitable.*`,
23912
24058
  ].join("\n");
23913
24059
  }
23914
24060
  /**
@@ -24220,7 +24366,7 @@ const CREATE_FILE_NAME_FN$b = (symbol, strategyName, exchangeName, frameName, ti
24220
24366
  * @param value - Value to check
24221
24367
  * @returns true if value is unsafe, false otherwise
24222
24368
  */
24223
- function isUnsafe$2(value) {
24369
+ function isUnsafe$3(value) {
24224
24370
  if (typeof value !== "number") {
24225
24371
  return true;
24226
24372
  }
@@ -24232,6 +24378,25 @@ function isUnsafe$2(value) {
24232
24378
  }
24233
24379
  return false;
24234
24380
  }
24381
+ /** Minimum closed signals required to annualize Sharpe / yearly returns / Calmar. */
24382
+ const MIN_SIGNALS_FOR_ANNUALIZATION$1 = 10;
24383
+ /** Minimum signals required for ANY ratio metric (Sharpe / Sortino / stdDev). Below this,
24384
+ * sample size is too small to estimate variance meaningfully. */
24385
+ const MIN_SIGNALS_FOR_RATIOS$1 = 10;
24386
+ /** Minimum calendar span (days) for trade-frequency extrapolation. */
24387
+ const MIN_CALENDAR_SPAN_DAYS$1 = 14;
24388
+ /** Hard cap on tradesPerYear — prevents absurd extrapolation from short windows / clustered trades. */
24389
+ const MAX_TRADES_PER_YEAR$1 = 365;
24390
+ /** Hard cap on |expectedYearlyReturns| percent. Compound interest on high avgPnl × frequency
24391
+ * blows up to mathematically correct but business-unrealistic values. ±100% = 2x equity —
24392
+ * anything above this we suspect is a noisy estimate, not a genuine edge. Above the cap → null. */
24393
+ const MAX_EXPECTED_YEARLY_RETURNS$1 = 100;
24394
+ /** Hard cap on |calmarRatio|. Prevents explosion when equityMaxDrawdown is near zero. */
24395
+ const MAX_CALMAR_RATIO$1 = 1000;
24396
+ /** Minimum stdDev required for Sharpe/Sortino. Identical-returns series produce
24397
+ * float-artifact stdDev (~1e-17) that's > 0 but spuriously inflates sharpe to
24398
+ * astronomical magnitudes (avgPnl / epsilon). */
24399
+ const STDDEV_EPSILON$1 = 1e-9;
24235
24400
  /**
24236
24401
  * Storage class for accumulating all tick events per strategy.
24237
24402
  * Maintains a chronological list of all events (idle, opened, active, closed).
@@ -24515,84 +24680,190 @@ let ReportStorage$9 = class ReportStorage {
24515
24680
  };
24516
24681
  }
24517
24682
  const closedEvents = this._eventList.filter((e) => e.action === "closed");
24518
- const totalClosed = closedEvents.length;
24519
- const winCount = closedEvents.filter((e) => e.pnl && e.pnl > 0).length;
24520
- const lossCount = closedEvents.filter((e) => e.pnl && e.pnl < 0).length;
24521
- // Calculate basic statistics
24522
- const avgPnl = totalClosed > 0
24523
- ? closedEvents.reduce((sum, e) => sum + (e.pnl || 0), 0) / totalClosed
24683
+ // Valid closed set — single source of truth. Events must have numeric pnl AND valid
24684
+ // timestamps. Win/loss counts, returns, calendar span, equity curve — all derived
24685
+ // from this set so they cannot disagree.
24686
+ const validClosed = closedEvents.filter((e) => typeof e.pnl === "number" &&
24687
+ typeof e.timestamp === "number" &&
24688
+ e.timestamp > 0 &&
24689
+ typeof (e.pendingAt ?? e.timestamp) === "number");
24690
+ const totalClosed = validClosed.length;
24691
+ const winCount = validClosed.filter((e) => e.pnl > 0).length;
24692
+ const lossCount = validClosed.filter((e) => e.pnl < 0).length;
24693
+ const returns = validClosed.map((e) => e.pnl);
24694
+ const avgPnl = returns.length > 0
24695
+ ? returns.reduce((sum, r) => sum + r, 0) / returns.length
24524
24696
  : 0;
24525
- const totalPnl = closedEvents.reduce((sum, e) => sum + (e.pnl || 0), 0);
24526
- const winRate = (winCount / totalClosed) * 100;
24527
- // Calculate Sharpe Ratio (risk-free rate = 0)
24528
- let sharpeRatio = 0;
24529
- let stdDev = 0;
24530
- if (totalClosed > 0) {
24531
- const returns = closedEvents.map((e) => e.pnl || 0);
24532
- const variance = returns.reduce((sum, r) => sum + Math.pow(r - avgPnl, 2), 0) / totalClosed;
24533
- stdDev = Math.sqrt(variance);
24534
- sharpeRatio = stdDev > 0 ? avgPnl / stdDev : 0;
24535
- }
24536
- const annualizedSharpeRatio = sharpeRatio * Math.sqrt(365);
24537
- // Calculate Certainty Ratio
24538
- let certaintyRatio = 0;
24539
- if (totalClosed > 0) {
24540
- const wins = closedEvents.filter((e) => e.pnl && e.pnl > 0);
24541
- const losses = closedEvents.filter((e) => e.pnl && e.pnl < 0);
24697
+ const totalPnl = returns.reduce((sum, r) => sum + r, 0);
24698
+ // Win rate excludes break-even trades from both numerator and denominator.
24699
+ const decisiveTrades = winCount + lossCount;
24700
+ const winRate = decisiveTrades > 0 ? (winCount / decisiveTrades) * 100 : 0;
24701
+ // Trade frequency from calendar span — gated by minimum span and sample size to
24702
+ // suppress absurd annualization on short / sparse runs. Span built from validClosed
24703
+ // so denominator (calendarSpanDays) and numerator (returns.length) come from the
24704
+ // same event set.
24705
+ let firstPendingAt = Infinity;
24706
+ let lastCloseAt = -Infinity;
24707
+ for (const e of validClosed) {
24708
+ const startAt = e.pendingAt ?? e.timestamp;
24709
+ if (startAt < firstPendingAt)
24710
+ firstPendingAt = startAt;
24711
+ if (e.timestamp > lastCloseAt)
24712
+ lastCloseAt = e.timestamp;
24713
+ }
24714
+ const calendarSpanDays = validClosed.length > 0
24715
+ ? (lastCloseAt - firstPendingAt) / (1000 * 60 * 60 * 24)
24716
+ : 0;
24717
+ // tradesPerYear uses the RAW observed frequency — no clipping. Clipping would
24718
+ // silently understate Sharpe / Calmar / expectedYearlyReturns. Instead, if the
24719
+ // raw frequency exceeds MAX_TRADES_PER_YEAR we treat the sample as too clustered
24720
+ // for reliable annualization and surface every annualized metric as null.
24721
+ const rawTradesPerYear = returns.length >= MIN_SIGNALS_FOR_ANNUALIZATION$1 &&
24722
+ calendarSpanDays >= MIN_CALENDAR_SPAN_DAYS$1
24723
+ ? (returns.length / calendarSpanDays) * 365
24724
+ : 0;
24725
+ const canAnnualize = rawTradesPerYear > 0 && rawTradesPerYear <= MAX_TRADES_PER_YEAR$1;
24726
+ const tradesPerYear = canAnnualize ? rawTradesPerYear : 0;
24727
+ // Per-trade Sharpe Ratio (risk-free rate = 0). Sample stddev (N-1).
24728
+ // Per-trade ratios are gated by MIN_SIGNALS_FOR_RATIOS — below that, variance estimates
24729
+ // are too noisy to publish (high chance of spurious ±Sharpe).
24730
+ const canComputeRatios = returns.length >= MIN_SIGNALS_FOR_RATIOS$1;
24731
+ const stdDev = canComputeRatios
24732
+ ? Math.sqrt(returns.reduce((sum, r) => sum + Math.pow(r - avgPnl, 2), 0) / (returns.length - 1))
24733
+ : 0;
24734
+ // STDDEV_EPSILON guard — protects against float-artifact stdDev from identical
24735
+ // returns producing spuriously astronomical sharpe.
24736
+ const sharpeRatio = canComputeRatios && stdDev > STDDEV_EPSILON$1
24737
+ ? avgPnl / stdDev
24738
+ : null;
24739
+ // Annualize only when gate passes; otherwise null.
24740
+ const annualizedSharpeRatio = canAnnualize && sharpeRatio !== null
24741
+ ? sharpeRatio * Math.sqrt(tradesPerYear)
24742
+ : null;
24743
+ // Certainty Ratio: null (not zero) when there are no losing trades — a flawless
24744
+ // strategy has undefined Certainty Ratio, not "worst case zero". Computed on
24745
+ // validClosed for consistency with other ratios.
24746
+ // Gated below MIN_SIGNALS_FOR_RATIOS — same sample-size gate as Sharpe/Sortino,
24747
+ // so the report doesn't surface certainty on a handful of trades while
24748
+ // withholding the rest.
24749
+ let certaintyRatio = null;
24750
+ if (canComputeRatios && totalClosed > 0) {
24751
+ const wins = validClosed.filter((e) => e.pnl > 0);
24752
+ const losses = validClosed.filter((e) => e.pnl < 0);
24542
24753
  const avgWin = wins.length > 0
24543
- ? wins.reduce((sum, e) => sum + (e.pnl || 0), 0) / wins.length
24754
+ ? wins.reduce((sum, e) => sum + e.pnl, 0) / wins.length
24544
24755
  : 0;
24545
24756
  const avgLoss = losses.length > 0
24546
- ? losses.reduce((sum, e) => sum + (e.pnl || 0), 0) / losses.length
24757
+ ? losses.reduce((sum, e) => sum + e.pnl, 0) / losses.length
24547
24758
  : 0;
24548
- certaintyRatio = avgLoss < 0 ? avgWin / Math.abs(avgLoss) : 0;
24549
- }
24550
- // Calculate Expected Yearly Returns
24551
- let expectedYearlyReturns = 0;
24552
- if (totalClosed > 0) {
24553
- const avgDurationMin = closedEvents.reduce((sum, e) => sum + (e.duration || 0), 0) / totalClosed;
24554
- const avgDurationDays = avgDurationMin / (60 * 24);
24555
- const tradesPerYear = avgDurationDays > 0 ? 365 / avgDurationDays : 0;
24556
- expectedYearlyReturns = avgPnl * tradesPerYear;
24557
- }
24558
- const avgPeakPnl = totalClosed > 0
24559
- ? closedEvents.reduce((sum, e) => sum + (e.peakPnl || 0), 0) / totalClosed
24560
- : 0;
24561
- const avgFallPnl = totalClosed > 0
24562
- ? closedEvents.reduce((sum, e) => sum + (e.fallPnl || 0), 0) / totalClosed
24563
- : 0;
24564
- // Downside per signal: fallPnl captures the worst intra-trade dip (maxDrawdown.pnlPercentage)
24565
- const fallReturns = closedEvents.map((e) => e.fallPnl || 0);
24566
- // Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
24567
- let sortinoRatio = 0;
24568
- if (totalClosed > 0) {
24569
- const fallVariance = fallReturns.reduce((sum, r) => sum + Math.pow(r, 2), 0) / totalClosed;
24570
- const fallDeviation = Math.sqrt(fallVariance);
24571
- sortinoRatio = fallDeviation > 0 ? avgPnl / fallDeviation : 0;
24572
- }
24573
- // Max absolute drawdown across all signals — denominator for Calmar and Recovery
24574
- const maxAbsFall = fallReturns.reduce((max, r) => Math.max(max, Math.abs(r)), 0);
24575
- const calmarRatio = maxAbsFall > 0 ? expectedYearlyReturns / maxAbsFall : 0;
24576
- const recoveryFactor = maxAbsFall > 0 ? totalPnl / maxAbsFall : 0;
24759
+ // STDDEV_EPSILON guard on |avgLoss| protects against float-artifact
24760
+ // losses producing spurious astronomical certaintyRatio.
24761
+ certaintyRatio = Math.abs(avgLoss) > STDDEV_EPSILON$1 && avgLoss < 0
24762
+ ? avgWin / Math.abs(avgLoss)
24763
+ : null;
24764
+ }
24765
+ // Average only over signals that have the value — do not dilute the mean with zeros.
24766
+ // Use validClosed to keep all metric denominators consistent.
24767
+ const peakValues = validClosed
24768
+ .map((e) => e.peakPnl)
24769
+ .filter((v) => typeof v === "number");
24770
+ const fallValues = validClosed
24771
+ .map((e) => e.fallPnl)
24772
+ .filter((v) => typeof v === "number");
24773
+ const avgPeakPnl = peakValues.length > 0
24774
+ ? peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length
24775
+ : null;
24776
+ const avgFallPnl = fallValues.length > 0
24777
+ ? fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length
24778
+ : null;
24779
+ // Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
24780
+ // downsideDev = ( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
24781
+ // so the numerator reduces to avgPnl and the squared term to r² for r < 0.
24782
+ // Dividing by N_total (not N_negative) properly penalises strategies with frequent
24783
+ // losses; the "modified" form (N_negative) hides frequency risk in catastrophic-tail
24784
+ // strategies.
24785
+ const sortinoRatio = (() => {
24786
+ if (!canComputeRatios)
24787
+ return null;
24788
+ const negativeReturns = returns.filter((r) => r < 0);
24789
+ if (negativeReturns.length === 0)
24790
+ return null;
24791
+ const downsideVariance = negativeReturns.reduce((sum, r) => sum + r * r, 0) / returns.length;
24792
+ const downsideDeviation = Math.sqrt(downsideVariance);
24793
+ // Same epsilon guard as Sharpe — protects against float-artifact downsideDev.
24794
+ return downsideDeviation > STDDEV_EPSILON$1 ? avgPnl / downsideDeviation : null;
24795
+ })();
24796
+ // Equity-curve max drawdown via compounded equity (multiplicative). Returns are per-trade
24797
+ // on cost basis — compounding assumes equal capital allocation per trade ("as-if 100%").
24798
+ // If equity ≤ 0 (leveraged short with r < -100%) — account blown, fix DD at 100%.
24799
+ // Built from validClosed (newest-first), iterated reverse for chronological order.
24800
+ const chronologicalReturns = [];
24801
+ for (let i = validClosed.length - 1; i >= 0; i--) {
24802
+ chronologicalReturns.push(validClosed[i].pnl);
24803
+ }
24804
+ let equity = 1;
24805
+ let peak = 1;
24806
+ let equityMaxDrawdown = 0;
24807
+ let blown = false;
24808
+ for (const r of chronologicalReturns) {
24809
+ equity *= 1 + r / 100;
24810
+ if (equity <= 0) {
24811
+ equityMaxDrawdown = 100;
24812
+ blown = true;
24813
+ break;
24814
+ }
24815
+ if (equity > peak)
24816
+ peak = equity;
24817
+ const dd = (peak - equity) / peak * 100;
24818
+ if (dd > equityMaxDrawdown)
24819
+ equityMaxDrawdown = dd;
24820
+ }
24821
+ const equityFinal = blown ? 0 : equity;
24822
+ // Compounded yearly return via geometric mean of equity curve:
24823
+ // equityFinal^(tradesPerYear / N) - 1 — accounts for volatility drag.
24824
+ // If account is blown, full loss. If raw value exceeds MAX_EXPECTED_YEARLY_RETURNS,
24825
+ // return null rather than showing the cap — capped numbers mislead users.
24826
+ const expectedYearlyReturns = canAnnualize
24827
+ ? blown
24828
+ ? -100
24829
+ : (() => {
24830
+ const raw = (Math.pow(equityFinal, tradesPerYear / returns.length) - 1) * 100;
24831
+ return Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS$1 ? null : raw;
24832
+ })()
24833
+ : null;
24834
+ // Calmar — cap |value| at MAX_CALMAR_RATIO to prevent explosion when DD is near zero.
24835
+ const calmarRatio = equityMaxDrawdown > 0 && expectedYearlyReturns !== null
24836
+ ? Math.max(-MAX_CALMAR_RATIO$1, Math.min(MAX_CALMAR_RATIO$1, expectedYearlyReturns / equityMaxDrawdown))
24837
+ : null;
24838
+ // Recovery Factor: numerator must be the compounded total return, not arithmetic totalPnl —
24839
+ // denominator is from the compounded equity curve, so mixing units inflates Recovery.
24840
+ // Null below MIN_SIGNALS_FOR_RATIOS — same sample-size gate as the other ratios,
24841
+ // so a 3-trade run doesn't surface a Recovery Factor while Sharpe/Calmar are N/A.
24842
+ // Null when account is blown.
24843
+ // Same MAX_CALMAR_RATIO clamp as Calmar — both are compounded-profit/DD ratios
24844
+ // and explode the same way when DD is near zero.
24845
+ const recoveryFactor = !canComputeRatios || blown || equityMaxDrawdown <= 0
24846
+ ? null
24847
+ : Math.max(-MAX_CALMAR_RATIO$1, Math.min(MAX_CALMAR_RATIO$1, ((equityFinal - 1) * 100) / equityMaxDrawdown));
24577
24848
  return {
24578
24849
  eventList: this._eventList,
24579
24850
  totalEvents: this._eventList.length,
24580
24851
  totalClosed,
24581
24852
  winCount,
24582
24853
  lossCount,
24583
- winRate: isUnsafe$2(winRate) ? null : winRate,
24584
- avgPnl: isUnsafe$2(avgPnl) ? null : avgPnl,
24585
- totalPnl: isUnsafe$2(totalPnl) ? null : totalPnl,
24586
- stdDev: isUnsafe$2(stdDev) ? null : stdDev,
24587
- sharpeRatio: isUnsafe$2(sharpeRatio) ? null : sharpeRatio,
24588
- annualizedSharpeRatio: isUnsafe$2(annualizedSharpeRatio) ? null : annualizedSharpeRatio,
24589
- certaintyRatio: isUnsafe$2(certaintyRatio) ? null : certaintyRatio,
24590
- expectedYearlyReturns: isUnsafe$2(expectedYearlyReturns) ? null : expectedYearlyReturns,
24591
- avgPeakPnl: isUnsafe$2(avgPeakPnl) ? null : avgPeakPnl,
24592
- avgFallPnl: isUnsafe$2(avgFallPnl) ? null : avgFallPnl,
24593
- sortinoRatio: isUnsafe$2(sortinoRatio) ? null : sortinoRatio,
24594
- calmarRatio: isUnsafe$2(calmarRatio) ? null : calmarRatio,
24595
- recoveryFactor: isUnsafe$2(recoveryFactor) ? null : recoveryFactor,
24854
+ winRate: isUnsafe$3(winRate) ? null : winRate,
24855
+ avgPnl: isUnsafe$3(avgPnl) ? null : avgPnl,
24856
+ totalPnl: isUnsafe$3(totalPnl) ? null : totalPnl,
24857
+ stdDev: isUnsafe$3(stdDev) ? null : stdDev,
24858
+ sharpeRatio: isUnsafe$3(sharpeRatio) ? null : sharpeRatio,
24859
+ annualizedSharpeRatio: isUnsafe$3(annualizedSharpeRatio) ? null : annualizedSharpeRatio,
24860
+ certaintyRatio: isUnsafe$3(certaintyRatio) ? null : certaintyRatio,
24861
+ expectedYearlyReturns: isUnsafe$3(expectedYearlyReturns) ? null : expectedYearlyReturns,
24862
+ avgPeakPnl: isUnsafe$3(avgPeakPnl) ? null : avgPeakPnl,
24863
+ avgFallPnl: isUnsafe$3(avgFallPnl) ? null : avgFallPnl,
24864
+ sortinoRatio: isUnsafe$3(sortinoRatio) ? null : sortinoRatio,
24865
+ calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
24866
+ recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
24596
24867
  };
24597
24868
  }
24598
24869
  /**
@@ -24640,18 +24911,20 @@ let ReportStorage$9 = class ReportStorage {
24640
24911
  `**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
24641
24912
  `**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
24642
24913
  `**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
24643
- `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better, theoretical)`}`,
24914
+ `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
24644
24915
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
24645
24916
  "",
24646
24917
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
24647
24918
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
24648
- `*Annualized Sharpe Ratio: theoretical maximum assuming continuous trading. Real-world value is lower due to idle periods.*`,
24649
- `*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
24919
+ `*Annualized Sharpe Ratio: per-trade Sharpe × √tradesPerYear; tradesPerYear = signals × 365 / calendarSpanDays. N/A unless ≥${MIN_SIGNALS_FOR_ANNUALIZATION$1} signals and span ≥${MIN_CALENDAR_SPAN_DAYS$1} days. Assumes returns are iid — autocorrelated strategies are overstated.*`,
24920
+ `*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals. N/A when no losing trades — Sortino is mathematically undefined (infinite) and we cannot distinguish "truly flawless" from "lucky streak so far".*`,
24650
24921
  `*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
24651
- `*Expected Yearly Returns: theoretical maximum assuming all capital is deployed continuously with no idle time.*`,
24652
- `*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Based on theoretical yearly returns.*`,
24653
- `*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good.*`,
24654
- `*All metrics require 100+ signals to be statistically reliable. Time period matters only for Annualized Sharpe Ratio and Expected Yearly Returns — they assume current market conditions hold year-round, which may not reflect reality.*`,
24922
+ `*Expected Yearly Returns: compounded geometric return from the equity curve, annualized by tradesPerYear. Same gating as Annualized Sharpe. Capped at ±${MAX_EXPECTED_YEARLY_RETURNS$1}% — values above the cap return N/A.*`,
24923
+ `*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is compounded equity-curve max drawdown. Capped at ±${MAX_CALMAR_RATIO$1}.*`,
24924
+ `*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good. Uses compounded total return as numerator.*`,
24925
+ `*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
24926
+ `*IMPORTANT: Equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per trade** (no sizing, no portfolio fraction). Per-trade pnlPercentage is treated as a return on full equity. If your strategy risks X% of capital per trade, the realized portfolio return / drawdown will be roughly X/100 of the reported figures. The framework does not track portfolio-level sizing, so these metrics represent a theoretical upper bound under full allocation.*`,
24927
+ `*Negative values for Sharpe / Sortino / Calmar / Recovery / Expected Yearly Returns indicate a losing strategy (avgPnl < 0 or totalPnl < 0). "Higher is better" still applies — closer to zero is less bad, positive is profitable.*`,
24655
24928
  ].join("\n");
24656
24929
  }
24657
24930
  /**
@@ -25030,7 +25303,9 @@ let ReportStorage$8 = class ReportStorage {
25030
25303
  */
25031
25304
  addOpenedEvent(data) {
25032
25305
  const durationMs = data.signal.pendingAt - data.signal.scheduledAt;
25033
- const durationMin = Math.round(durationMs / 60000);
25306
+ // Keep fractional minutes rounding to whole minutes zeroed out sub-30s durations,
25307
+ // which dragged high-frequency averages towards zero.
25308
+ const durationMin = durationMs / 60000;
25034
25309
  const newEvent = {
25035
25310
  timestamp: data.signal.pendingAt,
25036
25311
  action: "opened",
@@ -25066,7 +25341,8 @@ let ReportStorage$8 = class ReportStorage {
25066
25341
  */
25067
25342
  addCancelledEvent(data) {
25068
25343
  const durationMs = data.closeTimestamp - data.signal.scheduledAt;
25069
- const durationMin = Math.round(durationMs / 60000);
25344
+ // Keep fractional minutes rounding to whole minutes zeroed out sub-30s durations.
25345
+ const durationMin = durationMs / 60000;
25070
25346
  const newEvent = {
25071
25347
  timestamp: data.closeTimestamp,
25072
25348
  action: "cancelled",
@@ -25122,19 +25398,33 @@ let ReportStorage$8 = class ReportStorage {
25122
25398
  const totalScheduled = scheduledEvents.length;
25123
25399
  const totalOpened = openedEvents.length;
25124
25400
  const totalCancelled = cancelledEvents.length;
25125
- // Calculate cancellation rate
25126
- const cancellationRate = totalScheduled > 0 ? (totalCancelled / totalScheduled) * 100 : null;
25127
- // Calculate activation rate
25128
- const activationRate = totalScheduled > 0 ? (totalOpened / totalScheduled) * 100 : null;
25129
- // Calculate average wait time for cancelled signals
25130
- const avgWaitTime = totalCancelled > 0
25131
- ? cancelledEvents.reduce((sum, e) => sum + (e.duration || 0), 0) /
25132
- totalCancelled
25401
+ // Rate denominators must include only scheduled events whose outcome (opened/cancelled)
25402
+ // is also in the buffer. Otherwise a sliding window of 250 entries can drop the
25403
+ // "scheduled" record before its outcome arrives, inflating rates above 100% or
25404
+ // causing one rate to fire without the other. Match by signalId.
25405
+ const scheduledIds = new Set(scheduledEvents.map((e) => e.signalId).filter((id) => typeof id === "string"));
25406
+ const openedFromScheduled = openedEvents.filter((e) => typeof e.signalId === "string" && scheduledIds.has(e.signalId));
25407
+ const cancelledFromScheduled = cancelledEvents.filter((e) => typeof e.signalId === "string" && scheduledIds.has(e.signalId));
25408
+ const resolvedScheduled = openedFromScheduled.length + cancelledFromScheduled.length;
25409
+ const cancellationRate = resolvedScheduled > 0
25410
+ ? (cancelledFromScheduled.length / resolvedScheduled) * 100
25411
+ : null;
25412
+ const activationRate = resolvedScheduled > 0
25413
+ ? (openedFromScheduled.length / resolvedScheduled) * 100
25133
25414
  : null;
25134
- // Calculate average activation time for opened signals
25135
- const avgActivationTime = totalOpened > 0
25136
- ? openedEvents.reduce((sum, e) => sum + (e.duration || 0), 0) /
25137
- totalOpened
25415
+ // Average durations include only events with a numeric duration, do not dilute
25416
+ // the mean with zeros for missing values.
25417
+ const cancelledDurations = cancelledEvents
25418
+ .map((e) => e.duration)
25419
+ .filter((d) => typeof d === "number");
25420
+ const openedDurations = openedEvents
25421
+ .map((e) => e.duration)
25422
+ .filter((d) => typeof d === "number");
25423
+ const avgWaitTime = cancelledDurations.length > 0
25424
+ ? cancelledDurations.reduce((sum, d) => sum + d, 0) / cancelledDurations.length
25425
+ : null;
25426
+ const avgActivationTime = openedDurations.length > 0
25427
+ ? openedDurations.reduce((sum, d) => sum + d, 0) / openedDurations.length
25138
25428
  : null;
25139
25429
  return {
25140
25430
  eventList: this._eventList,
@@ -25181,13 +25471,15 @@ let ReportStorage$8 = class ReportStorage {
25181
25471
  table,
25182
25472
  "",
25183
25473
  `**Total events:** ${stats.totalEvents}`,
25184
- `**Scheduled signals:** ${stats.totalScheduled}`,
25474
+ `**Scheduled signals (raw):** ${stats.totalScheduled}`,
25185
25475
  `**Opened signals:** ${stats.totalOpened}`,
25186
25476
  `**Cancelled signals:** ${stats.totalCancelled}`,
25187
25477
  `**Activation rate:** ${stats.activationRate === null ? "N/A" : `${stats.activationRate.toFixed(2)}% (higher is better)`}`,
25188
25478
  `**Cancellation rate:** ${stats.cancellationRate === null ? "N/A" : `${stats.cancellationRate.toFixed(2)}% (lower is better)`}`,
25189
25479
  `**Average activation time:** ${stats.avgActivationTime === null ? "N/A" : `${stats.avgActivationTime.toFixed(2)} minutes`}`,
25190
- `**Average wait time (cancelled):** ${stats.avgWaitTime === null ? "N/A" : `${stats.avgWaitTime.toFixed(2)} minutes`}`
25480
+ `**Average wait time (cancelled):** ${stats.avgWaitTime === null ? "N/A" : `${stats.avgWaitTime.toFixed(2)} minutes`}`,
25481
+ "",
25482
+ `*Activation / Cancellation rates are computed over scheduled signals whose outcome (opened or cancelled) is also in the buffer — matched by signalId. "Scheduled signals (raw)" above is the unmatched count and may include records whose outcome has not yet arrived or was evicted from the buffer.*`
25191
25483
  ].join("\n");
25192
25484
  }
25193
25485
  /**
@@ -25492,13 +25784,37 @@ const CREATE_FILE_NAME_FN$9 = (symbol, strategyName, exchangeName, frameName, ti
25492
25784
  return `${parts.join("_")}-${timestamp}.md`;
25493
25785
  };
25494
25786
  /**
25495
- * Calculates percentile value from sorted array.
25787
+ * Checks if a value is unsafe for display (not a number, NaN, or Infinity).
25788
+ */
25789
+ function isUnsafe$2(value) {
25790
+ if (typeof value !== "number") {
25791
+ return true;
25792
+ }
25793
+ if (isNaN(value)) {
25794
+ return true;
25795
+ }
25796
+ if (!isFinite(value)) {
25797
+ return true;
25798
+ }
25799
+ return false;
25800
+ }
25801
+ /**
25802
+ * Calculates percentile value from sorted array using linear interpolation
25803
+ * between adjacent ranks (equivalent to numpy.percentile with default linear method).
25804
+ * Falls back to nearest-rank for length 0/1.
25496
25805
  */
25497
25806
  function percentile(sortedArray, p) {
25498
25807
  if (sortedArray.length === 0)
25499
25808
  return 0;
25500
- const index = Math.ceil((sortedArray.length * p) / 100) - 1;
25501
- return sortedArray[Math.max(0, index)];
25809
+ if (sortedArray.length === 1)
25810
+ return sortedArray[0];
25811
+ const rank = (p / 100) * (sortedArray.length - 1);
25812
+ const lower = Math.floor(rank);
25813
+ const upper = Math.ceil(rank);
25814
+ if (lower === upper)
25815
+ return sortedArray[lower];
25816
+ const fraction = rank - lower;
25817
+ return sortedArray[lower] * (1 - fraction) + sortedArray[upper] * fraction;
25502
25818
  }
25503
25819
  /**
25504
25820
  * Storage class for accumulating performance metrics per strategy.
@@ -25554,10 +25870,12 @@ class PerformanceStorage {
25554
25870
  const durations = events.map((e) => e.duration).sort((a, b) => a - b);
25555
25871
  const totalDuration = durations.reduce((sum, d) => sum + d, 0);
25556
25872
  const avgDuration = totalDuration / durations.length;
25557
- // Calculate standard deviation
25558
- const variance = durations.reduce((sum, d) => sum + Math.pow(d - avgDuration, 2), 0) /
25559
- durations.length;
25560
- const stdDev = Math.sqrt(variance);
25873
+ // Sample standard deviation (Bessel correction: divide by N-1, not N) — consistent
25874
+ // with Sharpe/Sortino calculations in Backtest/Live/Heat services.
25875
+ const stdDev = durations.length > 1
25876
+ ? Math.sqrt(durations.reduce((sum, d) => sum + Math.pow(d - avgDuration, 2), 0) /
25877
+ (durations.length - 1))
25878
+ : 0;
25561
25879
  // Calculate wait times between events
25562
25880
  const waitTimes = [];
25563
25881
  for (let i = 0; i < events.length; i++) {
@@ -25630,9 +25948,13 @@ class PerformanceStorage {
25630
25948
  const rows = await Promise.all(sortedMetrics.map(async (metric, index) => Promise.all(visibleColumns.map((col) => col.format(metric, index)))));
25631
25949
  const tableData = [header, separator, ...rows];
25632
25950
  const summaryTable = tableData.map((row) => `| ${row.join(" | ")} |`).join("\n");
25633
- // Calculate percentage of total time for each metric
25951
+ // Calculate percentage of total time for each metric. Guard against zero total
25952
+ // duration (all-instant operations) to avoid NaN% in the rendered report.
25634
25953
  const percentages = sortedMetrics.map((metric) => {
25635
- const pct = (metric.totalDuration / stats.totalDuration) * 100;
25954
+ const pctRaw = stats.totalDuration > 0
25955
+ ? (metric.totalDuration / stats.totalDuration) * 100
25956
+ : 0;
25957
+ const pct = isUnsafe$2(pctRaw) ? 0 : pctRaw;
25636
25958
  return `- **${metric.metricType}**: ${pct.toFixed(1)}% (${metric.totalDuration.toFixed(2)}ms total)`;
25637
25959
  });
25638
25960
  return [
@@ -26401,6 +26723,25 @@ function isUnsafe(value) {
26401
26723
  }
26402
26724
  return false;
26403
26725
  }
26726
+ /** Minimum closed signals required to annualize Sharpe / yearly returns / Calmar. */
26727
+ const MIN_SIGNALS_FOR_ANNUALIZATION = 10;
26728
+ /** Minimum signals required for ANY ratio metric (Sharpe / Sortino / stdDev). Below this,
26729
+ * sample size is too small to estimate variance meaningfully. */
26730
+ const MIN_SIGNALS_FOR_RATIOS = 10;
26731
+ /** Minimum calendar span (days) for trade-frequency extrapolation. */
26732
+ const MIN_CALENDAR_SPAN_DAYS = 14;
26733
+ /** Hard cap on tradesPerYear — prevents absurd extrapolation from short windows / clustered trades. */
26734
+ const MAX_TRADES_PER_YEAR = 365;
26735
+ /** Hard cap on |expectedYearlyReturns| percent. Compound interest on high avgPnl × frequency
26736
+ * blows up to mathematically correct but business-unrealistic values. ±100% = 2x equity —
26737
+ * anything above this we suspect is a noisy estimate, not a genuine edge. Above the cap → null. */
26738
+ const MAX_EXPECTED_YEARLY_RETURNS = 100;
26739
+ /** Hard cap on |calmarRatio|. Prevents explosion when equityMaxDrawdown is near zero. */
26740
+ const MAX_CALMAR_RATIO = 1000;
26741
+ /** Minimum stdDev required for Sharpe/Sortino. Identical-returns series produce
26742
+ * float-artifact stdDev (~1e-17) that's > 0 but spuriously inflates sharpe to
26743
+ * astronomical magnitudes (avgPnl / epsilon). */
26744
+ const STDDEV_EPSILON = 1e-9;
26404
26745
  /**
26405
26746
  * Storage class for accumulating closed signals per strategy and generating heatmap.
26406
26747
  * Maintains symbol-level statistics and provides portfolio-wide metrics.
@@ -26442,7 +26783,7 @@ class HeatmapStorage {
26442
26783
  * - **totalPnl** — sum of `pnlPercentage` across all signals
26443
26784
  * - **avgPnl** — arithmetic mean of `pnlPercentage`
26444
26785
  * - **stdDev** — population standard deviation of `pnlPercentage`
26445
- * - **sharpeRatio** — `avgPnl / stdDev`; requires ≥ 2 signals and `stdDev > 0`
26786
+ * - **sharpeRatio** — per-trade Sharpe: `avgPnl / stdDev`; requires ≥ 2 signals and `stdDev > 0`
26446
26787
  * - **maxDrawdown** — largest cumulative loss streak (absolute value of peak negative equity)
26447
26788
  * - **profitFactor** — `sumWins / |sumLosses|`; requires at least one win and one loss
26448
26789
  * - **avgWin / avgLoss** — mean of positive / negative trades respectively
@@ -26458,10 +26799,12 @@ class HeatmapStorage {
26458
26799
  const totalTrades = signals.length;
26459
26800
  const winCount = signals.filter((s) => s.pnl.pnlPercentage > 0).length;
26460
26801
  const lossCount = signals.filter((s) => s.pnl.pnlPercentage < 0).length;
26461
- // Calculate win rate
26802
+ // Win rate excludes break-even trades from both numerator and denominator —
26803
+ // they are neither wins nor losses.
26462
26804
  let winRate = null;
26463
- if (totalTrades > 0) {
26464
- winRate = (winCount / totalTrades) * 100;
26805
+ const decisiveTrades = winCount + lossCount;
26806
+ if (decisiveTrades > 0) {
26807
+ winRate = (winCount / decisiveTrades) * 100;
26465
26808
  }
26466
26809
  // Calculate total PNL
26467
26810
  let totalPnl = null;
@@ -26473,36 +26816,47 @@ class HeatmapStorage {
26473
26816
  if (signals.length > 0) {
26474
26817
  avgPnl = totalPnl / signals.length;
26475
26818
  }
26476
- // Calculate standard deviation
26819
+ // Sample standard deviation (Bessel correction: divide by N-1, not N).
26820
+ // Per-symbol ratios are gated by MIN_SIGNALS_FOR_RATIOS — variance estimates from
26821
+ // tiny samples are too noisy to publish.
26822
+ const canComputeRatios = signals.length >= MIN_SIGNALS_FOR_RATIOS;
26477
26823
  let stdDev = null;
26478
- if (signals.length > 1 && avgPnl !== null) {
26479
- const variance = signals.reduce((acc, s) => acc + Math.pow(s.pnl.pnlPercentage - avgPnl, 2), 0) / signals.length;
26824
+ if (canComputeRatios && avgPnl !== null) {
26825
+ const variance = signals.reduce((acc, s) => acc + Math.pow(s.pnl.pnlPercentage - avgPnl, 2), 0) / (signals.length - 1);
26480
26826
  stdDev = Math.sqrt(variance);
26481
26827
  }
26482
- // Calculate Sharpe Ratio
26828
+ // Per-trade Sharpe Ratio
26483
26829
  let sharpeRatio = null;
26484
- if (avgPnl !== null && stdDev !== null && stdDev !== 0) {
26830
+ // STDDEV_EPSILON guard protects against float-artifact stdDev producing
26831
+ // spuriously astronomical sharpe on identical-returns symbols.
26832
+ if (avgPnl !== null && stdDev !== null && stdDev > STDDEV_EPSILON) {
26485
26833
  sharpeRatio = avgPnl / stdDev;
26486
26834
  }
26487
- // Calculate Maximum Drawdown
26835
+ // Equity-curve max drawdown via compounded equity ("as-if 100% allocation per trade").
26836
+ // Signals are stored newest-first (unshift in addSignal), so iterate in reverse.
26837
+ // If equity ≤ 0 — account blown, fix DD at 100%. equityFinal feeds expectedYearlyReturns.
26488
26838
  let maxDrawdown = null;
26839
+ let equityFinal = 1;
26840
+ let blown = false;
26489
26841
  if (signals.length > 0) {
26490
- let peak = 0;
26491
- let currentDrawdown = 0;
26842
+ let equity = 1;
26843
+ let peak = 1;
26492
26844
  let maxDD = 0;
26493
- for (const signal of signals) {
26494
- peak += signal.pnl.pnlPercentage;
26495
- if (peak > 0) {
26496
- currentDrawdown = 0;
26497
- }
26498
- else {
26499
- currentDrawdown = Math.abs(peak);
26500
- if (currentDrawdown > maxDD) {
26501
- maxDD = currentDrawdown;
26502
- }
26845
+ for (let i = signals.length - 1; i >= 0; i--) {
26846
+ equity *= 1 + signals[i].pnl.pnlPercentage / 100;
26847
+ if (equity <= 0) {
26848
+ maxDD = 100;
26849
+ blown = true;
26850
+ break;
26503
26851
  }
26852
+ if (equity > peak)
26853
+ peak = equity;
26854
+ const dd = (peak - equity) / peak * 100;
26855
+ if (dd > maxDD)
26856
+ maxDD = dd;
26504
26857
  }
26505
26858
  maxDrawdown = maxDD;
26859
+ equityFinal = blown ? 0 : equity;
26506
26860
  }
26507
26861
  // Calculate Profit Factor
26508
26862
  let profitFactor = null;
@@ -26513,7 +26867,9 @@ class HeatmapStorage {
26513
26867
  const sumLosses = Math.abs(signals
26514
26868
  .filter((s) => s.pnl.pnlPercentage < 0)
26515
26869
  .reduce((acc, s) => acc + s.pnl.pnlPercentage, 0));
26516
- if (sumLosses > 0) {
26870
+ // STDDEV_EPSILON guard — float-artifact losses (≈1e-15) would otherwise
26871
+ // produce spurious astronomical profitFactor (≈1e14).
26872
+ if (sumLosses > STDDEV_EPSILON) {
26517
26873
  profitFactor = sumWins / sumLosses;
26518
26874
  }
26519
26875
  }
@@ -26553,45 +26909,110 @@ class HeatmapStorage {
26553
26909
  }
26554
26910
  }
26555
26911
  }
26556
- // Calculate Expectancy
26912
+ // Expectancy — probabilities from observed win/loss counts (break-evens contribute 0).
26557
26913
  let expectancy = null;
26558
- if (winRate !== null && avgWin !== null && avgLoss !== null) {
26559
- const lossRate = 100 - winRate;
26560
- expectancy = (winRate / 100) * avgWin + (lossRate / 100) * avgLoss;
26914
+ if (totalTrades > 0 && avgWin !== null && avgLoss !== null) {
26915
+ const winProb = winCount / totalTrades;
26916
+ const lossProb = lossCount / totalTrades;
26917
+ expectancy = winProb * avgWin + lossProb * avgLoss;
26918
+ }
26919
+ else if (totalTrades > 0 && avgWin !== null && avgLoss === null) {
26920
+ // No losing trades — expectancy is just average win frequency × avgWin
26921
+ expectancy = (winCount / totalTrades) * avgWin;
26922
+ }
26923
+ else if (totalTrades > 0 && avgWin === null && avgLoss !== null) {
26924
+ expectancy = (lossCount / totalTrades) * avgLoss;
26561
26925
  }
26562
- // Calculate average peak and fall PNL
26926
+ // Average only over signals that have the value — do not dilute the mean with zeros.
26563
26927
  let avgPeakPnl = null;
26564
26928
  let avgFallPnl = null;
26565
26929
  if (signals.length > 0) {
26566
- avgPeakPnl = signals.reduce((acc, s) => acc + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / signals.length;
26567
- avgFallPnl = signals.reduce((acc, s) => acc + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / signals.length;
26930
+ const peakValues = signals
26931
+ .map((s) => s.signal.peakProfit?.pnlPercentage)
26932
+ .filter((v) => typeof v === "number");
26933
+ const fallValues = signals
26934
+ .map((s) => s.signal.maxDrawdown?.pnlPercentage)
26935
+ .filter((v) => typeof v === "number");
26936
+ avgPeakPnl = peakValues.length > 0
26937
+ ? peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length
26938
+ : null;
26939
+ avgFallPnl = fallValues.length > 0
26940
+ ? fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length
26941
+ : null;
26568
26942
  }
26569
- // Downside per signal: maxDrawdown.pnlPercentage captures the worst intra-trade dip
26570
- const fallReturns = signals.map((s) => s.signal.maxDrawdown?.pnlPercentage ?? 0);
26571
- // Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
26943
+ // Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
26944
+ // downsideDev = ( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
26945
+ // so the numerator reduces to avgPnl and the squared term to r² for r < 0.
26946
+ // Dividing by N_total (not N_negative) properly penalises strategies with frequent
26947
+ // losses; the "modified" form (N_negative) hides frequency risk in catastrophic-tail
26948
+ // strategies.
26572
26949
  let sortinoRatio = null;
26573
- if (signals.length > 0 && avgPnl !== null) {
26574
- const fallVariance = fallReturns.reduce((acc, r) => acc + Math.pow(r, 2), 0) / signals.length;
26575
- const fallDeviation = Math.sqrt(fallVariance);
26576
- if (fallDeviation > 0) {
26577
- sortinoRatio = avgPnl / fallDeviation;
26578
- }
26579
- }
26580
- // Max absolute drawdown across all signals denominator for Calmar and Recovery
26581
- const maxAbsFall = fallReturns.reduce((max, r) => Math.max(max, Math.abs(r)), 0);
26582
- // Expected yearly returns — needed for Calmar
26583
- let expectedYearlyReturns = 0;
26584
- if (signals.length > 0 && avgPnl !== null) {
26585
- const avgDurationMs = signals.reduce((sum, s) => sum + (s.closeTimestamp - s.signal.pendingAt), 0) / signals.length;
26586
- const avgDurationDays = avgDurationMs / (1000 * 60 * 60 * 24);
26587
- const tradesPerYear = avgDurationDays > 0 ? 365 / avgDurationDays : 0;
26588
- expectedYearlyReturns = avgPnl * tradesPerYear;
26950
+ if (canComputeRatios && avgPnl !== null) {
26951
+ const negativeReturns = signals
26952
+ .map((s) => s.pnl.pnlPercentage)
26953
+ .filter((r) => r < 0);
26954
+ if (negativeReturns.length > 0) {
26955
+ const downsideVariance = negativeReturns.reduce((acc, r) => acc + r * r, 0) / signals.length;
26956
+ const downsideDeviation = Math.sqrt(downsideVariance);
26957
+ // Same epsilon guard as Sharpeprotects against float-artifact downsideDev.
26958
+ if (downsideDeviation > STDDEV_EPSILON) {
26959
+ sortinoRatio = avgPnl / downsideDeviation;
26960
+ }
26961
+ }
26589
26962
  }
26963
+ // Expected yearly returns via geometric mean of equity curve.
26964
+ // equityFinal^(tradesPerYear / N) - 1 — accounts for volatility drag.
26965
+ // Gated by sample size and calendar span; if account blown → full loss.
26966
+ let expectedYearlyReturns = null;
26967
+ let tradesPerYear = null;
26968
+ if (signals.length >= MIN_SIGNALS_FOR_ANNUALIZATION) {
26969
+ let firstPendingAt = Infinity;
26970
+ let lastCloseAt = -Infinity;
26971
+ for (const s of signals) {
26972
+ if (s.signal.pendingAt < firstPendingAt)
26973
+ firstPendingAt = s.signal.pendingAt;
26974
+ if (s.closeTimestamp > lastCloseAt)
26975
+ lastCloseAt = s.closeTimestamp;
26976
+ }
26977
+ const calendarSpanDays = (lastCloseAt - firstPendingAt) / (1000 * 60 * 60 * 24);
26978
+ if (calendarSpanDays >= MIN_CALENDAR_SPAN_DAYS) {
26979
+ // tradesPerYear uses RAW observed frequency — no clipping. If the raw value
26980
+ // exceeds MAX_TRADES_PER_YEAR the sample is too clustered for reliable
26981
+ // annualization, and we leave the annualized metric null instead of silently
26982
+ // understating it with a clipped frequency.
26983
+ const rawTradesPerYear = (signals.length / calendarSpanDays) * 365;
26984
+ if (rawTradesPerYear <= MAX_TRADES_PER_YEAR) {
26985
+ tradesPerYear = rawTradesPerYear;
26986
+ if (blown) {
26987
+ expectedYearlyReturns = -100;
26988
+ }
26989
+ else {
26990
+ // If raw value exceeds MAX_EXPECTED_YEARLY_RETURNS, leave null rather than
26991
+ // show the cap — capped numbers mislead users into trusting them.
26992
+ const raw = (Math.pow(equityFinal, tradesPerYear / signals.length) - 1) * 100;
26993
+ expectedYearlyReturns = Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS ? null : raw;
26994
+ }
26995
+ }
26996
+ }
26997
+ }
26998
+ // Calmar = annualized return / equity-curve max drawdown, capped at ±MAX_CALMAR_RATIO.
26999
+ // Recovery Factor uses the compounded total return (equityFinal-1)*100, not arithmetic
27000
+ // totalPnl — denominator is compounded so numerator must match. Null when account blown.
26590
27001
  let calmarRatio = null;
26591
27002
  let recoveryFactor = null;
26592
- if (maxAbsFall > 0 && totalPnl !== null) {
26593
- calmarRatio = expectedYearlyReturns / maxAbsFall;
26594
- recoveryFactor = totalPnl / maxAbsFall;
27003
+ if (maxDrawdown !== null && maxDrawdown > 0) {
27004
+ if (expectedYearlyReturns !== null) {
27005
+ const raw = expectedYearlyReturns / maxDrawdown;
27006
+ calmarRatio = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, raw));
27007
+ }
27008
+ if (!blown && canComputeRatios) {
27009
+ // Gated below MIN_SIGNALS_FOR_RATIOS like Sharpe — a Recovery Factor on
27010
+ // a handful of trades is statistically meaningless, so don't surface it
27011
+ // per-symbol while Sharpe is N/A.
27012
+ // Same MAX_CALMAR_RATIO clamp as Calmar — both compounded-profit/DD ratios.
27013
+ const rawRec = ((equityFinal - 1) * 100) / maxDrawdown;
27014
+ recoveryFactor = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, rawRec));
27015
+ }
26595
27016
  }
26596
27017
  // Apply safe math checks
26597
27018
  if (isUnsafe(winRate))
@@ -26656,12 +27077,18 @@ class HeatmapStorage {
26656
27077
  * 2. Sorts symbols by `sharpeRatio` descending — best performers first,
26657
27078
  * symbols with `null` sharpeRatio placed at the end.
26658
27079
  * 3. Computes portfolio-wide aggregates:
26659
- * - `portfolioTotalPnl` — sum of all per-symbol `totalPnl` values (treats `null` as 0)
26660
- * - `portfolioTotalTrades` sum of all per-symbol `totalTrades`
26661
- * - `portfolioSharpeRatio` trade-count-weighted average of per-symbol sharpe ratios
26662
- *
26663
- * @returns Promise resolving to `HeatmapStatisticsModel` with per-symbol rows and
26664
- * portfolio-wide `portfolioTotalPnl`, `portfolioSharpeRatio`, `portfolioTotalTrades`
27080
+ * - `portfolioTotalPnl` — sum of per-symbol `totalPnl` values, skipping `null` entries
27081
+ * (so a symbol with no data does not silently contribute 0). If every symbol's
27082
+ * `totalPnl` is null, the portfolio value is null.
27083
+ * - `portfolioTotalTrades` — sum of per-symbol `totalTrades`
27084
+ * - `portfolioSharpeRatio` POOLED Sharpe over all trades across symbols (sample
27085
+ * stddev, N-1). NOT a Markowitz portfolio Sharpe — ignores cross-symbol
27086
+ * correlations and capital allocation. Rendered as "Pooled Sharpe" in the report.
27087
+ * Gated by `MIN_SIGNALS_FOR_RATIOS` on the pooled count.
27088
+ * - `portfolioAvgPeakPnl` / `portfolioAvgFallPnl` — trade-count-weighted means
27089
+ * over symbols that have non-null values.
27090
+ *
27091
+ * @returns Promise resolving to `HeatmapStatisticsModel`
26665
27092
  */
26666
27093
  async getData() {
26667
27094
  const symbols = [];
@@ -26680,31 +27107,53 @@ class HeatmapStorage {
26680
27107
  return -1;
26681
27108
  return b.sharpeRatio - a.sharpeRatio;
26682
27109
  });
26683
- // Calculate portfolio-wide metrics
27110
+ // Portfolio totals — sum only over symbols with non-null totalPnl. `s.totalPnl || 0`
27111
+ // would silently treat a missing value as zero and hide that some symbols had no data.
26684
27112
  const totalSymbols = symbols.length;
26685
27113
  let portfolioTotalPnl = null;
26686
27114
  let portfolioTotalTrades = 0;
26687
27115
  if (symbols.length > 0) {
26688
- portfolioTotalPnl = symbols.reduce((acc, s) => acc + (s.totalPnl || 0), 0);
27116
+ const validTotalPnls = symbols.filter((s) => s.totalPnl !== null);
27117
+ portfolioTotalPnl = validTotalPnls.length > 0
27118
+ ? validTotalPnls.reduce((acc, s) => acc + s.totalPnl, 0)
27119
+ : null;
26689
27120
  portfolioTotalTrades = symbols.reduce((acc, s) => acc + s.totalTrades, 0);
26690
27121
  }
26691
- // Calculate portfolio Sharpe Ratio (weighted by number of trades)
27122
+ // Pooled Sharpe over all returns across symbols. NOTE: this is NOT a Markowitz
27123
+ // portfolio Sharpe — it ignores cross-symbol correlations and treats trades as a
27124
+ // single pooled sample. Gated by MIN_SIGNALS_FOR_RATIOS so a 2-trade pool cannot
27125
+ // produce a noisy ±Sharpe.
26692
27126
  let portfolioSharpeRatio = null;
26693
- const validSharpes = symbols.filter((s) => s.sharpeRatio !== null);
26694
- if (validSharpes.length > 0 && portfolioTotalTrades > 0) {
26695
- const weightedSum = validSharpes.reduce((acc, s) => acc + s.sharpeRatio * s.totalTrades, 0);
26696
- portfolioSharpeRatio = weightedSum / portfolioTotalTrades;
27127
+ const allReturns = [];
27128
+ for (const signals of this.symbolData.values()) {
27129
+ for (const s of signals) {
27130
+ allReturns.push(s.pnl.pnlPercentage);
27131
+ }
27132
+ }
27133
+ if (allReturns.length >= MIN_SIGNALS_FOR_RATIOS) {
27134
+ const portfolioAvg = allReturns.reduce((acc, r) => acc + r, 0) / allReturns.length;
27135
+ const portfolioVariance = allReturns.reduce((acc, r) => acc + Math.pow(r - portfolioAvg, 2), 0) /
27136
+ (allReturns.length - 1);
27137
+ const portfolioStdDev = Math.sqrt(portfolioVariance);
27138
+ // STDDEV_EPSILON guard — same protection as per-symbol Sharpe.
27139
+ if (portfolioStdDev > STDDEV_EPSILON) {
27140
+ portfolioSharpeRatio = portfolioAvg / portfolioStdDev;
27141
+ }
26697
27142
  }
26698
- // Calculate portfolio-wide weighted average peak/fall PNL
27143
+ // Portfolio-wide weighted average peak/fall PNL. Denominator must include only
27144
+ // symbols that contributed a value — otherwise trade-count-weighted mean is diluted
27145
+ // by symbols without the metric.
26699
27146
  let portfolioAvgPeakPnl = null;
26700
27147
  let portfolioAvgFallPnl = null;
26701
27148
  const validPeak = symbols.filter((s) => s.avgPeakPnl !== null);
26702
27149
  const validFall = symbols.filter((s) => s.avgFallPnl !== null);
26703
- if (validPeak.length > 0 && portfolioTotalTrades > 0) {
26704
- portfolioAvgPeakPnl = validPeak.reduce((acc, s) => acc + s.avgPeakPnl * s.totalTrades, 0) / portfolioTotalTrades;
27150
+ const peakTradesTotal = validPeak.reduce((acc, s) => acc + s.totalTrades, 0);
27151
+ const fallTradesTotal = validFall.reduce((acc, s) => acc + s.totalTrades, 0);
27152
+ if (validPeak.length > 0 && peakTradesTotal > 0) {
27153
+ portfolioAvgPeakPnl = validPeak.reduce((acc, s) => acc + s.avgPeakPnl * s.totalTrades, 0) / peakTradesTotal;
26705
27154
  }
26706
- if (validFall.length > 0 && portfolioTotalTrades > 0) {
26707
- portfolioAvgFallPnl = validFall.reduce((acc, s) => acc + s.avgFallPnl * s.totalTrades, 0) / portfolioTotalTrades;
27155
+ if (validFall.length > 0 && fallTradesTotal > 0) {
27156
+ portfolioAvgFallPnl = validFall.reduce((acc, s) => acc + s.avgFallPnl * s.totalTrades, 0) / fallTradesTotal;
26708
27157
  }
26709
27158
  // Apply safe math
26710
27159
  if (isUnsafe(portfolioTotalPnl))
@@ -26732,7 +27181,7 @@ class HeatmapStorage {
26732
27181
  * ```
26733
27182
  * # Portfolio Heatmap: {strategyName}
26734
27183
  *
26735
- * **Total Symbols:** N | **Portfolio PNL:** X% | **Portfolio Sharpe:** Y | **Total Trades:** Z
27184
+ * **Total Symbols:** N | **Portfolio PNL:** X% | **Pooled Sharpe:** Y | **Total Trades:** Z
26736
27185
  *
26737
27186
  * | col1 | col2 | ... |
26738
27187
  * | --- | --- | ... |
@@ -26771,18 +27220,21 @@ class HeatmapStorage {
26771
27220
  return [
26772
27221
  `# Portfolio Heatmap: ${strategyName}`,
26773
27222
  "",
26774
- `**Total Symbols:** ${data.totalSymbols} | **Portfolio PNL:** ${data.portfolioTotalPnl !== null ? functoolsKit.str(data.portfolioTotalPnl, "%") : "N/A"} | **Portfolio Sharpe:** ${data.portfolioSharpeRatio !== null ? functoolsKit.str(data.portfolioSharpeRatio) : "N/A"} | **Total Trades:** ${data.portfolioTotalTrades} | **Avg Peak PNL:** ${data.portfolioAvgPeakPnl !== null ? functoolsKit.str(data.portfolioAvgPeakPnl, "%") : "N/A"} | **Avg Max Drawdown PNL:** ${data.portfolioAvgFallPnl !== null ? functoolsKit.str(data.portfolioAvgFallPnl, "%") : "N/A"}`,
27223
+ `**Total Symbols:** ${data.totalSymbols} | **Portfolio PNL:** ${data.portfolioTotalPnl !== null ? functoolsKit.str(data.portfolioTotalPnl, "%") : "N/A"} | **Pooled Sharpe:** ${data.portfolioSharpeRatio !== null ? functoolsKit.str(data.portfolioSharpeRatio) : "N/A"} | **Total Trades:** ${data.portfolioTotalTrades} | **Avg Peak PNL:** ${data.portfolioAvgPeakPnl !== null ? functoolsKit.str(data.portfolioAvgPeakPnl, "%") : "N/A"} | **Avg Max Drawdown PNL:** ${data.portfolioAvgFallPnl !== null ? functoolsKit.str(data.portfolioAvgFallPnl, "%") : "N/A"}`,
26775
27224
  "",
26776
27225
  table,
26777
27226
  "",
26778
27227
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
27228
+ `*Pooled Sharpe: Sharpe computed over all trades across symbols treated as one sample. NOT a Markowitz portfolio Sharpe — ignores cross-symbol correlations and capital allocation. N/A unless ≥${MIN_SIGNALS_FOR_RATIOS} pooled trades.*`,
26779
27229
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals per symbol.*`,
26780
- `*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
27230
+ `*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals. N/A when no losing trades — Sortino is mathematically undefined (infinite) and we cannot distinguish "truly flawless" from "lucky streak so far".*`,
26781
27231
  `*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
26782
27232
  `*Profit Factor: below 1.0 means strategy is losing overall. Above 1.5 is considered good.*`,
26783
- `*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Based on theoretical yearly returns.*`,
26784
- `*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good.*`,
26785
- `*All metrics require 100+ signals per symbol to be statistically reliable. Time period matters only for Calmar Ratio it assumes current market conditions hold year-round, which may not reflect reality.*`,
27233
+ `*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is compounded equity-curve max drawdown. N/A unless ≥${MIN_SIGNALS_FOR_ANNUALIZATION} signals per symbol and span ≥${MIN_CALENDAR_SPAN_DAYS} days. Capped at ±${MAX_CALMAR_RATIO}.*`,
27234
+ `*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good. Uses compounded total return as numerator.*`,
27235
+ `*All metrics require 100+ signals per symbol to be statistically reliable. Annualized metrics assume the observed trading frequency persists year-round.*`,
27236
+ `*IMPORTANT: Per-symbol equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per trade** (no sizing, no portfolio fraction). If your strategy risks X% of capital per trade, the realized return / drawdown will be roughly X/100 of the reported figures. The framework does not track portfolio-level sizing, so these metrics represent a theoretical upper bound under full allocation.*`,
27237
+ `*Negative values for Sharpe / Sortino / Calmar / Recovery indicate a losing symbol (avgPnl < 0 or totalPnl < 0). "Higher is better" still applies — closer to zero is less bad, positive is profitable.*`,
26786
27238
  ].join("\n");
26787
27239
  }
26788
27240
  /**
@@ -26977,7 +27429,7 @@ class HeatMarkdownService {
26977
27429
  * console.log(markdown);
26978
27430
  * // # Portfolio Heatmap: my-strategy
26979
27431
  * //
26980
- * // **Total Symbols:** 5 | **Portfolio PNL:** +45.3% | **Portfolio Sharpe:** 1.85 | **Total Trades:** 120
27432
+ * // **Total Symbols:** 5 | **Portfolio PNL:** +45.3% | **Pooled Sharpe:** 1.85 | **Total Trades:** 120
26981
27433
  * //
26982
27434
  * // | Symbol | Total PNL | Sharpe | Max DD | Trades |
26983
27435
  * // | --- | --- | --- | --- | --- |
@@ -63283,6 +63735,7 @@ const CRON_METHOD_NAME_CLEAR = "CronUtils.clear";
63283
63735
  const CRON_METHOD_NAME_TICK = "CronUtils._tick";
63284
63736
  const CRON_METHOD_NAME_ENABLE = "CronUtils.enable";
63285
63737
  const CRON_METHOD_NAME_DISABLE = "CronUtils.disable";
63738
+ const CRON_METHOD_NAME_DISPOSE = "CronUtils.dispose";
63286
63739
  /**
63287
63740
  * Local logger instance.
63288
63741
  *
@@ -63672,6 +64125,38 @@ class CronUtils {
63672
64125
  lastSubscription();
63673
64126
  }
63674
64127
  };
64128
+ /**
64129
+ * Hard-reset the entire `Cron` state.
64130
+ *
64131
+ * Performs in order:
64132
+ * 1. {@link disable} — tears down lifecycle subscriptions and resets the
64133
+ * `enable` singleshot so a future `enable()` re-subscribes cleanly.
64134
+ * 2. Wipes `_entries` — every {@link register}'ed entry is forgotten.
64135
+ * Disposers returned by previous `register()` calls become no-ops
64136
+ * (their `unregister(name)` will not find anything to remove).
64137
+ * 3. Wipes `_firedOnce` — all fire-once marks are dropped, so any future
64138
+ * re-registration of the same `name` fires again on the next matching
64139
+ * tick.
64140
+ * 4. Does **not** touch `_inFlight` — in-flight handlers continue to
64141
+ * settle in the background and clear their own slots via `.finally()`.
64142
+ * Their final `_firedOnce.add(firedKey)` writes carry old-generation
64143
+ * keys and are harmless (lookup uses the post-dispose generation).
64144
+ *
64145
+ * Use from a CLI/session teardown when you want to throw away every
64146
+ * registration along with the lifecycle wiring — e.g. between two
64147
+ * independent runner scopes. For "just snap the subscriptions but keep
64148
+ * registrations" use {@link disable} instead; for "just re-arm fire-once
64149
+ * marks" use {@link clear}.
64150
+ *
64151
+ * Idempotent. Safe to call multiple times and safe to call before
64152
+ * `enable()` / without any registrations.
64153
+ */
64154
+ this.dispose = () => {
64155
+ LOGGER_SERVICE$1.info(CRON_METHOD_NAME_DISPOSE);
64156
+ this.disable();
64157
+ this._entries.clear();
64158
+ this._firedOnce.clear();
64159
+ };
63675
64160
  }
63676
64161
  /**
63677
64162
  * Garbage-collect every `_firedOnce` key that belongs to the entry `name`