backtest-kit 11.3.0 → 11.5.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 +1 -0
- package/build/index.cjs +144 -32
- package/build/index.mjs +144 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,6 +75,7 @@ Install the core library and peer dependencies manually. Use this approach when
|
|
|
75
75
|
- 🔄 **Efficient Execution**: Streaming architecture for large datasets; VWAP pricing for realism.
|
|
76
76
|
- 🤖 **AI Integration**: LLM-powered strategy generation (Optimizer) with multi-timeframe analysis.
|
|
77
77
|
- 📊 **Reports & Metrics**: Auto Markdown reports with PNL, Sharpe Ratio, win rate, and more.
|
|
78
|
+
- 📐 **Portfolio Heatmap**: Cross-symbol portfolio with Pooled Sharpe, Sortino & Calmar Ratio, Recovery Factor, Expectancy and other measures
|
|
78
79
|
- 🛡️ **Risk Management**: Custom rules for position limits, time windows, and multi-strategy coordination.
|
|
79
80
|
- 🔌 **Pluggable**: Custom data sources (CCXT), persistence (file/Redis), and sizing calculators.
|
|
80
81
|
- 🗃️ **Transactional Live Orders**: Broker adapter intercepts every trade mutation before internal state changes — exchange rejection rolls back the operation atomically.
|
package/build/index.cjs
CHANGED
|
@@ -23885,13 +23885,34 @@ let ReportStorage$a = class ReportStorage {
|
|
|
23885
23885
|
// per trade ("as-if 100% allocation"). Walks validSignals in chronological order
|
|
23886
23886
|
// (storage is newest-first, so iterate in reverse). Using validSignals (same set as
|
|
23887
23887
|
// tradesPerYear) keeps equityFinal consistent with the annualization exponent.
|
|
23888
|
-
//
|
|
23889
|
-
//
|
|
23888
|
+
//
|
|
23889
|
+
// MARK-TO-MARKET DD: each trade's worst intra-trade excursion (signal.maxDrawdown,
|
|
23890
|
+
// i.e. the `_fall` snapshot, ≤ 0) is applied as a trough BEFORE booking the realized
|
|
23891
|
+
// close. Without this the curve only steps at close, so a trade that dipped to -18%
|
|
23892
|
+
// and recovered to +2% would register zero drawdown — understating DD and inflating
|
|
23893
|
+
// Calmar/Recovery. The trough does not persist into equity (it's a transient
|
|
23894
|
+
// mark-to-market low); equity then moves to the realized close.
|
|
23895
|
+
// If equity (at trough or close) goes ≤ 0 (e.g. leveraged loss < -100%) — account
|
|
23896
|
+
// blown, fix DD at 100% and stop walking the curve.
|
|
23890
23897
|
let equity = 1;
|
|
23891
23898
|
let peak = 1;
|
|
23892
23899
|
let equityMaxDrawdown = 0;
|
|
23893
23900
|
let blown = false;
|
|
23894
23901
|
for (let i = validSignals.length - 1; i >= 0; i--) {
|
|
23902
|
+
// Intra-trade trough — mark-to-market low while the position was open.
|
|
23903
|
+
const fallPct = validSignals[i].signal.maxDrawdown?.pnlPercentage;
|
|
23904
|
+
if (typeof fallPct === "number" && fallPct < 0) {
|
|
23905
|
+
const trough = equity * (1 + fallPct / 100);
|
|
23906
|
+
if (trough <= 0) {
|
|
23907
|
+
equityMaxDrawdown = 100;
|
|
23908
|
+
blown = true;
|
|
23909
|
+
break;
|
|
23910
|
+
}
|
|
23911
|
+
const troughDd = (peak - trough) / peak * 100;
|
|
23912
|
+
if (troughDd > equityMaxDrawdown)
|
|
23913
|
+
equityMaxDrawdown = troughDd;
|
|
23914
|
+
}
|
|
23915
|
+
// Realized close — book the final per-trade result.
|
|
23895
23916
|
equity *= 1 + validSignals[i].pnl.pnlPercentage / 100;
|
|
23896
23917
|
if (equity <= 0) {
|
|
23897
23918
|
equityMaxDrawdown = 100;
|
|
@@ -24049,7 +24070,7 @@ let ReportStorage$a = class ReportStorage {
|
|
|
24049
24070
|
`**Win rate:** ${stats.winRate === null ? "N/A" : `${stats.winRate.toFixed(2)}% (${stats.winCount}W / ${stats.lossCount}L) (higher is better)`}`,
|
|
24050
24071
|
`**Average PNL:** ${stats.avgPnl === null ? "N/A" : `${stats.avgPnl > 0 ? "+" : ""}${stats.avgPnl.toFixed(2)}% (higher is better)`}`,
|
|
24051
24072
|
`**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`,
|
|
24052
|
-
`**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`,
|
|
24073
|
+
`**Standard Deviation Per Trade:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`,
|
|
24053
24074
|
`**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`,
|
|
24054
24075
|
`**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`,
|
|
24055
24076
|
`**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`,
|
|
@@ -24067,11 +24088,12 @@ let ReportStorage$a = class ReportStorage {
|
|
|
24067
24088
|
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals. N/A when no losing trades — Sortino is mathematically undefined (infinite) and we cannot distinguish "truly flawless" from "lucky streak so far".*`,
|
|
24068
24089
|
`*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
|
|
24069
24090
|
`*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.*`,
|
|
24070
|
-
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is
|
|
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.*`,
|
|
24091
|
+
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is the mark-to-market max drawdown (see below). Capped at ±${MAX_CALMAR_RATIO$2}.*`,
|
|
24092
|
+
`*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 and the mark-to-market max drawdown as denominator.*`,
|
|
24093
|
+
`*Max Drawdown: mark-to-market — the compounded equity curve applies each trade's worst intra-trade excursion (the lowest unrealized point while the position was open) before booking its realized close, so deep round-trip dips count. It is NOT realized-only (close-to-close); a realized-only curve would understate drawdown and inflate Calmar/Recovery.*`,
|
|
24072
24094
|
`*Expectancy: per-trade expected value (winProb × avgWin + lossProb × avgLoss). Positive = profitable on average per trade. Break-even trades contribute 0.*`,
|
|
24073
24095
|
`*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
|
|
24074
|
-
`*IMPORTANT: Equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per
|
|
24096
|
+
`*IMPORTANT: Equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per position** (no portfolio fraction). These metrics ignore the position-sizing subsystem (PositionSize / Kelly / ATR): pnlPercentage is a return on the position's own invested capital, never scaled by account balance. With DCA (commitAverageBuy) the cost basis is the sum of all entries and the entry price is dollar-cost-weighted, so per-trade % is measured against the averaged position, not a fixed stake. If your strategy risks X% of capital per trade, the realized portfolio return / drawdown will be roughly X/100 of the reported figures — these metrics represent a theoretical upper bound under full allocation.*`,
|
|
24075
24097
|
`*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.*`,
|
|
24076
24098
|
].join("\n");
|
|
24077
24099
|
}
|
|
@@ -24820,15 +24842,37 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
24820
24842
|
// on cost basis — compounding assumes equal capital allocation per trade ("as-if 100%").
|
|
24821
24843
|
// If equity ≤ 0 (leveraged short with r < -100%) — account blown, fix DD at 100%.
|
|
24822
24844
|
// Built from validClosed (newest-first), iterated reverse for chronological order.
|
|
24823
|
-
|
|
24845
|
+
//
|
|
24846
|
+
// MARK-TO-MARKET DD: each trade's worst intra-trade excursion (fallPnl, the `_fall`
|
|
24847
|
+
// snapshot, ≤ 0) is applied as a trough BEFORE booking the realized close. Without it
|
|
24848
|
+
// the curve only steps at close, so a trade that dipped to -18% and recovered to +2%
|
|
24849
|
+
// would register zero drawdown — understating DD and inflating Calmar/Recovery.
|
|
24850
|
+
const chronological = [];
|
|
24824
24851
|
for (let i = validClosed.length - 1; i >= 0; i--) {
|
|
24825
|
-
|
|
24852
|
+
const fall = validClosed[i].fallPnl;
|
|
24853
|
+
chronological.push({
|
|
24854
|
+
r: validClosed[i].pnl,
|
|
24855
|
+
fall: typeof fall === "number" ? fall : null,
|
|
24856
|
+
});
|
|
24826
24857
|
}
|
|
24827
24858
|
let equity = 1;
|
|
24828
24859
|
let peak = 1;
|
|
24829
24860
|
let equityMaxDrawdown = 0;
|
|
24830
24861
|
let blown = false;
|
|
24831
|
-
for (const r of
|
|
24862
|
+
for (const { r, fall } of chronological) {
|
|
24863
|
+
// Intra-trade trough — mark-to-market low while the position was open.
|
|
24864
|
+
if (fall !== null && fall < 0) {
|
|
24865
|
+
const trough = equity * (1 + fall / 100);
|
|
24866
|
+
if (trough <= 0) {
|
|
24867
|
+
equityMaxDrawdown = 100;
|
|
24868
|
+
blown = true;
|
|
24869
|
+
break;
|
|
24870
|
+
}
|
|
24871
|
+
const troughDd = (peak - trough) / peak * 100;
|
|
24872
|
+
if (troughDd > equityMaxDrawdown)
|
|
24873
|
+
equityMaxDrawdown = troughDd;
|
|
24874
|
+
}
|
|
24875
|
+
// Realized close.
|
|
24832
24876
|
equity *= 1 + r / 100;
|
|
24833
24877
|
if (equity <= 0) {
|
|
24834
24878
|
equityMaxDrawdown = 100;
|
|
@@ -24927,7 +24971,7 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
24927
24971
|
`**Win rate:** ${stats.winRate === null ? "N/A" : `${stats.winRate.toFixed(2)}% (${stats.winCount}W / ${stats.lossCount}L) (higher is better)`}`,
|
|
24928
24972
|
`**Average PNL:** ${stats.avgPnl === null ? "N/A" : `${stats.avgPnl > 0 ? "+" : ""}${stats.avgPnl.toFixed(2)}% (higher is better)`}`,
|
|
24929
24973
|
`**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`,
|
|
24930
|
-
`**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`,
|
|
24974
|
+
`**Standard Deviation Per Trade:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`,
|
|
24931
24975
|
`**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`,
|
|
24932
24976
|
`**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`,
|
|
24933
24977
|
`**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`,
|
|
@@ -24945,11 +24989,12 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
24945
24989
|
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals. N/A when no losing trades — Sortino is mathematically undefined (infinite) and we cannot distinguish "truly flawless" from "lucky streak so far".*`,
|
|
24946
24990
|
`*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
|
|
24947
24991
|
`*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.*`,
|
|
24948
|
-
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is
|
|
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.*`,
|
|
24992
|
+
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is the mark-to-market max drawdown (see below). Capped at ±${MAX_CALMAR_RATIO$1}.*`,
|
|
24993
|
+
`*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 and the mark-to-market max drawdown as denominator.*`,
|
|
24994
|
+
`*Max Drawdown: mark-to-market — the compounded equity curve applies each trade's worst intra-trade excursion (the lowest unrealized point while the position was open) before booking its realized close, so deep round-trip dips count. It is NOT realized-only (close-to-close); a realized-only curve would understate drawdown and inflate Calmar/Recovery.*`,
|
|
24950
24995
|
`*Expectancy: per-trade expected value (winProb × avgWin + lossProb × avgLoss). Positive = profitable on average per trade. Break-even trades contribute 0.*`,
|
|
24951
24996
|
`*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
|
|
24952
|
-
`*IMPORTANT: Equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per
|
|
24997
|
+
`*IMPORTANT: Equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per position** (no portfolio fraction). These metrics ignore the position-sizing subsystem (PositionSize / Kelly / ATR): pnlPercentage is a return on the position's own invested capital, never scaled by account balance. With DCA (commitAverageBuy) the cost basis is the sum of all entries and the entry price is dollar-cost-weighted, so per-trade % is measured against the averaged position, not a fixed stake. If your strategy risks X% of capital per trade, the realized portfolio return / drawdown will be roughly X/100 of the reported figures — these metrics represent a theoretical upper bound under full allocation.*`,
|
|
24953
24998
|
`*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.*`,
|
|
24954
24999
|
].join("\n");
|
|
24955
25000
|
}
|
|
@@ -26861,6 +26906,12 @@ class HeatmapStorage {
|
|
|
26861
26906
|
// Equity-curve max drawdown via compounded equity ("as-if 100% allocation per trade").
|
|
26862
26907
|
// Signals are stored newest-first (unshift in addSignal), so iterate in reverse.
|
|
26863
26908
|
// If equity ≤ 0 — account blown, fix DD at 100%. equityFinal feeds expectedYearlyReturns.
|
|
26909
|
+
//
|
|
26910
|
+
// MARK-TO-MARKET DD: each trade's worst intra-trade excursion (signal.maxDrawdown,
|
|
26911
|
+
// the `_fall` snapshot, ≤ 0) is applied as a trough BEFORE booking the realized close.
|
|
26912
|
+
// Without it the curve only steps at close, so a trade that dipped to -18% and
|
|
26913
|
+
// recovered to +2% would register zero drawdown — understating DD and inflating
|
|
26914
|
+
// Calmar/Recovery.
|
|
26864
26915
|
let maxDrawdown = null;
|
|
26865
26916
|
let equityFinal = 1;
|
|
26866
26917
|
let blown = false;
|
|
@@ -26869,6 +26920,18 @@ class HeatmapStorage {
|
|
|
26869
26920
|
let peak = 1;
|
|
26870
26921
|
let maxDD = 0;
|
|
26871
26922
|
for (let i = signals.length - 1; i >= 0; i--) {
|
|
26923
|
+
const fallPct = signals[i].signal.maxDrawdown?.pnlPercentage;
|
|
26924
|
+
if (typeof fallPct === "number" && fallPct < 0) {
|
|
26925
|
+
const trough = equity * (1 + fallPct / 100);
|
|
26926
|
+
if (trough <= 0) {
|
|
26927
|
+
maxDD = 100;
|
|
26928
|
+
blown = true;
|
|
26929
|
+
break;
|
|
26930
|
+
}
|
|
26931
|
+
const troughDd = (peak - trough) / peak * 100;
|
|
26932
|
+
if (troughDd > maxDD)
|
|
26933
|
+
maxDD = troughDd;
|
|
26934
|
+
}
|
|
26872
26935
|
equity *= 1 + signals[i].pnl.pnlPercentage / 100;
|
|
26873
26936
|
if (equity <= 0) {
|
|
26874
26937
|
maxDD = 100;
|
|
@@ -27155,9 +27218,20 @@ class HeatmapStorage {
|
|
|
27155
27218
|
let portfolioCalmarRatio = null;
|
|
27156
27219
|
let portfolioRecoveryFactor = null;
|
|
27157
27220
|
const allReturns = [];
|
|
27221
|
+
// Parallel array of intra-trade troughs (≤ 0), aligned 1:1 with allReturns,
|
|
27222
|
+
// used for mark-to-market DD in the pooled equity curve below.
|
|
27223
|
+
const allFalls = [];
|
|
27224
|
+
let poolFirstPendingAt = Infinity;
|
|
27225
|
+
let poolLastCloseAt = -Infinity;
|
|
27158
27226
|
for (const signals of this.symbolData.values()) {
|
|
27159
27227
|
for (const s of signals) {
|
|
27160
27228
|
allReturns.push(s.pnl.pnlPercentage);
|
|
27229
|
+
const fall = s.signal.maxDrawdown?.pnlPercentage;
|
|
27230
|
+
allFalls.push(typeof fall === "number" ? fall : null);
|
|
27231
|
+
if (s.signal.pendingAt < poolFirstPendingAt)
|
|
27232
|
+
poolFirstPendingAt = s.signal.pendingAt;
|
|
27233
|
+
if (s.closeTimestamp > poolLastCloseAt)
|
|
27234
|
+
poolLastCloseAt = s.closeTimestamp;
|
|
27161
27235
|
}
|
|
27162
27236
|
}
|
|
27163
27237
|
if (allReturns.length >= MIN_SIGNALS_FOR_RATIOS) {
|
|
@@ -27189,13 +27263,27 @@ class HeatmapStorage {
|
|
|
27189
27263
|
if (wins.length > 0 || losses.length > 0) {
|
|
27190
27264
|
portfolioExpectancy = (wins.length / total) * avgWin + (losses.length / total) * avgLoss;
|
|
27191
27265
|
}
|
|
27192
|
-
// Pooled equity-curve max drawdown (compounded).
|
|
27266
|
+
// Pooled equity-curve max drawdown (compounded). MARK-TO-MARKET: each trade's
|
|
27267
|
+
// intra-trade trough (allFalls, ≤ 0) is applied before booking the realized close,
|
|
27268
|
+
// so deep round-trip dips are captured rather than understating DD.
|
|
27193
27269
|
let equity = 1;
|
|
27194
27270
|
let peak = 1;
|
|
27195
27271
|
let maxDD = 0;
|
|
27196
27272
|
let blown = false;
|
|
27197
|
-
for (
|
|
27198
|
-
|
|
27273
|
+
for (let i = 0; i < allReturns.length; i++) {
|
|
27274
|
+
const fall = allFalls[i];
|
|
27275
|
+
if (fall !== null && fall < 0) {
|
|
27276
|
+
const trough = equity * (1 + fall / 100);
|
|
27277
|
+
if (trough <= 0) {
|
|
27278
|
+
maxDD = 100;
|
|
27279
|
+
blown = true;
|
|
27280
|
+
break;
|
|
27281
|
+
}
|
|
27282
|
+
const troughDd = ((peak - trough) / peak) * 100;
|
|
27283
|
+
if (troughDd > maxDD)
|
|
27284
|
+
maxDD = troughDd;
|
|
27285
|
+
}
|
|
27286
|
+
equity *= 1 + allReturns[i] / 100;
|
|
27199
27287
|
if (equity <= 0) {
|
|
27200
27288
|
maxDD = 100;
|
|
27201
27289
|
blown = true;
|
|
@@ -27208,20 +27296,43 @@ class HeatmapStorage {
|
|
|
27208
27296
|
maxDD = dd;
|
|
27209
27297
|
}
|
|
27210
27298
|
const equityFinal = blown ? 0 : equity;
|
|
27211
|
-
// Pooled
|
|
27212
|
-
//
|
|
27213
|
-
|
|
27214
|
-
|
|
27215
|
-
|
|
27216
|
-
|
|
27217
|
-
|
|
27218
|
-
|
|
27219
|
-
|
|
27220
|
-
|
|
27221
|
-
|
|
27222
|
-
|
|
27299
|
+
// Pooled expected yearly returns — geometric annualization of the pooled
|
|
27300
|
+
// equity curve, gated exactly like the per-symbol path: requires a valid
|
|
27301
|
+
// calendar span (≥ MIN_CALENDAR_SPAN_DAYS) and a non-clustered trade
|
|
27302
|
+
// frequency (≤ MAX_TRADES_PER_YEAR). Above MAX_EXPECTED_YEARLY_RETURNS → null
|
|
27303
|
+
// (don't surface the cap as a real figure). This is the numerator for Calmar.
|
|
27304
|
+
let pooledExpectedYearlyReturns = null;
|
|
27305
|
+
const poolSpanDays = isFinite(poolFirstPendingAt) && isFinite(poolLastCloseAt)
|
|
27306
|
+
? (poolLastCloseAt - poolFirstPendingAt) / (1000 * 60 * 60 * 24)
|
|
27307
|
+
: 0;
|
|
27308
|
+
if (poolSpanDays >= MIN_CALENDAR_SPAN_DAYS) {
|
|
27309
|
+
const rawTradesPerYear = (allReturns.length / poolSpanDays) * 365;
|
|
27310
|
+
if (rawTradesPerYear <= MAX_TRADES_PER_YEAR) {
|
|
27311
|
+
if (blown) {
|
|
27312
|
+
pooledExpectedYearlyReturns = -100;
|
|
27313
|
+
}
|
|
27314
|
+
else {
|
|
27315
|
+
const raw = (Math.pow(equityFinal, rawTradesPerYear / allReturns.length) - 1) * 100;
|
|
27316
|
+
pooledExpectedYearlyReturns =
|
|
27317
|
+
Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS ? null : raw;
|
|
27318
|
+
}
|
|
27223
27319
|
}
|
|
27224
27320
|
}
|
|
27321
|
+
// Pooled Calmar = annualized return / max drawdown — same formula and
|
|
27322
|
+
// gating as per-symbol Calmar. NULL when the annualized numerator is
|
|
27323
|
+
// unavailable (span/frequency gate, or over the yearly cap). This is what
|
|
27324
|
+
// distinguishes it from Recovery, which uses the compounded TOTAL return —
|
|
27325
|
+
// previously both used total return, making Calmar == Recovery (a bug).
|
|
27326
|
+
if (maxDD > 0 && pooledExpectedYearlyReturns !== null) {
|
|
27327
|
+
portfolioCalmarRatio = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, pooledExpectedYearlyReturns / maxDD));
|
|
27328
|
+
}
|
|
27329
|
+
// Pooled Recovery Factor = compounded TOTAL return / max drawdown, clamped.
|
|
27330
|
+
// Time-independent (no annualization), so it needs no span gate — only a
|
|
27331
|
+
// valid DD and a non-blown account (ratio is meaningless after total loss).
|
|
27332
|
+
if (maxDD > 0 && !blown) {
|
|
27333
|
+
const rawRec = ((equityFinal - 1) * 100) / maxDD;
|
|
27334
|
+
portfolioRecoveryFactor = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, rawRec));
|
|
27335
|
+
}
|
|
27225
27336
|
}
|
|
27226
27337
|
// Portfolio-wide weighted average peak/fall PNL. Denominator must include only
|
|
27227
27338
|
// symbols that contributed a value — otherwise trade-count-weighted mean is diluted
|
|
@@ -27319,7 +27430,7 @@ class HeatmapStorage {
|
|
|
27319
27430
|
`# Portfolio Heatmap: ${strategyName}`,
|
|
27320
27431
|
"",
|
|
27321
27432
|
`**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"}`,
|
|
27433
|
+
`**Standard Deviation Per Trade:** ${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"}`,
|
|
27323
27434
|
"",
|
|
27324
27435
|
table,
|
|
27325
27436
|
"",
|
|
@@ -27329,10 +27440,11 @@ class HeatmapStorage {
|
|
|
27329
27440
|
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals. N/A when no losing trades — Sortino is mathematically undefined (infinite) and we cannot distinguish "truly flawless" from "lucky streak so far".*`,
|
|
27330
27441
|
`*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
|
|
27331
27442
|
`*Profit Factor: below 1.0 means strategy is losing overall. Above 1.5 is considered good.*`,
|
|
27332
|
-
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is
|
|
27333
|
-
`*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.*`,
|
|
27443
|
+
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is the mark-to-market max drawdown (see below). N/A unless ≥${MIN_SIGNALS_FOR_ANNUALIZATION} signals per symbol and span ≥${MIN_CALENDAR_SPAN_DAYS} days. Capped at ±${MAX_CALMAR_RATIO}.*`,
|
|
27444
|
+
`*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 and the mark-to-market max drawdown as denominator.*`,
|
|
27445
|
+
`*Max Drawdown: mark-to-market — both the per-symbol and pooled equity curves apply each trade's worst intra-trade excursion (the lowest unrealized point while the position was open) before booking its realized close, so deep round-trip dips count. It is NOT realized-only (close-to-close); a realized-only curve would understate drawdown and inflate Calmar/Recovery. NOTE: the pooled curve orders trades by storage sequence, not wall-clock time, so simultaneous cross-symbol drawdowns are not modelled.*`,
|
|
27334
27446
|
`*All metrics require 100+ signals per symbol to be statistically reliable. Annualized metrics assume the observed trading frequency persists year-round.*`,
|
|
27335
|
-
`*IMPORTANT: Per-symbol equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per
|
|
27447
|
+
`*IMPORTANT: Per-symbol equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per position** (no portfolio fraction). These metrics ignore the position-sizing subsystem (PositionSize / Kelly / ATR): pnlPercentage is a return on the position's own invested capital, never scaled by account balance. With DCA (commitAverageBuy) the cost basis is the sum of all entries and the entry price is dollar-cost-weighted, so per-trade % is measured against the averaged position, not a fixed stake. If your strategy risks X% of capital per trade, the realized return / drawdown will be roughly X/100 of the reported figures — these metrics represent a theoretical upper bound under full allocation.*`,
|
|
27336
27448
|
`*Negative values for Sharpe / Sortino / Calmar / Recovery indicate a losing symbol (avgPnl < 0 or totalPnl < 0). "Higher is better" still applies — closer to zero is less bad, positive is profitable.*`,
|
|
27337
27449
|
].join("\n");
|
|
27338
27450
|
}
|
package/build/index.mjs
CHANGED
|
@@ -23865,13 +23865,34 @@ let ReportStorage$a = class ReportStorage {
|
|
|
23865
23865
|
// per trade ("as-if 100% allocation"). Walks validSignals in chronological order
|
|
23866
23866
|
// (storage is newest-first, so iterate in reverse). Using validSignals (same set as
|
|
23867
23867
|
// tradesPerYear) keeps equityFinal consistent with the annualization exponent.
|
|
23868
|
-
//
|
|
23869
|
-
//
|
|
23868
|
+
//
|
|
23869
|
+
// MARK-TO-MARKET DD: each trade's worst intra-trade excursion (signal.maxDrawdown,
|
|
23870
|
+
// i.e. the `_fall` snapshot, ≤ 0) is applied as a trough BEFORE booking the realized
|
|
23871
|
+
// close. Without this the curve only steps at close, so a trade that dipped to -18%
|
|
23872
|
+
// and recovered to +2% would register zero drawdown — understating DD and inflating
|
|
23873
|
+
// Calmar/Recovery. The trough does not persist into equity (it's a transient
|
|
23874
|
+
// mark-to-market low); equity then moves to the realized close.
|
|
23875
|
+
// If equity (at trough or close) goes ≤ 0 (e.g. leveraged loss < -100%) — account
|
|
23876
|
+
// blown, fix DD at 100% and stop walking the curve.
|
|
23870
23877
|
let equity = 1;
|
|
23871
23878
|
let peak = 1;
|
|
23872
23879
|
let equityMaxDrawdown = 0;
|
|
23873
23880
|
let blown = false;
|
|
23874
23881
|
for (let i = validSignals.length - 1; i >= 0; i--) {
|
|
23882
|
+
// Intra-trade trough — mark-to-market low while the position was open.
|
|
23883
|
+
const fallPct = validSignals[i].signal.maxDrawdown?.pnlPercentage;
|
|
23884
|
+
if (typeof fallPct === "number" && fallPct < 0) {
|
|
23885
|
+
const trough = equity * (1 + fallPct / 100);
|
|
23886
|
+
if (trough <= 0) {
|
|
23887
|
+
equityMaxDrawdown = 100;
|
|
23888
|
+
blown = true;
|
|
23889
|
+
break;
|
|
23890
|
+
}
|
|
23891
|
+
const troughDd = (peak - trough) / peak * 100;
|
|
23892
|
+
if (troughDd > equityMaxDrawdown)
|
|
23893
|
+
equityMaxDrawdown = troughDd;
|
|
23894
|
+
}
|
|
23895
|
+
// Realized close — book the final per-trade result.
|
|
23875
23896
|
equity *= 1 + validSignals[i].pnl.pnlPercentage / 100;
|
|
23876
23897
|
if (equity <= 0) {
|
|
23877
23898
|
equityMaxDrawdown = 100;
|
|
@@ -24029,7 +24050,7 @@ let ReportStorage$a = class ReportStorage {
|
|
|
24029
24050
|
`**Win rate:** ${stats.winRate === null ? "N/A" : `${stats.winRate.toFixed(2)}% (${stats.winCount}W / ${stats.lossCount}L) (higher is better)`}`,
|
|
24030
24051
|
`**Average PNL:** ${stats.avgPnl === null ? "N/A" : `${stats.avgPnl > 0 ? "+" : ""}${stats.avgPnl.toFixed(2)}% (higher is better)`}`,
|
|
24031
24052
|
`**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`,
|
|
24032
|
-
`**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`,
|
|
24053
|
+
`**Standard Deviation Per Trade:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`,
|
|
24033
24054
|
`**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`,
|
|
24034
24055
|
`**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`,
|
|
24035
24056
|
`**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`,
|
|
@@ -24047,11 +24068,12 @@ let ReportStorage$a = class ReportStorage {
|
|
|
24047
24068
|
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals. N/A when no losing trades — Sortino is mathematically undefined (infinite) and we cannot distinguish "truly flawless" from "lucky streak so far".*`,
|
|
24048
24069
|
`*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
|
|
24049
24070
|
`*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.*`,
|
|
24050
|
-
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is
|
|
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.*`,
|
|
24071
|
+
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is the mark-to-market max drawdown (see below). Capped at ±${MAX_CALMAR_RATIO$2}.*`,
|
|
24072
|
+
`*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 and the mark-to-market max drawdown as denominator.*`,
|
|
24073
|
+
`*Max Drawdown: mark-to-market — the compounded equity curve applies each trade's worst intra-trade excursion (the lowest unrealized point while the position was open) before booking its realized close, so deep round-trip dips count. It is NOT realized-only (close-to-close); a realized-only curve would understate drawdown and inflate Calmar/Recovery.*`,
|
|
24052
24074
|
`*Expectancy: per-trade expected value (winProb × avgWin + lossProb × avgLoss). Positive = profitable on average per trade. Break-even trades contribute 0.*`,
|
|
24053
24075
|
`*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
|
|
24054
|
-
`*IMPORTANT: Equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per
|
|
24076
|
+
`*IMPORTANT: Equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per position** (no portfolio fraction). These metrics ignore the position-sizing subsystem (PositionSize / Kelly / ATR): pnlPercentage is a return on the position's own invested capital, never scaled by account balance. With DCA (commitAverageBuy) the cost basis is the sum of all entries and the entry price is dollar-cost-weighted, so per-trade % is measured against the averaged position, not a fixed stake. If your strategy risks X% of capital per trade, the realized portfolio return / drawdown will be roughly X/100 of the reported figures — these metrics represent a theoretical upper bound under full allocation.*`,
|
|
24055
24077
|
`*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.*`,
|
|
24056
24078
|
].join("\n");
|
|
24057
24079
|
}
|
|
@@ -24800,15 +24822,37 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
24800
24822
|
// on cost basis — compounding assumes equal capital allocation per trade ("as-if 100%").
|
|
24801
24823
|
// If equity ≤ 0 (leveraged short with r < -100%) — account blown, fix DD at 100%.
|
|
24802
24824
|
// Built from validClosed (newest-first), iterated reverse for chronological order.
|
|
24803
|
-
|
|
24825
|
+
//
|
|
24826
|
+
// MARK-TO-MARKET DD: each trade's worst intra-trade excursion (fallPnl, the `_fall`
|
|
24827
|
+
// snapshot, ≤ 0) is applied as a trough BEFORE booking the realized close. Without it
|
|
24828
|
+
// the curve only steps at close, so a trade that dipped to -18% and recovered to +2%
|
|
24829
|
+
// would register zero drawdown — understating DD and inflating Calmar/Recovery.
|
|
24830
|
+
const chronological = [];
|
|
24804
24831
|
for (let i = validClosed.length - 1; i >= 0; i--) {
|
|
24805
|
-
|
|
24832
|
+
const fall = validClosed[i].fallPnl;
|
|
24833
|
+
chronological.push({
|
|
24834
|
+
r: validClosed[i].pnl,
|
|
24835
|
+
fall: typeof fall === "number" ? fall : null,
|
|
24836
|
+
});
|
|
24806
24837
|
}
|
|
24807
24838
|
let equity = 1;
|
|
24808
24839
|
let peak = 1;
|
|
24809
24840
|
let equityMaxDrawdown = 0;
|
|
24810
24841
|
let blown = false;
|
|
24811
|
-
for (const r of
|
|
24842
|
+
for (const { r, fall } of chronological) {
|
|
24843
|
+
// Intra-trade trough — mark-to-market low while the position was open.
|
|
24844
|
+
if (fall !== null && fall < 0) {
|
|
24845
|
+
const trough = equity * (1 + fall / 100);
|
|
24846
|
+
if (trough <= 0) {
|
|
24847
|
+
equityMaxDrawdown = 100;
|
|
24848
|
+
blown = true;
|
|
24849
|
+
break;
|
|
24850
|
+
}
|
|
24851
|
+
const troughDd = (peak - trough) / peak * 100;
|
|
24852
|
+
if (troughDd > equityMaxDrawdown)
|
|
24853
|
+
equityMaxDrawdown = troughDd;
|
|
24854
|
+
}
|
|
24855
|
+
// Realized close.
|
|
24812
24856
|
equity *= 1 + r / 100;
|
|
24813
24857
|
if (equity <= 0) {
|
|
24814
24858
|
equityMaxDrawdown = 100;
|
|
@@ -24907,7 +24951,7 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
24907
24951
|
`**Win rate:** ${stats.winRate === null ? "N/A" : `${stats.winRate.toFixed(2)}% (${stats.winCount}W / ${stats.lossCount}L) (higher is better)`}`,
|
|
24908
24952
|
`**Average PNL:** ${stats.avgPnl === null ? "N/A" : `${stats.avgPnl > 0 ? "+" : ""}${stats.avgPnl.toFixed(2)}% (higher is better)`}`,
|
|
24909
24953
|
`**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`,
|
|
24910
|
-
`**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`,
|
|
24954
|
+
`**Standard Deviation Per Trade:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`,
|
|
24911
24955
|
`**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`,
|
|
24912
24956
|
`**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`,
|
|
24913
24957
|
`**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`,
|
|
@@ -24925,11 +24969,12 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
24925
24969
|
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals. N/A when no losing trades — Sortino is mathematically undefined (infinite) and we cannot distinguish "truly flawless" from "lucky streak so far".*`,
|
|
24926
24970
|
`*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
|
|
24927
24971
|
`*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.*`,
|
|
24928
|
-
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is
|
|
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.*`,
|
|
24972
|
+
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is the mark-to-market max drawdown (see below). Capped at ±${MAX_CALMAR_RATIO$1}.*`,
|
|
24973
|
+
`*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 and the mark-to-market max drawdown as denominator.*`,
|
|
24974
|
+
`*Max Drawdown: mark-to-market — the compounded equity curve applies each trade's worst intra-trade excursion (the lowest unrealized point while the position was open) before booking its realized close, so deep round-trip dips count. It is NOT realized-only (close-to-close); a realized-only curve would understate drawdown and inflate Calmar/Recovery.*`,
|
|
24930
24975
|
`*Expectancy: per-trade expected value (winProb × avgWin + lossProb × avgLoss). Positive = profitable on average per trade. Break-even trades contribute 0.*`,
|
|
24931
24976
|
`*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
|
|
24932
|
-
`*IMPORTANT: Equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per
|
|
24977
|
+
`*IMPORTANT: Equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per position** (no portfolio fraction). These metrics ignore the position-sizing subsystem (PositionSize / Kelly / ATR): pnlPercentage is a return on the position's own invested capital, never scaled by account balance. With DCA (commitAverageBuy) the cost basis is the sum of all entries and the entry price is dollar-cost-weighted, so per-trade % is measured against the averaged position, not a fixed stake. If your strategy risks X% of capital per trade, the realized portfolio return / drawdown will be roughly X/100 of the reported figures — these metrics represent a theoretical upper bound under full allocation.*`,
|
|
24933
24978
|
`*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.*`,
|
|
24934
24979
|
].join("\n");
|
|
24935
24980
|
}
|
|
@@ -26841,6 +26886,12 @@ class HeatmapStorage {
|
|
|
26841
26886
|
// Equity-curve max drawdown via compounded equity ("as-if 100% allocation per trade").
|
|
26842
26887
|
// Signals are stored newest-first (unshift in addSignal), so iterate in reverse.
|
|
26843
26888
|
// If equity ≤ 0 — account blown, fix DD at 100%. equityFinal feeds expectedYearlyReturns.
|
|
26889
|
+
//
|
|
26890
|
+
// MARK-TO-MARKET DD: each trade's worst intra-trade excursion (signal.maxDrawdown,
|
|
26891
|
+
// the `_fall` snapshot, ≤ 0) is applied as a trough BEFORE booking the realized close.
|
|
26892
|
+
// Without it the curve only steps at close, so a trade that dipped to -18% and
|
|
26893
|
+
// recovered to +2% would register zero drawdown — understating DD and inflating
|
|
26894
|
+
// Calmar/Recovery.
|
|
26844
26895
|
let maxDrawdown = null;
|
|
26845
26896
|
let equityFinal = 1;
|
|
26846
26897
|
let blown = false;
|
|
@@ -26849,6 +26900,18 @@ class HeatmapStorage {
|
|
|
26849
26900
|
let peak = 1;
|
|
26850
26901
|
let maxDD = 0;
|
|
26851
26902
|
for (let i = signals.length - 1; i >= 0; i--) {
|
|
26903
|
+
const fallPct = signals[i].signal.maxDrawdown?.pnlPercentage;
|
|
26904
|
+
if (typeof fallPct === "number" && fallPct < 0) {
|
|
26905
|
+
const trough = equity * (1 + fallPct / 100);
|
|
26906
|
+
if (trough <= 0) {
|
|
26907
|
+
maxDD = 100;
|
|
26908
|
+
blown = true;
|
|
26909
|
+
break;
|
|
26910
|
+
}
|
|
26911
|
+
const troughDd = (peak - trough) / peak * 100;
|
|
26912
|
+
if (troughDd > maxDD)
|
|
26913
|
+
maxDD = troughDd;
|
|
26914
|
+
}
|
|
26852
26915
|
equity *= 1 + signals[i].pnl.pnlPercentage / 100;
|
|
26853
26916
|
if (equity <= 0) {
|
|
26854
26917
|
maxDD = 100;
|
|
@@ -27135,9 +27198,20 @@ class HeatmapStorage {
|
|
|
27135
27198
|
let portfolioCalmarRatio = null;
|
|
27136
27199
|
let portfolioRecoveryFactor = null;
|
|
27137
27200
|
const allReturns = [];
|
|
27201
|
+
// Parallel array of intra-trade troughs (≤ 0), aligned 1:1 with allReturns,
|
|
27202
|
+
// used for mark-to-market DD in the pooled equity curve below.
|
|
27203
|
+
const allFalls = [];
|
|
27204
|
+
let poolFirstPendingAt = Infinity;
|
|
27205
|
+
let poolLastCloseAt = -Infinity;
|
|
27138
27206
|
for (const signals of this.symbolData.values()) {
|
|
27139
27207
|
for (const s of signals) {
|
|
27140
27208
|
allReturns.push(s.pnl.pnlPercentage);
|
|
27209
|
+
const fall = s.signal.maxDrawdown?.pnlPercentage;
|
|
27210
|
+
allFalls.push(typeof fall === "number" ? fall : null);
|
|
27211
|
+
if (s.signal.pendingAt < poolFirstPendingAt)
|
|
27212
|
+
poolFirstPendingAt = s.signal.pendingAt;
|
|
27213
|
+
if (s.closeTimestamp > poolLastCloseAt)
|
|
27214
|
+
poolLastCloseAt = s.closeTimestamp;
|
|
27141
27215
|
}
|
|
27142
27216
|
}
|
|
27143
27217
|
if (allReturns.length >= MIN_SIGNALS_FOR_RATIOS) {
|
|
@@ -27169,13 +27243,27 @@ class HeatmapStorage {
|
|
|
27169
27243
|
if (wins.length > 0 || losses.length > 0) {
|
|
27170
27244
|
portfolioExpectancy = (wins.length / total) * avgWin + (losses.length / total) * avgLoss;
|
|
27171
27245
|
}
|
|
27172
|
-
// Pooled equity-curve max drawdown (compounded).
|
|
27246
|
+
// Pooled equity-curve max drawdown (compounded). MARK-TO-MARKET: each trade's
|
|
27247
|
+
// intra-trade trough (allFalls, ≤ 0) is applied before booking the realized close,
|
|
27248
|
+
// so deep round-trip dips are captured rather than understating DD.
|
|
27173
27249
|
let equity = 1;
|
|
27174
27250
|
let peak = 1;
|
|
27175
27251
|
let maxDD = 0;
|
|
27176
27252
|
let blown = false;
|
|
27177
|
-
for (
|
|
27178
|
-
|
|
27253
|
+
for (let i = 0; i < allReturns.length; i++) {
|
|
27254
|
+
const fall = allFalls[i];
|
|
27255
|
+
if (fall !== null && fall < 0) {
|
|
27256
|
+
const trough = equity * (1 + fall / 100);
|
|
27257
|
+
if (trough <= 0) {
|
|
27258
|
+
maxDD = 100;
|
|
27259
|
+
blown = true;
|
|
27260
|
+
break;
|
|
27261
|
+
}
|
|
27262
|
+
const troughDd = ((peak - trough) / peak) * 100;
|
|
27263
|
+
if (troughDd > maxDD)
|
|
27264
|
+
maxDD = troughDd;
|
|
27265
|
+
}
|
|
27266
|
+
equity *= 1 + allReturns[i] / 100;
|
|
27179
27267
|
if (equity <= 0) {
|
|
27180
27268
|
maxDD = 100;
|
|
27181
27269
|
blown = true;
|
|
@@ -27188,20 +27276,43 @@ class HeatmapStorage {
|
|
|
27188
27276
|
maxDD = dd;
|
|
27189
27277
|
}
|
|
27190
27278
|
const equityFinal = blown ? 0 : equity;
|
|
27191
|
-
// Pooled
|
|
27192
|
-
//
|
|
27193
|
-
|
|
27194
|
-
|
|
27195
|
-
|
|
27196
|
-
|
|
27197
|
-
|
|
27198
|
-
|
|
27199
|
-
|
|
27200
|
-
|
|
27201
|
-
|
|
27202
|
-
|
|
27279
|
+
// Pooled expected yearly returns — geometric annualization of the pooled
|
|
27280
|
+
// equity curve, gated exactly like the per-symbol path: requires a valid
|
|
27281
|
+
// calendar span (≥ MIN_CALENDAR_SPAN_DAYS) and a non-clustered trade
|
|
27282
|
+
// frequency (≤ MAX_TRADES_PER_YEAR). Above MAX_EXPECTED_YEARLY_RETURNS → null
|
|
27283
|
+
// (don't surface the cap as a real figure). This is the numerator for Calmar.
|
|
27284
|
+
let pooledExpectedYearlyReturns = null;
|
|
27285
|
+
const poolSpanDays = isFinite(poolFirstPendingAt) && isFinite(poolLastCloseAt)
|
|
27286
|
+
? (poolLastCloseAt - poolFirstPendingAt) / (1000 * 60 * 60 * 24)
|
|
27287
|
+
: 0;
|
|
27288
|
+
if (poolSpanDays >= MIN_CALENDAR_SPAN_DAYS) {
|
|
27289
|
+
const rawTradesPerYear = (allReturns.length / poolSpanDays) * 365;
|
|
27290
|
+
if (rawTradesPerYear <= MAX_TRADES_PER_YEAR) {
|
|
27291
|
+
if (blown) {
|
|
27292
|
+
pooledExpectedYearlyReturns = -100;
|
|
27293
|
+
}
|
|
27294
|
+
else {
|
|
27295
|
+
const raw = (Math.pow(equityFinal, rawTradesPerYear / allReturns.length) - 1) * 100;
|
|
27296
|
+
pooledExpectedYearlyReturns =
|
|
27297
|
+
Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS ? null : raw;
|
|
27298
|
+
}
|
|
27203
27299
|
}
|
|
27204
27300
|
}
|
|
27301
|
+
// Pooled Calmar = annualized return / max drawdown — same formula and
|
|
27302
|
+
// gating as per-symbol Calmar. NULL when the annualized numerator is
|
|
27303
|
+
// unavailable (span/frequency gate, or over the yearly cap). This is what
|
|
27304
|
+
// distinguishes it from Recovery, which uses the compounded TOTAL return —
|
|
27305
|
+
// previously both used total return, making Calmar == Recovery (a bug).
|
|
27306
|
+
if (maxDD > 0 && pooledExpectedYearlyReturns !== null) {
|
|
27307
|
+
portfolioCalmarRatio = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, pooledExpectedYearlyReturns / maxDD));
|
|
27308
|
+
}
|
|
27309
|
+
// Pooled Recovery Factor = compounded TOTAL return / max drawdown, clamped.
|
|
27310
|
+
// Time-independent (no annualization), so it needs no span gate — only a
|
|
27311
|
+
// valid DD and a non-blown account (ratio is meaningless after total loss).
|
|
27312
|
+
if (maxDD > 0 && !blown) {
|
|
27313
|
+
const rawRec = ((equityFinal - 1) * 100) / maxDD;
|
|
27314
|
+
portfolioRecoveryFactor = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, rawRec));
|
|
27315
|
+
}
|
|
27205
27316
|
}
|
|
27206
27317
|
// Portfolio-wide weighted average peak/fall PNL. Denominator must include only
|
|
27207
27318
|
// symbols that contributed a value — otherwise trade-count-weighted mean is diluted
|
|
@@ -27299,7 +27410,7 @@ class HeatmapStorage {
|
|
|
27299
27410
|
`# Portfolio Heatmap: ${strategyName}`,
|
|
27300
27411
|
"",
|
|
27301
27412
|
`**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"}`,
|
|
27413
|
+
`**Standard Deviation Per Trade:** ${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"}`,
|
|
27303
27414
|
"",
|
|
27304
27415
|
table,
|
|
27305
27416
|
"",
|
|
@@ -27309,10 +27420,11 @@ class HeatmapStorage {
|
|
|
27309
27420
|
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals. N/A when no losing trades — Sortino is mathematically undefined (infinite) and we cannot distinguish "truly flawless" from "lucky streak so far".*`,
|
|
27310
27421
|
`*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
|
|
27311
27422
|
`*Profit Factor: below 1.0 means strategy is losing overall. Above 1.5 is considered good.*`,
|
|
27312
|
-
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is
|
|
27313
|
-
`*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.*`,
|
|
27423
|
+
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is the mark-to-market max drawdown (see below). N/A unless ≥${MIN_SIGNALS_FOR_ANNUALIZATION} signals per symbol and span ≥${MIN_CALENDAR_SPAN_DAYS} days. Capped at ±${MAX_CALMAR_RATIO}.*`,
|
|
27424
|
+
`*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 and the mark-to-market max drawdown as denominator.*`,
|
|
27425
|
+
`*Max Drawdown: mark-to-market — both the per-symbol and pooled equity curves apply each trade's worst intra-trade excursion (the lowest unrealized point while the position was open) before booking its realized close, so deep round-trip dips count. It is NOT realized-only (close-to-close); a realized-only curve would understate drawdown and inflate Calmar/Recovery. NOTE: the pooled curve orders trades by storage sequence, not wall-clock time, so simultaneous cross-symbol drawdowns are not modelled.*`,
|
|
27314
27426
|
`*All metrics require 100+ signals per symbol to be statistically reliable. Annualized metrics assume the observed trading frequency persists year-round.*`,
|
|
27315
|
-
`*IMPORTANT: Per-symbol equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per
|
|
27427
|
+
`*IMPORTANT: Per-symbol equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per position** (no portfolio fraction). These metrics ignore the position-sizing subsystem (PositionSize / Kelly / ATR): pnlPercentage is a return on the position's own invested capital, never scaled by account balance. With DCA (commitAverageBuy) the cost basis is the sum of all entries and the entry price is dollar-cost-weighted, so per-trade % is measured against the averaged position, not a fixed stake. If your strategy risks X% of capital per trade, the realized return / drawdown will be roughly X/100 of the reported figures — these metrics represent a theoretical upper bound under full allocation.*`,
|
|
27316
27428
|
`*Negative values for Sharpe / Sortino / Calmar / Recovery indicate a losing symbol (avgPnl < 0 or totalPnl < 0). "Higher is better" still applies — closer to zero is less bad, positive is profitable.*`,
|
|
27317
27429
|
].join("\n");
|
|
27318
27430
|
}
|