backtest-kit 11.5.2 → 11.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.mjs CHANGED
@@ -21261,6 +21261,32 @@ const heat_columns = [
21261
21261
  format: (data) => data.sharpeRatio !== null ? str(data.sharpeRatio) : "N/A",
21262
21262
  isVisible: () => true,
21263
21263
  },
21264
+ {
21265
+ key: "annualizedSharpeRatio",
21266
+ label: "Ann Sharpe",
21267
+ format: (data) => data.annualizedSharpeRatio !== null ? str(data.annualizedSharpeRatio) : "N/A",
21268
+ isVisible: () => true,
21269
+ },
21270
+ {
21271
+ key: "certaintyRatio",
21272
+ label: "Certainty",
21273
+ format: (data) => data.certaintyRatio !== null ? str(data.certaintyRatio) : "N/A",
21274
+ isVisible: () => true,
21275
+ },
21276
+ {
21277
+ key: "expectedYearlyReturns",
21278
+ label: "Exp Yearly",
21279
+ format: (data) => data.expectedYearlyReturns !== null
21280
+ ? str(data.expectedYearlyReturns, "%")
21281
+ : "N/A",
21282
+ isVisible: () => true,
21283
+ },
21284
+ {
21285
+ key: "tradesPerYear",
21286
+ label: "Trades/Yr",
21287
+ format: (data) => data.tradesPerYear !== null ? data.tradesPerYear.toFixed(1) : "N/A",
21288
+ isVisible: () => true,
21289
+ },
21264
21290
  {
21265
21291
  key: "profitFactor",
21266
21292
  label: "PF",
@@ -21327,6 +21353,58 @@ const heat_columns = [
21327
21353
  format: (data) => data.avgFallPnl !== null ? str(data.avgFallPnl, "%") : "N/A",
21328
21354
  isVisible: () => true,
21329
21355
  },
21356
+ {
21357
+ key: "peakProfitPnl",
21358
+ label: "Peak Profit PNL",
21359
+ format: (data) => data.peakProfitPnl !== null ? str(data.peakProfitPnl, "%") : "N/A",
21360
+ isVisible: () => true,
21361
+ },
21362
+ {
21363
+ key: "maxDrawdownPnl",
21364
+ label: "Max DD PNL",
21365
+ format: (data) => data.maxDrawdownPnl !== null ? str(data.maxDrawdownPnl, "%") : "N/A",
21366
+ isVisible: () => true,
21367
+ },
21368
+ {
21369
+ key: "medianPnl",
21370
+ label: "Median PNL",
21371
+ format: (data) => data.medianPnl !== null ? str(data.medianPnl, "%") : "N/A",
21372
+ isVisible: () => true,
21373
+ },
21374
+ {
21375
+ key: "avgDuration",
21376
+ label: "Avg Dur (min)",
21377
+ format: (data) => data.avgDuration !== null ? data.avgDuration.toFixed(1) : "N/A",
21378
+ isVisible: () => true,
21379
+ },
21380
+ {
21381
+ key: "avgWinDuration",
21382
+ label: "Avg Win Dur",
21383
+ format: (data) => data.avgWinDuration !== null ? data.avgWinDuration.toFixed(1) : "N/A",
21384
+ isVisible: () => true,
21385
+ },
21386
+ {
21387
+ key: "avgLossDuration",
21388
+ label: "Avg Loss Dur",
21389
+ format: (data) => data.avgLossDuration !== null ? data.avgLossDuration.toFixed(1) : "N/A",
21390
+ isVisible: () => true,
21391
+ },
21392
+ {
21393
+ key: "avgConsecutiveWinPnl",
21394
+ label: "Avg Win Streak PNL",
21395
+ format: (data) => data.avgConsecutiveWinPnl !== null
21396
+ ? str(data.avgConsecutiveWinPnl, "%")
21397
+ : "N/A",
21398
+ isVisible: () => true,
21399
+ },
21400
+ {
21401
+ key: "avgConsecutiveLossPnl",
21402
+ label: "Avg Loss Streak PNL",
21403
+ format: (data) => data.avgConsecutiveLossPnl !== null
21404
+ ? str(data.avgConsecutiveLossPnl, "%")
21405
+ : "N/A",
21406
+ isVisible: () => true,
21407
+ },
21330
21408
  {
21331
21409
  key: "sortinoRatio",
21332
21410
  label: "Sortino",
@@ -23797,6 +23875,12 @@ let ReportStorage$a = class ReportStorage {
23797
23875
  calmarRatio: null,
23798
23876
  recoveryFactor: null,
23799
23877
  expectancy: null,
23878
+ avgDuration: null,
23879
+ medianPnl: null,
23880
+ avgConsecutiveWinPnl: null,
23881
+ avgConsecutiveLossPnl: null,
23882
+ avgWinDuration: null,
23883
+ avgLossDuration: null,
23800
23884
  };
23801
23885
  }
23802
23886
  // Valid signal set — those with usable pendingAt AND closeTimestamp. Single source
@@ -23946,6 +24030,101 @@ let ReportStorage$a = class ReportStorage {
23946
24030
  const expectancy = canComputeRatios && totalSignals > 0
23947
24031
  ? (wins.length / totalSignals) * avgWin + (losses.length / totalSignals) * avgLoss
23948
24032
  : null;
24033
+ // Median pnlPercentage — robust to outliers; reveals skew when avgPnl is
24034
+ // dragged by a whale trade. Sort a copy (do not mutate validSignals).
24035
+ let medianPnl = null;
24036
+ if (returns.length > 0) {
24037
+ const sortedReturns = returns.slice().sort((a, b) => a - b);
24038
+ const mid = sortedReturns.length >> 1;
24039
+ medianPnl = sortedReturns.length % 2 === 0
24040
+ ? (sortedReturns[mid - 1] + sortedReturns[mid]) / 2
24041
+ : sortedReturns[mid];
24042
+ }
24043
+ // Trade duration metrics in minutes (synchronized with strategy
24044
+ // `minuteEstimatedTime`). validSignals already requires pendingAt > 0 and
24045
+ // closeTimestamp > 0, so every signal here contributes a valid duration.
24046
+ let avgDuration = null;
24047
+ let avgWinDuration = null;
24048
+ let avgLossDuration = null;
24049
+ if (totalSignals > 0) {
24050
+ const durations = [];
24051
+ const winDurations = [];
24052
+ const lossDurations = [];
24053
+ for (const s of validSignals) {
24054
+ const minutes = (s.closeTimestamp - s.signal.pendingAt) / 60000;
24055
+ durations.push(minutes);
24056
+ const pnl = s.pnl.pnlPercentage;
24057
+ if (pnl > 0)
24058
+ winDurations.push(minutes);
24059
+ else if (pnl < 0)
24060
+ lossDurations.push(minutes);
24061
+ }
24062
+ avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
24063
+ if (winDurations.length > 0) {
24064
+ avgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
24065
+ }
24066
+ if (lossDurations.length > 0) {
24067
+ avgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
24068
+ }
24069
+ }
24070
+ // Consecutive streak averages: sum the per-streak pnl, then mean across
24071
+ // streaks. Storage is newest-first, so iterate in reverse for chronological
24072
+ // streaks. Break-even (pnl=0) closes both runs (neither a win nor a loss).
24073
+ let avgConsecutiveWinPnl = null;
24074
+ let avgConsecutiveLossPnl = null;
24075
+ {
24076
+ const winStreakSums = [];
24077
+ const lossStreakSums = [];
24078
+ let curWin = 0;
24079
+ let curLoss = 0;
24080
+ let curWinSum = 0;
24081
+ let curLossSum = 0;
24082
+ for (let i = validSignals.length - 1; i >= 0; i--) {
24083
+ const pnl = validSignals[i].pnl.pnlPercentage;
24084
+ if (pnl > 0) {
24085
+ if (curLoss > 0) {
24086
+ lossStreakSums.push(curLossSum);
24087
+ curLoss = 0;
24088
+ curLossSum = 0;
24089
+ }
24090
+ curWin++;
24091
+ curWinSum += pnl;
24092
+ }
24093
+ else if (pnl < 0) {
24094
+ if (curWin > 0) {
24095
+ winStreakSums.push(curWinSum);
24096
+ curWin = 0;
24097
+ curWinSum = 0;
24098
+ }
24099
+ curLoss++;
24100
+ curLossSum += pnl;
24101
+ }
24102
+ else {
24103
+ if (curWin > 0) {
24104
+ winStreakSums.push(curWinSum);
24105
+ curWin = 0;
24106
+ curWinSum = 0;
24107
+ }
24108
+ if (curLoss > 0) {
24109
+ lossStreakSums.push(curLossSum);
24110
+ curLoss = 0;
24111
+ curLossSum = 0;
24112
+ }
24113
+ }
24114
+ }
24115
+ if (curWin > 0)
24116
+ winStreakSums.push(curWinSum);
24117
+ if (curLoss > 0)
24118
+ lossStreakSums.push(curLossSum);
24119
+ if (winStreakSums.length > 0) {
24120
+ avgConsecutiveWinPnl =
24121
+ winStreakSums.reduce((a, b) => a + b, 0) / winStreakSums.length;
24122
+ }
24123
+ if (lossStreakSums.length > 0) {
24124
+ avgConsecutiveLossPnl =
24125
+ lossStreakSums.reduce((a, b) => a + b, 0) / lossStreakSums.length;
24126
+ }
24127
+ }
23949
24128
  // Average peak/fall PNL — over validSignals; only signals that actually have the
23950
24129
  // value contribute (no zero dilution from missing peakProfit/maxDrawdown).
23951
24130
  const peakValues = validSignals
@@ -24011,6 +24190,12 @@ let ReportStorage$a = class ReportStorage {
24011
24190
  calmarRatio: isUnsafe$4(calmarRatio) ? null : calmarRatio,
24012
24191
  recoveryFactor: isUnsafe$4(recoveryFactor) ? null : recoveryFactor,
24013
24192
  expectancy: isUnsafe$4(expectancy) ? null : expectancy,
24193
+ avgDuration: isUnsafe$4(avgDuration) ? null : avgDuration,
24194
+ medianPnl: isUnsafe$4(medianPnl) ? null : medianPnl,
24195
+ avgConsecutiveWinPnl: isUnsafe$4(avgConsecutiveWinPnl) ? null : avgConsecutiveWinPnl,
24196
+ avgConsecutiveLossPnl: isUnsafe$4(avgConsecutiveLossPnl) ? null : avgConsecutiveLossPnl,
24197
+ avgWinDuration: isUnsafe$4(avgWinDuration) ? null : avgWinDuration,
24198
+ avgLossDuration: isUnsafe$4(avgLossDuration) ? null : avgLossDuration,
24014
24199
  };
24015
24200
  }
24016
24201
  /**
@@ -24061,6 +24246,12 @@ let ReportStorage$a = class ReportStorage {
24061
24246
  `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
24062
24247
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
24063
24248
  `**Expectancy:** ${stats.expectancy === null ? "N/A" : `${stats.expectancy > 0 ? "+" : ""}${stats.expectancy.toFixed(3)}% (higher is better)`}`,
24249
+ `**Median PNL:** ${stats.medianPnl === null ? "N/A" : `${stats.medianPnl > 0 ? "+" : ""}${stats.medianPnl.toFixed(3)}% (closer to avgPnl = symmetric distribution)`}`,
24250
+ `**Avg Duration:** ${stats.avgDuration === null ? "N/A" : `${stats.avgDuration.toFixed(1)} min`}`,
24251
+ `**Avg Win Duration:** ${stats.avgWinDuration === null ? "N/A" : `${stats.avgWinDuration.toFixed(1)} min`}`,
24252
+ `**Avg Loss Duration:** ${stats.avgLossDuration === null ? "N/A" : `${stats.avgLossDuration.toFixed(1)} min`}`,
24253
+ `**Avg Consecutive Win PNL:** ${stats.avgConsecutiveWinPnl === null ? "N/A" : `${stats.avgConsecutiveWinPnl > 0 ? "+" : ""}${stats.avgConsecutiveWinPnl.toFixed(3)}% (higher is better)`}`,
24254
+ `**Avg Consecutive Loss PNL:** ${stats.avgConsecutiveLossPnl === null ? "N/A" : `${stats.avgConsecutiveLossPnl.toFixed(3)}% (closer to 0 is better)`}`,
24064
24255
  "",
24065
24256
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
24066
24257
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
@@ -24698,6 +24889,12 @@ let ReportStorage$9 = class ReportStorage {
24698
24889
  calmarRatio: null,
24699
24890
  recoveryFactor: null,
24700
24891
  expectancy: null,
24892
+ avgDuration: null,
24893
+ medianPnl: null,
24894
+ avgConsecutiveWinPnl: null,
24895
+ avgConsecutiveLossPnl: null,
24896
+ avgWinDuration: null,
24897
+ avgLossDuration: null,
24701
24898
  };
24702
24899
  }
24703
24900
  const closedEvents = this._eventList.filter((e) => e.action === "closed");
@@ -24787,6 +24984,104 @@ let ReportStorage$9 = class ReportStorage {
24787
24984
  // trades contribute 0 (excluded from both probabilities).
24788
24985
  expectancy = (wins.length / totalClosed) * avgWin + (losses.length / totalClosed) * avgLoss;
24789
24986
  }
24987
+ // Median pnl — robust to outliers; reveals skew when avgPnl is dragged
24988
+ // by a whale trade. Sort a copy (do not mutate returns).
24989
+ let medianPnl = null;
24990
+ if (returns.length > 0) {
24991
+ const sortedReturns = returns.slice().sort((a, b) => a - b);
24992
+ const mid = sortedReturns.length >> 1;
24993
+ medianPnl = sortedReturns.length % 2 === 0
24994
+ ? (sortedReturns[mid - 1] + sortedReturns[mid]) / 2
24995
+ : sortedReturns[mid];
24996
+ }
24997
+ // Trade duration metrics in minutes (synchronized with strategy
24998
+ // `minuteEstimatedTime`). Source: e.timestamp (close) - (e.pendingAt ?? e.timestamp).
24999
+ // validClosed already guarantees e.timestamp > 0; if pendingAt is missing the
25000
+ // event contributes a 0-minute duration, matching the validation fallback.
25001
+ let avgDuration = null;
25002
+ let avgWinDuration = null;
25003
+ let avgLossDuration = null;
25004
+ if (totalClosed > 0) {
25005
+ const durations = [];
25006
+ const winDurations = [];
25007
+ const lossDurations = [];
25008
+ for (const e of validClosed) {
25009
+ const closeTs = e.timestamp;
25010
+ const openTs = e.pendingAt ?? e.timestamp;
25011
+ const minutes = (closeTs - openTs) / 60000;
25012
+ durations.push(minutes);
25013
+ const pnl = e.pnl;
25014
+ if (pnl > 0)
25015
+ winDurations.push(minutes);
25016
+ else if (pnl < 0)
25017
+ lossDurations.push(minutes);
25018
+ }
25019
+ avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
25020
+ if (winDurations.length > 0) {
25021
+ avgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
25022
+ }
25023
+ if (lossDurations.length > 0) {
25024
+ avgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
25025
+ }
25026
+ }
25027
+ // Consecutive streak averages: sum the per-streak pnl, then mean across
25028
+ // streaks. validClosed is newest-first (events unshifted), so iterate in
25029
+ // reverse for chronological streaks. Break-even (pnl=0) closes both runs.
25030
+ let avgConsecutiveWinPnl = null;
25031
+ let avgConsecutiveLossPnl = null;
25032
+ {
25033
+ const winStreakSums = [];
25034
+ const lossStreakSums = [];
25035
+ let curWin = 0;
25036
+ let curLoss = 0;
25037
+ let curWinSum = 0;
25038
+ let curLossSum = 0;
25039
+ for (let i = validClosed.length - 1; i >= 0; i--) {
25040
+ const pnl = validClosed[i].pnl;
25041
+ if (pnl > 0) {
25042
+ if (curLoss > 0) {
25043
+ lossStreakSums.push(curLossSum);
25044
+ curLoss = 0;
25045
+ curLossSum = 0;
25046
+ }
25047
+ curWin++;
25048
+ curWinSum += pnl;
25049
+ }
25050
+ else if (pnl < 0) {
25051
+ if (curWin > 0) {
25052
+ winStreakSums.push(curWinSum);
25053
+ curWin = 0;
25054
+ curWinSum = 0;
25055
+ }
25056
+ curLoss++;
25057
+ curLossSum += pnl;
25058
+ }
25059
+ else {
25060
+ if (curWin > 0) {
25061
+ winStreakSums.push(curWinSum);
25062
+ curWin = 0;
25063
+ curWinSum = 0;
25064
+ }
25065
+ if (curLoss > 0) {
25066
+ lossStreakSums.push(curLossSum);
25067
+ curLoss = 0;
25068
+ curLossSum = 0;
25069
+ }
25070
+ }
25071
+ }
25072
+ if (curWin > 0)
25073
+ winStreakSums.push(curWinSum);
25074
+ if (curLoss > 0)
25075
+ lossStreakSums.push(curLossSum);
25076
+ if (winStreakSums.length > 0) {
25077
+ avgConsecutiveWinPnl =
25078
+ winStreakSums.reduce((a, b) => a + b, 0) / winStreakSums.length;
25079
+ }
25080
+ if (lossStreakSums.length > 0) {
25081
+ avgConsecutiveLossPnl =
25082
+ lossStreakSums.reduce((a, b) => a + b, 0) / lossStreakSums.length;
25083
+ }
25084
+ }
24790
25085
  // Average only over signals that have the value — do not dilute the mean with zeros.
24791
25086
  // Use validClosed to keep all metric denominators consistent.
24792
25087
  const peakValues = validClosed
@@ -24912,6 +25207,12 @@ let ReportStorage$9 = class ReportStorage {
24912
25207
  calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
24913
25208
  recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
24914
25209
  expectancy: isUnsafe$3(expectancy) ? null : expectancy,
25210
+ avgDuration: isUnsafe$3(avgDuration) ? null : avgDuration,
25211
+ medianPnl: isUnsafe$3(medianPnl) ? null : medianPnl,
25212
+ avgConsecutiveWinPnl: isUnsafe$3(avgConsecutiveWinPnl) ? null : avgConsecutiveWinPnl,
25213
+ avgConsecutiveLossPnl: isUnsafe$3(avgConsecutiveLossPnl) ? null : avgConsecutiveLossPnl,
25214
+ avgWinDuration: isUnsafe$3(avgWinDuration) ? null : avgWinDuration,
25215
+ avgLossDuration: isUnsafe$3(avgLossDuration) ? null : avgLossDuration,
24915
25216
  };
24916
25217
  }
24917
25218
  /**
@@ -24962,6 +25263,12 @@ let ReportStorage$9 = class ReportStorage {
24962
25263
  `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
24963
25264
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
24964
25265
  `**Expectancy:** ${stats.expectancy === null ? "N/A" : `${stats.expectancy > 0 ? "+" : ""}${stats.expectancy.toFixed(3)}% (higher is better)`}`,
25266
+ `**Median PNL:** ${stats.medianPnl === null ? "N/A" : `${stats.medianPnl > 0 ? "+" : ""}${stats.medianPnl.toFixed(3)}% (closer to avgPnl = symmetric distribution)`}`,
25267
+ `**Avg Duration:** ${stats.avgDuration === null ? "N/A" : `${stats.avgDuration.toFixed(1)} min`}`,
25268
+ `**Avg Win Duration:** ${stats.avgWinDuration === null ? "N/A" : `${stats.avgWinDuration.toFixed(1)} min`}`,
25269
+ `**Avg Loss Duration:** ${stats.avgLossDuration === null ? "N/A" : `${stats.avgLossDuration.toFixed(1)} min`}`,
25270
+ `**Avg Consecutive Win PNL:** ${stats.avgConsecutiveWinPnl === null ? "N/A" : `${stats.avgConsecutiveWinPnl > 0 ? "+" : ""}${stats.avgConsecutiveWinPnl.toFixed(3)}% (higher is better)`}`,
25271
+ `**Avg Consecutive Loss PNL:** ${stats.avgConsecutiveLossPnl === null ? "N/A" : `${stats.avgConsecutiveLossPnl.toFixed(3)}% (closer to 0 is better)`}`,
24965
25272
  "",
24966
25273
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
24967
25274
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
@@ -26957,26 +27264,113 @@ class HeatmapStorage {
26957
27264
  .filter((s) => s.pnl.pnlPercentage < 0)
26958
27265
  .reduce((acc, s) => acc + s.pnl.pnlPercentage, 0) / lossCount;
26959
27266
  }
26960
- // Calculate Win/Loss Streaks
27267
+ // Calculate Win/Loss Streaks AND per-streak pnl sums.
27268
+ // A streak is a run of same-signed trades; break-even (pnl=0) ends both runs.
27269
+ // The sign sequence is invariant under reversal, so iterating signals (newest
27270
+ // first) gives the same streak boundaries as chronological order.
26961
27271
  let maxWinStreak = 0;
26962
27272
  let maxLossStreak = 0;
26963
27273
  let currentWinStreak = 0;
26964
27274
  let currentLossStreak = 0;
27275
+ let currentWinStreakSum = 0;
27276
+ let currentLossStreakSum = 0;
27277
+ const winStreakSums = [];
27278
+ const lossStreakSums = [];
26965
27279
  for (const signal of signals) {
26966
- if (signal.pnl.pnlPercentage > 0) {
27280
+ const pnl = signal.pnl.pnlPercentage;
27281
+ if (pnl > 0) {
27282
+ if (currentLossStreak > 0) {
27283
+ lossStreakSums.push(currentLossStreakSum);
27284
+ currentLossStreak = 0;
27285
+ currentLossStreakSum = 0;
27286
+ }
26967
27287
  currentWinStreak++;
26968
- currentLossStreak = 0;
27288
+ currentWinStreakSum += pnl;
26969
27289
  if (currentWinStreak > maxWinStreak) {
26970
27290
  maxWinStreak = currentWinStreak;
26971
27291
  }
26972
27292
  }
26973
- else if (signal.pnl.pnlPercentage < 0) {
27293
+ else if (pnl < 0) {
27294
+ if (currentWinStreak > 0) {
27295
+ winStreakSums.push(currentWinStreakSum);
27296
+ currentWinStreak = 0;
27297
+ currentWinStreakSum = 0;
27298
+ }
26974
27299
  currentLossStreak++;
26975
- currentWinStreak = 0;
27300
+ currentLossStreakSum += pnl;
26976
27301
  if (currentLossStreak > maxLossStreak) {
26977
27302
  maxLossStreak = currentLossStreak;
26978
27303
  }
26979
27304
  }
27305
+ else {
27306
+ // Break-even closes both runs (it's neither a win nor a loss).
27307
+ if (currentWinStreak > 0) {
27308
+ winStreakSums.push(currentWinStreakSum);
27309
+ currentWinStreak = 0;
27310
+ currentWinStreakSum = 0;
27311
+ }
27312
+ if (currentLossStreak > 0) {
27313
+ lossStreakSums.push(currentLossStreakSum);
27314
+ currentLossStreak = 0;
27315
+ currentLossStreakSum = 0;
27316
+ }
27317
+ }
27318
+ }
27319
+ // Flush trailing streak.
27320
+ if (currentWinStreak > 0)
27321
+ winStreakSums.push(currentWinStreakSum);
27322
+ if (currentLossStreak > 0)
27323
+ lossStreakSums.push(currentLossStreakSum);
27324
+ let avgConsecutiveWinPnl = winStreakSums.length > 0
27325
+ ? winStreakSums.reduce((a, b) => a + b, 0) / winStreakSums.length
27326
+ : null;
27327
+ let avgConsecutiveLossPnl = lossStreakSums.length > 0
27328
+ ? lossStreakSums.reduce((a, b) => a + b, 0) / lossStreakSums.length
27329
+ : null;
27330
+ // Trade duration metrics. Source: closeTimestamp - signal.pendingAt, in minutes
27331
+ // (synchronized with strategy `minuteEstimatedTime`). A signal missing either
27332
+ // timestamp is excluded from the corresponding average — silent zeros would
27333
+ // otherwise pull the mean towards zero.
27334
+ let avgDuration = null;
27335
+ let avgWinDuration = null;
27336
+ let avgLossDuration = null;
27337
+ {
27338
+ const durations = [];
27339
+ const winDurations = [];
27340
+ const lossDurations = [];
27341
+ for (const s of signals) {
27342
+ const pendingAt = s.signal.pendingAt;
27343
+ const closeTs = s.closeTimestamp;
27344
+ if (typeof pendingAt !== "number" || pendingAt <= 0)
27345
+ continue;
27346
+ if (typeof closeTs !== "number" || closeTs <= 0)
27347
+ continue;
27348
+ const minutes = (closeTs - pendingAt) / 60000;
27349
+ durations.push(minutes);
27350
+ const pnl = s.pnl.pnlPercentage;
27351
+ if (pnl > 0)
27352
+ winDurations.push(minutes);
27353
+ else if (pnl < 0)
27354
+ lossDurations.push(minutes);
27355
+ }
27356
+ if (durations.length > 0) {
27357
+ avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
27358
+ }
27359
+ if (winDurations.length > 0) {
27360
+ avgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
27361
+ }
27362
+ if (lossDurations.length > 0) {
27363
+ avgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
27364
+ }
27365
+ }
27366
+ // Median pnlPercentage — robust to outliers. Sort a copy (do not mutate signals).
27367
+ let medianPnl = null;
27368
+ if (signals.length > 0) {
27369
+ const sorted = signals.map((s) => s.pnl.pnlPercentage).sort((a, b) => a - b);
27370
+ const mid = sorted.length >> 1;
27371
+ medianPnl = sorted.length % 2 === 0
27372
+ ? (sorted[mid - 1] + sorted[mid]) / 2
27373
+ : sorted[mid];
26980
27374
  }
26981
27375
  // Expectancy — probabilities from observed win/loss counts (break-evens contribute 0).
26982
27376
  let expectancy = null;
@@ -26993,8 +27387,12 @@ class HeatmapStorage {
26993
27387
  expectancy = (lossCount / totalTrades) * avgLoss;
26994
27388
  }
26995
27389
  // Average only over signals that have the value — do not dilute the mean with zeros.
27390
+ // Extremes (peakProfitPnl / maxDrawdownPnl) are the best/worst observation
27391
+ // across all trades, surfacing tail behaviour the average hides.
26996
27392
  let avgPeakPnl = null;
26997
27393
  let avgFallPnl = null;
27394
+ let peakProfitPnl = null;
27395
+ let maxDrawdownPnl = null;
26998
27396
  if (signals.length > 0) {
26999
27397
  const peakValues = signals
27000
27398
  .map((s) => s.signal.peakProfit?.pnlPercentage)
@@ -27002,12 +27400,14 @@ class HeatmapStorage {
27002
27400
  const fallValues = signals
27003
27401
  .map((s) => s.signal.maxDrawdown?.pnlPercentage)
27004
27402
  .filter((v) => typeof v === "number");
27005
- avgPeakPnl = peakValues.length > 0
27006
- ? peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length
27007
- : null;
27008
- avgFallPnl = fallValues.length > 0
27009
- ? fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length
27010
- : null;
27403
+ if (peakValues.length > 0) {
27404
+ avgPeakPnl = peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length;
27405
+ peakProfitPnl = Math.max(...peakValues);
27406
+ }
27407
+ if (fallValues.length > 0) {
27408
+ avgFallPnl = fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length;
27409
+ maxDrawdownPnl = Math.min(...fallValues);
27410
+ }
27011
27411
  }
27012
27412
  // Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
27013
27413
  // downsideDev = √( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
@@ -27083,6 +27483,25 @@ class HeatmapStorage {
27083
27483
  recoveryFactor = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, rawRec));
27084
27484
  }
27085
27485
  }
27486
+ // Annualized Sharpe — sharpeRatio × √tradesPerYear. Both inputs already
27487
+ // carry their own gates (sharpeRatio: N>=MIN_SIGNALS_FOR_RATIOS + STDDEV_EPSILON;
27488
+ // tradesPerYear: N>=MIN_SIGNALS_FOR_ANNUALIZATION + span>=MIN_CALENDAR_SPAN_DAYS
27489
+ // + raw frequency under MAX_TRADES_PER_YEAR), so we just propagate nulls.
27490
+ let annualizedSharpeRatio = null;
27491
+ if (sharpeRatio !== null && tradesPerYear !== null && tradesPerYear > 0) {
27492
+ annualizedSharpeRatio = sharpeRatio * Math.sqrt(tradesPerYear);
27493
+ }
27494
+ // Certainty Ratio = avgWin / |avgLoss|. Same gating shape as Backtest/Live:
27495
+ // N >= MIN_SIGNALS_FOR_RATIOS, AND |avgLoss| above STDDEV_EPSILON (float-artifact
27496
+ // losses near zero would otherwise produce spurious astronomical values).
27497
+ let certaintyRatio = null;
27498
+ if (canComputeRatios &&
27499
+ avgWin !== null &&
27500
+ avgLoss !== null &&
27501
+ avgLoss < 0 &&
27502
+ Math.abs(avgLoss) > STDDEV_EPSILON) {
27503
+ certaintyRatio = avgWin / Math.abs(avgLoss);
27504
+ }
27086
27505
  // Apply safe math checks
27087
27506
  if (isUnsafe(winRate))
27088
27507
  winRate = null;
@@ -27094,6 +27513,14 @@ class HeatmapStorage {
27094
27513
  stdDev = null;
27095
27514
  if (isUnsafe(sharpeRatio))
27096
27515
  sharpeRatio = null;
27516
+ if (isUnsafe(annualizedSharpeRatio))
27517
+ annualizedSharpeRatio = null;
27518
+ if (isUnsafe(certaintyRatio))
27519
+ certaintyRatio = null;
27520
+ if (isUnsafe(expectedYearlyReturns))
27521
+ expectedYearlyReturns = null;
27522
+ if (isUnsafe(tradesPerYear))
27523
+ tradesPerYear = null;
27097
27524
  if (isUnsafe(maxDrawdown))
27098
27525
  maxDrawdown = null;
27099
27526
  if (isUnsafe(profitFactor))
@@ -27108,6 +27535,22 @@ class HeatmapStorage {
27108
27535
  avgPeakPnl = null;
27109
27536
  if (isUnsafe(avgFallPnl))
27110
27537
  avgFallPnl = null;
27538
+ if (isUnsafe(peakProfitPnl))
27539
+ peakProfitPnl = null;
27540
+ if (isUnsafe(maxDrawdownPnl))
27541
+ maxDrawdownPnl = null;
27542
+ if (isUnsafe(avgDuration))
27543
+ avgDuration = null;
27544
+ if (isUnsafe(medianPnl))
27545
+ medianPnl = null;
27546
+ if (isUnsafe(avgConsecutiveWinPnl))
27547
+ avgConsecutiveWinPnl = null;
27548
+ if (isUnsafe(avgConsecutiveLossPnl))
27549
+ avgConsecutiveLossPnl = null;
27550
+ if (isUnsafe(avgWinDuration))
27551
+ avgWinDuration = null;
27552
+ if (isUnsafe(avgLossDuration))
27553
+ avgLossDuration = null;
27111
27554
  if (isUnsafe(sortinoRatio))
27112
27555
  sortinoRatio = null;
27113
27556
  if (isUnsafe(calmarRatio))
@@ -27133,9 +27576,21 @@ class HeatmapStorage {
27133
27576
  expectancy,
27134
27577
  avgPeakPnl,
27135
27578
  avgFallPnl,
27579
+ peakProfitPnl,
27580
+ maxDrawdownPnl,
27581
+ avgDuration,
27582
+ medianPnl,
27583
+ avgConsecutiveWinPnl,
27584
+ avgConsecutiveLossPnl,
27585
+ avgWinDuration,
27586
+ avgLossDuration,
27136
27587
  sortinoRatio,
27137
27588
  calmarRatio,
27138
27589
  recoveryFactor,
27590
+ annualizedSharpeRatio,
27591
+ certaintyRatio,
27592
+ expectedYearlyReturns,
27593
+ tradesPerYear,
27139
27594
  };
27140
27595
  }
27141
27596
  /**
@@ -27197,6 +27652,10 @@ class HeatmapStorage {
27197
27652
  let portfolioExpectancy = null;
27198
27653
  let portfolioCalmarRatio = null;
27199
27654
  let portfolioRecoveryFactor = null;
27655
+ let portfolioAnnualizedSharpeRatio = null;
27656
+ let portfolioCertaintyRatio = null;
27657
+ let portfolioExpectedYearlyReturns = null;
27658
+ let portfolioTradesPerYear = null;
27200
27659
  const allReturns = [];
27201
27660
  // Parallel array of intra-trade troughs (≤ 0), aligned 1:1 with allReturns,
27202
27661
  // used for mark-to-market DD in the pooled equity curve below.
@@ -27243,6 +27702,12 @@ class HeatmapStorage {
27243
27702
  if (wins.length > 0 || losses.length > 0) {
27244
27703
  portfolioExpectancy = (wins.length / total) * avgWin + (losses.length / total) * avgLoss;
27245
27704
  }
27705
+ // Pooled Certainty Ratio = pooledAvgWin / |pooledAvgLoss|. Same STDDEV_EPSILON
27706
+ // guard as per-symbol — protects against float-artifact losses producing
27707
+ // spuriously astronomical values.
27708
+ if (losses.length > 0 && Math.abs(avgLoss) > STDDEV_EPSILON && avgLoss < 0) {
27709
+ portfolioCertaintyRatio = avgWin / Math.abs(avgLoss);
27710
+ }
27246
27711
  // Pooled equity-curve max drawdown (compounded). MARK-TO-MARKET: each trade's
27247
27712
  // intra-trade trough (allFalls, ≤ 0) is applied before booking the realized close,
27248
27713
  // so deep round-trip dips are captured rather than understating DD.
@@ -27281,30 +27746,38 @@ class HeatmapStorage {
27281
27746
  // calendar span (≥ MIN_CALENDAR_SPAN_DAYS) and a non-clustered trade
27282
27747
  // frequency (≤ MAX_TRADES_PER_YEAR). Above MAX_EXPECTED_YEARLY_RETURNS → null
27283
27748
  // (don't surface the cap as a real figure). This is the numerator for Calmar.
27284
- let pooledExpectedYearlyReturns = null;
27285
27749
  const poolSpanDays = isFinite(poolFirstPendingAt) && isFinite(poolLastCloseAt)
27286
27750
  ? (poolLastCloseAt - poolFirstPendingAt) / (1000 * 60 * 60 * 24)
27287
27751
  : 0;
27288
27752
  if (poolSpanDays >= MIN_CALENDAR_SPAN_DAYS) {
27289
27753
  const rawTradesPerYear = (allReturns.length / poolSpanDays) * 365;
27290
27754
  if (rawTradesPerYear <= MAX_TRADES_PER_YEAR) {
27755
+ portfolioTradesPerYear = rawTradesPerYear;
27291
27756
  if (blown) {
27292
- pooledExpectedYearlyReturns = -100;
27757
+ portfolioExpectedYearlyReturns = -100;
27293
27758
  }
27294
27759
  else {
27295
27760
  const raw = (Math.pow(equityFinal, rawTradesPerYear / allReturns.length) - 1) * 100;
27296
- pooledExpectedYearlyReturns =
27761
+ portfolioExpectedYearlyReturns =
27297
27762
  Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS ? null : raw;
27298
27763
  }
27299
27764
  }
27300
27765
  }
27766
+ // Pooled Annualized Sharpe — pooledSharpe × √pooledTradesPerYear. Both
27767
+ // gates already enforced upstream; just propagate nulls.
27768
+ if (portfolioSharpeRatio !== null &&
27769
+ portfolioTradesPerYear !== null &&
27770
+ portfolioTradesPerYear > 0) {
27771
+ portfolioAnnualizedSharpeRatio =
27772
+ portfolioSharpeRatio * Math.sqrt(portfolioTradesPerYear);
27773
+ }
27301
27774
  // Pooled Calmar = annualized return / max drawdown — same formula and
27302
27775
  // gating as per-symbol Calmar. NULL when the annualized numerator is
27303
27776
  // unavailable (span/frequency gate, or over the yearly cap). This is what
27304
27777
  // distinguishes it from Recovery, which uses the compounded TOTAL return —
27305
27778
  // 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));
27779
+ if (maxDD > 0 && portfolioExpectedYearlyReturns !== null) {
27780
+ portfolioCalmarRatio = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, portfolioExpectedYearlyReturns / maxDD));
27308
27781
  }
27309
27782
  // Pooled Recovery Factor = compounded TOTAL return / max drawdown, clamped.
27310
27783
  // Time-independent (no annualization), so it needs no span gate — only a
@@ -27329,6 +27802,91 @@ class HeatmapStorage {
27329
27802
  if (validFall.length > 0 && fallTradesTotal > 0) {
27330
27803
  portfolioAvgFallPnl = validFall.reduce((acc, s) => acc + s.avgFallPnl * s.totalTrades, 0) / fallTradesTotal;
27331
27804
  }
27805
+ // Portfolio-wide extremes: best best-case and worst worst-case across
27806
+ // every per-symbol extreme. Skips symbols whose extreme is null (no
27807
+ // peakProfit/maxDrawdown snapshots) — they cannot vote in either direction.
27808
+ let portfolioPeakProfitPnl = null;
27809
+ let portfolioMaxDrawdownPnl = null;
27810
+ const peakExtremes = symbols
27811
+ .map((s) => s.peakProfitPnl)
27812
+ .filter((v) => typeof v === "number");
27813
+ const fallExtremes = symbols
27814
+ .map((s) => s.maxDrawdownPnl)
27815
+ .filter((v) => typeof v === "number");
27816
+ if (peakExtremes.length > 0) {
27817
+ portfolioPeakProfitPnl = Math.max(...peakExtremes);
27818
+ }
27819
+ if (fallExtremes.length > 0) {
27820
+ portfolioMaxDrawdownPnl = Math.min(...fallExtremes);
27821
+ }
27822
+ // Portfolio duration metrics — pooled means over every trade with valid
27823
+ // timestamps, regardless of symbol. A signal missing pendingAt/closeTimestamp
27824
+ // is excluded from its average (the same rule as per-symbol).
27825
+ let portfolioAvgDuration = null;
27826
+ let portfolioAvgWinDuration = null;
27827
+ let portfolioAvgLossDuration = null;
27828
+ {
27829
+ const durations = [];
27830
+ const winDurations = [];
27831
+ const lossDurations = [];
27832
+ for (const signals of this.symbolData.values()) {
27833
+ for (const s of signals) {
27834
+ const pendingAt = s.signal.pendingAt;
27835
+ const closeTs = s.closeTimestamp;
27836
+ if (typeof pendingAt !== "number" || pendingAt <= 0)
27837
+ continue;
27838
+ if (typeof closeTs !== "number" || closeTs <= 0)
27839
+ continue;
27840
+ const minutes = (closeTs - pendingAt) / 60000;
27841
+ durations.push(minutes);
27842
+ const pnl = s.pnl.pnlPercentage;
27843
+ if (pnl > 0)
27844
+ winDurations.push(minutes);
27845
+ else if (pnl < 0)
27846
+ lossDurations.push(minutes);
27847
+ }
27848
+ }
27849
+ if (durations.length > 0) {
27850
+ portfolioAvgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
27851
+ }
27852
+ if (winDurations.length > 0) {
27853
+ portfolioAvgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
27854
+ }
27855
+ if (lossDurations.length > 0) {
27856
+ portfolioAvgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
27857
+ }
27858
+ }
27859
+ // Portfolio median — pooled over allReturns (already collected for the
27860
+ // Sharpe block). Robust to outliers like the per-symbol counterpart.
27861
+ let portfolioMedianPnl = null;
27862
+ if (allReturns.length > 0) {
27863
+ const sortedAll = allReturns.slice().sort((a, b) => a - b);
27864
+ const mid = sortedAll.length >> 1;
27865
+ portfolioMedianPnl = sortedAll.length % 2 === 0
27866
+ ? (sortedAll[mid - 1] + sortedAll[mid]) / 2
27867
+ : sortedAll[mid];
27868
+ }
27869
+ // Portfolio streak averages — trade-count-weighted mean of per-symbol
27870
+ // averages. Concatenating streaks across symbols would be wrong: trades on
27871
+ // different symbols are not "consecutive" in any meaningful sense (different
27872
+ // markets, different timeframes). Weighting by totalTrades matches the
27873
+ // weighting used for portfolioAvgPeakPnl / portfolioAvgFallPnl.
27874
+ let portfolioAvgConsecutiveWinPnl = null;
27875
+ let portfolioAvgConsecutiveLossPnl = null;
27876
+ const validWinStreak = symbols.filter((s) => s.avgConsecutiveWinPnl !== null);
27877
+ const validLossStreak = symbols.filter((s) => s.avgConsecutiveLossPnl !== null);
27878
+ const winStreakWeight = validWinStreak.reduce((acc, s) => acc + s.totalTrades, 0);
27879
+ const lossStreakWeight = validLossStreak.reduce((acc, s) => acc + s.totalTrades, 0);
27880
+ if (validWinStreak.length > 0 && winStreakWeight > 0) {
27881
+ portfolioAvgConsecutiveWinPnl =
27882
+ validWinStreak.reduce((acc, s) => acc + s.avgConsecutiveWinPnl * s.totalTrades, 0) /
27883
+ winStreakWeight;
27884
+ }
27885
+ if (validLossStreak.length > 0 && lossStreakWeight > 0) {
27886
+ portfolioAvgConsecutiveLossPnl =
27887
+ validLossStreak.reduce((acc, s) => acc + s.avgConsecutiveLossPnl * s.totalTrades, 0) /
27888
+ lossStreakWeight;
27889
+ }
27332
27890
  // Apply safe math
27333
27891
  if (isUnsafe(portfolioTotalPnl))
27334
27892
  portfolioTotalPnl = null;
@@ -27338,6 +27896,10 @@ class HeatmapStorage {
27338
27896
  portfolioAvgPeakPnl = null;
27339
27897
  if (isUnsafe(portfolioAvgFallPnl))
27340
27898
  portfolioAvgFallPnl = null;
27899
+ if (isUnsafe(portfolioPeakProfitPnl))
27900
+ portfolioPeakProfitPnl = null;
27901
+ if (isUnsafe(portfolioMaxDrawdownPnl))
27902
+ portfolioMaxDrawdownPnl = null;
27341
27903
  if (isUnsafe(portfolioStdDev))
27342
27904
  portfolioStdDev = null;
27343
27905
  if (isUnsafe(portfolioSortinoRatio))
@@ -27348,6 +27910,26 @@ class HeatmapStorage {
27348
27910
  portfolioRecoveryFactor = null;
27349
27911
  if (isUnsafe(portfolioExpectancy))
27350
27912
  portfolioExpectancy = null;
27913
+ if (isUnsafe(portfolioAvgDuration))
27914
+ portfolioAvgDuration = null;
27915
+ if (isUnsafe(portfolioMedianPnl))
27916
+ portfolioMedianPnl = null;
27917
+ if (isUnsafe(portfolioAvgConsecutiveWinPnl))
27918
+ portfolioAvgConsecutiveWinPnl = null;
27919
+ if (isUnsafe(portfolioAvgConsecutiveLossPnl))
27920
+ portfolioAvgConsecutiveLossPnl = null;
27921
+ if (isUnsafe(portfolioAvgWinDuration))
27922
+ portfolioAvgWinDuration = null;
27923
+ if (isUnsafe(portfolioAvgLossDuration))
27924
+ portfolioAvgLossDuration = null;
27925
+ if (isUnsafe(portfolioAnnualizedSharpeRatio))
27926
+ portfolioAnnualizedSharpeRatio = null;
27927
+ if (isUnsafe(portfolioCertaintyRatio))
27928
+ portfolioCertaintyRatio = null;
27929
+ if (isUnsafe(portfolioExpectedYearlyReturns))
27930
+ portfolioExpectedYearlyReturns = null;
27931
+ if (isUnsafe(portfolioTradesPerYear))
27932
+ portfolioTradesPerYear = null;
27351
27933
  return {
27352
27934
  symbols,
27353
27935
  totalSymbols,
@@ -27356,11 +27938,23 @@ class HeatmapStorage {
27356
27938
  portfolioTotalTrades,
27357
27939
  portfolioAvgPeakPnl,
27358
27940
  portfolioAvgFallPnl,
27941
+ portfolioPeakProfitPnl,
27942
+ portfolioMaxDrawdownPnl,
27359
27943
  portfolioStdDev,
27360
27944
  portfolioSortinoRatio,
27361
27945
  portfolioCalmarRatio,
27362
27946
  portfolioRecoveryFactor,
27363
27947
  portfolioExpectancy,
27948
+ portfolioAvgDuration,
27949
+ portfolioMedianPnl,
27950
+ portfolioAvgConsecutiveWinPnl,
27951
+ portfolioAvgConsecutiveLossPnl,
27952
+ portfolioAvgWinDuration,
27953
+ portfolioAvgLossDuration,
27954
+ portfolioAnnualizedSharpeRatio,
27955
+ portfolioCertaintyRatio,
27956
+ portfolioExpectedYearlyReturns,
27957
+ portfolioTradesPerYear,
27364
27958
  };
27365
27959
  }
27366
27960
  /**
@@ -27409,32 +28003,53 @@ class HeatmapStorage {
27409
28003
  return [
27410
28004
  `# Portfolio Heatmap: ${strategyName}`,
27411
28005
  "",
27412
- `**Total Symbols:** ${data.totalSymbols}
27413
- **Portfolio PNL:** ${data.portfolioTotalPnl !== null ? str(data.portfolioTotalPnl, "%") : "N/A"}
27414
- **Pooled Sharpe:** ${data.portfolioSharpeRatio !== null ? str(data.portfolioSharpeRatio) : "N/A"}
27415
- **Total Trades:** ${data.portfolioTotalTrades}
27416
- **Avg Peak PNL:** ${data.portfolioAvgPeakPnl !== null ? str(data.portfolioAvgPeakPnl, "%") : "N/A"}
27417
- **Avg Max Drawdown PNL:** ${data.portfolioAvgFallPnl !== null ? str(data.portfolioAvgFallPnl, "%") : "N/A"}`,
27418
- `**Standard Deviation Per Trade:** ${data.portfolioStdDev !== null ? str(data.portfolioStdDev, "%") : "N/A"}
27419
- **Sortino Ratio:** ${data.portfolioSortinoRatio !== null ? str(data.portfolioSortinoRatio) : "N/A"}
27420
- **Calmar Ratio:** ${data.portfolioCalmarRatio !== null ? str(data.portfolioCalmarRatio) : "N/A"}
27421
- **Recovery Factor:** ${data.portfolioRecoveryFactor !== null ? str(data.portfolioRecoveryFactor) : "N/A"}
27422
- **Expectancy:** ${data.portfolioExpectancy !== null ? str(data.portfolioExpectancy, "%") : "N/A"}`,
28006
+ `**Total Symbols:** ${data.totalSymbols}`,
28007
+ `**Portfolio PNL:** ${data.portfolioTotalPnl !== null ? str(data.portfolioTotalPnl, "%") : "N/A"}`,
28008
+ `**Pooled Sharpe:** ${data.portfolioSharpeRatio !== null ? str(data.portfolioSharpeRatio) : "N/A"}`,
28009
+ `**Annualized Sharpe:** ${data.portfolioAnnualizedSharpeRatio !== null ? str(data.portfolioAnnualizedSharpeRatio) : "N/A"}`,
28010
+ `**Certainty Ratio:** ${data.portfolioCertaintyRatio !== null ? str(data.portfolioCertaintyRatio) : "N/A"}`,
28011
+ `**Expected Yearly Returns:** ${data.portfolioExpectedYearlyReturns !== null ? str(data.portfolioExpectedYearlyReturns, "%") : "N/A"}`,
28012
+ `**Trades Per Year:** ${data.portfolioTradesPerYear !== null ? data.portfolioTradesPerYear.toFixed(1) : "N/A"}`,
28013
+ `**Total Trades:** ${data.portfolioTotalTrades}`,
28014
+ `**Avg Peak PNL:** ${data.portfolioAvgPeakPnl !== null ? str(data.portfolioAvgPeakPnl, "%") : "N/A"}`,
28015
+ `**Avg Max Drawdown PNL:** ${data.portfolioAvgFallPnl !== null ? str(data.portfolioAvgFallPnl, "%") : "N/A"}`,
28016
+ `**Peak Profit PNL:** ${data.portfolioPeakProfitPnl !== null ? str(data.portfolioPeakProfitPnl, "%") : "N/A"}`,
28017
+ `**Max Drawdown PNL:** ${data.portfolioMaxDrawdownPnl !== null ? str(data.portfolioMaxDrawdownPnl, "%") : "N/A"}`,
28018
+ `**Median PNL:** ${data.portfolioMedianPnl !== null ? str(data.portfolioMedianPnl, "%") : "N/A"}`,
28019
+ `**Avg Duration:** ${data.portfolioAvgDuration !== null ? `${data.portfolioAvgDuration.toFixed(1)} min` : "N/A"}`,
28020
+ `**Avg Win Duration:** ${data.portfolioAvgWinDuration !== null ? `${data.portfolioAvgWinDuration.toFixed(1)} min` : "N/A"}`,
28021
+ `**Avg Loss Duration:** ${data.portfolioAvgLossDuration !== null ? `${data.portfolioAvgLossDuration.toFixed(1)} min` : "N/A"}`,
28022
+ `**Avg Consecutive Win PNL:** ${data.portfolioAvgConsecutiveWinPnl !== null ? str(data.portfolioAvgConsecutiveWinPnl, "%") : "N/A"}`,
28023
+ `**Avg Consecutive Loss PNL:** ${data.portfolioAvgConsecutiveLossPnl !== null ? str(data.portfolioAvgConsecutiveLossPnl, "%") : "N/A"}`,
28024
+ `**Standard Deviation Per Trade:** ${data.portfolioStdDev !== null ? str(data.portfolioStdDev, "%") : "N/A"}`,
28025
+ `**Sortino Ratio:** ${data.portfolioSortinoRatio !== null ? str(data.portfolioSortinoRatio) : "N/A"}`,
28026
+ `**Calmar Ratio:** ${data.portfolioCalmarRatio !== null ? str(data.portfolioCalmarRatio) : "N/A"}`,
28027
+ `**Recovery Factor:** ${data.portfolioRecoveryFactor !== null ? str(data.portfolioRecoveryFactor) : "N/A"}`,
28028
+ `**Expectancy:** ${data.portfolioExpectancy !== null ? str(data.portfolioExpectancy, "%") : "N/A"}`,
27423
28029
  "",
27424
28030
  table,
27425
28031
  "",
27426
28032
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
27427
28033
  `*Pooled Sharpe: Sharpe computed over all trades across symbols treated as one sample. NOT a Markowitz portfolio Sharpe — ignores cross-symbol correlations and capital allocation. N/A unless ≥${MIN_SIGNALS_FOR_RATIOS} pooled trades.*`,
28034
+ `*Annualized Sharpe: per-trade Sharpe × √tradesPerYear. N/A unless the underlying Sharpe and tradesPerYear are both available (≥${MIN_SIGNALS_FOR_ANNUALIZATION} signals, span ≥${MIN_CALENDAR_SPAN_DAYS} days, raw frequency ≤${MAX_TRADES_PER_YEAR}). Assumes returns are iid — autocorrelated strategies are overstated.*`,
28035
+ `*Certainty Ratio: avgWin / |avgLoss|. Below 1.0 means average loss exceeds average win. Above 1.5 is considered good. N/A when no losing trades or |avgLoss| is sub-epsilon.*`,
28036
+ `*Expected Yearly Returns: compounded geometric return from the equity curve, annualized by tradesPerYear. Same gating as Annualized Sharpe. Capped at ±${MAX_EXPECTED_YEARLY_RETURNS}% — values above the cap return N/A.*`,
28037
+ `*Trades Per Year: observed trade frequency extrapolated to one year (signals × 365 / calendarSpanDays). N/A when too few signals or too short a calendar span; also null when the raw frequency exceeds ${MAX_TRADES_PER_YEAR} (too clustered for reliable annualization).*`,
27428
28038
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals per symbol.*`,
27429
28039
  `*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".*`,
27430
- `*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
27431
28040
  `*Profit Factor: below 1.0 means strategy is losing overall. Above 1.5 is considered good.*`,
27432
28041
  `*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}.*`,
27433
28042
  `*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.*`,
28043
+ `*Expectancy: per-trade expected value (winProb × avgWin + lossProb × avgLoss). Positive = profitable on average per trade. Break-even trades contribute 0.*`,
28044
+ `*Median PNL: middle value of the pnl distribution. Robust to outliers; compare to Average PNL — a large gap signals a skewed distribution (e.g. one whale trade dragging the mean).*`,
28045
+ `*Avg Peak PNL / Avg Max Drawdown PNL: mean of per-trade _peak.pnlPercentage / _fall.pnlPercentage. Higher avg-peak with deeper avg-drawdown means strategy needs to tolerate bigger swings to capture the upside.*`,
28046
+ `*Peak Profit PNL / Max Drawdown PNL: extremes — the best best-case and worst worst-case observed across all trades. Tail behaviour the averages hide.*`,
28047
+ `*Avg Duration / Avg Win Duration / Avg Loss Duration: mean hold time in minutes (closeTimestamp - pendingAt). Winner-shorter-than-loser is a red flag ("cut winners short, let losers run").*`,
28048
+ `*Avg Consecutive Win/Loss PNL: average sum of pnlPercentage across consecutive streaks. Pairs with max streak length to show the typical (not worst-case) streak magnitude. Portfolio uses trade-count-weighted mean of per-symbol streak averages — concatenating streaks across symbols would be meaningless (different markets, different timeframes).*`,
27434
28049
  `*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.*`,
27435
28050
  `*All metrics require 100+ signals per symbol to be statistically reliable. Annualized metrics assume the observed trading frequency persists year-round.*`,
27436
28051
  `*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.*`,
27437
- `*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.*`,
28052
+ `*Negative values for Sharpe / Annualized Sharpe / Sortino / Calmar / Recovery / Expectancy / Expected Yearly Returns indicate a losing symbol (avgPnl < 0 or totalPnl < 0). "Higher is better" still applies — closer to zero is less bad, positive is profitable.*`,
27438
28053
  ].join("\n");
27439
28054
  }
27440
28055
  /**