backtest-kit 11.0.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
  "",
@@ -63859,6 +63950,34 @@ class CronUtils {
63859
63950
  * on successful settle.
63860
63951
  */
63861
63952
  this._firedOnce = new Set();
63953
+ /**
63954
+ * Last interval boundary already fired per periodic slot.
63955
+ *
63956
+ * Key shape (no `alignedMs` segment β€” one entry per logical slot, not per
63957
+ * boundary; always carries the generation suffix `:g${generation}`, and the
63958
+ * `:${symbol}` scope only in fan-out mode):
63959
+ * - Periodic global: `${name}${genSuffix}`.
63960
+ * - Periodic fan-out: `${name}:${symbol}${genSuffix}`.
63961
+ *
63962
+ * Value is the aligned-boundary epoch ms (`alignedMs`) most recently opened
63963
+ * for that slot. `_tick` fires a periodic entry whenever the incoming tick's
63964
+ * aligned boundary is **strictly greater** than the stored value, instead of
63965
+ * requiring the tick to land *exactly* on the boundary. This fixes the
63966
+ * dropped-boundary bug: when virtual time jumps over a boundary (e.g. a
63967
+ * `5m`-driven loop skipping from 00:14 to 00:29 never lands on the `15m`
63968
+ * 00:15 boundary), the old `ts === alignedMs` check silently lost the tick.
63969
+ * With the watermark, the next tick whose `alignedMs` advanced past the
63970
+ * stored value fires once for the newest crossed boundary (catch-up
63971
+ * collapses multiple skipped boundaries into a single invocation at the
63972
+ * latest one).
63973
+ *
63974
+ * Written synchronously in `_tick` at slot-open time (before the `await`),
63975
+ * so a still-in-flight handler does not let a later tick re-open the same
63976
+ * (or an already-passed) boundary. Fire-once entries never touch this map β€”
63977
+ * they use `_firedOnce`. Pruned by `_clearBoundaryFor` on
63978
+ * `register`/`unregister` and wiped by `dispose`.
63979
+ */
63980
+ this._lastBoundary = new Map();
63862
63981
  /**
63863
63982
  * Register a periodic cron entry.
63864
63983
  *
@@ -63904,6 +64023,7 @@ class CronUtils {
63904
64023
  }
63905
64024
  }
63906
64025
  this._clearFiredOnceFor(entry.name);
64026
+ this._clearBoundaryFor(entry.name);
63907
64027
  const generation = ++this._generationCounter;
63908
64028
  this._entries.set(entry.name, { entry, generation });
63909
64029
  return () => this.unregister(entry.name);
@@ -63920,6 +64040,7 @@ class CronUtils {
63920
64040
  LOGGER_SERVICE$1.info(CRON_METHOD_NAME_UNREGISTER, { name });
63921
64041
  this._entries.delete(name);
63922
64042
  this._clearFiredOnceFor(name);
64043
+ this._clearBoundaryFor(name);
63923
64044
  };
63924
64045
  /**
63925
64046
  * Clear fire-once marks so that fire-once entries can fire again.
@@ -63996,11 +64117,24 @@ class CronUtils {
63996
64117
  * - Slot key: `${name}:once` (+ scope) (+ gen).
63997
64118
  * - `aligned` = the 1-minute-aligned `when` from step 0.
63998
64119
  * 5. **Periodic** (`entry.interval` set):
63999
- * - Align `when` further to the entry's interval via {@link alignToInterval}.
64000
- * - If `ts !== alignedMs`, the tick is mid-interval β€” skip.
64001
- * (This is the "remainder === 0" boundary check from the spec;
64002
- * since `ts` is already on the 1-minute boundary, the check is exact
64003
- * for `1m` and consistent for higher intervals.)
64120
+ * - Align `when` to the entry's interval via {@link alignToInterval} to
64121
+ * get `alignedMs`, the boundary this tick belongs to.
64122
+ * - Compare against the slot's watermark in `_lastBoundary` (keyed by
64123
+ * `${name}` + scope + gen, without the `alignedMs` segment). If a
64124
+ * watermark exists and `alignedMs <= lastBoundary`, this boundary was
64125
+ * already fired β€” skip.
64126
+ * - This **watermark** check replaces the old exact `ts === alignedMs`
64127
+ * match. The exact match required virtual time to land *precisely* on
64128
+ * the boundary; when a tick jumped clean over a boundary (e.g. a `5m`
64129
+ * loop going 00:14 β†’ 00:29 never touching the `15m` 00:15 boundary)
64130
+ * the boundary was silently lost. With the watermark, the first tick
64131
+ * whose `alignedMs` advanced past the stored value fires once, at the
64132
+ * newest crossed boundary (catch-up collapses several skipped
64133
+ * boundaries into a single invocation at the latest one).
64134
+ * - The watermark is advanced to `alignedMs` synchronously when the slot
64135
+ * is opened (before the `await`), so a concurrent tick on the same or
64136
+ * an already-passed boundary cannot open a duplicate slot while the
64137
+ * handler is still in flight.
64004
64138
  * - Slot key: `${name}:${alignedMs}` (+ scope) (+ gen).
64005
64139
  * 6. Singleshot per slot key: look up the slot in `_inFlight`. If a promise
64006
64140
  * already exists, `await` the same promise. Otherwise invoke
@@ -64049,6 +64183,9 @@ class CronUtils {
64049
64183
  let alignedMs;
64050
64184
  let slotKey;
64051
64185
  let firedKey;
64186
+ // Periodic-only watermark key (no `alignedMs` segment); null for
64187
+ // fire-once entries, which coordinate via `_firedOnce` instead.
64188
+ let boundaryKey;
64052
64189
  if (entry.interval === undefined) {
64053
64190
  const onceKey = `${entry.name}${scope}${genSuffix}`;
64054
64191
  if (this._firedOnce.has(onceKey)) {
@@ -64058,11 +64195,18 @@ class CronUtils {
64058
64195
  alignedMs = ts;
64059
64196
  slotKey = `${entry.name}:once${scope}${genSuffix}`;
64060
64197
  firedKey = onceKey;
64198
+ boundaryKey = null;
64061
64199
  }
64062
64200
  else {
64063
64201
  aligned = alignToInterval(when, entry.interval);
64064
64202
  alignedMs = aligned.getTime();
64065
- if (ts !== alignedMs) {
64203
+ boundaryKey = `${entry.name}${scope}${genSuffix}`;
64204
+ const lastBoundary = this._lastBoundary.get(boundaryKey);
64205
+ // Fire when the tick's aligned boundary has advanced past the last one
64206
+ // we fired for this slot. Using `>` instead of the old `ts === alignedMs`
64207
+ // means a virtual-time jump that skips clean over a boundary still
64208
+ // fires once, at the newest crossed boundary, rather than dropping it.
64209
+ if (lastBoundary !== undefined && alignedMs <= lastBoundary) {
64066
64210
  continue;
64067
64211
  }
64068
64212
  slotKey = `${entry.name}:${alignedMs}${scope}${genSuffix}`;
@@ -64070,6 +64214,13 @@ class CronUtils {
64070
64214
  }
64071
64215
  let pending = this._inFlight.get(slotKey);
64072
64216
  if (!pending) {
64217
+ // Advance the watermark synchronously at slot-open time, before the
64218
+ // await below. Otherwise a later tick on the same (or an already
64219
+ // crossed) boundary, arriving while this handler is still in flight,
64220
+ // would see the stale watermark and open a duplicate slot.
64221
+ if (boundaryKey !== null) {
64222
+ this._lastBoundary.set(boundaryKey, alignedMs);
64223
+ }
64073
64224
  pending = this._runEntry(entry, symbol, aligned, alignedMs, slotKey, firedKey, backtest);
64074
64225
  this._inFlight.set(slotKey, pending);
64075
64226
  }
@@ -64161,7 +64312,10 @@ class CronUtils {
64161
64312
  * 3. Wipes `_firedOnce` β€” all fire-once marks are dropped, so any future
64162
64313
  * re-registration of the same `name` fires again on the next matching
64163
64314
  * tick.
64164
- * 4. Does **not** touch `_inFlight` β€” in-flight handlers continue to
64315
+ * 4. Wipes `_lastBoundary` β€” all periodic watermarks are dropped, so a
64316
+ * re-registered periodic entry starts firing from its next crossed
64317
+ * boundary again.
64318
+ * 5. Does **not** touch `_inFlight` β€” in-flight handlers continue to
64165
64319
  * settle in the background and clear their own slots via `.finally()`.
64166
64320
  * Their final `_firedOnce.add(firedKey)` writes carry old-generation
64167
64321
  * keys and are harmless (lookup uses the post-dispose generation).
@@ -64180,8 +64334,29 @@ class CronUtils {
64180
64334
  this.disable();
64181
64335
  this._entries.clear();
64182
64336
  this._firedOnce.clear();
64337
+ this._lastBoundary.clear();
64183
64338
  };
64184
64339
  }
64340
+ /**
64341
+ * Garbage-collect every `_lastBoundary` key that belongs to the entry `name`
64342
+ * (any generation, global or fan-out).
64343
+ *
64344
+ * Called from `register`/`unregister` alongside `_clearFiredOnceFor`. Like
64345
+ * that helper this is memory hygiene, not correctness β€” the generation suffix
64346
+ * already isolates re-registrations, so a stale watermark from an old
64347
+ * generation can never gate a new entry.
64348
+ */
64349
+ _clearBoundaryFor(name) {
64350
+ if (!name) {
64351
+ return;
64352
+ }
64353
+ const prefix = `${name}:`;
64354
+ for (const key of this._lastBoundary.keys()) {
64355
+ if (key === name || key.startsWith(prefix)) {
64356
+ this._lastBoundary.delete(key);
64357
+ }
64358
+ }
64359
+ }
64185
64360
  /**
64186
64361
  * Garbage-collect every `_firedOnce` key that belongs to the entry `name`
64187
64362
  * (any generation, global or fan-out).
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
  "",
@@ -63839,6 +63930,34 @@ class CronUtils {
63839
63930
  * on successful settle.
63840
63931
  */
63841
63932
  this._firedOnce = new Set();
63933
+ /**
63934
+ * Last interval boundary already fired per periodic slot.
63935
+ *
63936
+ * Key shape (no `alignedMs` segment β€” one entry per logical slot, not per
63937
+ * boundary; always carries the generation suffix `:g${generation}`, and the
63938
+ * `:${symbol}` scope only in fan-out mode):
63939
+ * - Periodic global: `${name}${genSuffix}`.
63940
+ * - Periodic fan-out: `${name}:${symbol}${genSuffix}`.
63941
+ *
63942
+ * Value is the aligned-boundary epoch ms (`alignedMs`) most recently opened
63943
+ * for that slot. `_tick` fires a periodic entry whenever the incoming tick's
63944
+ * aligned boundary is **strictly greater** than the stored value, instead of
63945
+ * requiring the tick to land *exactly* on the boundary. This fixes the
63946
+ * dropped-boundary bug: when virtual time jumps over a boundary (e.g. a
63947
+ * `5m`-driven loop skipping from 00:14 to 00:29 never lands on the `15m`
63948
+ * 00:15 boundary), the old `ts === alignedMs` check silently lost the tick.
63949
+ * With the watermark, the next tick whose `alignedMs` advanced past the
63950
+ * stored value fires once for the newest crossed boundary (catch-up
63951
+ * collapses multiple skipped boundaries into a single invocation at the
63952
+ * latest one).
63953
+ *
63954
+ * Written synchronously in `_tick` at slot-open time (before the `await`),
63955
+ * so a still-in-flight handler does not let a later tick re-open the same
63956
+ * (or an already-passed) boundary. Fire-once entries never touch this map β€”
63957
+ * they use `_firedOnce`. Pruned by `_clearBoundaryFor` on
63958
+ * `register`/`unregister` and wiped by `dispose`.
63959
+ */
63960
+ this._lastBoundary = new Map();
63842
63961
  /**
63843
63962
  * Register a periodic cron entry.
63844
63963
  *
@@ -63884,6 +64003,7 @@ class CronUtils {
63884
64003
  }
63885
64004
  }
63886
64005
  this._clearFiredOnceFor(entry.name);
64006
+ this._clearBoundaryFor(entry.name);
63887
64007
  const generation = ++this._generationCounter;
63888
64008
  this._entries.set(entry.name, { entry, generation });
63889
64009
  return () => this.unregister(entry.name);
@@ -63900,6 +64020,7 @@ class CronUtils {
63900
64020
  LOGGER_SERVICE$1.info(CRON_METHOD_NAME_UNREGISTER, { name });
63901
64021
  this._entries.delete(name);
63902
64022
  this._clearFiredOnceFor(name);
64023
+ this._clearBoundaryFor(name);
63903
64024
  };
63904
64025
  /**
63905
64026
  * Clear fire-once marks so that fire-once entries can fire again.
@@ -63976,11 +64097,24 @@ class CronUtils {
63976
64097
  * - Slot key: `${name}:once` (+ scope) (+ gen).
63977
64098
  * - `aligned` = the 1-minute-aligned `when` from step 0.
63978
64099
  * 5. **Periodic** (`entry.interval` set):
63979
- * - Align `when` further to the entry's interval via {@link alignToInterval}.
63980
- * - If `ts !== alignedMs`, the tick is mid-interval β€” skip.
63981
- * (This is the "remainder === 0" boundary check from the spec;
63982
- * since `ts` is already on the 1-minute boundary, the check is exact
63983
- * for `1m` and consistent for higher intervals.)
64100
+ * - Align `when` to the entry's interval via {@link alignToInterval} to
64101
+ * get `alignedMs`, the boundary this tick belongs to.
64102
+ * - Compare against the slot's watermark in `_lastBoundary` (keyed by
64103
+ * `${name}` + scope + gen, without the `alignedMs` segment). If a
64104
+ * watermark exists and `alignedMs <= lastBoundary`, this boundary was
64105
+ * already fired β€” skip.
64106
+ * - This **watermark** check replaces the old exact `ts === alignedMs`
64107
+ * match. The exact match required virtual time to land *precisely* on
64108
+ * the boundary; when a tick jumped clean over a boundary (e.g. a `5m`
64109
+ * loop going 00:14 β†’ 00:29 never touching the `15m` 00:15 boundary)
64110
+ * the boundary was silently lost. With the watermark, the first tick
64111
+ * whose `alignedMs` advanced past the stored value fires once, at the
64112
+ * newest crossed boundary (catch-up collapses several skipped
64113
+ * boundaries into a single invocation at the latest one).
64114
+ * - The watermark is advanced to `alignedMs` synchronously when the slot
64115
+ * is opened (before the `await`), so a concurrent tick on the same or
64116
+ * an already-passed boundary cannot open a duplicate slot while the
64117
+ * handler is still in flight.
63984
64118
  * - Slot key: `${name}:${alignedMs}` (+ scope) (+ gen).
63985
64119
  * 6. Singleshot per slot key: look up the slot in `_inFlight`. If a promise
63986
64120
  * already exists, `await` the same promise. Otherwise invoke
@@ -64029,6 +64163,9 @@ class CronUtils {
64029
64163
  let alignedMs;
64030
64164
  let slotKey;
64031
64165
  let firedKey;
64166
+ // Periodic-only watermark key (no `alignedMs` segment); null for
64167
+ // fire-once entries, which coordinate via `_firedOnce` instead.
64168
+ let boundaryKey;
64032
64169
  if (entry.interval === undefined) {
64033
64170
  const onceKey = `${entry.name}${scope}${genSuffix}`;
64034
64171
  if (this._firedOnce.has(onceKey)) {
@@ -64038,11 +64175,18 @@ class CronUtils {
64038
64175
  alignedMs = ts;
64039
64176
  slotKey = `${entry.name}:once${scope}${genSuffix}`;
64040
64177
  firedKey = onceKey;
64178
+ boundaryKey = null;
64041
64179
  }
64042
64180
  else {
64043
64181
  aligned = alignToInterval(when, entry.interval);
64044
64182
  alignedMs = aligned.getTime();
64045
- if (ts !== alignedMs) {
64183
+ boundaryKey = `${entry.name}${scope}${genSuffix}`;
64184
+ const lastBoundary = this._lastBoundary.get(boundaryKey);
64185
+ // Fire when the tick's aligned boundary has advanced past the last one
64186
+ // we fired for this slot. Using `>` instead of the old `ts === alignedMs`
64187
+ // means a virtual-time jump that skips clean over a boundary still
64188
+ // fires once, at the newest crossed boundary, rather than dropping it.
64189
+ if (lastBoundary !== undefined && alignedMs <= lastBoundary) {
64046
64190
  continue;
64047
64191
  }
64048
64192
  slotKey = `${entry.name}:${alignedMs}${scope}${genSuffix}`;
@@ -64050,6 +64194,13 @@ class CronUtils {
64050
64194
  }
64051
64195
  let pending = this._inFlight.get(slotKey);
64052
64196
  if (!pending) {
64197
+ // Advance the watermark synchronously at slot-open time, before the
64198
+ // await below. Otherwise a later tick on the same (or an already
64199
+ // crossed) boundary, arriving while this handler is still in flight,
64200
+ // would see the stale watermark and open a duplicate slot.
64201
+ if (boundaryKey !== null) {
64202
+ this._lastBoundary.set(boundaryKey, alignedMs);
64203
+ }
64053
64204
  pending = this._runEntry(entry, symbol, aligned, alignedMs, slotKey, firedKey, backtest);
64054
64205
  this._inFlight.set(slotKey, pending);
64055
64206
  }
@@ -64141,7 +64292,10 @@ class CronUtils {
64141
64292
  * 3. Wipes `_firedOnce` β€” all fire-once marks are dropped, so any future
64142
64293
  * re-registration of the same `name` fires again on the next matching
64143
64294
  * tick.
64144
- * 4. Does **not** touch `_inFlight` β€” in-flight handlers continue to
64295
+ * 4. Wipes `_lastBoundary` β€” all periodic watermarks are dropped, so a
64296
+ * re-registered periodic entry starts firing from its next crossed
64297
+ * boundary again.
64298
+ * 5. Does **not** touch `_inFlight` β€” in-flight handlers continue to
64145
64299
  * settle in the background and clear their own slots via `.finally()`.
64146
64300
  * Their final `_firedOnce.add(firedKey)` writes carry old-generation
64147
64301
  * keys and are harmless (lookup uses the post-dispose generation).
@@ -64160,8 +64314,29 @@ class CronUtils {
64160
64314
  this.disable();
64161
64315
  this._entries.clear();
64162
64316
  this._firedOnce.clear();
64317
+ this._lastBoundary.clear();
64163
64318
  };
64164
64319
  }
64320
+ /**
64321
+ * Garbage-collect every `_lastBoundary` key that belongs to the entry `name`
64322
+ * (any generation, global or fan-out).
64323
+ *
64324
+ * Called from `register`/`unregister` alongside `_clearFiredOnceFor`. Like
64325
+ * that helper this is memory hygiene, not correctness β€” the generation suffix
64326
+ * already isolates re-registrations, so a stale watermark from an old
64327
+ * generation can never gate a new entry.
64328
+ */
64329
+ _clearBoundaryFor(name) {
64330
+ if (!name) {
64331
+ return;
64332
+ }
64333
+ const prefix = `${name}:`;
64334
+ for (const key of this._lastBoundary.keys()) {
64335
+ if (key === name || key.startsWith(prefix)) {
64336
+ this._lastBoundary.delete(key);
64337
+ }
64338
+ }
64339
+ }
64165
64340
  /**
64166
64341
  * Garbage-collect every `_firedOnce` key that belongs to the entry `name`
64167
64342
  * (any generation, global or fan-out).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backtest-kit",
3
- "version": "11.0.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
  /**
@@ -26322,6 +26336,44 @@ declare class CronUtils {
26322
26336
  * on successful settle.
26323
26337
  */
26324
26338
  private readonly _firedOnce;
26339
+ /**
26340
+ * Last interval boundary already fired per periodic slot.
26341
+ *
26342
+ * Key shape (no `alignedMs` segment β€” one entry per logical slot, not per
26343
+ * boundary; always carries the generation suffix `:g${generation}`, and the
26344
+ * `:${symbol}` scope only in fan-out mode):
26345
+ * - Periodic global: `${name}${genSuffix}`.
26346
+ * - Periodic fan-out: `${name}:${symbol}${genSuffix}`.
26347
+ *
26348
+ * Value is the aligned-boundary epoch ms (`alignedMs`) most recently opened
26349
+ * for that slot. `_tick` fires a periodic entry whenever the incoming tick's
26350
+ * aligned boundary is **strictly greater** than the stored value, instead of
26351
+ * requiring the tick to land *exactly* on the boundary. This fixes the
26352
+ * dropped-boundary bug: when virtual time jumps over a boundary (e.g. a
26353
+ * `5m`-driven loop skipping from 00:14 to 00:29 never lands on the `15m`
26354
+ * 00:15 boundary), the old `ts === alignedMs` check silently lost the tick.
26355
+ * With the watermark, the next tick whose `alignedMs` advanced past the
26356
+ * stored value fires once for the newest crossed boundary (catch-up
26357
+ * collapses multiple skipped boundaries into a single invocation at the
26358
+ * latest one).
26359
+ *
26360
+ * Written synchronously in `_tick` at slot-open time (before the `await`),
26361
+ * so a still-in-flight handler does not let a later tick re-open the same
26362
+ * (or an already-passed) boundary. Fire-once entries never touch this map β€”
26363
+ * they use `_firedOnce`. Pruned by `_clearBoundaryFor` on
26364
+ * `register`/`unregister` and wiped by `dispose`.
26365
+ */
26366
+ private readonly _lastBoundary;
26367
+ /**
26368
+ * Garbage-collect every `_lastBoundary` key that belongs to the entry `name`
26369
+ * (any generation, global or fan-out).
26370
+ *
26371
+ * Called from `register`/`unregister` alongside `_clearFiredOnceFor`. Like
26372
+ * that helper this is memory hygiene, not correctness β€” the generation suffix
26373
+ * already isolates re-registrations, so a stale watermark from an old
26374
+ * generation can never gate a new entry.
26375
+ */
26376
+ private _clearBoundaryFor;
26325
26377
  /**
26326
26378
  * Garbage-collect every `_firedOnce` key that belongs to the entry `name`
26327
26379
  * (any generation, global or fan-out).
@@ -26443,11 +26495,24 @@ declare class CronUtils {
26443
26495
  * - Slot key: `${name}:once` (+ scope) (+ gen).
26444
26496
  * - `aligned` = the 1-minute-aligned `when` from step 0.
26445
26497
  * 5. **Periodic** (`entry.interval` set):
26446
- * - Align `when` further to the entry's interval via {@link alignToInterval}.
26447
- * - If `ts !== alignedMs`, the tick is mid-interval β€” skip.
26448
- * (This is the "remainder === 0" boundary check from the spec;
26449
- * since `ts` is already on the 1-minute boundary, the check is exact
26450
- * for `1m` and consistent for higher intervals.)
26498
+ * - Align `when` to the entry's interval via {@link alignToInterval} to
26499
+ * get `alignedMs`, the boundary this tick belongs to.
26500
+ * - Compare against the slot's watermark in `_lastBoundary` (keyed by
26501
+ * `${name}` + scope + gen, without the `alignedMs` segment). If a
26502
+ * watermark exists and `alignedMs <= lastBoundary`, this boundary was
26503
+ * already fired β€” skip.
26504
+ * - This **watermark** check replaces the old exact `ts === alignedMs`
26505
+ * match. The exact match required virtual time to land *precisely* on
26506
+ * the boundary; when a tick jumped clean over a boundary (e.g. a `5m`
26507
+ * loop going 00:14 β†’ 00:29 never touching the `15m` 00:15 boundary)
26508
+ * the boundary was silently lost. With the watermark, the first tick
26509
+ * whose `alignedMs` advanced past the stored value fires once, at the
26510
+ * newest crossed boundary (catch-up collapses several skipped
26511
+ * boundaries into a single invocation at the latest one).
26512
+ * - The watermark is advanced to `alignedMs` synchronously when the slot
26513
+ * is opened (before the `await`), so a concurrent tick on the same or
26514
+ * an already-passed boundary cannot open a duplicate slot while the
26515
+ * handler is still in flight.
26451
26516
  * - Slot key: `${name}:${alignedMs}` (+ scope) (+ gen).
26452
26517
  * 6. Singleshot per slot key: look up the slot in `_inFlight`. If a promise
26453
26518
  * already exists, `await` the same promise. Otherwise invoke
@@ -26541,7 +26606,10 @@ declare class CronUtils {
26541
26606
  * 3. Wipes `_firedOnce` β€” all fire-once marks are dropped, so any future
26542
26607
  * re-registration of the same `name` fires again on the next matching
26543
26608
  * tick.
26544
- * 4. Does **not** touch `_inFlight` β€” in-flight handlers continue to
26609
+ * 4. Wipes `_lastBoundary` β€” all periodic watermarks are dropped, so a
26610
+ * re-registered periodic entry starts firing from its next crossed
26611
+ * boundary again.
26612
+ * 5. Does **not** touch `_inFlight` β€” in-flight handlers continue to
26545
26613
  * settle in the background and clear their own slots via `.finally()`.
26546
26614
  * Their final `_firedOnce.add(firedKey)` writes carry old-generation
26547
26615
  * keys and are harmless (lookup uses the post-dispose generation).