backtest-kit 11.2.0 β†’ 11.3.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/README.md CHANGED
@@ -79,7 +79,7 @@ Install the core library and peer dependencies manually. Use this approach when
79
79
  - πŸ”Œ **Pluggable**: Custom data sources (CCXT), persistence (file/Redis), and sizing calculators.
80
80
  - πŸ—ƒοΈ **Transactional Live Orders**: Broker adapter intercepts every trade mutation before internal state changes β€” exchange rejection rolls back the operation atomically.
81
81
  - ⏰ **Built-in Crontab**: Register periodic or fire-once jobs that fire on virtual-time boundaries with singleshot coordination across parallel backtests β€” one handler invocation per boundary, no double-fires.
82
- - πŸ§ͺ **Tested**: 740+ unit/integration tests for validation, recovery, and events.
82
+ - πŸ§ͺ **Tested**: 770+ unit/integration tests for validation, recovery, and events.
83
83
  - πŸ”“ **Self hosted**: Zero dependency on third-party node_modules or platforms; run entirely in your own environment.
84
84
 
85
85
  ## πŸ“‹ Supported Order Types
@@ -1983,7 +1983,7 @@ Python-based (WASI) strategy that uses EMA(9) and EMA(21) crossover signals exec
1983
1983
 
1984
1984
  ## βœ… Tested & Reliable
1985
1985
 
1986
- 740+ tests cover validation, recovery, reports, and events.
1986
+ 770+ tests cover validation, recovery, reports, and events.
1987
1987
 
1988
1988
  ## 🀝 Contribute
1989
1989
 
package/build/index.cjs CHANGED
@@ -23816,6 +23816,7 @@ let ReportStorage$a = class ReportStorage {
23816
23816
  sortinoRatio: null,
23817
23817
  calmarRatio: null,
23818
23818
  recoveryFactor: null,
23819
+ expectancy: null,
23819
23820
  };
23820
23821
  }
23821
23822
  // Valid signal set β€” those with usable pendingAt AND closeTimestamp. Single source
@@ -23938,6 +23939,12 @@ let ReportStorage$a = class ReportStorage {
23938
23939
  const certaintyRatio = canComputeRatios && Math.abs(avgLoss) > STDDEV_EPSILON$2 && avgLoss < 0
23939
23940
  ? avgWin / Math.abs(avgLoss)
23940
23941
  : null;
23942
+ // Per-trade Expectancy: winProb*avgWin + lossProb*avgLoss. Break-even trades
23943
+ // contribute 0 (they're excluded from both probabilities). N-gated like the
23944
+ // other ratios β€” on a tiny sample the per-trade EV is too noisy to publish.
23945
+ const expectancy = canComputeRatios && totalSignals > 0
23946
+ ? (wins.length / totalSignals) * avgWin + (losses.length / totalSignals) * avgLoss
23947
+ : null;
23941
23948
  // Average peak/fall PNL β€” over validSignals; only signals that actually have the
23942
23949
  // value contribute (no zero dilution from missing peakProfit/maxDrawdown).
23943
23950
  const peakValues = validSignals
@@ -24002,6 +24009,7 @@ let ReportStorage$a = class ReportStorage {
24002
24009
  sortinoRatio: isUnsafe$4(sortinoRatio) ? null : sortinoRatio,
24003
24010
  calmarRatio: isUnsafe$4(calmarRatio) ? null : calmarRatio,
24004
24011
  recoveryFactor: isUnsafe$4(recoveryFactor) ? null : recoveryFactor,
24012
+ expectancy: isUnsafe$4(expectancy) ? null : expectancy,
24005
24013
  };
24006
24014
  }
24007
24015
  /**
@@ -24051,6 +24059,7 @@ let ReportStorage$a = class ReportStorage {
24051
24059
  `**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
24052
24060
  `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
24053
24061
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
24062
+ `**Expectancy:** ${stats.expectancy === null ? "N/A" : `${stats.expectancy > 0 ? "+" : ""}${stats.expectancy.toFixed(3)}% (higher is better)`}`,
24054
24063
  "",
24055
24064
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
24056
24065
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
@@ -24060,6 +24069,7 @@ let ReportStorage$a = class ReportStorage {
24060
24069
  `*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.*`,
24061
24070
  `*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}.*`,
24062
24071
  `*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.*`,
24072
+ `*Expectancy: per-trade expected value (winProb Γ— avgWin + lossProb Γ— avgLoss). Positive = profitable on average per trade. Break-even trades contribute 0.*`,
24063
24073
  `*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
24064
24074
  `*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.*`,
24065
24075
  `*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.*`,
@@ -24685,6 +24695,7 @@ let ReportStorage$9 = class ReportStorage {
24685
24695
  sortinoRatio: null,
24686
24696
  calmarRatio: null,
24687
24697
  recoveryFactor: null,
24698
+ expectancy: null,
24688
24699
  };
24689
24700
  }
24690
24701
  const closedEvents = this._eventList.filter((e) => e.action === "closed");
@@ -24755,6 +24766,7 @@ let ReportStorage$9 = class ReportStorage {
24755
24766
  // so the report doesn't surface certainty on a handful of trades while
24756
24767
  // withholding the rest.
24757
24768
  let certaintyRatio = null;
24769
+ let expectancy = null;
24758
24770
  if (canComputeRatios && totalClosed > 0) {
24759
24771
  const wins = validClosed.filter((e) => e.pnl > 0);
24760
24772
  const losses = validClosed.filter((e) => e.pnl < 0);
@@ -24769,6 +24781,9 @@ let ReportStorage$9 = class ReportStorage {
24769
24781
  certaintyRatio = Math.abs(avgLoss) > STDDEV_EPSILON$1 && avgLoss < 0
24770
24782
  ? avgWin / Math.abs(avgLoss)
24771
24783
  : null;
24784
+ // Per-trade Expectancy: winProb*avgWin + lossProb*avgLoss. Break-even
24785
+ // trades contribute 0 (excluded from both probabilities).
24786
+ expectancy = (wins.length / totalClosed) * avgWin + (losses.length / totalClosed) * avgLoss;
24772
24787
  }
24773
24788
  // Average only over signals that have the value β€” do not dilute the mean with zeros.
24774
24789
  // Use validClosed to keep all metric denominators consistent.
@@ -24872,6 +24887,7 @@ let ReportStorage$9 = class ReportStorage {
24872
24887
  sortinoRatio: isUnsafe$3(sortinoRatio) ? null : sortinoRatio,
24873
24888
  calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
24874
24889
  recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
24890
+ expectancy: isUnsafe$3(expectancy) ? null : expectancy,
24875
24891
  };
24876
24892
  }
24877
24893
  /**
@@ -24921,6 +24937,7 @@ let ReportStorage$9 = class ReportStorage {
24921
24937
  `**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
24922
24938
  `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
24923
24939
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
24940
+ `**Expectancy:** ${stats.expectancy === null ? "N/A" : `${stats.expectancy > 0 ? "+" : ""}${stats.expectancy.toFixed(3)}% (higher is better)`}`,
24924
24941
  "",
24925
24942
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
24926
24943
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
@@ -24930,6 +24947,7 @@ let ReportStorage$9 = class ReportStorage {
24930
24947
  `*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.*`,
24931
24948
  `*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}.*`,
24932
24949
  `*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.*`,
24950
+ `*Expectancy: per-trade expected value (winProb Γ— avgWin + lossProb Γ— avgLoss). Positive = profitable on average per trade. Break-even trades contribute 0.*`,
24933
24951
  `*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
24934
24952
  `*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.*`,
24935
24953
  `*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.*`,
@@ -27127,11 +27145,15 @@ class HeatmapStorage {
27127
27145
  : null;
27128
27146
  portfolioTotalTrades = symbols.reduce((acc, s) => acc + s.totalTrades, 0);
27129
27147
  }
27130
- // Pooled Sharpe over all returns across symbols. NOTE: this is NOT a Markowitz
27131
- // portfolio Sharpe β€” it ignores cross-symbol correlations and treats trades as a
27132
- // single pooled sample. Gated by MIN_SIGNALS_FOR_RATIOS so a 2-trade pool cannot
27133
- // produce a noisy Β±Sharpe.
27148
+ // Pooled metrics over all returns across symbols. NOT a Markowitz portfolio β€”
27149
+ // ignores cross-symbol correlations, treats trades as a single pooled sample.
27150
+ // Gated by MIN_SIGNALS_FOR_RATIOS so a tiny pool can't produce noisy ratios.
27134
27151
  let portfolioSharpeRatio = null;
27152
+ let portfolioStdDev = null;
27153
+ let portfolioSortinoRatio = null;
27154
+ let portfolioExpectancy = null;
27155
+ let portfolioCalmarRatio = null;
27156
+ let portfolioRecoveryFactor = null;
27135
27157
  const allReturns = [];
27136
27158
  for (const signals of this.symbolData.values()) {
27137
27159
  for (const s of signals) {
@@ -27142,10 +27164,63 @@ class HeatmapStorage {
27142
27164
  const portfolioAvg = allReturns.reduce((acc, r) => acc + r, 0) / allReturns.length;
27143
27165
  const portfolioVariance = allReturns.reduce((acc, r) => acc + Math.pow(r - portfolioAvg, 2), 0) /
27144
27166
  (allReturns.length - 1);
27145
- const portfolioStdDev = Math.sqrt(portfolioVariance);
27167
+ const stdDev = Math.sqrt(portfolioVariance);
27146
27168
  // STDDEV_EPSILON guard β€” same protection as per-symbol Sharpe.
27147
- if (portfolioStdDev > STDDEV_EPSILON) {
27148
- portfolioSharpeRatio = portfolioAvg / portfolioStdDev;
27169
+ portfolioStdDev = stdDev;
27170
+ if (stdDev > STDDEV_EPSILON) {
27171
+ portfolioSharpeRatio = portfolioAvg / stdDev;
27172
+ }
27173
+ // Canonical Sortino: downside dev = √( Σ min(0, r)² / N_total ), MAR=0.
27174
+ const negativeReturns = allReturns.filter((r) => r < 0);
27175
+ if (negativeReturns.length > 0) {
27176
+ const downsideVariance = negativeReturns.reduce((acc, r) => acc + r * r, 0) / allReturns.length;
27177
+ const downsideDeviation = Math.sqrt(downsideVariance);
27178
+ if (downsideDeviation > STDDEV_EPSILON) {
27179
+ portfolioSortinoRatio = portfolioAvg / downsideDeviation;
27180
+ }
27181
+ }
27182
+ // Pooled Expectancy: per-trade EV = winProb*avgWin + lossProb*avgLoss.
27183
+ // Break-even trades contribute 0 (excluded from both probs).
27184
+ const wins = allReturns.filter((r) => r > 0);
27185
+ const losses = allReturns.filter((r) => r < 0);
27186
+ const total = allReturns.length;
27187
+ const avgWin = wins.length > 0 ? wins.reduce((a, b) => a + b, 0) / wins.length : 0;
27188
+ const avgLoss = losses.length > 0 ? losses.reduce((a, b) => a + b, 0) / losses.length : 0;
27189
+ if (wins.length > 0 || losses.length > 0) {
27190
+ portfolioExpectancy = (wins.length / total) * avgWin + (losses.length / total) * avgLoss;
27191
+ }
27192
+ // Pooled equity-curve max drawdown (compounded).
27193
+ let equity = 1;
27194
+ let peak = 1;
27195
+ let maxDD = 0;
27196
+ let blown = false;
27197
+ for (const r of allReturns) {
27198
+ equity *= 1 + r / 100;
27199
+ if (equity <= 0) {
27200
+ maxDD = 100;
27201
+ blown = true;
27202
+ break;
27203
+ }
27204
+ if (equity > peak)
27205
+ peak = equity;
27206
+ const dd = ((peak - equity) / peak) * 100;
27207
+ if (dd > maxDD)
27208
+ maxDD = dd;
27209
+ }
27210
+ const equityFinal = blown ? 0 : equity;
27211
+ // Pooled Calmar / Recovery, both clamped at Β±MAX_CALMAR_RATIO and using
27212
+ // compounded total return / DD. Same shape as per-symbol formula.
27213
+ if (maxDD > 0) {
27214
+ if (!blown) {
27215
+ const rawCalmar = ((equityFinal - 1) * 100) / maxDD;
27216
+ portfolioCalmarRatio = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, rawCalmar));
27217
+ const rawRec = ((equityFinal - 1) * 100) / maxDD;
27218
+ portfolioRecoveryFactor = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, rawRec));
27219
+ }
27220
+ else {
27221
+ // Blown β€” full loss is the only meaningful value; recovery undefined.
27222
+ portfolioCalmarRatio = -1; // -100 / 100
27223
+ }
27149
27224
  }
27150
27225
  }
27151
27226
  // Portfolio-wide weighted average peak/fall PNL. Denominator must include only
@@ -27172,6 +27247,16 @@ class HeatmapStorage {
27172
27247
  portfolioAvgPeakPnl = null;
27173
27248
  if (isUnsafe(portfolioAvgFallPnl))
27174
27249
  portfolioAvgFallPnl = null;
27250
+ if (isUnsafe(portfolioStdDev))
27251
+ portfolioStdDev = null;
27252
+ if (isUnsafe(portfolioSortinoRatio))
27253
+ portfolioSortinoRatio = null;
27254
+ if (isUnsafe(portfolioCalmarRatio))
27255
+ portfolioCalmarRatio = null;
27256
+ if (isUnsafe(portfolioRecoveryFactor))
27257
+ portfolioRecoveryFactor = null;
27258
+ if (isUnsafe(portfolioExpectancy))
27259
+ portfolioExpectancy = null;
27175
27260
  return {
27176
27261
  symbols,
27177
27262
  totalSymbols,
@@ -27180,6 +27265,11 @@ class HeatmapStorage {
27180
27265
  portfolioTotalTrades,
27181
27266
  portfolioAvgPeakPnl,
27182
27267
  portfolioAvgFallPnl,
27268
+ portfolioStdDev,
27269
+ portfolioSortinoRatio,
27270
+ portfolioCalmarRatio,
27271
+ portfolioRecoveryFactor,
27272
+ portfolioExpectancy,
27183
27273
  };
27184
27274
  }
27185
27275
  /**
@@ -27229,6 +27319,7 @@ class HeatmapStorage {
27229
27319
  `# Portfolio Heatmap: ${strategyName}`,
27230
27320
  "",
27231
27321
  `**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"}`,
27322
+ `**Standard Deviation:** ${data.portfolioStdDev !== null ? functoolsKit.str(data.portfolioStdDev, "%") : "N/A"} | **Sortino Ratio:** ${data.portfolioSortinoRatio !== null ? functoolsKit.str(data.portfolioSortinoRatio) : "N/A"} | **Calmar Ratio:** ${data.portfolioCalmarRatio !== null ? functoolsKit.str(data.portfolioCalmarRatio) : "N/A"} | **Recovery Factor:** ${data.portfolioRecoveryFactor !== null ? functoolsKit.str(data.portfolioRecoveryFactor) : "N/A"} | **Expectancy:** ${data.portfolioExpectancy !== null ? functoolsKit.str(data.portfolioExpectancy, "%") : "N/A"}`,
27232
27323
  "",
27233
27324
  table,
27234
27325
  "",
package/build/index.mjs CHANGED
@@ -23796,6 +23796,7 @@ let ReportStorage$a = class ReportStorage {
23796
23796
  sortinoRatio: null,
23797
23797
  calmarRatio: null,
23798
23798
  recoveryFactor: null,
23799
+ expectancy: null,
23799
23800
  };
23800
23801
  }
23801
23802
  // Valid signal set β€” those with usable pendingAt AND closeTimestamp. Single source
@@ -23918,6 +23919,12 @@ let ReportStorage$a = class ReportStorage {
23918
23919
  const certaintyRatio = canComputeRatios && Math.abs(avgLoss) > STDDEV_EPSILON$2 && avgLoss < 0
23919
23920
  ? avgWin / Math.abs(avgLoss)
23920
23921
  : null;
23922
+ // Per-trade Expectancy: winProb*avgWin + lossProb*avgLoss. Break-even trades
23923
+ // contribute 0 (they're excluded from both probabilities). N-gated like the
23924
+ // other ratios β€” on a tiny sample the per-trade EV is too noisy to publish.
23925
+ const expectancy = canComputeRatios && totalSignals > 0
23926
+ ? (wins.length / totalSignals) * avgWin + (losses.length / totalSignals) * avgLoss
23927
+ : null;
23921
23928
  // Average peak/fall PNL β€” over validSignals; only signals that actually have the
23922
23929
  // value contribute (no zero dilution from missing peakProfit/maxDrawdown).
23923
23930
  const peakValues = validSignals
@@ -23982,6 +23989,7 @@ let ReportStorage$a = class ReportStorage {
23982
23989
  sortinoRatio: isUnsafe$4(sortinoRatio) ? null : sortinoRatio,
23983
23990
  calmarRatio: isUnsafe$4(calmarRatio) ? null : calmarRatio,
23984
23991
  recoveryFactor: isUnsafe$4(recoveryFactor) ? null : recoveryFactor,
23992
+ expectancy: isUnsafe$4(expectancy) ? null : expectancy,
23985
23993
  };
23986
23994
  }
23987
23995
  /**
@@ -24031,6 +24039,7 @@ let ReportStorage$a = class ReportStorage {
24031
24039
  `**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
24032
24040
  `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
24033
24041
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
24042
+ `**Expectancy:** ${stats.expectancy === null ? "N/A" : `${stats.expectancy > 0 ? "+" : ""}${stats.expectancy.toFixed(3)}% (higher is better)`}`,
24034
24043
  "",
24035
24044
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
24036
24045
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
@@ -24040,6 +24049,7 @@ let ReportStorage$a = class ReportStorage {
24040
24049
  `*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.*`,
24041
24050
  `*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}.*`,
24042
24051
  `*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.*`,
24052
+ `*Expectancy: per-trade expected value (winProb Γ— avgWin + lossProb Γ— avgLoss). Positive = profitable on average per trade. Break-even trades contribute 0.*`,
24043
24053
  `*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
24044
24054
  `*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.*`,
24045
24055
  `*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.*`,
@@ -24665,6 +24675,7 @@ let ReportStorage$9 = class ReportStorage {
24665
24675
  sortinoRatio: null,
24666
24676
  calmarRatio: null,
24667
24677
  recoveryFactor: null,
24678
+ expectancy: null,
24668
24679
  };
24669
24680
  }
24670
24681
  const closedEvents = this._eventList.filter((e) => e.action === "closed");
@@ -24735,6 +24746,7 @@ let ReportStorage$9 = class ReportStorage {
24735
24746
  // so the report doesn't surface certainty on a handful of trades while
24736
24747
  // withholding the rest.
24737
24748
  let certaintyRatio = null;
24749
+ let expectancy = null;
24738
24750
  if (canComputeRatios && totalClosed > 0) {
24739
24751
  const wins = validClosed.filter((e) => e.pnl > 0);
24740
24752
  const losses = validClosed.filter((e) => e.pnl < 0);
@@ -24749,6 +24761,9 @@ let ReportStorage$9 = class ReportStorage {
24749
24761
  certaintyRatio = Math.abs(avgLoss) > STDDEV_EPSILON$1 && avgLoss < 0
24750
24762
  ? avgWin / Math.abs(avgLoss)
24751
24763
  : null;
24764
+ // Per-trade Expectancy: winProb*avgWin + lossProb*avgLoss. Break-even
24765
+ // trades contribute 0 (excluded from both probabilities).
24766
+ expectancy = (wins.length / totalClosed) * avgWin + (losses.length / totalClosed) * avgLoss;
24752
24767
  }
24753
24768
  // Average only over signals that have the value β€” do not dilute the mean with zeros.
24754
24769
  // Use validClosed to keep all metric denominators consistent.
@@ -24852,6 +24867,7 @@ let ReportStorage$9 = class ReportStorage {
24852
24867
  sortinoRatio: isUnsafe$3(sortinoRatio) ? null : sortinoRatio,
24853
24868
  calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
24854
24869
  recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
24870
+ expectancy: isUnsafe$3(expectancy) ? null : expectancy,
24855
24871
  };
24856
24872
  }
24857
24873
  /**
@@ -24901,6 +24917,7 @@ let ReportStorage$9 = class ReportStorage {
24901
24917
  `**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
24902
24918
  `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
24903
24919
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
24920
+ `**Expectancy:** ${stats.expectancy === null ? "N/A" : `${stats.expectancy > 0 ? "+" : ""}${stats.expectancy.toFixed(3)}% (higher is better)`}`,
24904
24921
  "",
24905
24922
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
24906
24923
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
@@ -24910,6 +24927,7 @@ let ReportStorage$9 = class ReportStorage {
24910
24927
  `*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.*`,
24911
24928
  `*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}.*`,
24912
24929
  `*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.*`,
24930
+ `*Expectancy: per-trade expected value (winProb Γ— avgWin + lossProb Γ— avgLoss). Positive = profitable on average per trade. Break-even trades contribute 0.*`,
24913
24931
  `*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
24914
24932
  `*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.*`,
24915
24933
  `*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.*`,
@@ -27107,11 +27125,15 @@ class HeatmapStorage {
27107
27125
  : null;
27108
27126
  portfolioTotalTrades = symbols.reduce((acc, s) => acc + s.totalTrades, 0);
27109
27127
  }
27110
- // Pooled Sharpe over all returns across symbols. NOTE: this is NOT a Markowitz
27111
- // portfolio Sharpe β€” it ignores cross-symbol correlations and treats trades as a
27112
- // single pooled sample. Gated by MIN_SIGNALS_FOR_RATIOS so a 2-trade pool cannot
27113
- // produce a noisy Β±Sharpe.
27128
+ // Pooled metrics over all returns across symbols. NOT a Markowitz portfolio β€”
27129
+ // ignores cross-symbol correlations, treats trades as a single pooled sample.
27130
+ // Gated by MIN_SIGNALS_FOR_RATIOS so a tiny pool can't produce noisy ratios.
27114
27131
  let portfolioSharpeRatio = null;
27132
+ let portfolioStdDev = null;
27133
+ let portfolioSortinoRatio = null;
27134
+ let portfolioExpectancy = null;
27135
+ let portfolioCalmarRatio = null;
27136
+ let portfolioRecoveryFactor = null;
27115
27137
  const allReturns = [];
27116
27138
  for (const signals of this.symbolData.values()) {
27117
27139
  for (const s of signals) {
@@ -27122,10 +27144,63 @@ class HeatmapStorage {
27122
27144
  const portfolioAvg = allReturns.reduce((acc, r) => acc + r, 0) / allReturns.length;
27123
27145
  const portfolioVariance = allReturns.reduce((acc, r) => acc + Math.pow(r - portfolioAvg, 2), 0) /
27124
27146
  (allReturns.length - 1);
27125
- const portfolioStdDev = Math.sqrt(portfolioVariance);
27147
+ const stdDev = Math.sqrt(portfolioVariance);
27126
27148
  // STDDEV_EPSILON guard β€” same protection as per-symbol Sharpe.
27127
- if (portfolioStdDev > STDDEV_EPSILON) {
27128
- portfolioSharpeRatio = portfolioAvg / portfolioStdDev;
27149
+ portfolioStdDev = stdDev;
27150
+ if (stdDev > STDDEV_EPSILON) {
27151
+ portfolioSharpeRatio = portfolioAvg / stdDev;
27152
+ }
27153
+ // Canonical Sortino: downside dev = √( Σ min(0, r)² / N_total ), MAR=0.
27154
+ const negativeReturns = allReturns.filter((r) => r < 0);
27155
+ if (negativeReturns.length > 0) {
27156
+ const downsideVariance = negativeReturns.reduce((acc, r) => acc + r * r, 0) / allReturns.length;
27157
+ const downsideDeviation = Math.sqrt(downsideVariance);
27158
+ if (downsideDeviation > STDDEV_EPSILON) {
27159
+ portfolioSortinoRatio = portfolioAvg / downsideDeviation;
27160
+ }
27161
+ }
27162
+ // Pooled Expectancy: per-trade EV = winProb*avgWin + lossProb*avgLoss.
27163
+ // Break-even trades contribute 0 (excluded from both probs).
27164
+ const wins = allReturns.filter((r) => r > 0);
27165
+ const losses = allReturns.filter((r) => r < 0);
27166
+ const total = allReturns.length;
27167
+ const avgWin = wins.length > 0 ? wins.reduce((a, b) => a + b, 0) / wins.length : 0;
27168
+ const avgLoss = losses.length > 0 ? losses.reduce((a, b) => a + b, 0) / losses.length : 0;
27169
+ if (wins.length > 0 || losses.length > 0) {
27170
+ portfolioExpectancy = (wins.length / total) * avgWin + (losses.length / total) * avgLoss;
27171
+ }
27172
+ // Pooled equity-curve max drawdown (compounded).
27173
+ let equity = 1;
27174
+ let peak = 1;
27175
+ let maxDD = 0;
27176
+ let blown = false;
27177
+ for (const r of allReturns) {
27178
+ equity *= 1 + r / 100;
27179
+ if (equity <= 0) {
27180
+ maxDD = 100;
27181
+ blown = true;
27182
+ break;
27183
+ }
27184
+ if (equity > peak)
27185
+ peak = equity;
27186
+ const dd = ((peak - equity) / peak) * 100;
27187
+ if (dd > maxDD)
27188
+ maxDD = dd;
27189
+ }
27190
+ const equityFinal = blown ? 0 : equity;
27191
+ // Pooled Calmar / Recovery, both clamped at Β±MAX_CALMAR_RATIO and using
27192
+ // compounded total return / DD. Same shape as per-symbol formula.
27193
+ if (maxDD > 0) {
27194
+ if (!blown) {
27195
+ const rawCalmar = ((equityFinal - 1) * 100) / maxDD;
27196
+ portfolioCalmarRatio = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, rawCalmar));
27197
+ const rawRec = ((equityFinal - 1) * 100) / maxDD;
27198
+ portfolioRecoveryFactor = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, rawRec));
27199
+ }
27200
+ else {
27201
+ // Blown β€” full loss is the only meaningful value; recovery undefined.
27202
+ portfolioCalmarRatio = -1; // -100 / 100
27203
+ }
27129
27204
  }
27130
27205
  }
27131
27206
  // Portfolio-wide weighted average peak/fall PNL. Denominator must include only
@@ -27152,6 +27227,16 @@ class HeatmapStorage {
27152
27227
  portfolioAvgPeakPnl = null;
27153
27228
  if (isUnsafe(portfolioAvgFallPnl))
27154
27229
  portfolioAvgFallPnl = null;
27230
+ if (isUnsafe(portfolioStdDev))
27231
+ portfolioStdDev = null;
27232
+ if (isUnsafe(portfolioSortinoRatio))
27233
+ portfolioSortinoRatio = null;
27234
+ if (isUnsafe(portfolioCalmarRatio))
27235
+ portfolioCalmarRatio = null;
27236
+ if (isUnsafe(portfolioRecoveryFactor))
27237
+ portfolioRecoveryFactor = null;
27238
+ if (isUnsafe(portfolioExpectancy))
27239
+ portfolioExpectancy = null;
27155
27240
  return {
27156
27241
  symbols,
27157
27242
  totalSymbols,
@@ -27160,6 +27245,11 @@ class HeatmapStorage {
27160
27245
  portfolioTotalTrades,
27161
27246
  portfolioAvgPeakPnl,
27162
27247
  portfolioAvgFallPnl,
27248
+ portfolioStdDev,
27249
+ portfolioSortinoRatio,
27250
+ portfolioCalmarRatio,
27251
+ portfolioRecoveryFactor,
27252
+ portfolioExpectancy,
27163
27253
  };
27164
27254
  }
27165
27255
  /**
@@ -27209,6 +27299,7 @@ class HeatmapStorage {
27209
27299
  `# Portfolio Heatmap: ${strategyName}`,
27210
27300
  "",
27211
27301
  `**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"}`,
27302
+ `**Standard Deviation:** ${data.portfolioStdDev !== null ? str(data.portfolioStdDev, "%") : "N/A"} | **Sortino Ratio:** ${data.portfolioSortinoRatio !== null ? str(data.portfolioSortinoRatio) : "N/A"} | **Calmar Ratio:** ${data.portfolioCalmarRatio !== null ? str(data.portfolioCalmarRatio) : "N/A"} | **Recovery Factor:** ${data.portfolioRecoveryFactor !== null ? str(data.portfolioRecoveryFactor) : "N/A"} | **Expectancy:** ${data.portfolioExpectancy !== null ? str(data.portfolioExpectancy, "%") : "N/A"}`,
27212
27303
  "",
27213
27304
  table,
27214
27305
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backtest-kit",
3
- "version": "11.2.0",
3
+ "version": "11.3.0",
4
4
  "description": "A TypeScript library for trading system backtest",
5
5
  "author": {
6
6
  "name": "Petr Tripolsky",
package/types.d.ts CHANGED
@@ -4634,6 +4634,8 @@ interface BacktestStatisticsModel {
4634
4634
  calmarRatio: number | null;
4635
4635
  /** Recovery Factor (totalPnl / max drawdown), null if unsafe. Higher is better. */
4636
4636
  recoveryFactor: number | null;
4637
+ /** Per-trade Expectancy (winProb*avgWin + lossProb*avgLoss), null if unsafe. Higher is better. */
4638
+ expectancy: number | null;
4637
4639
  }
4638
4640
 
4639
4641
  /**
@@ -12609,6 +12611,8 @@ interface LiveStatisticsModel {
12609
12611
  calmarRatio: number | null;
12610
12612
  /** Recovery Factor (totalPnl / max drawdown), null if unsafe. Higher is better. */
12611
12613
  recoveryFactor: number | null;
12614
+ /** Per-trade Expectancy (winProb*avgWin + lossProb*avgLoss), null if unsafe. Higher is better. */
12615
+ expectancy: number | null;
12612
12616
  }
12613
12617
 
12614
12618
  /**
@@ -12630,6 +12634,16 @@ interface HeatmapStatisticsModel {
12630
12634
  portfolioAvgPeakPnl: number | null;
12631
12635
  /** Trade-count-weighted average fall PNL across all symbols. Closer to 0 is better. */
12632
12636
  portfolioAvgFallPnl: number | null;
12637
+ /** Pooled sample standard deviation of returns across all symbols. */
12638
+ portfolioStdDev: number | null;
12639
+ /** Pooled Sortino Ratio over all trades. Same canonical formula as per-symbol. */
12640
+ portfolioSortinoRatio: number | null;
12641
+ /** Pooled Calmar Ratio: pooled compound annual / equity drawdown. Capped at Β±MAX_CALMAR_RATIO. */
12642
+ portfolioCalmarRatio: number | null;
12643
+ /** Pooled Recovery Factor: (equityFinal-1)*100 / equityMaxDrawdown. Capped at Β±MAX_CALMAR_RATIO. */
12644
+ portfolioRecoveryFactor: number | null;
12645
+ /** Pooled Expectancy: winProb*avgWin + lossProb*avgLoss (per-trade expected %). */
12646
+ portfolioExpectancy: number | null;
12633
12647
  }
12634
12648
 
12635
12649
  /**