backtest-kit 11.5.0 → 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.cjs CHANGED
@@ -21281,6 +21281,32 @@ const heat_columns = [
21281
21281
  format: (data) => data.sharpeRatio !== null ? functoolsKit.str(data.sharpeRatio) : "N/A",
21282
21282
  isVisible: () => true,
21283
21283
  },
21284
+ {
21285
+ key: "annualizedSharpeRatio",
21286
+ label: "Ann Sharpe",
21287
+ format: (data) => data.annualizedSharpeRatio !== null ? functoolsKit.str(data.annualizedSharpeRatio) : "N/A",
21288
+ isVisible: () => true,
21289
+ },
21290
+ {
21291
+ key: "certaintyRatio",
21292
+ label: "Certainty",
21293
+ format: (data) => data.certaintyRatio !== null ? functoolsKit.str(data.certaintyRatio) : "N/A",
21294
+ isVisible: () => true,
21295
+ },
21296
+ {
21297
+ key: "expectedYearlyReturns",
21298
+ label: "Exp Yearly",
21299
+ format: (data) => data.expectedYearlyReturns !== null
21300
+ ? functoolsKit.str(data.expectedYearlyReturns, "%")
21301
+ : "N/A",
21302
+ isVisible: () => true,
21303
+ },
21304
+ {
21305
+ key: "tradesPerYear",
21306
+ label: "Trades/Yr",
21307
+ format: (data) => data.tradesPerYear !== null ? data.tradesPerYear.toFixed(1) : "N/A",
21308
+ isVisible: () => true,
21309
+ },
21284
21310
  {
21285
21311
  key: "profitFactor",
21286
21312
  label: "PF",
@@ -21347,6 +21373,58 @@ const heat_columns = [
21347
21373
  format: (data) => data.avgFallPnl !== null ? functoolsKit.str(data.avgFallPnl, "%") : "N/A",
21348
21374
  isVisible: () => true,
21349
21375
  },
21376
+ {
21377
+ key: "peakProfitPnl",
21378
+ label: "Peak Profit PNL",
21379
+ format: (data) => data.peakProfitPnl !== null ? functoolsKit.str(data.peakProfitPnl, "%") : "N/A",
21380
+ isVisible: () => true,
21381
+ },
21382
+ {
21383
+ key: "maxDrawdownPnl",
21384
+ label: "Max DD PNL",
21385
+ format: (data) => data.maxDrawdownPnl !== null ? functoolsKit.str(data.maxDrawdownPnl, "%") : "N/A",
21386
+ isVisible: () => true,
21387
+ },
21388
+ {
21389
+ key: "medianPnl",
21390
+ label: "Median PNL",
21391
+ format: (data) => data.medianPnl !== null ? functoolsKit.str(data.medianPnl, "%") : "N/A",
21392
+ isVisible: () => true,
21393
+ },
21394
+ {
21395
+ key: "avgDuration",
21396
+ label: "Avg Dur (min)",
21397
+ format: (data) => data.avgDuration !== null ? data.avgDuration.toFixed(1) : "N/A",
21398
+ isVisible: () => true,
21399
+ },
21400
+ {
21401
+ key: "avgWinDuration",
21402
+ label: "Avg Win Dur",
21403
+ format: (data) => data.avgWinDuration !== null ? data.avgWinDuration.toFixed(1) : "N/A",
21404
+ isVisible: () => true,
21405
+ },
21406
+ {
21407
+ key: "avgLossDuration",
21408
+ label: "Avg Loss Dur",
21409
+ format: (data) => data.avgLossDuration !== null ? data.avgLossDuration.toFixed(1) : "N/A",
21410
+ isVisible: () => true,
21411
+ },
21412
+ {
21413
+ key: "avgConsecutiveWinPnl",
21414
+ label: "Avg Win Streak PNL",
21415
+ format: (data) => data.avgConsecutiveWinPnl !== null
21416
+ ? functoolsKit.str(data.avgConsecutiveWinPnl, "%")
21417
+ : "N/A",
21418
+ isVisible: () => true,
21419
+ },
21420
+ {
21421
+ key: "avgConsecutiveLossPnl",
21422
+ label: "Avg Loss Streak PNL",
21423
+ format: (data) => data.avgConsecutiveLossPnl !== null
21424
+ ? functoolsKit.str(data.avgConsecutiveLossPnl, "%")
21425
+ : "N/A",
21426
+ isVisible: () => true,
21427
+ },
21350
21428
  {
21351
21429
  key: "sortinoRatio",
21352
21430
  label: "Sortino",
@@ -23817,6 +23895,12 @@ let ReportStorage$a = class ReportStorage {
23817
23895
  calmarRatio: null,
23818
23896
  recoveryFactor: null,
23819
23897
  expectancy: null,
23898
+ avgDuration: null,
23899
+ medianPnl: null,
23900
+ avgConsecutiveWinPnl: null,
23901
+ avgConsecutiveLossPnl: null,
23902
+ avgWinDuration: null,
23903
+ avgLossDuration: null,
23820
23904
  };
23821
23905
  }
23822
23906
  // Valid signal set — those with usable pendingAt AND closeTimestamp. Single source
@@ -23966,6 +24050,101 @@ let ReportStorage$a = class ReportStorage {
23966
24050
  const expectancy = canComputeRatios && totalSignals > 0
23967
24051
  ? (wins.length / totalSignals) * avgWin + (losses.length / totalSignals) * avgLoss
23968
24052
  : null;
24053
+ // Median pnlPercentage — robust to outliers; reveals skew when avgPnl is
24054
+ // dragged by a whale trade. Sort a copy (do not mutate validSignals).
24055
+ let medianPnl = null;
24056
+ if (returns.length > 0) {
24057
+ const sortedReturns = returns.slice().sort((a, b) => a - b);
24058
+ const mid = sortedReturns.length >> 1;
24059
+ medianPnl = sortedReturns.length % 2 === 0
24060
+ ? (sortedReturns[mid - 1] + sortedReturns[mid]) / 2
24061
+ : sortedReturns[mid];
24062
+ }
24063
+ // Trade duration metrics in minutes (synchronized with strategy
24064
+ // `minuteEstimatedTime`). validSignals already requires pendingAt > 0 and
24065
+ // closeTimestamp > 0, so every signal here contributes a valid duration.
24066
+ let avgDuration = null;
24067
+ let avgWinDuration = null;
24068
+ let avgLossDuration = null;
24069
+ if (totalSignals > 0) {
24070
+ const durations = [];
24071
+ const winDurations = [];
24072
+ const lossDurations = [];
24073
+ for (const s of validSignals) {
24074
+ const minutes = (s.closeTimestamp - s.signal.pendingAt) / 60000;
24075
+ durations.push(minutes);
24076
+ const pnl = s.pnl.pnlPercentage;
24077
+ if (pnl > 0)
24078
+ winDurations.push(minutes);
24079
+ else if (pnl < 0)
24080
+ lossDurations.push(minutes);
24081
+ }
24082
+ avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
24083
+ if (winDurations.length > 0) {
24084
+ avgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
24085
+ }
24086
+ if (lossDurations.length > 0) {
24087
+ avgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
24088
+ }
24089
+ }
24090
+ // Consecutive streak averages: sum the per-streak pnl, then mean across
24091
+ // streaks. Storage is newest-first, so iterate in reverse for chronological
24092
+ // streaks. Break-even (pnl=0) closes both runs (neither a win nor a loss).
24093
+ let avgConsecutiveWinPnl = null;
24094
+ let avgConsecutiveLossPnl = null;
24095
+ {
24096
+ const winStreakSums = [];
24097
+ const lossStreakSums = [];
24098
+ let curWin = 0;
24099
+ let curLoss = 0;
24100
+ let curWinSum = 0;
24101
+ let curLossSum = 0;
24102
+ for (let i = validSignals.length - 1; i >= 0; i--) {
24103
+ const pnl = validSignals[i].pnl.pnlPercentage;
24104
+ if (pnl > 0) {
24105
+ if (curLoss > 0) {
24106
+ lossStreakSums.push(curLossSum);
24107
+ curLoss = 0;
24108
+ curLossSum = 0;
24109
+ }
24110
+ curWin++;
24111
+ curWinSum += pnl;
24112
+ }
24113
+ else if (pnl < 0) {
24114
+ if (curWin > 0) {
24115
+ winStreakSums.push(curWinSum);
24116
+ curWin = 0;
24117
+ curWinSum = 0;
24118
+ }
24119
+ curLoss++;
24120
+ curLossSum += pnl;
24121
+ }
24122
+ else {
24123
+ if (curWin > 0) {
24124
+ winStreakSums.push(curWinSum);
24125
+ curWin = 0;
24126
+ curWinSum = 0;
24127
+ }
24128
+ if (curLoss > 0) {
24129
+ lossStreakSums.push(curLossSum);
24130
+ curLoss = 0;
24131
+ curLossSum = 0;
24132
+ }
24133
+ }
24134
+ }
24135
+ if (curWin > 0)
24136
+ winStreakSums.push(curWinSum);
24137
+ if (curLoss > 0)
24138
+ lossStreakSums.push(curLossSum);
24139
+ if (winStreakSums.length > 0) {
24140
+ avgConsecutiveWinPnl =
24141
+ winStreakSums.reduce((a, b) => a + b, 0) / winStreakSums.length;
24142
+ }
24143
+ if (lossStreakSums.length > 0) {
24144
+ avgConsecutiveLossPnl =
24145
+ lossStreakSums.reduce((a, b) => a + b, 0) / lossStreakSums.length;
24146
+ }
24147
+ }
23969
24148
  // Average peak/fall PNL — over validSignals; only signals that actually have the
23970
24149
  // value contribute (no zero dilution from missing peakProfit/maxDrawdown).
23971
24150
  const peakValues = validSignals
@@ -24031,6 +24210,12 @@ let ReportStorage$a = class ReportStorage {
24031
24210
  calmarRatio: isUnsafe$4(calmarRatio) ? null : calmarRatio,
24032
24211
  recoveryFactor: isUnsafe$4(recoveryFactor) ? null : recoveryFactor,
24033
24212
  expectancy: isUnsafe$4(expectancy) ? null : expectancy,
24213
+ avgDuration: isUnsafe$4(avgDuration) ? null : avgDuration,
24214
+ medianPnl: isUnsafe$4(medianPnl) ? null : medianPnl,
24215
+ avgConsecutiveWinPnl: isUnsafe$4(avgConsecutiveWinPnl) ? null : avgConsecutiveWinPnl,
24216
+ avgConsecutiveLossPnl: isUnsafe$4(avgConsecutiveLossPnl) ? null : avgConsecutiveLossPnl,
24217
+ avgWinDuration: isUnsafe$4(avgWinDuration) ? null : avgWinDuration,
24218
+ avgLossDuration: isUnsafe$4(avgLossDuration) ? null : avgLossDuration,
24034
24219
  };
24035
24220
  }
24036
24221
  /**
@@ -24081,6 +24266,12 @@ let ReportStorage$a = class ReportStorage {
24081
24266
  `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
24082
24267
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
24083
24268
  `**Expectancy:** ${stats.expectancy === null ? "N/A" : `${stats.expectancy > 0 ? "+" : ""}${stats.expectancy.toFixed(3)}% (higher is better)`}`,
24269
+ `**Median PNL:** ${stats.medianPnl === null ? "N/A" : `${stats.medianPnl > 0 ? "+" : ""}${stats.medianPnl.toFixed(3)}% (closer to avgPnl = symmetric distribution)`}`,
24270
+ `**Avg Duration:** ${stats.avgDuration === null ? "N/A" : `${stats.avgDuration.toFixed(1)} min`}`,
24271
+ `**Avg Win Duration:** ${stats.avgWinDuration === null ? "N/A" : `${stats.avgWinDuration.toFixed(1)} min`}`,
24272
+ `**Avg Loss Duration:** ${stats.avgLossDuration === null ? "N/A" : `${stats.avgLossDuration.toFixed(1)} min`}`,
24273
+ `**Avg Consecutive Win PNL:** ${stats.avgConsecutiveWinPnl === null ? "N/A" : `${stats.avgConsecutiveWinPnl > 0 ? "+" : ""}${stats.avgConsecutiveWinPnl.toFixed(3)}% (higher is better)`}`,
24274
+ `**Avg Consecutive Loss PNL:** ${stats.avgConsecutiveLossPnl === null ? "N/A" : `${stats.avgConsecutiveLossPnl.toFixed(3)}% (closer to 0 is better)`}`,
24084
24275
  "",
24085
24276
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
24086
24277
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
@@ -24718,6 +24909,12 @@ let ReportStorage$9 = class ReportStorage {
24718
24909
  calmarRatio: null,
24719
24910
  recoveryFactor: null,
24720
24911
  expectancy: null,
24912
+ avgDuration: null,
24913
+ medianPnl: null,
24914
+ avgConsecutiveWinPnl: null,
24915
+ avgConsecutiveLossPnl: null,
24916
+ avgWinDuration: null,
24917
+ avgLossDuration: null,
24721
24918
  };
24722
24919
  }
24723
24920
  const closedEvents = this._eventList.filter((e) => e.action === "closed");
@@ -24807,6 +25004,104 @@ let ReportStorage$9 = class ReportStorage {
24807
25004
  // trades contribute 0 (excluded from both probabilities).
24808
25005
  expectancy = (wins.length / totalClosed) * avgWin + (losses.length / totalClosed) * avgLoss;
24809
25006
  }
25007
+ // Median pnl — robust to outliers; reveals skew when avgPnl is dragged
25008
+ // by a whale trade. Sort a copy (do not mutate returns).
25009
+ let medianPnl = null;
25010
+ if (returns.length > 0) {
25011
+ const sortedReturns = returns.slice().sort((a, b) => a - b);
25012
+ const mid = sortedReturns.length >> 1;
25013
+ medianPnl = sortedReturns.length % 2 === 0
25014
+ ? (sortedReturns[mid - 1] + sortedReturns[mid]) / 2
25015
+ : sortedReturns[mid];
25016
+ }
25017
+ // Trade duration metrics in minutes (synchronized with strategy
25018
+ // `minuteEstimatedTime`). Source: e.timestamp (close) - (e.pendingAt ?? e.timestamp).
25019
+ // validClosed already guarantees e.timestamp > 0; if pendingAt is missing the
25020
+ // event contributes a 0-minute duration, matching the validation fallback.
25021
+ let avgDuration = null;
25022
+ let avgWinDuration = null;
25023
+ let avgLossDuration = null;
25024
+ if (totalClosed > 0) {
25025
+ const durations = [];
25026
+ const winDurations = [];
25027
+ const lossDurations = [];
25028
+ for (const e of validClosed) {
25029
+ const closeTs = e.timestamp;
25030
+ const openTs = e.pendingAt ?? e.timestamp;
25031
+ const minutes = (closeTs - openTs) / 60000;
25032
+ durations.push(minutes);
25033
+ const pnl = e.pnl;
25034
+ if (pnl > 0)
25035
+ winDurations.push(minutes);
25036
+ else if (pnl < 0)
25037
+ lossDurations.push(minutes);
25038
+ }
25039
+ avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
25040
+ if (winDurations.length > 0) {
25041
+ avgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
25042
+ }
25043
+ if (lossDurations.length > 0) {
25044
+ avgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
25045
+ }
25046
+ }
25047
+ // Consecutive streak averages: sum the per-streak pnl, then mean across
25048
+ // streaks. validClosed is newest-first (events unshifted), so iterate in
25049
+ // reverse for chronological streaks. Break-even (pnl=0) closes both runs.
25050
+ let avgConsecutiveWinPnl = null;
25051
+ let avgConsecutiveLossPnl = null;
25052
+ {
25053
+ const winStreakSums = [];
25054
+ const lossStreakSums = [];
25055
+ let curWin = 0;
25056
+ let curLoss = 0;
25057
+ let curWinSum = 0;
25058
+ let curLossSum = 0;
25059
+ for (let i = validClosed.length - 1; i >= 0; i--) {
25060
+ const pnl = validClosed[i].pnl;
25061
+ if (pnl > 0) {
25062
+ if (curLoss > 0) {
25063
+ lossStreakSums.push(curLossSum);
25064
+ curLoss = 0;
25065
+ curLossSum = 0;
25066
+ }
25067
+ curWin++;
25068
+ curWinSum += pnl;
25069
+ }
25070
+ else if (pnl < 0) {
25071
+ if (curWin > 0) {
25072
+ winStreakSums.push(curWinSum);
25073
+ curWin = 0;
25074
+ curWinSum = 0;
25075
+ }
25076
+ curLoss++;
25077
+ curLossSum += pnl;
25078
+ }
25079
+ else {
25080
+ if (curWin > 0) {
25081
+ winStreakSums.push(curWinSum);
25082
+ curWin = 0;
25083
+ curWinSum = 0;
25084
+ }
25085
+ if (curLoss > 0) {
25086
+ lossStreakSums.push(curLossSum);
25087
+ curLoss = 0;
25088
+ curLossSum = 0;
25089
+ }
25090
+ }
25091
+ }
25092
+ if (curWin > 0)
25093
+ winStreakSums.push(curWinSum);
25094
+ if (curLoss > 0)
25095
+ lossStreakSums.push(curLossSum);
25096
+ if (winStreakSums.length > 0) {
25097
+ avgConsecutiveWinPnl =
25098
+ winStreakSums.reduce((a, b) => a + b, 0) / winStreakSums.length;
25099
+ }
25100
+ if (lossStreakSums.length > 0) {
25101
+ avgConsecutiveLossPnl =
25102
+ lossStreakSums.reduce((a, b) => a + b, 0) / lossStreakSums.length;
25103
+ }
25104
+ }
24810
25105
  // Average only over signals that have the value — do not dilute the mean with zeros.
24811
25106
  // Use validClosed to keep all metric denominators consistent.
24812
25107
  const peakValues = validClosed
@@ -24932,6 +25227,12 @@ let ReportStorage$9 = class ReportStorage {
24932
25227
  calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
24933
25228
  recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
24934
25229
  expectancy: isUnsafe$3(expectancy) ? null : expectancy,
25230
+ avgDuration: isUnsafe$3(avgDuration) ? null : avgDuration,
25231
+ medianPnl: isUnsafe$3(medianPnl) ? null : medianPnl,
25232
+ avgConsecutiveWinPnl: isUnsafe$3(avgConsecutiveWinPnl) ? null : avgConsecutiveWinPnl,
25233
+ avgConsecutiveLossPnl: isUnsafe$3(avgConsecutiveLossPnl) ? null : avgConsecutiveLossPnl,
25234
+ avgWinDuration: isUnsafe$3(avgWinDuration) ? null : avgWinDuration,
25235
+ avgLossDuration: isUnsafe$3(avgLossDuration) ? null : avgLossDuration,
24935
25236
  };
24936
25237
  }
24937
25238
  /**
@@ -24982,6 +25283,12 @@ let ReportStorage$9 = class ReportStorage {
24982
25283
  `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
24983
25284
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
24984
25285
  `**Expectancy:** ${stats.expectancy === null ? "N/A" : `${stats.expectancy > 0 ? "+" : ""}${stats.expectancy.toFixed(3)}% (higher is better)`}`,
25286
+ `**Median PNL:** ${stats.medianPnl === null ? "N/A" : `${stats.medianPnl > 0 ? "+" : ""}${stats.medianPnl.toFixed(3)}% (closer to avgPnl = symmetric distribution)`}`,
25287
+ `**Avg Duration:** ${stats.avgDuration === null ? "N/A" : `${stats.avgDuration.toFixed(1)} min`}`,
25288
+ `**Avg Win Duration:** ${stats.avgWinDuration === null ? "N/A" : `${stats.avgWinDuration.toFixed(1)} min`}`,
25289
+ `**Avg Loss Duration:** ${stats.avgLossDuration === null ? "N/A" : `${stats.avgLossDuration.toFixed(1)} min`}`,
25290
+ `**Avg Consecutive Win PNL:** ${stats.avgConsecutiveWinPnl === null ? "N/A" : `${stats.avgConsecutiveWinPnl > 0 ? "+" : ""}${stats.avgConsecutiveWinPnl.toFixed(3)}% (higher is better)`}`,
25291
+ `**Avg Consecutive Loss PNL:** ${stats.avgConsecutiveLossPnl === null ? "N/A" : `${stats.avgConsecutiveLossPnl.toFixed(3)}% (closer to 0 is better)`}`,
24985
25292
  "",
24986
25293
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
24987
25294
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
@@ -26977,26 +27284,113 @@ class HeatmapStorage {
26977
27284
  .filter((s) => s.pnl.pnlPercentage < 0)
26978
27285
  .reduce((acc, s) => acc + s.pnl.pnlPercentage, 0) / lossCount;
26979
27286
  }
26980
- // Calculate Win/Loss Streaks
27287
+ // Calculate Win/Loss Streaks AND per-streak pnl sums.
27288
+ // A streak is a run of same-signed trades; break-even (pnl=0) ends both runs.
27289
+ // The sign sequence is invariant under reversal, so iterating signals (newest
27290
+ // first) gives the same streak boundaries as chronological order.
26981
27291
  let maxWinStreak = 0;
26982
27292
  let maxLossStreak = 0;
26983
27293
  let currentWinStreak = 0;
26984
27294
  let currentLossStreak = 0;
27295
+ let currentWinStreakSum = 0;
27296
+ let currentLossStreakSum = 0;
27297
+ const winStreakSums = [];
27298
+ const lossStreakSums = [];
26985
27299
  for (const signal of signals) {
26986
- if (signal.pnl.pnlPercentage > 0) {
27300
+ const pnl = signal.pnl.pnlPercentage;
27301
+ if (pnl > 0) {
27302
+ if (currentLossStreak > 0) {
27303
+ lossStreakSums.push(currentLossStreakSum);
27304
+ currentLossStreak = 0;
27305
+ currentLossStreakSum = 0;
27306
+ }
26987
27307
  currentWinStreak++;
26988
- currentLossStreak = 0;
27308
+ currentWinStreakSum += pnl;
26989
27309
  if (currentWinStreak > maxWinStreak) {
26990
27310
  maxWinStreak = currentWinStreak;
26991
27311
  }
26992
27312
  }
26993
- else if (signal.pnl.pnlPercentage < 0) {
27313
+ else if (pnl < 0) {
27314
+ if (currentWinStreak > 0) {
27315
+ winStreakSums.push(currentWinStreakSum);
27316
+ currentWinStreak = 0;
27317
+ currentWinStreakSum = 0;
27318
+ }
26994
27319
  currentLossStreak++;
26995
- currentWinStreak = 0;
27320
+ currentLossStreakSum += pnl;
26996
27321
  if (currentLossStreak > maxLossStreak) {
26997
27322
  maxLossStreak = currentLossStreak;
26998
27323
  }
26999
27324
  }
27325
+ else {
27326
+ // Break-even closes both runs (it's neither a win nor a loss).
27327
+ if (currentWinStreak > 0) {
27328
+ winStreakSums.push(currentWinStreakSum);
27329
+ currentWinStreak = 0;
27330
+ currentWinStreakSum = 0;
27331
+ }
27332
+ if (currentLossStreak > 0) {
27333
+ lossStreakSums.push(currentLossStreakSum);
27334
+ currentLossStreak = 0;
27335
+ currentLossStreakSum = 0;
27336
+ }
27337
+ }
27338
+ }
27339
+ // Flush trailing streak.
27340
+ if (currentWinStreak > 0)
27341
+ winStreakSums.push(currentWinStreakSum);
27342
+ if (currentLossStreak > 0)
27343
+ lossStreakSums.push(currentLossStreakSum);
27344
+ let avgConsecutiveWinPnl = winStreakSums.length > 0
27345
+ ? winStreakSums.reduce((a, b) => a + b, 0) / winStreakSums.length
27346
+ : null;
27347
+ let avgConsecutiveLossPnl = lossStreakSums.length > 0
27348
+ ? lossStreakSums.reduce((a, b) => a + b, 0) / lossStreakSums.length
27349
+ : null;
27350
+ // Trade duration metrics. Source: closeTimestamp - signal.pendingAt, in minutes
27351
+ // (synchronized with strategy `minuteEstimatedTime`). A signal missing either
27352
+ // timestamp is excluded from the corresponding average — silent zeros would
27353
+ // otherwise pull the mean towards zero.
27354
+ let avgDuration = null;
27355
+ let avgWinDuration = null;
27356
+ let avgLossDuration = null;
27357
+ {
27358
+ const durations = [];
27359
+ const winDurations = [];
27360
+ const lossDurations = [];
27361
+ for (const s of signals) {
27362
+ const pendingAt = s.signal.pendingAt;
27363
+ const closeTs = s.closeTimestamp;
27364
+ if (typeof pendingAt !== "number" || pendingAt <= 0)
27365
+ continue;
27366
+ if (typeof closeTs !== "number" || closeTs <= 0)
27367
+ continue;
27368
+ const minutes = (closeTs - pendingAt) / 60000;
27369
+ durations.push(minutes);
27370
+ const pnl = s.pnl.pnlPercentage;
27371
+ if (pnl > 0)
27372
+ winDurations.push(minutes);
27373
+ else if (pnl < 0)
27374
+ lossDurations.push(minutes);
27375
+ }
27376
+ if (durations.length > 0) {
27377
+ avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
27378
+ }
27379
+ if (winDurations.length > 0) {
27380
+ avgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
27381
+ }
27382
+ if (lossDurations.length > 0) {
27383
+ avgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
27384
+ }
27385
+ }
27386
+ // Median pnlPercentage — robust to outliers. Sort a copy (do not mutate signals).
27387
+ let medianPnl = null;
27388
+ if (signals.length > 0) {
27389
+ const sorted = signals.map((s) => s.pnl.pnlPercentage).sort((a, b) => a - b);
27390
+ const mid = sorted.length >> 1;
27391
+ medianPnl = sorted.length % 2 === 0
27392
+ ? (sorted[mid - 1] + sorted[mid]) / 2
27393
+ : sorted[mid];
27000
27394
  }
27001
27395
  // Expectancy — probabilities from observed win/loss counts (break-evens contribute 0).
27002
27396
  let expectancy = null;
@@ -27013,8 +27407,12 @@ class HeatmapStorage {
27013
27407
  expectancy = (lossCount / totalTrades) * avgLoss;
27014
27408
  }
27015
27409
  // Average only over signals that have the value — do not dilute the mean with zeros.
27410
+ // Extremes (peakProfitPnl / maxDrawdownPnl) are the best/worst observation
27411
+ // across all trades, surfacing tail behaviour the average hides.
27016
27412
  let avgPeakPnl = null;
27017
27413
  let avgFallPnl = null;
27414
+ let peakProfitPnl = null;
27415
+ let maxDrawdownPnl = null;
27018
27416
  if (signals.length > 0) {
27019
27417
  const peakValues = signals
27020
27418
  .map((s) => s.signal.peakProfit?.pnlPercentage)
@@ -27022,12 +27420,14 @@ class HeatmapStorage {
27022
27420
  const fallValues = signals
27023
27421
  .map((s) => s.signal.maxDrawdown?.pnlPercentage)
27024
27422
  .filter((v) => typeof v === "number");
27025
- avgPeakPnl = peakValues.length > 0
27026
- ? peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length
27027
- : null;
27028
- avgFallPnl = fallValues.length > 0
27029
- ? fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length
27030
- : null;
27423
+ if (peakValues.length > 0) {
27424
+ avgPeakPnl = peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length;
27425
+ peakProfitPnl = Math.max(...peakValues);
27426
+ }
27427
+ if (fallValues.length > 0) {
27428
+ avgFallPnl = fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length;
27429
+ maxDrawdownPnl = Math.min(...fallValues);
27430
+ }
27031
27431
  }
27032
27432
  // Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
27033
27433
  // downsideDev = √( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
@@ -27103,6 +27503,25 @@ class HeatmapStorage {
27103
27503
  recoveryFactor = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, rawRec));
27104
27504
  }
27105
27505
  }
27506
+ // Annualized Sharpe — sharpeRatio × √tradesPerYear. Both inputs already
27507
+ // carry their own gates (sharpeRatio: N>=MIN_SIGNALS_FOR_RATIOS + STDDEV_EPSILON;
27508
+ // tradesPerYear: N>=MIN_SIGNALS_FOR_ANNUALIZATION + span>=MIN_CALENDAR_SPAN_DAYS
27509
+ // + raw frequency under MAX_TRADES_PER_YEAR), so we just propagate nulls.
27510
+ let annualizedSharpeRatio = null;
27511
+ if (sharpeRatio !== null && tradesPerYear !== null && tradesPerYear > 0) {
27512
+ annualizedSharpeRatio = sharpeRatio * Math.sqrt(tradesPerYear);
27513
+ }
27514
+ // Certainty Ratio = avgWin / |avgLoss|. Same gating shape as Backtest/Live:
27515
+ // N >= MIN_SIGNALS_FOR_RATIOS, AND |avgLoss| above STDDEV_EPSILON (float-artifact
27516
+ // losses near zero would otherwise produce spurious astronomical values).
27517
+ let certaintyRatio = null;
27518
+ if (canComputeRatios &&
27519
+ avgWin !== null &&
27520
+ avgLoss !== null &&
27521
+ avgLoss < 0 &&
27522
+ Math.abs(avgLoss) > STDDEV_EPSILON) {
27523
+ certaintyRatio = avgWin / Math.abs(avgLoss);
27524
+ }
27106
27525
  // Apply safe math checks
27107
27526
  if (isUnsafe(winRate))
27108
27527
  winRate = null;
@@ -27114,6 +27533,14 @@ class HeatmapStorage {
27114
27533
  stdDev = null;
27115
27534
  if (isUnsafe(sharpeRatio))
27116
27535
  sharpeRatio = null;
27536
+ if (isUnsafe(annualizedSharpeRatio))
27537
+ annualizedSharpeRatio = null;
27538
+ if (isUnsafe(certaintyRatio))
27539
+ certaintyRatio = null;
27540
+ if (isUnsafe(expectedYearlyReturns))
27541
+ expectedYearlyReturns = null;
27542
+ if (isUnsafe(tradesPerYear))
27543
+ tradesPerYear = null;
27117
27544
  if (isUnsafe(maxDrawdown))
27118
27545
  maxDrawdown = null;
27119
27546
  if (isUnsafe(profitFactor))
@@ -27128,6 +27555,22 @@ class HeatmapStorage {
27128
27555
  avgPeakPnl = null;
27129
27556
  if (isUnsafe(avgFallPnl))
27130
27557
  avgFallPnl = null;
27558
+ if (isUnsafe(peakProfitPnl))
27559
+ peakProfitPnl = null;
27560
+ if (isUnsafe(maxDrawdownPnl))
27561
+ maxDrawdownPnl = null;
27562
+ if (isUnsafe(avgDuration))
27563
+ avgDuration = null;
27564
+ if (isUnsafe(medianPnl))
27565
+ medianPnl = null;
27566
+ if (isUnsafe(avgConsecutiveWinPnl))
27567
+ avgConsecutiveWinPnl = null;
27568
+ if (isUnsafe(avgConsecutiveLossPnl))
27569
+ avgConsecutiveLossPnl = null;
27570
+ if (isUnsafe(avgWinDuration))
27571
+ avgWinDuration = null;
27572
+ if (isUnsafe(avgLossDuration))
27573
+ avgLossDuration = null;
27131
27574
  if (isUnsafe(sortinoRatio))
27132
27575
  sortinoRatio = null;
27133
27576
  if (isUnsafe(calmarRatio))
@@ -27153,9 +27596,21 @@ class HeatmapStorage {
27153
27596
  expectancy,
27154
27597
  avgPeakPnl,
27155
27598
  avgFallPnl,
27599
+ peakProfitPnl,
27600
+ maxDrawdownPnl,
27601
+ avgDuration,
27602
+ medianPnl,
27603
+ avgConsecutiveWinPnl,
27604
+ avgConsecutiveLossPnl,
27605
+ avgWinDuration,
27606
+ avgLossDuration,
27156
27607
  sortinoRatio,
27157
27608
  calmarRatio,
27158
27609
  recoveryFactor,
27610
+ annualizedSharpeRatio,
27611
+ certaintyRatio,
27612
+ expectedYearlyReturns,
27613
+ tradesPerYear,
27159
27614
  };
27160
27615
  }
27161
27616
  /**
@@ -27217,6 +27672,10 @@ class HeatmapStorage {
27217
27672
  let portfolioExpectancy = null;
27218
27673
  let portfolioCalmarRatio = null;
27219
27674
  let portfolioRecoveryFactor = null;
27675
+ let portfolioAnnualizedSharpeRatio = null;
27676
+ let portfolioCertaintyRatio = null;
27677
+ let portfolioExpectedYearlyReturns = null;
27678
+ let portfolioTradesPerYear = null;
27220
27679
  const allReturns = [];
27221
27680
  // Parallel array of intra-trade troughs (≤ 0), aligned 1:1 with allReturns,
27222
27681
  // used for mark-to-market DD in the pooled equity curve below.
@@ -27263,6 +27722,12 @@ class HeatmapStorage {
27263
27722
  if (wins.length > 0 || losses.length > 0) {
27264
27723
  portfolioExpectancy = (wins.length / total) * avgWin + (losses.length / total) * avgLoss;
27265
27724
  }
27725
+ // Pooled Certainty Ratio = pooledAvgWin / |pooledAvgLoss|. Same STDDEV_EPSILON
27726
+ // guard as per-symbol — protects against float-artifact losses producing
27727
+ // spuriously astronomical values.
27728
+ if (losses.length > 0 && Math.abs(avgLoss) > STDDEV_EPSILON && avgLoss < 0) {
27729
+ portfolioCertaintyRatio = avgWin / Math.abs(avgLoss);
27730
+ }
27266
27731
  // Pooled equity-curve max drawdown (compounded). MARK-TO-MARKET: each trade's
27267
27732
  // intra-trade trough (allFalls, ≤ 0) is applied before booking the realized close,
27268
27733
  // so deep round-trip dips are captured rather than understating DD.
@@ -27301,30 +27766,38 @@ class HeatmapStorage {
27301
27766
  // calendar span (≥ MIN_CALENDAR_SPAN_DAYS) and a non-clustered trade
27302
27767
  // frequency (≤ MAX_TRADES_PER_YEAR). Above MAX_EXPECTED_YEARLY_RETURNS → null
27303
27768
  // (don't surface the cap as a real figure). This is the numerator for Calmar.
27304
- let pooledExpectedYearlyReturns = null;
27305
27769
  const poolSpanDays = isFinite(poolFirstPendingAt) && isFinite(poolLastCloseAt)
27306
27770
  ? (poolLastCloseAt - poolFirstPendingAt) / (1000 * 60 * 60 * 24)
27307
27771
  : 0;
27308
27772
  if (poolSpanDays >= MIN_CALENDAR_SPAN_DAYS) {
27309
27773
  const rawTradesPerYear = (allReturns.length / poolSpanDays) * 365;
27310
27774
  if (rawTradesPerYear <= MAX_TRADES_PER_YEAR) {
27775
+ portfolioTradesPerYear = rawTradesPerYear;
27311
27776
  if (blown) {
27312
- pooledExpectedYearlyReturns = -100;
27777
+ portfolioExpectedYearlyReturns = -100;
27313
27778
  }
27314
27779
  else {
27315
27780
  const raw = (Math.pow(equityFinal, rawTradesPerYear / allReturns.length) - 1) * 100;
27316
- pooledExpectedYearlyReturns =
27781
+ portfolioExpectedYearlyReturns =
27317
27782
  Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS ? null : raw;
27318
27783
  }
27319
27784
  }
27320
27785
  }
27786
+ // Pooled Annualized Sharpe — pooledSharpe × √pooledTradesPerYear. Both
27787
+ // gates already enforced upstream; just propagate nulls.
27788
+ if (portfolioSharpeRatio !== null &&
27789
+ portfolioTradesPerYear !== null &&
27790
+ portfolioTradesPerYear > 0) {
27791
+ portfolioAnnualizedSharpeRatio =
27792
+ portfolioSharpeRatio * Math.sqrt(portfolioTradesPerYear);
27793
+ }
27321
27794
  // Pooled Calmar = annualized return / max drawdown — same formula and
27322
27795
  // gating as per-symbol Calmar. NULL when the annualized numerator is
27323
27796
  // unavailable (span/frequency gate, or over the yearly cap). This is what
27324
27797
  // distinguishes it from Recovery, which uses the compounded TOTAL return —
27325
27798
  // 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));
27799
+ if (maxDD > 0 && portfolioExpectedYearlyReturns !== null) {
27800
+ portfolioCalmarRatio = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, portfolioExpectedYearlyReturns / maxDD));
27328
27801
  }
27329
27802
  // Pooled Recovery Factor = compounded TOTAL return / max drawdown, clamped.
27330
27803
  // Time-independent (no annualization), so it needs no span gate — only a
@@ -27349,6 +27822,91 @@ class HeatmapStorage {
27349
27822
  if (validFall.length > 0 && fallTradesTotal > 0) {
27350
27823
  portfolioAvgFallPnl = validFall.reduce((acc, s) => acc + s.avgFallPnl * s.totalTrades, 0) / fallTradesTotal;
27351
27824
  }
27825
+ // Portfolio-wide extremes: best best-case and worst worst-case across
27826
+ // every per-symbol extreme. Skips symbols whose extreme is null (no
27827
+ // peakProfit/maxDrawdown snapshots) — they cannot vote in either direction.
27828
+ let portfolioPeakProfitPnl = null;
27829
+ let portfolioMaxDrawdownPnl = null;
27830
+ const peakExtremes = symbols
27831
+ .map((s) => s.peakProfitPnl)
27832
+ .filter((v) => typeof v === "number");
27833
+ const fallExtremes = symbols
27834
+ .map((s) => s.maxDrawdownPnl)
27835
+ .filter((v) => typeof v === "number");
27836
+ if (peakExtremes.length > 0) {
27837
+ portfolioPeakProfitPnl = Math.max(...peakExtremes);
27838
+ }
27839
+ if (fallExtremes.length > 0) {
27840
+ portfolioMaxDrawdownPnl = Math.min(...fallExtremes);
27841
+ }
27842
+ // Portfolio duration metrics — pooled means over every trade with valid
27843
+ // timestamps, regardless of symbol. A signal missing pendingAt/closeTimestamp
27844
+ // is excluded from its average (the same rule as per-symbol).
27845
+ let portfolioAvgDuration = null;
27846
+ let portfolioAvgWinDuration = null;
27847
+ let portfolioAvgLossDuration = null;
27848
+ {
27849
+ const durations = [];
27850
+ const winDurations = [];
27851
+ const lossDurations = [];
27852
+ for (const signals of this.symbolData.values()) {
27853
+ for (const s of signals) {
27854
+ const pendingAt = s.signal.pendingAt;
27855
+ const closeTs = s.closeTimestamp;
27856
+ if (typeof pendingAt !== "number" || pendingAt <= 0)
27857
+ continue;
27858
+ if (typeof closeTs !== "number" || closeTs <= 0)
27859
+ continue;
27860
+ const minutes = (closeTs - pendingAt) / 60000;
27861
+ durations.push(minutes);
27862
+ const pnl = s.pnl.pnlPercentage;
27863
+ if (pnl > 0)
27864
+ winDurations.push(minutes);
27865
+ else if (pnl < 0)
27866
+ lossDurations.push(minutes);
27867
+ }
27868
+ }
27869
+ if (durations.length > 0) {
27870
+ portfolioAvgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
27871
+ }
27872
+ if (winDurations.length > 0) {
27873
+ portfolioAvgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
27874
+ }
27875
+ if (lossDurations.length > 0) {
27876
+ portfolioAvgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
27877
+ }
27878
+ }
27879
+ // Portfolio median — pooled over allReturns (already collected for the
27880
+ // Sharpe block). Robust to outliers like the per-symbol counterpart.
27881
+ let portfolioMedianPnl = null;
27882
+ if (allReturns.length > 0) {
27883
+ const sortedAll = allReturns.slice().sort((a, b) => a - b);
27884
+ const mid = sortedAll.length >> 1;
27885
+ portfolioMedianPnl = sortedAll.length % 2 === 0
27886
+ ? (sortedAll[mid - 1] + sortedAll[mid]) / 2
27887
+ : sortedAll[mid];
27888
+ }
27889
+ // Portfolio streak averages — trade-count-weighted mean of per-symbol
27890
+ // averages. Concatenating streaks across symbols would be wrong: trades on
27891
+ // different symbols are not "consecutive" in any meaningful sense (different
27892
+ // markets, different timeframes). Weighting by totalTrades matches the
27893
+ // weighting used for portfolioAvgPeakPnl / portfolioAvgFallPnl.
27894
+ let portfolioAvgConsecutiveWinPnl = null;
27895
+ let portfolioAvgConsecutiveLossPnl = null;
27896
+ const validWinStreak = symbols.filter((s) => s.avgConsecutiveWinPnl !== null);
27897
+ const validLossStreak = symbols.filter((s) => s.avgConsecutiveLossPnl !== null);
27898
+ const winStreakWeight = validWinStreak.reduce((acc, s) => acc + s.totalTrades, 0);
27899
+ const lossStreakWeight = validLossStreak.reduce((acc, s) => acc + s.totalTrades, 0);
27900
+ if (validWinStreak.length > 0 && winStreakWeight > 0) {
27901
+ portfolioAvgConsecutiveWinPnl =
27902
+ validWinStreak.reduce((acc, s) => acc + s.avgConsecutiveWinPnl * s.totalTrades, 0) /
27903
+ winStreakWeight;
27904
+ }
27905
+ if (validLossStreak.length > 0 && lossStreakWeight > 0) {
27906
+ portfolioAvgConsecutiveLossPnl =
27907
+ validLossStreak.reduce((acc, s) => acc + s.avgConsecutiveLossPnl * s.totalTrades, 0) /
27908
+ lossStreakWeight;
27909
+ }
27352
27910
  // Apply safe math
27353
27911
  if (isUnsafe(portfolioTotalPnl))
27354
27912
  portfolioTotalPnl = null;
@@ -27358,6 +27916,10 @@ class HeatmapStorage {
27358
27916
  portfolioAvgPeakPnl = null;
27359
27917
  if (isUnsafe(portfolioAvgFallPnl))
27360
27918
  portfolioAvgFallPnl = null;
27919
+ if (isUnsafe(portfolioPeakProfitPnl))
27920
+ portfolioPeakProfitPnl = null;
27921
+ if (isUnsafe(portfolioMaxDrawdownPnl))
27922
+ portfolioMaxDrawdownPnl = null;
27361
27923
  if (isUnsafe(portfolioStdDev))
27362
27924
  portfolioStdDev = null;
27363
27925
  if (isUnsafe(portfolioSortinoRatio))
@@ -27368,6 +27930,26 @@ class HeatmapStorage {
27368
27930
  portfolioRecoveryFactor = null;
27369
27931
  if (isUnsafe(portfolioExpectancy))
27370
27932
  portfolioExpectancy = null;
27933
+ if (isUnsafe(portfolioAvgDuration))
27934
+ portfolioAvgDuration = null;
27935
+ if (isUnsafe(portfolioMedianPnl))
27936
+ portfolioMedianPnl = null;
27937
+ if (isUnsafe(portfolioAvgConsecutiveWinPnl))
27938
+ portfolioAvgConsecutiveWinPnl = null;
27939
+ if (isUnsafe(portfolioAvgConsecutiveLossPnl))
27940
+ portfolioAvgConsecutiveLossPnl = null;
27941
+ if (isUnsafe(portfolioAvgWinDuration))
27942
+ portfolioAvgWinDuration = null;
27943
+ if (isUnsafe(portfolioAvgLossDuration))
27944
+ portfolioAvgLossDuration = null;
27945
+ if (isUnsafe(portfolioAnnualizedSharpeRatio))
27946
+ portfolioAnnualizedSharpeRatio = null;
27947
+ if (isUnsafe(portfolioCertaintyRatio))
27948
+ portfolioCertaintyRatio = null;
27949
+ if (isUnsafe(portfolioExpectedYearlyReturns))
27950
+ portfolioExpectedYearlyReturns = null;
27951
+ if (isUnsafe(portfolioTradesPerYear))
27952
+ portfolioTradesPerYear = null;
27371
27953
  return {
27372
27954
  symbols,
27373
27955
  totalSymbols,
@@ -27376,11 +27958,23 @@ class HeatmapStorage {
27376
27958
  portfolioTotalTrades,
27377
27959
  portfolioAvgPeakPnl,
27378
27960
  portfolioAvgFallPnl,
27961
+ portfolioPeakProfitPnl,
27962
+ portfolioMaxDrawdownPnl,
27379
27963
  portfolioStdDev,
27380
27964
  portfolioSortinoRatio,
27381
27965
  portfolioCalmarRatio,
27382
27966
  portfolioRecoveryFactor,
27383
27967
  portfolioExpectancy,
27968
+ portfolioAvgDuration,
27969
+ portfolioMedianPnl,
27970
+ portfolioAvgConsecutiveWinPnl,
27971
+ portfolioAvgConsecutiveLossPnl,
27972
+ portfolioAvgWinDuration,
27973
+ portfolioAvgLossDuration,
27974
+ portfolioAnnualizedSharpeRatio,
27975
+ portfolioCertaintyRatio,
27976
+ portfolioExpectedYearlyReturns,
27977
+ portfolioTradesPerYear,
27384
27978
  };
27385
27979
  }
27386
27980
  /**
@@ -27429,23 +28023,53 @@ class HeatmapStorage {
27429
28023
  return [
27430
28024
  `# Portfolio Heatmap: ${strategyName}`,
27431
28025
  "",
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"}`,
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"}`,
28026
+ `**Total Symbols:** ${data.totalSymbols}`,
28027
+ `**Portfolio PNL:** ${data.portfolioTotalPnl !== null ? functoolsKit.str(data.portfolioTotalPnl, "%") : "N/A"}`,
28028
+ `**Pooled Sharpe:** ${data.portfolioSharpeRatio !== null ? functoolsKit.str(data.portfolioSharpeRatio) : "N/A"}`,
28029
+ `**Annualized Sharpe:** ${data.portfolioAnnualizedSharpeRatio !== null ? functoolsKit.str(data.portfolioAnnualizedSharpeRatio) : "N/A"}`,
28030
+ `**Certainty Ratio:** ${data.portfolioCertaintyRatio !== null ? functoolsKit.str(data.portfolioCertaintyRatio) : "N/A"}`,
28031
+ `**Expected Yearly Returns:** ${data.portfolioExpectedYearlyReturns !== null ? functoolsKit.str(data.portfolioExpectedYearlyReturns, "%") : "N/A"}`,
28032
+ `**Trades Per Year:** ${data.portfolioTradesPerYear !== null ? data.portfolioTradesPerYear.toFixed(1) : "N/A"}`,
28033
+ `**Total Trades:** ${data.portfolioTotalTrades}`,
28034
+ `**Avg Peak PNL:** ${data.portfolioAvgPeakPnl !== null ? functoolsKit.str(data.portfolioAvgPeakPnl, "%") : "N/A"}`,
28035
+ `**Avg Max Drawdown PNL:** ${data.portfolioAvgFallPnl !== null ? functoolsKit.str(data.portfolioAvgFallPnl, "%") : "N/A"}`,
28036
+ `**Peak Profit PNL:** ${data.portfolioPeakProfitPnl !== null ? functoolsKit.str(data.portfolioPeakProfitPnl, "%") : "N/A"}`,
28037
+ `**Max Drawdown PNL:** ${data.portfolioMaxDrawdownPnl !== null ? functoolsKit.str(data.portfolioMaxDrawdownPnl, "%") : "N/A"}`,
28038
+ `**Median PNL:** ${data.portfolioMedianPnl !== null ? functoolsKit.str(data.portfolioMedianPnl, "%") : "N/A"}`,
28039
+ `**Avg Duration:** ${data.portfolioAvgDuration !== null ? `${data.portfolioAvgDuration.toFixed(1)} min` : "N/A"}`,
28040
+ `**Avg Win Duration:** ${data.portfolioAvgWinDuration !== null ? `${data.portfolioAvgWinDuration.toFixed(1)} min` : "N/A"}`,
28041
+ `**Avg Loss Duration:** ${data.portfolioAvgLossDuration !== null ? `${data.portfolioAvgLossDuration.toFixed(1)} min` : "N/A"}`,
28042
+ `**Avg Consecutive Win PNL:** ${data.portfolioAvgConsecutiveWinPnl !== null ? functoolsKit.str(data.portfolioAvgConsecutiveWinPnl, "%") : "N/A"}`,
28043
+ `**Avg Consecutive Loss PNL:** ${data.portfolioAvgConsecutiveLossPnl !== null ? functoolsKit.str(data.portfolioAvgConsecutiveLossPnl, "%") : "N/A"}`,
28044
+ `**Standard Deviation Per Trade:** ${data.portfolioStdDev !== null ? functoolsKit.str(data.portfolioStdDev, "%") : "N/A"}`,
28045
+ `**Sortino Ratio:** ${data.portfolioSortinoRatio !== null ? functoolsKit.str(data.portfolioSortinoRatio) : "N/A"}`,
28046
+ `**Calmar Ratio:** ${data.portfolioCalmarRatio !== null ? functoolsKit.str(data.portfolioCalmarRatio) : "N/A"}`,
28047
+ `**Recovery Factor:** ${data.portfolioRecoveryFactor !== null ? functoolsKit.str(data.portfolioRecoveryFactor) : "N/A"}`,
28048
+ `**Expectancy:** ${data.portfolioExpectancy !== null ? functoolsKit.str(data.portfolioExpectancy, "%") : "N/A"}`,
27434
28049
  "",
27435
28050
  table,
27436
28051
  "",
27437
28052
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
27438
28053
  `*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.*`,
28054
+ `*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.*`,
28055
+ `*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.*`,
28056
+ `*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.*`,
28057
+ `*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).*`,
27439
28058
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals per symbol.*`,
27440
28059
  `*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".*`,
27441
- `*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
27442
28060
  `*Profit Factor: below 1.0 means strategy is losing overall. Above 1.5 is considered good.*`,
27443
28061
  `*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
28062
  `*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.*`,
28063
+ `*Expectancy: per-trade expected value (winProb × avgWin + lossProb × avgLoss). Positive = profitable on average per trade. Break-even trades contribute 0.*`,
28064
+ `*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).*`,
28065
+ `*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.*`,
28066
+ `*Peak Profit PNL / Max Drawdown PNL: extremes — the best best-case and worst worst-case observed across all trades. Tail behaviour the averages hide.*`,
28067
+ `*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").*`,
28068
+ `*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).*`,
27445
28069
  `*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.*`,
27446
28070
  `*All metrics require 100+ signals per symbol to be statistically reliable. Annualized metrics assume the observed trading frequency persists year-round.*`,
27447
28071
  `*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.*`,
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.*`,
28072
+ `*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.*`,
27449
28073
  ].join("\n");
27450
28074
  }
27451
28075
  /**