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.mjs CHANGED
@@ -23706,7 +23706,7 @@ const CREATE_FILE_NAME_FN$c = (symbol, strategyName, exchangeName, frameName, ti
23706
23706
  * @param value - Value to check
23707
23707
  * @returns true if value is unsafe, false otherwise
23708
23708
  */
23709
- function isUnsafe$3(value) {
23709
+ function isUnsafe$4(value) {
23710
23710
  if (typeof value !== "number") {
23711
23711
  return true;
23712
23712
  }
@@ -23718,6 +23718,25 @@ function isUnsafe$3(value) {
23718
23718
  }
23719
23719
  return false;
23720
23720
  }
23721
+ /** Minimum closed signals required to annualize Sharpe / yearly returns / Calmar. */
23722
+ const MIN_SIGNALS_FOR_ANNUALIZATION$2 = 10;
23723
+ /** Minimum signals required for ANY ratio metric (Sharpe / Sortino / stdDev). Below this,
23724
+ * sample size is too small to estimate variance meaningfully. */
23725
+ const MIN_SIGNALS_FOR_RATIOS$2 = 10;
23726
+ /** Minimum calendar span (days) for trade-frequency extrapolation. */
23727
+ const MIN_CALENDAR_SPAN_DAYS$2 = 14;
23728
+ /** Hard cap on tradesPerYear — prevents absurd extrapolation from short windows / clustered trades. */
23729
+ const MAX_TRADES_PER_YEAR$2 = 365;
23730
+ /** Hard cap on |expectedYearlyReturns| percent. Compound interest on high avgPnl × frequency
23731
+ * blows up to mathematically correct but business-unrealistic values. ±100% = 2x equity —
23732
+ * anything above this we suspect is a noisy estimate, not a genuine edge. Above the cap → null. */
23733
+ const MAX_EXPECTED_YEARLY_RETURNS$2 = 100;
23734
+ /** Hard cap on |calmarRatio|. Prevents explosion when equityMaxDrawdown is near zero. */
23735
+ const MAX_CALMAR_RATIO$2 = 1000;
23736
+ /** Minimum stdDev required for Sharpe/Sortino computation. Identical-returns series produce
23737
+ * float-artifact stdDev (~1e-17) that's mathematically > 0 but spuriously inflates
23738
+ * sharpe to astronomical values. Treat any stdDev below this threshold as zero. */
23739
+ const STDDEV_EPSILON$2 = 1e-9;
23721
23740
  /**
23722
23741
  * Storage class for accumulating closed signals per strategy.
23723
23742
  * Maintains a list of all closed signals and provides methods to generate reports.
@@ -23771,65 +23790,190 @@ let ReportStorage$a = class ReportStorage {
23771
23790
  recoveryFactor: null,
23772
23791
  };
23773
23792
  }
23774
- const totalSignals = this._signalList.length;
23775
- const winCount = this._signalList.filter((s) => s.pnl.pnlPercentage > 0).length;
23776
- const lossCount = this._signalList.filter((s) => s.pnl.pnlPercentage < 0).length;
23777
- // Calculate basic statistics
23778
- const avgPnl = this._signalList.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0) / totalSignals;
23779
- const totalPnl = this._signalList.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0);
23780
- const winRate = (winCount / totalSignals) * 100;
23781
- // Calculate Sharpe Ratio (risk-free rate = 0)
23782
- const returns = this._signalList.map((s) => s.pnl.pnlPercentage);
23783
- const variance = returns.reduce((sum, r) => sum + Math.pow(r - avgPnl, 2), 0) / totalSignals;
23784
- const stdDev = Math.sqrt(variance);
23785
- const sharpeRatio = stdDev > 0 ? avgPnl / stdDev : 0;
23786
- const annualizedSharpeRatio = sharpeRatio * Math.sqrt(365);
23787
- // Calculate Certainty Ratio
23788
- const wins = this._signalList.filter((s) => s.pnl.pnlPercentage > 0);
23789
- const losses = this._signalList.filter((s) => s.pnl.pnlPercentage < 0);
23793
+ // Valid signal set — those with usable pendingAt AND closeTimestamp. Single source
23794
+ // of truth for EVERY metric in this method (counts, sums, span, equity curve,
23795
+ // ratios, annualization). If we used different subsets for different metrics, the
23796
+ // numerator of one ratio could be drawn from a different population than the
23797
+ // denominator of another and the report would silently lie. On clean data
23798
+ // validSignals === this._signalList; the filter only matters for corrupted runtime
23799
+ // data.
23800
+ const validSignals = this._signalList.filter((s) => typeof s.signal.pendingAt === "number" && s.signal.pendingAt > 0 &&
23801
+ typeof s.closeTimestamp === "number" && s.closeTimestamp > 0);
23802
+ const totalSignals = validSignals.length;
23803
+ const winCount = validSignals.filter((s) => s.pnl.pnlPercentage > 0).length;
23804
+ const lossCount = validSignals.filter((s) => s.pnl.pnlPercentage < 0).length;
23805
+ // Basic statistics guard against an empty validSignals (e.g. every signal had
23806
+ // corrupted timestamps) so we don't divide by zero.
23807
+ const avgPnl = totalSignals > 0
23808
+ ? validSignals.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0) / totalSignals
23809
+ : 0;
23810
+ const totalPnl = validSignals.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0);
23811
+ // Win rate excludes break-even trades from both numerator and denominator.
23812
+ const decisiveTrades = winCount + lossCount;
23813
+ const winRate = decisiveTrades > 0 ? (winCount / decisiveTrades) * 100 : 0;
23814
+ // Calendar span over the same validSignals set used for ratios.
23815
+ let firstPendingAt = Infinity;
23816
+ let lastCloseAt = -Infinity;
23817
+ for (const s of validSignals) {
23818
+ if (s.signal.pendingAt < firstPendingAt)
23819
+ firstPendingAt = s.signal.pendingAt;
23820
+ if (s.closeTimestamp > lastCloseAt)
23821
+ lastCloseAt = s.closeTimestamp;
23822
+ }
23823
+ const calendarSpanDays = isFinite(firstPendingAt) && isFinite(lastCloseAt)
23824
+ ? (lastCloseAt - firstPendingAt) / (1000 * 60 * 60 * 24)
23825
+ : 0;
23826
+ // tradesPerYear uses the RAW observed frequency — no clipping. Clipping would
23827
+ // silently understate Sharpe / Calmar / expectedYearlyReturns. Instead, if the
23828
+ // raw frequency exceeds MAX_TRADES_PER_YEAR we treat the sample as too clustered
23829
+ // for reliable annualization and surface every annualized metric as null.
23830
+ const rawTradesPerYear = totalSignals >= MIN_SIGNALS_FOR_ANNUALIZATION$2 &&
23831
+ calendarSpanDays >= MIN_CALENDAR_SPAN_DAYS$2
23832
+ ? (totalSignals / calendarSpanDays) * 365
23833
+ : 0;
23834
+ const canAnnualize = rawTradesPerYear > 0 && rawTradesPerYear <= MAX_TRADES_PER_YEAR$2;
23835
+ const tradesPerYear = canAnnualize ? rawTradesPerYear : 0;
23836
+ // Per-trade Sharpe Ratio (risk-free rate = 0). Sample stddev (N-1) for unbiased estimate.
23837
+ // Per-trade ratios are gated by MIN_SIGNALS_FOR_RATIOS — below that, variance estimates
23838
+ // are too noisy to publish (high chance of spurious ±Sharpe).
23839
+ const returns = validSignals.map((s) => s.pnl.pnlPercentage);
23840
+ const canComputeRatios = totalSignals >= MIN_SIGNALS_FOR_RATIOS$2;
23841
+ const stdDev = canComputeRatios
23842
+ ? Math.sqrt(returns.reduce((sum, r) => sum + Math.pow(r - avgPnl, 2), 0) / (totalSignals - 1))
23843
+ : 0;
23844
+ // Use STDDEV_EPSILON gate (not stdDev > 0) — identical-returns series produce
23845
+ // float-artifact stdDev (~1e-17) that's mathematically > 0 but spuriously
23846
+ // inflates sharpe to astronomical magnitudes (avgPnl / epsilon).
23847
+ const sharpeRatio = canComputeRatios && stdDev > STDDEV_EPSILON$2
23848
+ ? avgPnl / stdDev
23849
+ : null;
23850
+ // Annualize only when gate passes; otherwise null.
23851
+ const annualizedSharpeRatio = canAnnualize && sharpeRatio !== null
23852
+ ? sharpeRatio * Math.sqrt(tradesPerYear)
23853
+ : null;
23854
+ // Equity-curve max drawdown via compounded equity (multiplicative, not additive).
23855
+ // Returns are per-trade on cost basis — compounding assumes equal capital allocation
23856
+ // per trade ("as-if 100% allocation"). Walks validSignals in chronological order
23857
+ // (storage is newest-first, so iterate in reverse). Using validSignals (same set as
23858
+ // tradesPerYear) keeps equityFinal consistent with the annualization exponent.
23859
+ // If equity goes ≤ 0 (e.g. leveraged short with r < -100%) — account blown,
23860
+ // fix DD at 100% and stop walking the curve.
23861
+ let equity = 1;
23862
+ let peak = 1;
23863
+ let equityMaxDrawdown = 0;
23864
+ let blown = false;
23865
+ for (let i = validSignals.length - 1; i >= 0; i--) {
23866
+ equity *= 1 + validSignals[i].pnl.pnlPercentage / 100;
23867
+ if (equity <= 0) {
23868
+ equityMaxDrawdown = 100;
23869
+ blown = true;
23870
+ break;
23871
+ }
23872
+ if (equity > peak)
23873
+ peak = equity;
23874
+ const dd = (peak - equity) / peak * 100;
23875
+ if (dd > equityMaxDrawdown)
23876
+ equityMaxDrawdown = dd;
23877
+ }
23878
+ const equityFinal = blown ? 0 : equity;
23879
+ // Compounded yearly return via geometric mean of equity curve.
23880
+ // equityFinal^(tradesPerYear / N) - 1 — accounts for volatility drag that
23881
+ // arithmetic-mean compounding ((1+avgPnl)^N) misses. If account is blown, full loss.
23882
+ // If the raw value would exceed MAX_EXPECTED_YEARLY_RETURNS, return null rather than
23883
+ // showing the cap as a real figure — capped numbers mislead users into trusting them.
23884
+ const expectedYearlyReturns = canAnnualize
23885
+ ? blown
23886
+ ? -100
23887
+ : (() => {
23888
+ // Geometric annualization uses validSignals.length (same set that defined
23889
+ // tradesPerYear); using totalSignals here would mismatch numerator/denominator.
23890
+ const raw = (Math.pow(equityFinal, tradesPerYear / validSignals.length) - 1) * 100;
23891
+ return Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS$2 ? null : raw;
23892
+ })()
23893
+ : null;
23894
+ // Certainty Ratio — over validSignals so wins/losses come from the same set as
23895
+ // winCount/lossCount/avgPnl above.
23896
+ const wins = validSignals.filter((s) => s.pnl.pnlPercentage > 0);
23897
+ const losses = validSignals.filter((s) => s.pnl.pnlPercentage < 0);
23790
23898
  const avgWin = wins.length > 0
23791
23899
  ? wins.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0) / wins.length
23792
23900
  : 0;
23793
23901
  const avgLoss = losses.length > 0
23794
23902
  ? losses.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0) / losses.length
23795
23903
  : 0;
23796
- const certaintyRatio = avgLoss < 0 ? avgWin / Math.abs(avgLoss) : 0;
23797
- // Calculate Expected Yearly Returns
23798
- const avgDurationMs = this._signalList.reduce((sum, s) => sum + (s.closeTimestamp - s.signal.pendingAt), 0) / totalSignals;
23799
- const avgDurationDays = avgDurationMs / (1000 * 60 * 60 * 24);
23800
- const tradesPerYear = avgDurationDays > 0 ? 365 / avgDurationDays : 0;
23801
- const expectedYearlyReturns = avgPnl * tradesPerYear;
23802
- // Calculate average peak and fall PNL across all signals
23803
- const avgPeakPnl = this._signalList.reduce((sum, s) => sum + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / totalSignals;
23804
- const avgFallPnl = this._signalList.reduce((sum, s) => sum + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / totalSignals;
23805
- // Downside per signal: maxDrawdown.pnlPercentage captures the worst intra-trade dip
23806
- const fallReturns = this._signalList.map((s) => s.signal.maxDrawdown?.pnlPercentage ?? 0);
23807
- // Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
23808
- const fallVariance = fallReturns.reduce((sum, r) => sum + Math.pow(r, 2), 0) / totalSignals;
23809
- const fallDeviation = Math.sqrt(fallVariance);
23810
- const sortinoRatio = fallDeviation > 0 ? avgPnl / fallDeviation : 0;
23811
- // Max absolute drawdown across all signals — used as denominator for Calmar and Recovery
23812
- const maxAbsFall = fallReturns.reduce((max, r) => Math.max(max, Math.abs(r)), 0);
23813
- const calmarRatio = maxAbsFall > 0 ? expectedYearlyReturns / maxAbsFall : 0;
23814
- const recoveryFactor = maxAbsFall > 0 ? totalPnl / maxAbsFall : 0;
23904
+ // Null below MIN_SIGNALS_FOR_RATIOS on a handful of trades the win/loss
23905
+ // means are too noisy to publish a ratio (same sample-size gate as Sharpe/
23906
+ // Sortino, so the report doesn't surface certainty while withholding the rest).
23907
+ // Also null when no losing trades OR when |avgLoss| is below STDDEV_EPSILON
23908
+ // (float-artifact losses (-1e-15) would otherwise produce a spurious
23909
+ // astronomical certaintyRatio ≈1e14).
23910
+ const certaintyRatio = canComputeRatios && Math.abs(avgLoss) > STDDEV_EPSILON$2 && avgLoss < 0
23911
+ ? avgWin / Math.abs(avgLoss)
23912
+ : null;
23913
+ // Average peak/fall PNL over validSignals; only signals that actually have the
23914
+ // value contribute (no zero dilution from missing peakProfit/maxDrawdown).
23915
+ const peakValues = validSignals
23916
+ .map((s) => s.signal.peakProfit?.pnlPercentage)
23917
+ .filter((v) => typeof v === "number");
23918
+ const fallValues = validSignals
23919
+ .map((s) => s.signal.maxDrawdown?.pnlPercentage)
23920
+ .filter((v) => typeof v === "number");
23921
+ const avgPeakPnl = peakValues.length > 0
23922
+ ? peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length
23923
+ : null;
23924
+ const avgFallPnl = fallValues.length > 0
23925
+ ? fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length
23926
+ : null;
23927
+ // Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
23928
+ // downsideDev = √( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
23929
+ // so the numerator reduces to avgPnl and the squared term to r² for r < 0.
23930
+ // Dividing by N_total (not N_negative) properly penalises strategies with frequent
23931
+ // losses; the "modified" form (N_negative) hides frequency risk in catastrophic-tail
23932
+ // strategies.
23933
+ const negativeReturns = returns.filter((r) => r < 0);
23934
+ const sortinoRatio = (() => {
23935
+ if (!canComputeRatios)
23936
+ return null;
23937
+ if (negativeReturns.length === 0)
23938
+ return null;
23939
+ const downsideVariance = negativeReturns.reduce((sum, r) => sum + r * r, 0) / returns.length;
23940
+ const downsideDeviation = Math.sqrt(downsideVariance);
23941
+ // Same epsilon guard as Sharpe — protects against float-artifact downsideDev.
23942
+ return downsideDeviation > STDDEV_EPSILON$2 ? avgPnl / downsideDeviation : null;
23943
+ })();
23944
+ // Calmar — cap |value| at MAX_CALMAR_RATIO to prevent explosion when DD is near zero.
23945
+ const calmarRatio = equityMaxDrawdown > 0 && expectedYearlyReturns !== null
23946
+ ? Math.max(-MAX_CALMAR_RATIO$2, Math.min(MAX_CALMAR_RATIO$2, expectedYearlyReturns / equityMaxDrawdown))
23947
+ : null;
23948
+ // Recovery Factor: numerator must be the compounded total return (equityFinal − 1) × 100,
23949
+ // not the arithmetic totalPnl — denominator (equityMaxDrawdown) is from the compounded
23950
+ // curve, so mixing units would inflate Recovery on long winning streaks.
23951
+ // Null below MIN_SIGNALS_FOR_RATIOS — same sample-size gate as the other ratios,
23952
+ // so a 3-trade run doesn't surface a Recovery Factor while Sharpe/Calmar are N/A.
23953
+ // Null when account is blown — ratio is meaningless after total loss.
23954
+ // Same MAX_CALMAR_RATIO clamp as Calmar — both are compounded-profit/DD ratios
23955
+ // and explode the same way when DD is near zero.
23956
+ const recoveryFactor = !canComputeRatios || blown || equityMaxDrawdown <= 0
23957
+ ? null
23958
+ : Math.max(-MAX_CALMAR_RATIO$2, Math.min(MAX_CALMAR_RATIO$2, ((equityFinal - 1) * 100) / equityMaxDrawdown));
23815
23959
  return {
23816
23960
  signalList: this._signalList,
23817
23961
  totalSignals,
23818
23962
  winCount,
23819
23963
  lossCount,
23820
- winRate: isUnsafe$3(winRate) ? null : winRate,
23821
- avgPnl: isUnsafe$3(avgPnl) ? null : avgPnl,
23822
- totalPnl: isUnsafe$3(totalPnl) ? null : totalPnl,
23823
- stdDev: isUnsafe$3(stdDev) ? null : stdDev,
23824
- sharpeRatio: isUnsafe$3(sharpeRatio) ? null : sharpeRatio,
23825
- annualizedSharpeRatio: isUnsafe$3(annualizedSharpeRatio) ? null : annualizedSharpeRatio,
23826
- certaintyRatio: isUnsafe$3(certaintyRatio) ? null : certaintyRatio,
23827
- expectedYearlyReturns: isUnsafe$3(expectedYearlyReturns) ? null : expectedYearlyReturns,
23828
- avgPeakPnl: isUnsafe$3(avgPeakPnl) ? null : avgPeakPnl,
23829
- avgFallPnl: isUnsafe$3(avgFallPnl) ? null : avgFallPnl,
23830
- sortinoRatio: isUnsafe$3(sortinoRatio) ? null : sortinoRatio,
23831
- calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
23832
- recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
23964
+ winRate: isUnsafe$4(winRate) ? null : winRate,
23965
+ avgPnl: isUnsafe$4(avgPnl) ? null : avgPnl,
23966
+ totalPnl: isUnsafe$4(totalPnl) ? null : totalPnl,
23967
+ stdDev: isUnsafe$4(stdDev) ? null : stdDev,
23968
+ sharpeRatio: isUnsafe$4(sharpeRatio) ? null : sharpeRatio,
23969
+ annualizedSharpeRatio: isUnsafe$4(annualizedSharpeRatio) ? null : annualizedSharpeRatio,
23970
+ certaintyRatio: isUnsafe$4(certaintyRatio) ? null : certaintyRatio,
23971
+ expectedYearlyReturns: isUnsafe$4(expectedYearlyReturns) ? null : expectedYearlyReturns,
23972
+ avgPeakPnl: isUnsafe$4(avgPeakPnl) ? null : avgPeakPnl,
23973
+ avgFallPnl: isUnsafe$4(avgFallPnl) ? null : avgFallPnl,
23974
+ sortinoRatio: isUnsafe$4(sortinoRatio) ? null : sortinoRatio,
23975
+ calmarRatio: isUnsafe$4(calmarRatio) ? null : calmarRatio,
23976
+ recoveryFactor: isUnsafe$4(recoveryFactor) ? null : recoveryFactor,
23833
23977
  };
23834
23978
  }
23835
23979
  /**
@@ -23871,24 +24015,26 @@ let ReportStorage$a = class ReportStorage {
23871
24015
  `**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`,
23872
24016
  `**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`,
23873
24017
  `**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`,
23874
- `**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better, theoretical)`}`,
24018
+ `**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`,
23875
24019
  `**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`,
23876
- `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better, theoretical)`}`,
24020
+ `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
23877
24021
  `**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
23878
24022
  `**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
23879
24023
  `**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
23880
- `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better, theoretical)`}`,
24024
+ `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
23881
24025
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
23882
24026
  "",
23883
24027
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
23884
24028
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
23885
- `*Annualized Sharpe Ratio: theoretical maximum assuming continuous trading. Real-world value is lower due to idle periods.*`,
23886
- `*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
24029
+ `*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.*`,
24030
+ `*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".*`,
23887
24031
  `*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
23888
- `*Expected Yearly Returns: theoretical maximum assuming all capital is deployed continuously with no idle time.*`,
23889
- `*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Based on theoretical yearly returns.*`,
23890
- `*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good.*`,
23891
- `*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.*`,
24032
+ `*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.*`,
24033
+ `*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}.*`,
24034
+ `*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.*`,
24035
+ `*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
24036
+ `*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.*`,
24037
+ `*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.*`,
23892
24038
  ].join("\n");
23893
24039
  }
23894
24040
  /**
@@ -24200,7 +24346,7 @@ const CREATE_FILE_NAME_FN$b = (symbol, strategyName, exchangeName, frameName, ti
24200
24346
  * @param value - Value to check
24201
24347
  * @returns true if value is unsafe, false otherwise
24202
24348
  */
24203
- function isUnsafe$2(value) {
24349
+ function isUnsafe$3(value) {
24204
24350
  if (typeof value !== "number") {
24205
24351
  return true;
24206
24352
  }
@@ -24212,6 +24358,25 @@ function isUnsafe$2(value) {
24212
24358
  }
24213
24359
  return false;
24214
24360
  }
24361
+ /** Minimum closed signals required to annualize Sharpe / yearly returns / Calmar. */
24362
+ const MIN_SIGNALS_FOR_ANNUALIZATION$1 = 10;
24363
+ /** Minimum signals required for ANY ratio metric (Sharpe / Sortino / stdDev). Below this,
24364
+ * sample size is too small to estimate variance meaningfully. */
24365
+ const MIN_SIGNALS_FOR_RATIOS$1 = 10;
24366
+ /** Minimum calendar span (days) for trade-frequency extrapolation. */
24367
+ const MIN_CALENDAR_SPAN_DAYS$1 = 14;
24368
+ /** Hard cap on tradesPerYear — prevents absurd extrapolation from short windows / clustered trades. */
24369
+ const MAX_TRADES_PER_YEAR$1 = 365;
24370
+ /** Hard cap on |expectedYearlyReturns| percent. Compound interest on high avgPnl × frequency
24371
+ * blows up to mathematically correct but business-unrealistic values. ±100% = 2x equity —
24372
+ * anything above this we suspect is a noisy estimate, not a genuine edge. Above the cap → null. */
24373
+ const MAX_EXPECTED_YEARLY_RETURNS$1 = 100;
24374
+ /** Hard cap on |calmarRatio|. Prevents explosion when equityMaxDrawdown is near zero. */
24375
+ const MAX_CALMAR_RATIO$1 = 1000;
24376
+ /** Minimum stdDev required for Sharpe/Sortino. Identical-returns series produce
24377
+ * float-artifact stdDev (~1e-17) that's > 0 but spuriously inflates sharpe to
24378
+ * astronomical magnitudes (avgPnl / epsilon). */
24379
+ const STDDEV_EPSILON$1 = 1e-9;
24215
24380
  /**
24216
24381
  * Storage class for accumulating all tick events per strategy.
24217
24382
  * Maintains a chronological list of all events (idle, opened, active, closed).
@@ -24495,84 +24660,190 @@ let ReportStorage$9 = class ReportStorage {
24495
24660
  };
24496
24661
  }
24497
24662
  const closedEvents = this._eventList.filter((e) => e.action === "closed");
24498
- const totalClosed = closedEvents.length;
24499
- const winCount = closedEvents.filter((e) => e.pnl && e.pnl > 0).length;
24500
- const lossCount = closedEvents.filter((e) => e.pnl && e.pnl < 0).length;
24501
- // Calculate basic statistics
24502
- const avgPnl = totalClosed > 0
24503
- ? closedEvents.reduce((sum, e) => sum + (e.pnl || 0), 0) / totalClosed
24663
+ // Valid closed set — single source of truth. Events must have numeric pnl AND valid
24664
+ // timestamps. Win/loss counts, returns, calendar span, equity curve — all derived
24665
+ // from this set so they cannot disagree.
24666
+ const validClosed = closedEvents.filter((e) => typeof e.pnl === "number" &&
24667
+ typeof e.timestamp === "number" &&
24668
+ e.timestamp > 0 &&
24669
+ typeof (e.pendingAt ?? e.timestamp) === "number");
24670
+ const totalClosed = validClosed.length;
24671
+ const winCount = validClosed.filter((e) => e.pnl > 0).length;
24672
+ const lossCount = validClosed.filter((e) => e.pnl < 0).length;
24673
+ const returns = validClosed.map((e) => e.pnl);
24674
+ const avgPnl = returns.length > 0
24675
+ ? returns.reduce((sum, r) => sum + r, 0) / returns.length
24504
24676
  : 0;
24505
- const totalPnl = closedEvents.reduce((sum, e) => sum + (e.pnl || 0), 0);
24506
- const winRate = (winCount / totalClosed) * 100;
24507
- // Calculate Sharpe Ratio (risk-free rate = 0)
24508
- let sharpeRatio = 0;
24509
- let stdDev = 0;
24510
- if (totalClosed > 0) {
24511
- const returns = closedEvents.map((e) => e.pnl || 0);
24512
- const variance = returns.reduce((sum, r) => sum + Math.pow(r - avgPnl, 2), 0) / totalClosed;
24513
- stdDev = Math.sqrt(variance);
24514
- sharpeRatio = stdDev > 0 ? avgPnl / stdDev : 0;
24515
- }
24516
- const annualizedSharpeRatio = sharpeRatio * Math.sqrt(365);
24517
- // Calculate Certainty Ratio
24518
- let certaintyRatio = 0;
24519
- if (totalClosed > 0) {
24520
- const wins = closedEvents.filter((e) => e.pnl && e.pnl > 0);
24521
- const losses = closedEvents.filter((e) => e.pnl && e.pnl < 0);
24677
+ const totalPnl = returns.reduce((sum, r) => sum + r, 0);
24678
+ // Win rate excludes break-even trades from both numerator and denominator.
24679
+ const decisiveTrades = winCount + lossCount;
24680
+ const winRate = decisiveTrades > 0 ? (winCount / decisiveTrades) * 100 : 0;
24681
+ // Trade frequency from calendar span — gated by minimum span and sample size to
24682
+ // suppress absurd annualization on short / sparse runs. Span built from validClosed
24683
+ // so denominator (calendarSpanDays) and numerator (returns.length) come from the
24684
+ // same event set.
24685
+ let firstPendingAt = Infinity;
24686
+ let lastCloseAt = -Infinity;
24687
+ for (const e of validClosed) {
24688
+ const startAt = e.pendingAt ?? e.timestamp;
24689
+ if (startAt < firstPendingAt)
24690
+ firstPendingAt = startAt;
24691
+ if (e.timestamp > lastCloseAt)
24692
+ lastCloseAt = e.timestamp;
24693
+ }
24694
+ const calendarSpanDays = validClosed.length > 0
24695
+ ? (lastCloseAt - firstPendingAt) / (1000 * 60 * 60 * 24)
24696
+ : 0;
24697
+ // tradesPerYear uses the RAW observed frequency — no clipping. Clipping would
24698
+ // silently understate Sharpe / Calmar / expectedYearlyReturns. Instead, if the
24699
+ // raw frequency exceeds MAX_TRADES_PER_YEAR we treat the sample as too clustered
24700
+ // for reliable annualization and surface every annualized metric as null.
24701
+ const rawTradesPerYear = returns.length >= MIN_SIGNALS_FOR_ANNUALIZATION$1 &&
24702
+ calendarSpanDays >= MIN_CALENDAR_SPAN_DAYS$1
24703
+ ? (returns.length / calendarSpanDays) * 365
24704
+ : 0;
24705
+ const canAnnualize = rawTradesPerYear > 0 && rawTradesPerYear <= MAX_TRADES_PER_YEAR$1;
24706
+ const tradesPerYear = canAnnualize ? rawTradesPerYear : 0;
24707
+ // Per-trade Sharpe Ratio (risk-free rate = 0). Sample stddev (N-1).
24708
+ // Per-trade ratios are gated by MIN_SIGNALS_FOR_RATIOS — below that, variance estimates
24709
+ // are too noisy to publish (high chance of spurious ±Sharpe).
24710
+ const canComputeRatios = returns.length >= MIN_SIGNALS_FOR_RATIOS$1;
24711
+ const stdDev = canComputeRatios
24712
+ ? Math.sqrt(returns.reduce((sum, r) => sum + Math.pow(r - avgPnl, 2), 0) / (returns.length - 1))
24713
+ : 0;
24714
+ // STDDEV_EPSILON guard — protects against float-artifact stdDev from identical
24715
+ // returns producing spuriously astronomical sharpe.
24716
+ const sharpeRatio = canComputeRatios && stdDev > STDDEV_EPSILON$1
24717
+ ? avgPnl / stdDev
24718
+ : null;
24719
+ // Annualize only when gate passes; otherwise null.
24720
+ const annualizedSharpeRatio = canAnnualize && sharpeRatio !== null
24721
+ ? sharpeRatio * Math.sqrt(tradesPerYear)
24722
+ : null;
24723
+ // Certainty Ratio: null (not zero) when there are no losing trades — a flawless
24724
+ // strategy has undefined Certainty Ratio, not "worst case zero". Computed on
24725
+ // validClosed for consistency with other ratios.
24726
+ // Gated below MIN_SIGNALS_FOR_RATIOS — same sample-size gate as Sharpe/Sortino,
24727
+ // so the report doesn't surface certainty on a handful of trades while
24728
+ // withholding the rest.
24729
+ let certaintyRatio = null;
24730
+ if (canComputeRatios && totalClosed > 0) {
24731
+ const wins = validClosed.filter((e) => e.pnl > 0);
24732
+ const losses = validClosed.filter((e) => e.pnl < 0);
24522
24733
  const avgWin = wins.length > 0
24523
- ? wins.reduce((sum, e) => sum + (e.pnl || 0), 0) / wins.length
24734
+ ? wins.reduce((sum, e) => sum + e.pnl, 0) / wins.length
24524
24735
  : 0;
24525
24736
  const avgLoss = losses.length > 0
24526
- ? losses.reduce((sum, e) => sum + (e.pnl || 0), 0) / losses.length
24737
+ ? losses.reduce((sum, e) => sum + e.pnl, 0) / losses.length
24527
24738
  : 0;
24528
- certaintyRatio = avgLoss < 0 ? avgWin / Math.abs(avgLoss) : 0;
24529
- }
24530
- // Calculate Expected Yearly Returns
24531
- let expectedYearlyReturns = 0;
24532
- if (totalClosed > 0) {
24533
- const avgDurationMin = closedEvents.reduce((sum, e) => sum + (e.duration || 0), 0) / totalClosed;
24534
- const avgDurationDays = avgDurationMin / (60 * 24);
24535
- const tradesPerYear = avgDurationDays > 0 ? 365 / avgDurationDays : 0;
24536
- expectedYearlyReturns = avgPnl * tradesPerYear;
24537
- }
24538
- const avgPeakPnl = totalClosed > 0
24539
- ? closedEvents.reduce((sum, e) => sum + (e.peakPnl || 0), 0) / totalClosed
24540
- : 0;
24541
- const avgFallPnl = totalClosed > 0
24542
- ? closedEvents.reduce((sum, e) => sum + (e.fallPnl || 0), 0) / totalClosed
24543
- : 0;
24544
- // Downside per signal: fallPnl captures the worst intra-trade dip (maxDrawdown.pnlPercentage)
24545
- const fallReturns = closedEvents.map((e) => e.fallPnl || 0);
24546
- // Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
24547
- let sortinoRatio = 0;
24548
- if (totalClosed > 0) {
24549
- const fallVariance = fallReturns.reduce((sum, r) => sum + Math.pow(r, 2), 0) / totalClosed;
24550
- const fallDeviation = Math.sqrt(fallVariance);
24551
- sortinoRatio = fallDeviation > 0 ? avgPnl / fallDeviation : 0;
24552
- }
24553
- // Max absolute drawdown across all signals — denominator for Calmar and Recovery
24554
- const maxAbsFall = fallReturns.reduce((max, r) => Math.max(max, Math.abs(r)), 0);
24555
- const calmarRatio = maxAbsFall > 0 ? expectedYearlyReturns / maxAbsFall : 0;
24556
- const recoveryFactor = maxAbsFall > 0 ? totalPnl / maxAbsFall : 0;
24739
+ // STDDEV_EPSILON guard on |avgLoss| protects against float-artifact
24740
+ // losses producing spurious astronomical certaintyRatio.
24741
+ certaintyRatio = Math.abs(avgLoss) > STDDEV_EPSILON$1 && avgLoss < 0
24742
+ ? avgWin / Math.abs(avgLoss)
24743
+ : null;
24744
+ }
24745
+ // Average only over signals that have the value — do not dilute the mean with zeros.
24746
+ // Use validClosed to keep all metric denominators consistent.
24747
+ const peakValues = validClosed
24748
+ .map((e) => e.peakPnl)
24749
+ .filter((v) => typeof v === "number");
24750
+ const fallValues = validClosed
24751
+ .map((e) => e.fallPnl)
24752
+ .filter((v) => typeof v === "number");
24753
+ const avgPeakPnl = peakValues.length > 0
24754
+ ? peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length
24755
+ : null;
24756
+ const avgFallPnl = fallValues.length > 0
24757
+ ? fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length
24758
+ : null;
24759
+ // Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
24760
+ // downsideDev = ( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
24761
+ // so the numerator reduces to avgPnl and the squared term to r² for r < 0.
24762
+ // Dividing by N_total (not N_negative) properly penalises strategies with frequent
24763
+ // losses; the "modified" form (N_negative) hides frequency risk in catastrophic-tail
24764
+ // strategies.
24765
+ const sortinoRatio = (() => {
24766
+ if (!canComputeRatios)
24767
+ return null;
24768
+ const negativeReturns = returns.filter((r) => r < 0);
24769
+ if (negativeReturns.length === 0)
24770
+ return null;
24771
+ const downsideVariance = negativeReturns.reduce((sum, r) => sum + r * r, 0) / returns.length;
24772
+ const downsideDeviation = Math.sqrt(downsideVariance);
24773
+ // Same epsilon guard as Sharpe — protects against float-artifact downsideDev.
24774
+ return downsideDeviation > STDDEV_EPSILON$1 ? avgPnl / downsideDeviation : null;
24775
+ })();
24776
+ // Equity-curve max drawdown via compounded equity (multiplicative). Returns are per-trade
24777
+ // on cost basis — compounding assumes equal capital allocation per trade ("as-if 100%").
24778
+ // If equity ≤ 0 (leveraged short with r < -100%) — account blown, fix DD at 100%.
24779
+ // Built from validClosed (newest-first), iterated reverse for chronological order.
24780
+ const chronologicalReturns = [];
24781
+ for (let i = validClosed.length - 1; i >= 0; i--) {
24782
+ chronologicalReturns.push(validClosed[i].pnl);
24783
+ }
24784
+ let equity = 1;
24785
+ let peak = 1;
24786
+ let equityMaxDrawdown = 0;
24787
+ let blown = false;
24788
+ for (const r of chronologicalReturns) {
24789
+ equity *= 1 + r / 100;
24790
+ if (equity <= 0) {
24791
+ equityMaxDrawdown = 100;
24792
+ blown = true;
24793
+ break;
24794
+ }
24795
+ if (equity > peak)
24796
+ peak = equity;
24797
+ const dd = (peak - equity) / peak * 100;
24798
+ if (dd > equityMaxDrawdown)
24799
+ equityMaxDrawdown = dd;
24800
+ }
24801
+ const equityFinal = blown ? 0 : equity;
24802
+ // Compounded yearly return via geometric mean of equity curve:
24803
+ // equityFinal^(tradesPerYear / N) - 1 — accounts for volatility drag.
24804
+ // If account is blown, full loss. If raw value exceeds MAX_EXPECTED_YEARLY_RETURNS,
24805
+ // return null rather than showing the cap — capped numbers mislead users.
24806
+ const expectedYearlyReturns = canAnnualize
24807
+ ? blown
24808
+ ? -100
24809
+ : (() => {
24810
+ const raw = (Math.pow(equityFinal, tradesPerYear / returns.length) - 1) * 100;
24811
+ return Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS$1 ? null : raw;
24812
+ })()
24813
+ : null;
24814
+ // Calmar — cap |value| at MAX_CALMAR_RATIO to prevent explosion when DD is near zero.
24815
+ const calmarRatio = equityMaxDrawdown > 0 && expectedYearlyReturns !== null
24816
+ ? Math.max(-MAX_CALMAR_RATIO$1, Math.min(MAX_CALMAR_RATIO$1, expectedYearlyReturns / equityMaxDrawdown))
24817
+ : null;
24818
+ // Recovery Factor: numerator must be the compounded total return, not arithmetic totalPnl —
24819
+ // denominator is from the compounded equity curve, so mixing units inflates Recovery.
24820
+ // Null below MIN_SIGNALS_FOR_RATIOS — same sample-size gate as the other ratios,
24821
+ // so a 3-trade run doesn't surface a Recovery Factor while Sharpe/Calmar are N/A.
24822
+ // Null when account is blown.
24823
+ // Same MAX_CALMAR_RATIO clamp as Calmar — both are compounded-profit/DD ratios
24824
+ // and explode the same way when DD is near zero.
24825
+ const recoveryFactor = !canComputeRatios || blown || equityMaxDrawdown <= 0
24826
+ ? null
24827
+ : Math.max(-MAX_CALMAR_RATIO$1, Math.min(MAX_CALMAR_RATIO$1, ((equityFinal - 1) * 100) / equityMaxDrawdown));
24557
24828
  return {
24558
24829
  eventList: this._eventList,
24559
24830
  totalEvents: this._eventList.length,
24560
24831
  totalClosed,
24561
24832
  winCount,
24562
24833
  lossCount,
24563
- winRate: isUnsafe$2(winRate) ? null : winRate,
24564
- avgPnl: isUnsafe$2(avgPnl) ? null : avgPnl,
24565
- totalPnl: isUnsafe$2(totalPnl) ? null : totalPnl,
24566
- stdDev: isUnsafe$2(stdDev) ? null : stdDev,
24567
- sharpeRatio: isUnsafe$2(sharpeRatio) ? null : sharpeRatio,
24568
- annualizedSharpeRatio: isUnsafe$2(annualizedSharpeRatio) ? null : annualizedSharpeRatio,
24569
- certaintyRatio: isUnsafe$2(certaintyRatio) ? null : certaintyRatio,
24570
- expectedYearlyReturns: isUnsafe$2(expectedYearlyReturns) ? null : expectedYearlyReturns,
24571
- avgPeakPnl: isUnsafe$2(avgPeakPnl) ? null : avgPeakPnl,
24572
- avgFallPnl: isUnsafe$2(avgFallPnl) ? null : avgFallPnl,
24573
- sortinoRatio: isUnsafe$2(sortinoRatio) ? null : sortinoRatio,
24574
- calmarRatio: isUnsafe$2(calmarRatio) ? null : calmarRatio,
24575
- recoveryFactor: isUnsafe$2(recoveryFactor) ? null : recoveryFactor,
24834
+ winRate: isUnsafe$3(winRate) ? null : winRate,
24835
+ avgPnl: isUnsafe$3(avgPnl) ? null : avgPnl,
24836
+ totalPnl: isUnsafe$3(totalPnl) ? null : totalPnl,
24837
+ stdDev: isUnsafe$3(stdDev) ? null : stdDev,
24838
+ sharpeRatio: isUnsafe$3(sharpeRatio) ? null : sharpeRatio,
24839
+ annualizedSharpeRatio: isUnsafe$3(annualizedSharpeRatio) ? null : annualizedSharpeRatio,
24840
+ certaintyRatio: isUnsafe$3(certaintyRatio) ? null : certaintyRatio,
24841
+ expectedYearlyReturns: isUnsafe$3(expectedYearlyReturns) ? null : expectedYearlyReturns,
24842
+ avgPeakPnl: isUnsafe$3(avgPeakPnl) ? null : avgPeakPnl,
24843
+ avgFallPnl: isUnsafe$3(avgFallPnl) ? null : avgFallPnl,
24844
+ sortinoRatio: isUnsafe$3(sortinoRatio) ? null : sortinoRatio,
24845
+ calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
24846
+ recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
24576
24847
  };
24577
24848
  }
24578
24849
  /**
@@ -24620,18 +24891,20 @@ let ReportStorage$9 = class ReportStorage {
24620
24891
  `**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
24621
24892
  `**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
24622
24893
  `**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
24623
- `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better, theoretical)`}`,
24894
+ `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
24624
24895
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
24625
24896
  "",
24626
24897
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
24627
24898
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
24628
- `*Annualized Sharpe Ratio: theoretical maximum assuming continuous trading. Real-world value is lower due to idle periods.*`,
24629
- `*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
24899
+ `*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.*`,
24900
+ `*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".*`,
24630
24901
  `*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
24631
- `*Expected Yearly Returns: theoretical maximum assuming all capital is deployed continuously with no idle time.*`,
24632
- `*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Based on theoretical yearly returns.*`,
24633
- `*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good.*`,
24634
- `*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.*`,
24902
+ `*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.*`,
24903
+ `*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}.*`,
24904
+ `*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.*`,
24905
+ `*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
24906
+ `*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.*`,
24907
+ `*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.*`,
24635
24908
  ].join("\n");
24636
24909
  }
24637
24910
  /**
@@ -25010,7 +25283,9 @@ let ReportStorage$8 = class ReportStorage {
25010
25283
  */
25011
25284
  addOpenedEvent(data) {
25012
25285
  const durationMs = data.signal.pendingAt - data.signal.scheduledAt;
25013
- const durationMin = Math.round(durationMs / 60000);
25286
+ // Keep fractional minutes rounding to whole minutes zeroed out sub-30s durations,
25287
+ // which dragged high-frequency averages towards zero.
25288
+ const durationMin = durationMs / 60000;
25014
25289
  const newEvent = {
25015
25290
  timestamp: data.signal.pendingAt,
25016
25291
  action: "opened",
@@ -25046,7 +25321,8 @@ let ReportStorage$8 = class ReportStorage {
25046
25321
  */
25047
25322
  addCancelledEvent(data) {
25048
25323
  const durationMs = data.closeTimestamp - data.signal.scheduledAt;
25049
- const durationMin = Math.round(durationMs / 60000);
25324
+ // Keep fractional minutes rounding to whole minutes zeroed out sub-30s durations.
25325
+ const durationMin = durationMs / 60000;
25050
25326
  const newEvent = {
25051
25327
  timestamp: data.closeTimestamp,
25052
25328
  action: "cancelled",
@@ -25102,19 +25378,33 @@ let ReportStorage$8 = class ReportStorage {
25102
25378
  const totalScheduled = scheduledEvents.length;
25103
25379
  const totalOpened = openedEvents.length;
25104
25380
  const totalCancelled = cancelledEvents.length;
25105
- // Calculate cancellation rate
25106
- const cancellationRate = totalScheduled > 0 ? (totalCancelled / totalScheduled) * 100 : null;
25107
- // Calculate activation rate
25108
- const activationRate = totalScheduled > 0 ? (totalOpened / totalScheduled) * 100 : null;
25109
- // Calculate average wait time for cancelled signals
25110
- const avgWaitTime = totalCancelled > 0
25111
- ? cancelledEvents.reduce((sum, e) => sum + (e.duration || 0), 0) /
25112
- totalCancelled
25381
+ // Rate denominators must include only scheduled events whose outcome (opened/cancelled)
25382
+ // is also in the buffer. Otherwise a sliding window of 250 entries can drop the
25383
+ // "scheduled" record before its outcome arrives, inflating rates above 100% or
25384
+ // causing one rate to fire without the other. Match by signalId.
25385
+ const scheduledIds = new Set(scheduledEvents.map((e) => e.signalId).filter((id) => typeof id === "string"));
25386
+ const openedFromScheduled = openedEvents.filter((e) => typeof e.signalId === "string" && scheduledIds.has(e.signalId));
25387
+ const cancelledFromScheduled = cancelledEvents.filter((e) => typeof e.signalId === "string" && scheduledIds.has(e.signalId));
25388
+ const resolvedScheduled = openedFromScheduled.length + cancelledFromScheduled.length;
25389
+ const cancellationRate = resolvedScheduled > 0
25390
+ ? (cancelledFromScheduled.length / resolvedScheduled) * 100
25391
+ : null;
25392
+ const activationRate = resolvedScheduled > 0
25393
+ ? (openedFromScheduled.length / resolvedScheduled) * 100
25113
25394
  : null;
25114
- // Calculate average activation time for opened signals
25115
- const avgActivationTime = totalOpened > 0
25116
- ? openedEvents.reduce((sum, e) => sum + (e.duration || 0), 0) /
25117
- totalOpened
25395
+ // Average durations include only events with a numeric duration, do not dilute
25396
+ // the mean with zeros for missing values.
25397
+ const cancelledDurations = cancelledEvents
25398
+ .map((e) => e.duration)
25399
+ .filter((d) => typeof d === "number");
25400
+ const openedDurations = openedEvents
25401
+ .map((e) => e.duration)
25402
+ .filter((d) => typeof d === "number");
25403
+ const avgWaitTime = cancelledDurations.length > 0
25404
+ ? cancelledDurations.reduce((sum, d) => sum + d, 0) / cancelledDurations.length
25405
+ : null;
25406
+ const avgActivationTime = openedDurations.length > 0
25407
+ ? openedDurations.reduce((sum, d) => sum + d, 0) / openedDurations.length
25118
25408
  : null;
25119
25409
  return {
25120
25410
  eventList: this._eventList,
@@ -25161,13 +25451,15 @@ let ReportStorage$8 = class ReportStorage {
25161
25451
  table,
25162
25452
  "",
25163
25453
  `**Total events:** ${stats.totalEvents}`,
25164
- `**Scheduled signals:** ${stats.totalScheduled}`,
25454
+ `**Scheduled signals (raw):** ${stats.totalScheduled}`,
25165
25455
  `**Opened signals:** ${stats.totalOpened}`,
25166
25456
  `**Cancelled signals:** ${stats.totalCancelled}`,
25167
25457
  `**Activation rate:** ${stats.activationRate === null ? "N/A" : `${stats.activationRate.toFixed(2)}% (higher is better)`}`,
25168
25458
  `**Cancellation rate:** ${stats.cancellationRate === null ? "N/A" : `${stats.cancellationRate.toFixed(2)}% (lower is better)`}`,
25169
25459
  `**Average activation time:** ${stats.avgActivationTime === null ? "N/A" : `${stats.avgActivationTime.toFixed(2)} minutes`}`,
25170
- `**Average wait time (cancelled):** ${stats.avgWaitTime === null ? "N/A" : `${stats.avgWaitTime.toFixed(2)} minutes`}`
25460
+ `**Average wait time (cancelled):** ${stats.avgWaitTime === null ? "N/A" : `${stats.avgWaitTime.toFixed(2)} minutes`}`,
25461
+ "",
25462
+ `*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.*`
25171
25463
  ].join("\n");
25172
25464
  }
25173
25465
  /**
@@ -25472,13 +25764,37 @@ const CREATE_FILE_NAME_FN$9 = (symbol, strategyName, exchangeName, frameName, ti
25472
25764
  return `${parts.join("_")}-${timestamp}.md`;
25473
25765
  };
25474
25766
  /**
25475
- * Calculates percentile value from sorted array.
25767
+ * Checks if a value is unsafe for display (not a number, NaN, or Infinity).
25768
+ */
25769
+ function isUnsafe$2(value) {
25770
+ if (typeof value !== "number") {
25771
+ return true;
25772
+ }
25773
+ if (isNaN(value)) {
25774
+ return true;
25775
+ }
25776
+ if (!isFinite(value)) {
25777
+ return true;
25778
+ }
25779
+ return false;
25780
+ }
25781
+ /**
25782
+ * Calculates percentile value from sorted array using linear interpolation
25783
+ * between adjacent ranks (equivalent to numpy.percentile with default linear method).
25784
+ * Falls back to nearest-rank for length 0/1.
25476
25785
  */
25477
25786
  function percentile(sortedArray, p) {
25478
25787
  if (sortedArray.length === 0)
25479
25788
  return 0;
25480
- const index = Math.ceil((sortedArray.length * p) / 100) - 1;
25481
- return sortedArray[Math.max(0, index)];
25789
+ if (sortedArray.length === 1)
25790
+ return sortedArray[0];
25791
+ const rank = (p / 100) * (sortedArray.length - 1);
25792
+ const lower = Math.floor(rank);
25793
+ const upper = Math.ceil(rank);
25794
+ if (lower === upper)
25795
+ return sortedArray[lower];
25796
+ const fraction = rank - lower;
25797
+ return sortedArray[lower] * (1 - fraction) + sortedArray[upper] * fraction;
25482
25798
  }
25483
25799
  /**
25484
25800
  * Storage class for accumulating performance metrics per strategy.
@@ -25534,10 +25850,12 @@ class PerformanceStorage {
25534
25850
  const durations = events.map((e) => e.duration).sort((a, b) => a - b);
25535
25851
  const totalDuration = durations.reduce((sum, d) => sum + d, 0);
25536
25852
  const avgDuration = totalDuration / durations.length;
25537
- // Calculate standard deviation
25538
- const variance = durations.reduce((sum, d) => sum + Math.pow(d - avgDuration, 2), 0) /
25539
- durations.length;
25540
- const stdDev = Math.sqrt(variance);
25853
+ // Sample standard deviation (Bessel correction: divide by N-1, not N) — consistent
25854
+ // with Sharpe/Sortino calculations in Backtest/Live/Heat services.
25855
+ const stdDev = durations.length > 1
25856
+ ? Math.sqrt(durations.reduce((sum, d) => sum + Math.pow(d - avgDuration, 2), 0) /
25857
+ (durations.length - 1))
25858
+ : 0;
25541
25859
  // Calculate wait times between events
25542
25860
  const waitTimes = [];
25543
25861
  for (let i = 0; i < events.length; i++) {
@@ -25610,9 +25928,13 @@ class PerformanceStorage {
25610
25928
  const rows = await Promise.all(sortedMetrics.map(async (metric, index) => Promise.all(visibleColumns.map((col) => col.format(metric, index)))));
25611
25929
  const tableData = [header, separator, ...rows];
25612
25930
  const summaryTable = tableData.map((row) => `| ${row.join(" | ")} |`).join("\n");
25613
- // Calculate percentage of total time for each metric
25931
+ // Calculate percentage of total time for each metric. Guard against zero total
25932
+ // duration (all-instant operations) to avoid NaN% in the rendered report.
25614
25933
  const percentages = sortedMetrics.map((metric) => {
25615
- const pct = (metric.totalDuration / stats.totalDuration) * 100;
25934
+ const pctRaw = stats.totalDuration > 0
25935
+ ? (metric.totalDuration / stats.totalDuration) * 100
25936
+ : 0;
25937
+ const pct = isUnsafe$2(pctRaw) ? 0 : pctRaw;
25616
25938
  return `- **${metric.metricType}**: ${pct.toFixed(1)}% (${metric.totalDuration.toFixed(2)}ms total)`;
25617
25939
  });
25618
25940
  return [
@@ -26381,6 +26703,25 @@ function isUnsafe(value) {
26381
26703
  }
26382
26704
  return false;
26383
26705
  }
26706
+ /** Minimum closed signals required to annualize Sharpe / yearly returns / Calmar. */
26707
+ const MIN_SIGNALS_FOR_ANNUALIZATION = 10;
26708
+ /** Minimum signals required for ANY ratio metric (Sharpe / Sortino / stdDev). Below this,
26709
+ * sample size is too small to estimate variance meaningfully. */
26710
+ const MIN_SIGNALS_FOR_RATIOS = 10;
26711
+ /** Minimum calendar span (days) for trade-frequency extrapolation. */
26712
+ const MIN_CALENDAR_SPAN_DAYS = 14;
26713
+ /** Hard cap on tradesPerYear — prevents absurd extrapolation from short windows / clustered trades. */
26714
+ const MAX_TRADES_PER_YEAR = 365;
26715
+ /** Hard cap on |expectedYearlyReturns| percent. Compound interest on high avgPnl × frequency
26716
+ * blows up to mathematically correct but business-unrealistic values. ±100% = 2x equity —
26717
+ * anything above this we suspect is a noisy estimate, not a genuine edge. Above the cap → null. */
26718
+ const MAX_EXPECTED_YEARLY_RETURNS = 100;
26719
+ /** Hard cap on |calmarRatio|. Prevents explosion when equityMaxDrawdown is near zero. */
26720
+ const MAX_CALMAR_RATIO = 1000;
26721
+ /** Minimum stdDev required for Sharpe/Sortino. Identical-returns series produce
26722
+ * float-artifact stdDev (~1e-17) that's > 0 but spuriously inflates sharpe to
26723
+ * astronomical magnitudes (avgPnl / epsilon). */
26724
+ const STDDEV_EPSILON = 1e-9;
26384
26725
  /**
26385
26726
  * Storage class for accumulating closed signals per strategy and generating heatmap.
26386
26727
  * Maintains symbol-level statistics and provides portfolio-wide metrics.
@@ -26422,7 +26763,7 @@ class HeatmapStorage {
26422
26763
  * - **totalPnl** — sum of `pnlPercentage` across all signals
26423
26764
  * - **avgPnl** — arithmetic mean of `pnlPercentage`
26424
26765
  * - **stdDev** — population standard deviation of `pnlPercentage`
26425
- * - **sharpeRatio** — `avgPnl / stdDev`; requires ≥ 2 signals and `stdDev > 0`
26766
+ * - **sharpeRatio** — per-trade Sharpe: `avgPnl / stdDev`; requires ≥ 2 signals and `stdDev > 0`
26426
26767
  * - **maxDrawdown** — largest cumulative loss streak (absolute value of peak negative equity)
26427
26768
  * - **profitFactor** — `sumWins / |sumLosses|`; requires at least one win and one loss
26428
26769
  * - **avgWin / avgLoss** — mean of positive / negative trades respectively
@@ -26438,10 +26779,12 @@ class HeatmapStorage {
26438
26779
  const totalTrades = signals.length;
26439
26780
  const winCount = signals.filter((s) => s.pnl.pnlPercentage > 0).length;
26440
26781
  const lossCount = signals.filter((s) => s.pnl.pnlPercentage < 0).length;
26441
- // Calculate win rate
26782
+ // Win rate excludes break-even trades from both numerator and denominator —
26783
+ // they are neither wins nor losses.
26442
26784
  let winRate = null;
26443
- if (totalTrades > 0) {
26444
- winRate = (winCount / totalTrades) * 100;
26785
+ const decisiveTrades = winCount + lossCount;
26786
+ if (decisiveTrades > 0) {
26787
+ winRate = (winCount / decisiveTrades) * 100;
26445
26788
  }
26446
26789
  // Calculate total PNL
26447
26790
  let totalPnl = null;
@@ -26453,36 +26796,47 @@ class HeatmapStorage {
26453
26796
  if (signals.length > 0) {
26454
26797
  avgPnl = totalPnl / signals.length;
26455
26798
  }
26456
- // Calculate standard deviation
26799
+ // Sample standard deviation (Bessel correction: divide by N-1, not N).
26800
+ // Per-symbol ratios are gated by MIN_SIGNALS_FOR_RATIOS — variance estimates from
26801
+ // tiny samples are too noisy to publish.
26802
+ const canComputeRatios = signals.length >= MIN_SIGNALS_FOR_RATIOS;
26457
26803
  let stdDev = null;
26458
- if (signals.length > 1 && avgPnl !== null) {
26459
- const variance = signals.reduce((acc, s) => acc + Math.pow(s.pnl.pnlPercentage - avgPnl, 2), 0) / signals.length;
26804
+ if (canComputeRatios && avgPnl !== null) {
26805
+ const variance = signals.reduce((acc, s) => acc + Math.pow(s.pnl.pnlPercentage - avgPnl, 2), 0) / (signals.length - 1);
26460
26806
  stdDev = Math.sqrt(variance);
26461
26807
  }
26462
- // Calculate Sharpe Ratio
26808
+ // Per-trade Sharpe Ratio
26463
26809
  let sharpeRatio = null;
26464
- if (avgPnl !== null && stdDev !== null && stdDev !== 0) {
26810
+ // STDDEV_EPSILON guard protects against float-artifact stdDev producing
26811
+ // spuriously astronomical sharpe on identical-returns symbols.
26812
+ if (avgPnl !== null && stdDev !== null && stdDev > STDDEV_EPSILON) {
26465
26813
  sharpeRatio = avgPnl / stdDev;
26466
26814
  }
26467
- // Calculate Maximum Drawdown
26815
+ // Equity-curve max drawdown via compounded equity ("as-if 100% allocation per trade").
26816
+ // Signals are stored newest-first (unshift in addSignal), so iterate in reverse.
26817
+ // If equity ≤ 0 — account blown, fix DD at 100%. equityFinal feeds expectedYearlyReturns.
26468
26818
  let maxDrawdown = null;
26819
+ let equityFinal = 1;
26820
+ let blown = false;
26469
26821
  if (signals.length > 0) {
26470
- let peak = 0;
26471
- let currentDrawdown = 0;
26822
+ let equity = 1;
26823
+ let peak = 1;
26472
26824
  let maxDD = 0;
26473
- for (const signal of signals) {
26474
- peak += signal.pnl.pnlPercentage;
26475
- if (peak > 0) {
26476
- currentDrawdown = 0;
26477
- }
26478
- else {
26479
- currentDrawdown = Math.abs(peak);
26480
- if (currentDrawdown > maxDD) {
26481
- maxDD = currentDrawdown;
26482
- }
26825
+ for (let i = signals.length - 1; i >= 0; i--) {
26826
+ equity *= 1 + signals[i].pnl.pnlPercentage / 100;
26827
+ if (equity <= 0) {
26828
+ maxDD = 100;
26829
+ blown = true;
26830
+ break;
26483
26831
  }
26832
+ if (equity > peak)
26833
+ peak = equity;
26834
+ const dd = (peak - equity) / peak * 100;
26835
+ if (dd > maxDD)
26836
+ maxDD = dd;
26484
26837
  }
26485
26838
  maxDrawdown = maxDD;
26839
+ equityFinal = blown ? 0 : equity;
26486
26840
  }
26487
26841
  // Calculate Profit Factor
26488
26842
  let profitFactor = null;
@@ -26493,7 +26847,9 @@ class HeatmapStorage {
26493
26847
  const sumLosses = Math.abs(signals
26494
26848
  .filter((s) => s.pnl.pnlPercentage < 0)
26495
26849
  .reduce((acc, s) => acc + s.pnl.pnlPercentage, 0));
26496
- if (sumLosses > 0) {
26850
+ // STDDEV_EPSILON guard — float-artifact losses (≈1e-15) would otherwise
26851
+ // produce spurious astronomical profitFactor (≈1e14).
26852
+ if (sumLosses > STDDEV_EPSILON) {
26497
26853
  profitFactor = sumWins / sumLosses;
26498
26854
  }
26499
26855
  }
@@ -26533,45 +26889,110 @@ class HeatmapStorage {
26533
26889
  }
26534
26890
  }
26535
26891
  }
26536
- // Calculate Expectancy
26892
+ // Expectancy — probabilities from observed win/loss counts (break-evens contribute 0).
26537
26893
  let expectancy = null;
26538
- if (winRate !== null && avgWin !== null && avgLoss !== null) {
26539
- const lossRate = 100 - winRate;
26540
- expectancy = (winRate / 100) * avgWin + (lossRate / 100) * avgLoss;
26894
+ if (totalTrades > 0 && avgWin !== null && avgLoss !== null) {
26895
+ const winProb = winCount / totalTrades;
26896
+ const lossProb = lossCount / totalTrades;
26897
+ expectancy = winProb * avgWin + lossProb * avgLoss;
26898
+ }
26899
+ else if (totalTrades > 0 && avgWin !== null && avgLoss === null) {
26900
+ // No losing trades — expectancy is just average win frequency × avgWin
26901
+ expectancy = (winCount / totalTrades) * avgWin;
26902
+ }
26903
+ else if (totalTrades > 0 && avgWin === null && avgLoss !== null) {
26904
+ expectancy = (lossCount / totalTrades) * avgLoss;
26541
26905
  }
26542
- // Calculate average peak and fall PNL
26906
+ // Average only over signals that have the value — do not dilute the mean with zeros.
26543
26907
  let avgPeakPnl = null;
26544
26908
  let avgFallPnl = null;
26545
26909
  if (signals.length > 0) {
26546
- avgPeakPnl = signals.reduce((acc, s) => acc + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / signals.length;
26547
- avgFallPnl = signals.reduce((acc, s) => acc + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / signals.length;
26910
+ const peakValues = signals
26911
+ .map((s) => s.signal.peakProfit?.pnlPercentage)
26912
+ .filter((v) => typeof v === "number");
26913
+ const fallValues = signals
26914
+ .map((s) => s.signal.maxDrawdown?.pnlPercentage)
26915
+ .filter((v) => typeof v === "number");
26916
+ avgPeakPnl = peakValues.length > 0
26917
+ ? peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length
26918
+ : null;
26919
+ avgFallPnl = fallValues.length > 0
26920
+ ? fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length
26921
+ : null;
26548
26922
  }
26549
- // Downside per signal: maxDrawdown.pnlPercentage captures the worst intra-trade dip
26550
- const fallReturns = signals.map((s) => s.signal.maxDrawdown?.pnlPercentage ?? 0);
26551
- // Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
26923
+ // Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
26924
+ // downsideDev = ( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
26925
+ // so the numerator reduces to avgPnl and the squared term to r² for r < 0.
26926
+ // Dividing by N_total (not N_negative) properly penalises strategies with frequent
26927
+ // losses; the "modified" form (N_negative) hides frequency risk in catastrophic-tail
26928
+ // strategies.
26552
26929
  let sortinoRatio = null;
26553
- if (signals.length > 0 && avgPnl !== null) {
26554
- const fallVariance = fallReturns.reduce((acc, r) => acc + Math.pow(r, 2), 0) / signals.length;
26555
- const fallDeviation = Math.sqrt(fallVariance);
26556
- if (fallDeviation > 0) {
26557
- sortinoRatio = avgPnl / fallDeviation;
26558
- }
26559
- }
26560
- // Max absolute drawdown across all signals denominator for Calmar and Recovery
26561
- const maxAbsFall = fallReturns.reduce((max, r) => Math.max(max, Math.abs(r)), 0);
26562
- // Expected yearly returns — needed for Calmar
26563
- let expectedYearlyReturns = 0;
26564
- if (signals.length > 0 && avgPnl !== null) {
26565
- const avgDurationMs = signals.reduce((sum, s) => sum + (s.closeTimestamp - s.signal.pendingAt), 0) / signals.length;
26566
- const avgDurationDays = avgDurationMs / (1000 * 60 * 60 * 24);
26567
- const tradesPerYear = avgDurationDays > 0 ? 365 / avgDurationDays : 0;
26568
- expectedYearlyReturns = avgPnl * tradesPerYear;
26930
+ if (canComputeRatios && avgPnl !== null) {
26931
+ const negativeReturns = signals
26932
+ .map((s) => s.pnl.pnlPercentage)
26933
+ .filter((r) => r < 0);
26934
+ if (negativeReturns.length > 0) {
26935
+ const downsideVariance = negativeReturns.reduce((acc, r) => acc + r * r, 0) / signals.length;
26936
+ const downsideDeviation = Math.sqrt(downsideVariance);
26937
+ // Same epsilon guard as Sharpeprotects against float-artifact downsideDev.
26938
+ if (downsideDeviation > STDDEV_EPSILON) {
26939
+ sortinoRatio = avgPnl / downsideDeviation;
26940
+ }
26941
+ }
26569
26942
  }
26943
+ // Expected yearly returns via geometric mean of equity curve.
26944
+ // equityFinal^(tradesPerYear / N) - 1 — accounts for volatility drag.
26945
+ // Gated by sample size and calendar span; if account blown → full loss.
26946
+ let expectedYearlyReturns = null;
26947
+ let tradesPerYear = null;
26948
+ if (signals.length >= MIN_SIGNALS_FOR_ANNUALIZATION) {
26949
+ let firstPendingAt = Infinity;
26950
+ let lastCloseAt = -Infinity;
26951
+ for (const s of signals) {
26952
+ if (s.signal.pendingAt < firstPendingAt)
26953
+ firstPendingAt = s.signal.pendingAt;
26954
+ if (s.closeTimestamp > lastCloseAt)
26955
+ lastCloseAt = s.closeTimestamp;
26956
+ }
26957
+ const calendarSpanDays = (lastCloseAt - firstPendingAt) / (1000 * 60 * 60 * 24);
26958
+ if (calendarSpanDays >= MIN_CALENDAR_SPAN_DAYS) {
26959
+ // tradesPerYear uses RAW observed frequency — no clipping. If the raw value
26960
+ // exceeds MAX_TRADES_PER_YEAR the sample is too clustered for reliable
26961
+ // annualization, and we leave the annualized metric null instead of silently
26962
+ // understating it with a clipped frequency.
26963
+ const rawTradesPerYear = (signals.length / calendarSpanDays) * 365;
26964
+ if (rawTradesPerYear <= MAX_TRADES_PER_YEAR) {
26965
+ tradesPerYear = rawTradesPerYear;
26966
+ if (blown) {
26967
+ expectedYearlyReturns = -100;
26968
+ }
26969
+ else {
26970
+ // If raw value exceeds MAX_EXPECTED_YEARLY_RETURNS, leave null rather than
26971
+ // show the cap — capped numbers mislead users into trusting them.
26972
+ const raw = (Math.pow(equityFinal, tradesPerYear / signals.length) - 1) * 100;
26973
+ expectedYearlyReturns = Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS ? null : raw;
26974
+ }
26975
+ }
26976
+ }
26977
+ }
26978
+ // Calmar = annualized return / equity-curve max drawdown, capped at ±MAX_CALMAR_RATIO.
26979
+ // Recovery Factor uses the compounded total return (equityFinal-1)*100, not arithmetic
26980
+ // totalPnl — denominator is compounded so numerator must match. Null when account blown.
26570
26981
  let calmarRatio = null;
26571
26982
  let recoveryFactor = null;
26572
- if (maxAbsFall > 0 && totalPnl !== null) {
26573
- calmarRatio = expectedYearlyReturns / maxAbsFall;
26574
- recoveryFactor = totalPnl / maxAbsFall;
26983
+ if (maxDrawdown !== null && maxDrawdown > 0) {
26984
+ if (expectedYearlyReturns !== null) {
26985
+ const raw = expectedYearlyReturns / maxDrawdown;
26986
+ calmarRatio = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, raw));
26987
+ }
26988
+ if (!blown && canComputeRatios) {
26989
+ // Gated below MIN_SIGNALS_FOR_RATIOS like Sharpe — a Recovery Factor on
26990
+ // a handful of trades is statistically meaningless, so don't surface it
26991
+ // per-symbol while Sharpe is N/A.
26992
+ // Same MAX_CALMAR_RATIO clamp as Calmar — both compounded-profit/DD ratios.
26993
+ const rawRec = ((equityFinal - 1) * 100) / maxDrawdown;
26994
+ recoveryFactor = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, rawRec));
26995
+ }
26575
26996
  }
26576
26997
  // Apply safe math checks
26577
26998
  if (isUnsafe(winRate))
@@ -26636,12 +27057,18 @@ class HeatmapStorage {
26636
27057
  * 2. Sorts symbols by `sharpeRatio` descending — best performers first,
26637
27058
  * symbols with `null` sharpeRatio placed at the end.
26638
27059
  * 3. Computes portfolio-wide aggregates:
26639
- * - `portfolioTotalPnl` — sum of all per-symbol `totalPnl` values (treats `null` as 0)
26640
- * - `portfolioTotalTrades` sum of all per-symbol `totalTrades`
26641
- * - `portfolioSharpeRatio` trade-count-weighted average of per-symbol sharpe ratios
26642
- *
26643
- * @returns Promise resolving to `HeatmapStatisticsModel` with per-symbol rows and
26644
- * portfolio-wide `portfolioTotalPnl`, `portfolioSharpeRatio`, `portfolioTotalTrades`
27060
+ * - `portfolioTotalPnl` — sum of per-symbol `totalPnl` values, skipping `null` entries
27061
+ * (so a symbol with no data does not silently contribute 0). If every symbol's
27062
+ * `totalPnl` is null, the portfolio value is null.
27063
+ * - `portfolioTotalTrades` — sum of per-symbol `totalTrades`
27064
+ * - `portfolioSharpeRatio` POOLED Sharpe over all trades across symbols (sample
27065
+ * stddev, N-1). NOT a Markowitz portfolio Sharpe — ignores cross-symbol
27066
+ * correlations and capital allocation. Rendered as "Pooled Sharpe" in the report.
27067
+ * Gated by `MIN_SIGNALS_FOR_RATIOS` on the pooled count.
27068
+ * - `portfolioAvgPeakPnl` / `portfolioAvgFallPnl` — trade-count-weighted means
27069
+ * over symbols that have non-null values.
27070
+ *
27071
+ * @returns Promise resolving to `HeatmapStatisticsModel`
26645
27072
  */
26646
27073
  async getData() {
26647
27074
  const symbols = [];
@@ -26660,31 +27087,53 @@ class HeatmapStorage {
26660
27087
  return -1;
26661
27088
  return b.sharpeRatio - a.sharpeRatio;
26662
27089
  });
26663
- // Calculate portfolio-wide metrics
27090
+ // Portfolio totals — sum only over symbols with non-null totalPnl. `s.totalPnl || 0`
27091
+ // would silently treat a missing value as zero and hide that some symbols had no data.
26664
27092
  const totalSymbols = symbols.length;
26665
27093
  let portfolioTotalPnl = null;
26666
27094
  let portfolioTotalTrades = 0;
26667
27095
  if (symbols.length > 0) {
26668
- portfolioTotalPnl = symbols.reduce((acc, s) => acc + (s.totalPnl || 0), 0);
27096
+ const validTotalPnls = symbols.filter((s) => s.totalPnl !== null);
27097
+ portfolioTotalPnl = validTotalPnls.length > 0
27098
+ ? validTotalPnls.reduce((acc, s) => acc + s.totalPnl, 0)
27099
+ : null;
26669
27100
  portfolioTotalTrades = symbols.reduce((acc, s) => acc + s.totalTrades, 0);
26670
27101
  }
26671
- // Calculate portfolio Sharpe Ratio (weighted by number of trades)
27102
+ // Pooled Sharpe over all returns across symbols. NOTE: this is NOT a Markowitz
27103
+ // portfolio Sharpe — it ignores cross-symbol correlations and treats trades as a
27104
+ // single pooled sample. Gated by MIN_SIGNALS_FOR_RATIOS so a 2-trade pool cannot
27105
+ // produce a noisy ±Sharpe.
26672
27106
  let portfolioSharpeRatio = null;
26673
- const validSharpes = symbols.filter((s) => s.sharpeRatio !== null);
26674
- if (validSharpes.length > 0 && portfolioTotalTrades > 0) {
26675
- const weightedSum = validSharpes.reduce((acc, s) => acc + s.sharpeRatio * s.totalTrades, 0);
26676
- portfolioSharpeRatio = weightedSum / portfolioTotalTrades;
27107
+ const allReturns = [];
27108
+ for (const signals of this.symbolData.values()) {
27109
+ for (const s of signals) {
27110
+ allReturns.push(s.pnl.pnlPercentage);
27111
+ }
27112
+ }
27113
+ if (allReturns.length >= MIN_SIGNALS_FOR_RATIOS) {
27114
+ const portfolioAvg = allReturns.reduce((acc, r) => acc + r, 0) / allReturns.length;
27115
+ const portfolioVariance = allReturns.reduce((acc, r) => acc + Math.pow(r - portfolioAvg, 2), 0) /
27116
+ (allReturns.length - 1);
27117
+ const portfolioStdDev = Math.sqrt(portfolioVariance);
27118
+ // STDDEV_EPSILON guard — same protection as per-symbol Sharpe.
27119
+ if (portfolioStdDev > STDDEV_EPSILON) {
27120
+ portfolioSharpeRatio = portfolioAvg / portfolioStdDev;
27121
+ }
26677
27122
  }
26678
- // Calculate portfolio-wide weighted average peak/fall PNL
27123
+ // Portfolio-wide weighted average peak/fall PNL. Denominator must include only
27124
+ // symbols that contributed a value — otherwise trade-count-weighted mean is diluted
27125
+ // by symbols without the metric.
26679
27126
  let portfolioAvgPeakPnl = null;
26680
27127
  let portfolioAvgFallPnl = null;
26681
27128
  const validPeak = symbols.filter((s) => s.avgPeakPnl !== null);
26682
27129
  const validFall = symbols.filter((s) => s.avgFallPnl !== null);
26683
- if (validPeak.length > 0 && portfolioTotalTrades > 0) {
26684
- portfolioAvgPeakPnl = validPeak.reduce((acc, s) => acc + s.avgPeakPnl * s.totalTrades, 0) / portfolioTotalTrades;
27130
+ const peakTradesTotal = validPeak.reduce((acc, s) => acc + s.totalTrades, 0);
27131
+ const fallTradesTotal = validFall.reduce((acc, s) => acc + s.totalTrades, 0);
27132
+ if (validPeak.length > 0 && peakTradesTotal > 0) {
27133
+ portfolioAvgPeakPnl = validPeak.reduce((acc, s) => acc + s.avgPeakPnl * s.totalTrades, 0) / peakTradesTotal;
26685
27134
  }
26686
- if (validFall.length > 0 && portfolioTotalTrades > 0) {
26687
- portfolioAvgFallPnl = validFall.reduce((acc, s) => acc + s.avgFallPnl * s.totalTrades, 0) / portfolioTotalTrades;
27135
+ if (validFall.length > 0 && fallTradesTotal > 0) {
27136
+ portfolioAvgFallPnl = validFall.reduce((acc, s) => acc + s.avgFallPnl * s.totalTrades, 0) / fallTradesTotal;
26688
27137
  }
26689
27138
  // Apply safe math
26690
27139
  if (isUnsafe(portfolioTotalPnl))
@@ -26712,7 +27161,7 @@ class HeatmapStorage {
26712
27161
  * ```
26713
27162
  * # Portfolio Heatmap: {strategyName}
26714
27163
  *
26715
- * **Total Symbols:** N | **Portfolio PNL:** X% | **Portfolio Sharpe:** Y | **Total Trades:** Z
27164
+ * **Total Symbols:** N | **Portfolio PNL:** X% | **Pooled Sharpe:** Y | **Total Trades:** Z
26716
27165
  *
26717
27166
  * | col1 | col2 | ... |
26718
27167
  * | --- | --- | ... |
@@ -26751,18 +27200,21 @@ class HeatmapStorage {
26751
27200
  return [
26752
27201
  `# Portfolio Heatmap: ${strategyName}`,
26753
27202
  "",
26754
- `**Total Symbols:** ${data.totalSymbols} | **Portfolio PNL:** ${data.portfolioTotalPnl !== null ? str(data.portfolioTotalPnl, "%") : "N/A"} | **Portfolio Sharpe:** ${data.portfolioSharpeRatio !== null ? str(data.portfolioSharpeRatio) : "N/A"} | **Total Trades:** ${data.portfolioTotalTrades} | **Avg Peak PNL:** ${data.portfolioAvgPeakPnl !== null ? str(data.portfolioAvgPeakPnl, "%") : "N/A"} | **Avg Max Drawdown PNL:** ${data.portfolioAvgFallPnl !== null ? str(data.portfolioAvgFallPnl, "%") : "N/A"}`,
27203
+ `**Total Symbols:** ${data.totalSymbols} | **Portfolio PNL:** ${data.portfolioTotalPnl !== null ? str(data.portfolioTotalPnl, "%") : "N/A"} | **Pooled Sharpe:** ${data.portfolioSharpeRatio !== null ? str(data.portfolioSharpeRatio) : "N/A"} | **Total Trades:** ${data.portfolioTotalTrades} | **Avg Peak PNL:** ${data.portfolioAvgPeakPnl !== null ? str(data.portfolioAvgPeakPnl, "%") : "N/A"} | **Avg Max Drawdown PNL:** ${data.portfolioAvgFallPnl !== null ? str(data.portfolioAvgFallPnl, "%") : "N/A"}`,
26755
27204
  "",
26756
27205
  table,
26757
27206
  "",
26758
27207
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
27208
+ `*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.*`,
26759
27209
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals per symbol.*`,
26760
- `*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
27210
+ `*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".*`,
26761
27211
  `*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
26762
27212
  `*Profit Factor: below 1.0 means strategy is losing overall. Above 1.5 is considered good.*`,
26763
- `*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Based on theoretical yearly returns.*`,
26764
- `*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good.*`,
26765
- `*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.*`,
27213
+ `*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}.*`,
27214
+ `*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.*`,
27215
+ `*All metrics require 100+ signals per symbol to be statistically reliable. Annualized metrics assume the observed trading frequency persists year-round.*`,
27216
+ `*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.*`,
27217
+ `*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.*`,
26766
27218
  ].join("\n");
26767
27219
  }
26768
27220
  /**
@@ -26957,7 +27409,7 @@ class HeatMarkdownService {
26957
27409
  * console.log(markdown);
26958
27410
  * // # Portfolio Heatmap: my-strategy
26959
27411
  * //
26960
- * // **Total Symbols:** 5 | **Portfolio PNL:** +45.3% | **Portfolio Sharpe:** 1.85 | **Total Trades:** 120
27412
+ * // **Total Symbols:** 5 | **Portfolio PNL:** +45.3% | **Pooled Sharpe:** 1.85 | **Total Trades:** 120
26961
27413
  * //
26962
27414
  * // | Symbol | Total PNL | Sharpe | Max DD | Trades |
26963
27415
  * // | --- | --- | --- | --- | --- |
@@ -63263,6 +63715,7 @@ const CRON_METHOD_NAME_CLEAR = "CronUtils.clear";
63263
63715
  const CRON_METHOD_NAME_TICK = "CronUtils._tick";
63264
63716
  const CRON_METHOD_NAME_ENABLE = "CronUtils.enable";
63265
63717
  const CRON_METHOD_NAME_DISABLE = "CronUtils.disable";
63718
+ const CRON_METHOD_NAME_DISPOSE = "CronUtils.dispose";
63266
63719
  /**
63267
63720
  * Local logger instance.
63268
63721
  *
@@ -63652,6 +64105,38 @@ class CronUtils {
63652
64105
  lastSubscription();
63653
64106
  }
63654
64107
  };
64108
+ /**
64109
+ * Hard-reset the entire `Cron` state.
64110
+ *
64111
+ * Performs in order:
64112
+ * 1. {@link disable} — tears down lifecycle subscriptions and resets the
64113
+ * `enable` singleshot so a future `enable()` re-subscribes cleanly.
64114
+ * 2. Wipes `_entries` — every {@link register}'ed entry is forgotten.
64115
+ * Disposers returned by previous `register()` calls become no-ops
64116
+ * (their `unregister(name)` will not find anything to remove).
64117
+ * 3. Wipes `_firedOnce` — all fire-once marks are dropped, so any future
64118
+ * re-registration of the same `name` fires again on the next matching
64119
+ * tick.
64120
+ * 4. Does **not** touch `_inFlight` — in-flight handlers continue to
64121
+ * settle in the background and clear their own slots via `.finally()`.
64122
+ * Their final `_firedOnce.add(firedKey)` writes carry old-generation
64123
+ * keys and are harmless (lookup uses the post-dispose generation).
64124
+ *
64125
+ * Use from a CLI/session teardown when you want to throw away every
64126
+ * registration along with the lifecycle wiring — e.g. between two
64127
+ * independent runner scopes. For "just snap the subscriptions but keep
64128
+ * registrations" use {@link disable} instead; for "just re-arm fire-once
64129
+ * marks" use {@link clear}.
64130
+ *
64131
+ * Idempotent. Safe to call multiple times and safe to call before
64132
+ * `enable()` / without any registrations.
64133
+ */
64134
+ this.dispose = () => {
64135
+ LOGGER_SERVICE$1.info(CRON_METHOD_NAME_DISPOSE);
64136
+ this.disable();
64137
+ this._entries.clear();
64138
+ this._firedOnce.clear();
64139
+ };
63655
64140
  }
63656
64141
  /**
63657
64142
  * Garbage-collect every `_firedOnce` key that belongs to the entry `name`