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 +2 -2
- package/build/index.cjs +189 -14
- package/build/index.mjs +189 -14
- package/package.json +1 -1
- package/types.d.ts +74 -6
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**:
|
|
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
|
-
|
|
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
|
|
27131
|
-
//
|
|
27132
|
-
//
|
|
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
|
|
27167
|
+
const stdDev = Math.sqrt(portfolioVariance);
|
|
27146
27168
|
// STDDEV_EPSILON guard β same protection as per-symbol Sharpe.
|
|
27147
|
-
|
|
27148
|
-
|
|
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`
|
|
64000
|
-
*
|
|
64001
|
-
*
|
|
64002
|
-
*
|
|
64003
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
|
27111
|
-
//
|
|
27112
|
-
//
|
|
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
|
|
27147
|
+
const stdDev = Math.sqrt(portfolioVariance);
|
|
27126
27148
|
// STDDEV_EPSILON guard β same protection as per-symbol Sharpe.
|
|
27127
|
-
|
|
27128
|
-
|
|
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`
|
|
63980
|
-
*
|
|
63981
|
-
*
|
|
63982
|
-
*
|
|
63983
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
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`
|
|
26447
|
-
*
|
|
26448
|
-
*
|
|
26449
|
-
*
|
|
26450
|
-
*
|
|
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.
|
|
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).
|