backtest-kit 11.5.2 → 11.7.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
@@ -23874,13 +23958,19 @@ let ReportStorage$a = class ReportStorage {
23874
23958
  // mark-to-market low); equity then moves to the realized close.
23875
23959
  // If equity (at trough or close) goes ≤ 0 (e.g. leveraged loss < -100%) — account
23876
23960
  // blown, fix DD at 100% and stop walking the curve.
23961
+ // Walk the equity curve in chronological close order. Storage is
23962
+ // newest-first (unshift on addSignal); reverse-storage iteration normally
23963
+ // gives chronological order, but explicitly sorting by closeTimestamp
23964
+ // removes the dependency on insertion-order matching close-order (which
23965
+ // can break under crash recovery, signal backfill, or disk replays).
23966
+ const orderedSignals = [...validSignals].sort((a, b) => a.closeTimestamp - b.closeTimestamp);
23877
23967
  let equity = 1;
23878
23968
  let peak = 1;
23879
23969
  let equityMaxDrawdown = 0;
23880
23970
  let blown = false;
23881
- for (let i = validSignals.length - 1; i >= 0; i--) {
23971
+ for (const s of orderedSignals) {
23882
23972
  // Intra-trade trough — mark-to-market low while the position was open.
23883
- const fallPct = validSignals[i].signal.maxDrawdown?.pnlPercentage;
23973
+ const fallPct = s.signal.maxDrawdown?.pnlPercentage;
23884
23974
  if (typeof fallPct === "number" && fallPct < 0) {
23885
23975
  const trough = equity * (1 + fallPct / 100);
23886
23976
  if (trough <= 0) {
@@ -23893,7 +23983,7 @@ let ReportStorage$a = class ReportStorage {
23893
23983
  equityMaxDrawdown = troughDd;
23894
23984
  }
23895
23985
  // Realized close — book the final per-trade result.
23896
- equity *= 1 + validSignals[i].pnl.pnlPercentage / 100;
23986
+ equity *= 1 + s.pnl.pnlPercentage / 100;
23897
23987
  if (equity <= 0) {
23898
23988
  equityMaxDrawdown = 100;
23899
23989
  blown = true;
@@ -23946,6 +24036,101 @@ let ReportStorage$a = class ReportStorage {
23946
24036
  const expectancy = canComputeRatios && totalSignals > 0
23947
24037
  ? (wins.length / totalSignals) * avgWin + (losses.length / totalSignals) * avgLoss
23948
24038
  : null;
24039
+ // Median pnlPercentage — robust to outliers; reveals skew when avgPnl is
24040
+ // dragged by a whale trade. Sort a copy (do not mutate validSignals).
24041
+ let medianPnl = null;
24042
+ if (returns.length > 0) {
24043
+ const sortedReturns = returns.slice().sort((a, b) => a - b);
24044
+ const mid = sortedReturns.length >> 1;
24045
+ medianPnl = sortedReturns.length % 2 === 0
24046
+ ? (sortedReturns[mid - 1] + sortedReturns[mid]) / 2
24047
+ : sortedReturns[mid];
24048
+ }
24049
+ // Trade duration metrics in minutes (synchronized with strategy
24050
+ // `minuteEstimatedTime`). validSignals already requires pendingAt > 0 and
24051
+ // closeTimestamp > 0, so every signal here contributes a valid duration.
24052
+ let avgDuration = null;
24053
+ let avgWinDuration = null;
24054
+ let avgLossDuration = null;
24055
+ if (totalSignals > 0) {
24056
+ const durations = [];
24057
+ const winDurations = [];
24058
+ const lossDurations = [];
24059
+ for (const s of validSignals) {
24060
+ const minutes = (s.closeTimestamp - s.signal.pendingAt) / 60000;
24061
+ durations.push(minutes);
24062
+ const pnl = s.pnl.pnlPercentage;
24063
+ if (pnl > 0)
24064
+ winDurations.push(minutes);
24065
+ else if (pnl < 0)
24066
+ lossDurations.push(minutes);
24067
+ }
24068
+ avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
24069
+ if (winDurations.length > 0) {
24070
+ avgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
24071
+ }
24072
+ if (lossDurations.length > 0) {
24073
+ avgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
24074
+ }
24075
+ }
24076
+ // Consecutive streak averages: sum the per-streak pnl, then mean across
24077
+ // streaks. Storage is newest-first, so iterate in reverse for chronological
24078
+ // streaks. Break-even (pnl=0) closes both runs (neither a win nor a loss).
24079
+ let avgConsecutiveWinPnl = null;
24080
+ let avgConsecutiveLossPnl = null;
24081
+ {
24082
+ const winStreakSums = [];
24083
+ const lossStreakSums = [];
24084
+ let curWin = 0;
24085
+ let curLoss = 0;
24086
+ let curWinSum = 0;
24087
+ let curLossSum = 0;
24088
+ for (let i = validSignals.length - 1; i >= 0; i--) {
24089
+ const pnl = validSignals[i].pnl.pnlPercentage;
24090
+ if (pnl > 0) {
24091
+ if (curLoss > 0) {
24092
+ lossStreakSums.push(curLossSum);
24093
+ curLoss = 0;
24094
+ curLossSum = 0;
24095
+ }
24096
+ curWin++;
24097
+ curWinSum += pnl;
24098
+ }
24099
+ else if (pnl < 0) {
24100
+ if (curWin > 0) {
24101
+ winStreakSums.push(curWinSum);
24102
+ curWin = 0;
24103
+ curWinSum = 0;
24104
+ }
24105
+ curLoss++;
24106
+ curLossSum += pnl;
24107
+ }
24108
+ else {
24109
+ if (curWin > 0) {
24110
+ winStreakSums.push(curWinSum);
24111
+ curWin = 0;
24112
+ curWinSum = 0;
24113
+ }
24114
+ if (curLoss > 0) {
24115
+ lossStreakSums.push(curLossSum);
24116
+ curLoss = 0;
24117
+ curLossSum = 0;
24118
+ }
24119
+ }
24120
+ }
24121
+ if (curWin > 0)
24122
+ winStreakSums.push(curWinSum);
24123
+ if (curLoss > 0)
24124
+ lossStreakSums.push(curLossSum);
24125
+ if (winStreakSums.length > 0) {
24126
+ avgConsecutiveWinPnl =
24127
+ winStreakSums.reduce((a, b) => a + b, 0) / winStreakSums.length;
24128
+ }
24129
+ if (lossStreakSums.length > 0) {
24130
+ avgConsecutiveLossPnl =
24131
+ lossStreakSums.reduce((a, b) => a + b, 0) / lossStreakSums.length;
24132
+ }
24133
+ }
23949
24134
  // Average peak/fall PNL — over validSignals; only signals that actually have the
23950
24135
  // value contribute (no zero dilution from missing peakProfit/maxDrawdown).
23951
24136
  const peakValues = validSignals
@@ -24011,6 +24196,12 @@ let ReportStorage$a = class ReportStorage {
24011
24196
  calmarRatio: isUnsafe$4(calmarRatio) ? null : calmarRatio,
24012
24197
  recoveryFactor: isUnsafe$4(recoveryFactor) ? null : recoveryFactor,
24013
24198
  expectancy: isUnsafe$4(expectancy) ? null : expectancy,
24199
+ avgDuration: isUnsafe$4(avgDuration) ? null : avgDuration,
24200
+ medianPnl: isUnsafe$4(medianPnl) ? null : medianPnl,
24201
+ avgConsecutiveWinPnl: isUnsafe$4(avgConsecutiveWinPnl) ? null : avgConsecutiveWinPnl,
24202
+ avgConsecutiveLossPnl: isUnsafe$4(avgConsecutiveLossPnl) ? null : avgConsecutiveLossPnl,
24203
+ avgWinDuration: isUnsafe$4(avgWinDuration) ? null : avgWinDuration,
24204
+ avgLossDuration: isUnsafe$4(avgLossDuration) ? null : avgLossDuration,
24014
24205
  };
24015
24206
  }
24016
24207
  /**
@@ -24061,6 +24252,12 @@ let ReportStorage$a = class ReportStorage {
24061
24252
  `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
24062
24253
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
24063
24254
  `**Expectancy:** ${stats.expectancy === null ? "N/A" : `${stats.expectancy > 0 ? "+" : ""}${stats.expectancy.toFixed(3)}% (higher is better)`}`,
24255
+ `**Median PNL:** ${stats.medianPnl === null ? "N/A" : `${stats.medianPnl > 0 ? "+" : ""}${stats.medianPnl.toFixed(3)}% (closer to avgPnl = symmetric distribution)`}`,
24256
+ `**Avg Duration:** ${stats.avgDuration === null ? "N/A" : `${stats.avgDuration.toFixed(1)} min`}`,
24257
+ `**Avg Win Duration:** ${stats.avgWinDuration === null ? "N/A" : `${stats.avgWinDuration.toFixed(1)} min`}`,
24258
+ `**Avg Loss Duration:** ${stats.avgLossDuration === null ? "N/A" : `${stats.avgLossDuration.toFixed(1)} min`}`,
24259
+ `**Avg Consecutive Win PNL:** ${stats.avgConsecutiveWinPnl === null ? "N/A" : `${stats.avgConsecutiveWinPnl > 0 ? "+" : ""}${stats.avgConsecutiveWinPnl.toFixed(3)}% (higher is better)`}`,
24260
+ `**Avg Consecutive Loss PNL:** ${stats.avgConsecutiveLossPnl === null ? "N/A" : `${stats.avgConsecutiveLossPnl.toFixed(3)}% (closer to 0 is better)`}`,
24064
24261
  "",
24065
24262
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
24066
24263
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
@@ -24698,6 +24895,12 @@ let ReportStorage$9 = class ReportStorage {
24698
24895
  calmarRatio: null,
24699
24896
  recoveryFactor: null,
24700
24897
  expectancy: null,
24898
+ avgDuration: null,
24899
+ medianPnl: null,
24900
+ avgConsecutiveWinPnl: null,
24901
+ avgConsecutiveLossPnl: null,
24902
+ avgWinDuration: null,
24903
+ avgLossDuration: null,
24701
24904
  };
24702
24905
  }
24703
24906
  const closedEvents = this._eventList.filter((e) => e.action === "closed");
@@ -24787,6 +24990,104 @@ let ReportStorage$9 = class ReportStorage {
24787
24990
  // trades contribute 0 (excluded from both probabilities).
24788
24991
  expectancy = (wins.length / totalClosed) * avgWin + (losses.length / totalClosed) * avgLoss;
24789
24992
  }
24993
+ // Median pnl — robust to outliers; reveals skew when avgPnl is dragged
24994
+ // by a whale trade. Sort a copy (do not mutate returns).
24995
+ let medianPnl = null;
24996
+ if (returns.length > 0) {
24997
+ const sortedReturns = returns.slice().sort((a, b) => a - b);
24998
+ const mid = sortedReturns.length >> 1;
24999
+ medianPnl = sortedReturns.length % 2 === 0
25000
+ ? (sortedReturns[mid - 1] + sortedReturns[mid]) / 2
25001
+ : sortedReturns[mid];
25002
+ }
25003
+ // Trade duration metrics in minutes (synchronized with strategy
25004
+ // `minuteEstimatedTime`). Source: e.timestamp (close) - (e.pendingAt ?? e.timestamp).
25005
+ // validClosed already guarantees e.timestamp > 0; if pendingAt is missing the
25006
+ // event contributes a 0-minute duration, matching the validation fallback.
25007
+ let avgDuration = null;
25008
+ let avgWinDuration = null;
25009
+ let avgLossDuration = null;
25010
+ if (totalClosed > 0) {
25011
+ const durations = [];
25012
+ const winDurations = [];
25013
+ const lossDurations = [];
25014
+ for (const e of validClosed) {
25015
+ const closeTs = e.timestamp;
25016
+ const openTs = e.pendingAt ?? e.timestamp;
25017
+ const minutes = (closeTs - openTs) / 60000;
25018
+ durations.push(minutes);
25019
+ const pnl = e.pnl;
25020
+ if (pnl > 0)
25021
+ winDurations.push(minutes);
25022
+ else if (pnl < 0)
25023
+ lossDurations.push(minutes);
25024
+ }
25025
+ avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
25026
+ if (winDurations.length > 0) {
25027
+ avgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
25028
+ }
25029
+ if (lossDurations.length > 0) {
25030
+ avgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
25031
+ }
25032
+ }
25033
+ // Consecutive streak averages: sum the per-streak pnl, then mean across
25034
+ // streaks. validClosed is newest-first (events unshifted), so iterate in
25035
+ // reverse for chronological streaks. Break-even (pnl=0) closes both runs.
25036
+ let avgConsecutiveWinPnl = null;
25037
+ let avgConsecutiveLossPnl = null;
25038
+ {
25039
+ const winStreakSums = [];
25040
+ const lossStreakSums = [];
25041
+ let curWin = 0;
25042
+ let curLoss = 0;
25043
+ let curWinSum = 0;
25044
+ let curLossSum = 0;
25045
+ for (let i = validClosed.length - 1; i >= 0; i--) {
25046
+ const pnl = validClosed[i].pnl;
25047
+ if (pnl > 0) {
25048
+ if (curLoss > 0) {
25049
+ lossStreakSums.push(curLossSum);
25050
+ curLoss = 0;
25051
+ curLossSum = 0;
25052
+ }
25053
+ curWin++;
25054
+ curWinSum += pnl;
25055
+ }
25056
+ else if (pnl < 0) {
25057
+ if (curWin > 0) {
25058
+ winStreakSums.push(curWinSum);
25059
+ curWin = 0;
25060
+ curWinSum = 0;
25061
+ }
25062
+ curLoss++;
25063
+ curLossSum += pnl;
25064
+ }
25065
+ else {
25066
+ if (curWin > 0) {
25067
+ winStreakSums.push(curWinSum);
25068
+ curWin = 0;
25069
+ curWinSum = 0;
25070
+ }
25071
+ if (curLoss > 0) {
25072
+ lossStreakSums.push(curLossSum);
25073
+ curLoss = 0;
25074
+ curLossSum = 0;
25075
+ }
25076
+ }
25077
+ }
25078
+ if (curWin > 0)
25079
+ winStreakSums.push(curWinSum);
25080
+ if (curLoss > 0)
25081
+ lossStreakSums.push(curLossSum);
25082
+ if (winStreakSums.length > 0) {
25083
+ avgConsecutiveWinPnl =
25084
+ winStreakSums.reduce((a, b) => a + b, 0) / winStreakSums.length;
25085
+ }
25086
+ if (lossStreakSums.length > 0) {
25087
+ avgConsecutiveLossPnl =
25088
+ lossStreakSums.reduce((a, b) => a + b, 0) / lossStreakSums.length;
25089
+ }
25090
+ }
24790
25091
  // Average only over signals that have the value — do not dilute the mean with zeros.
24791
25092
  // Use validClosed to keep all metric denominators consistent.
24792
25093
  const peakValues = validClosed
@@ -24827,14 +25128,20 @@ let ReportStorage$9 = class ReportStorage {
24827
25128
  // snapshot, ≤ 0) is applied as a trough BEFORE booking the realized close. Without it
24828
25129
  // the curve only steps at close, so a trade that dipped to -18% and recovered to +2%
24829
25130
  // would register zero drawdown — understating DD and inflating Calmar/Recovery.
24830
- const chronological = [];
24831
- for (let i = validClosed.length - 1; i >= 0; i--) {
24832
- const fall = validClosed[i].fallPnl;
24833
- chronological.push({
24834
- r: validClosed[i].pnl,
24835
- fall: typeof fall === "number" ? fall : null,
24836
- });
24837
- }
25131
+ // Walk the equity curve in chronological close order. Reverse-storage
25132
+ // iteration (newest-first storage reverse) normally yields chronological
25133
+ // order for live ingest, but explicitly sorting by event.timestamp removes
25134
+ // the dependency on insertion-order matching close-order. This matters
25135
+ // under crash recovery (events reloaded from disk in arbitrary order) and
25136
+ // when ingest latency reorders closed events relative to wall-clock time.
25137
+ const chronological = validClosed
25138
+ .map((e) => ({
25139
+ r: e.pnl,
25140
+ fall: typeof e.fallPnl === "number" ? e.fallPnl : null,
25141
+ ts: e.timestamp,
25142
+ }))
25143
+ .sort((a, b) => a.ts - b.ts)
25144
+ .map(({ r, fall }) => ({ r, fall }));
24838
25145
  let equity = 1;
24839
25146
  let peak = 1;
24840
25147
  let equityMaxDrawdown = 0;
@@ -24912,6 +25219,12 @@ let ReportStorage$9 = class ReportStorage {
24912
25219
  calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
24913
25220
  recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
24914
25221
  expectancy: isUnsafe$3(expectancy) ? null : expectancy,
25222
+ avgDuration: isUnsafe$3(avgDuration) ? null : avgDuration,
25223
+ medianPnl: isUnsafe$3(medianPnl) ? null : medianPnl,
25224
+ avgConsecutiveWinPnl: isUnsafe$3(avgConsecutiveWinPnl) ? null : avgConsecutiveWinPnl,
25225
+ avgConsecutiveLossPnl: isUnsafe$3(avgConsecutiveLossPnl) ? null : avgConsecutiveLossPnl,
25226
+ avgWinDuration: isUnsafe$3(avgWinDuration) ? null : avgWinDuration,
25227
+ avgLossDuration: isUnsafe$3(avgLossDuration) ? null : avgLossDuration,
24915
25228
  };
24916
25229
  }
24917
25230
  /**
@@ -24962,6 +25275,12 @@ let ReportStorage$9 = class ReportStorage {
24962
25275
  `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
24963
25276
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
24964
25277
  `**Expectancy:** ${stats.expectancy === null ? "N/A" : `${stats.expectancy > 0 ? "+" : ""}${stats.expectancy.toFixed(3)}% (higher is better)`}`,
25278
+ `**Median PNL:** ${stats.medianPnl === null ? "N/A" : `${stats.medianPnl > 0 ? "+" : ""}${stats.medianPnl.toFixed(3)}% (closer to avgPnl = symmetric distribution)`}`,
25279
+ `**Avg Duration:** ${stats.avgDuration === null ? "N/A" : `${stats.avgDuration.toFixed(1)} min`}`,
25280
+ `**Avg Win Duration:** ${stats.avgWinDuration === null ? "N/A" : `${stats.avgWinDuration.toFixed(1)} min`}`,
25281
+ `**Avg Loss Duration:** ${stats.avgLossDuration === null ? "N/A" : `${stats.avgLossDuration.toFixed(1)} min`}`,
25282
+ `**Avg Consecutive Win PNL:** ${stats.avgConsecutiveWinPnl === null ? "N/A" : `${stats.avgConsecutiveWinPnl > 0 ? "+" : ""}${stats.avgConsecutiveWinPnl.toFixed(3)}% (higher is better)`}`,
25283
+ `**Avg Consecutive Loss PNL:** ${stats.avgConsecutiveLossPnl === null ? "N/A" : `${stats.avgConsecutiveLossPnl.toFixed(3)}% (closer to 0 is better)`}`,
24965
25284
  "",
24966
25285
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
24967
25286
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
@@ -26896,11 +27215,18 @@ class HeatmapStorage {
26896
27215
  let equityFinal = 1;
26897
27216
  let blown = false;
26898
27217
  if (signals.length > 0) {
27218
+ // Walk the per-symbol equity curve in chronological close order.
27219
+ // Storage is newest-first (unshift on addSignal), but if signals were
27220
+ // ingested out-of-order (e.g. Live + crash recovery loading from disk in
27221
+ // arbitrary order, or a backfill replay), reverse-storage iteration
27222
+ // would misplace peak/trough and silently distort maxDrawdown. Sorting
27223
+ // by closeTimestamp explicitly removes that dependency.
27224
+ const ordered = [...signals].sort((a, b) => a.closeTimestamp - b.closeTimestamp);
26899
27225
  let equity = 1;
26900
27226
  let peak = 1;
26901
27227
  let maxDD = 0;
26902
- for (let i = signals.length - 1; i >= 0; i--) {
26903
- const fallPct = signals[i].signal.maxDrawdown?.pnlPercentage;
27228
+ for (const s of ordered) {
27229
+ const fallPct = s.signal.maxDrawdown?.pnlPercentage;
26904
27230
  if (typeof fallPct === "number" && fallPct < 0) {
26905
27231
  const trough = equity * (1 + fallPct / 100);
26906
27232
  if (trough <= 0) {
@@ -26912,7 +27238,7 @@ class HeatmapStorage {
26912
27238
  if (troughDd > maxDD)
26913
27239
  maxDD = troughDd;
26914
27240
  }
26915
- equity *= 1 + signals[i].pnl.pnlPercentage / 100;
27241
+ equity *= 1 + s.pnl.pnlPercentage / 100;
26916
27242
  if (equity <= 0) {
26917
27243
  maxDD = 100;
26918
27244
  blown = true;
@@ -26957,26 +27283,113 @@ class HeatmapStorage {
26957
27283
  .filter((s) => s.pnl.pnlPercentage < 0)
26958
27284
  .reduce((acc, s) => acc + s.pnl.pnlPercentage, 0) / lossCount;
26959
27285
  }
26960
- // Calculate Win/Loss Streaks
27286
+ // Calculate Win/Loss Streaks AND per-streak pnl sums.
27287
+ // A streak is a run of same-signed trades; break-even (pnl=0) ends both runs.
27288
+ // The sign sequence is invariant under reversal, so iterating signals (newest
27289
+ // first) gives the same streak boundaries as chronological order.
26961
27290
  let maxWinStreak = 0;
26962
27291
  let maxLossStreak = 0;
26963
27292
  let currentWinStreak = 0;
26964
27293
  let currentLossStreak = 0;
27294
+ let currentWinStreakSum = 0;
27295
+ let currentLossStreakSum = 0;
27296
+ const winStreakSums = [];
27297
+ const lossStreakSums = [];
26965
27298
  for (const signal of signals) {
26966
- if (signal.pnl.pnlPercentage > 0) {
27299
+ const pnl = signal.pnl.pnlPercentage;
27300
+ if (pnl > 0) {
27301
+ if (currentLossStreak > 0) {
27302
+ lossStreakSums.push(currentLossStreakSum);
27303
+ currentLossStreak = 0;
27304
+ currentLossStreakSum = 0;
27305
+ }
26967
27306
  currentWinStreak++;
26968
- currentLossStreak = 0;
27307
+ currentWinStreakSum += pnl;
26969
27308
  if (currentWinStreak > maxWinStreak) {
26970
27309
  maxWinStreak = currentWinStreak;
26971
27310
  }
26972
27311
  }
26973
- else if (signal.pnl.pnlPercentage < 0) {
27312
+ else if (pnl < 0) {
27313
+ if (currentWinStreak > 0) {
27314
+ winStreakSums.push(currentWinStreakSum);
27315
+ currentWinStreak = 0;
27316
+ currentWinStreakSum = 0;
27317
+ }
26974
27318
  currentLossStreak++;
26975
- currentWinStreak = 0;
27319
+ currentLossStreakSum += pnl;
26976
27320
  if (currentLossStreak > maxLossStreak) {
26977
27321
  maxLossStreak = currentLossStreak;
26978
27322
  }
26979
27323
  }
27324
+ else {
27325
+ // Break-even closes both runs (it's neither a win nor a loss).
27326
+ if (currentWinStreak > 0) {
27327
+ winStreakSums.push(currentWinStreakSum);
27328
+ currentWinStreak = 0;
27329
+ currentWinStreakSum = 0;
27330
+ }
27331
+ if (currentLossStreak > 0) {
27332
+ lossStreakSums.push(currentLossStreakSum);
27333
+ currentLossStreak = 0;
27334
+ currentLossStreakSum = 0;
27335
+ }
27336
+ }
27337
+ }
27338
+ // Flush trailing streak.
27339
+ if (currentWinStreak > 0)
27340
+ winStreakSums.push(currentWinStreakSum);
27341
+ if (currentLossStreak > 0)
27342
+ lossStreakSums.push(currentLossStreakSum);
27343
+ let avgConsecutiveWinPnl = winStreakSums.length > 0
27344
+ ? winStreakSums.reduce((a, b) => a + b, 0) / winStreakSums.length
27345
+ : null;
27346
+ let avgConsecutiveLossPnl = lossStreakSums.length > 0
27347
+ ? lossStreakSums.reduce((a, b) => a + b, 0) / lossStreakSums.length
27348
+ : null;
27349
+ // Trade duration metrics. Source: closeTimestamp - signal.pendingAt, in minutes
27350
+ // (synchronized with strategy `minuteEstimatedTime`). A signal missing either
27351
+ // timestamp is excluded from the corresponding average — silent zeros would
27352
+ // otherwise pull the mean towards zero.
27353
+ let avgDuration = null;
27354
+ let avgWinDuration = null;
27355
+ let avgLossDuration = null;
27356
+ {
27357
+ const durations = [];
27358
+ const winDurations = [];
27359
+ const lossDurations = [];
27360
+ for (const s of signals) {
27361
+ const pendingAt = s.signal.pendingAt;
27362
+ const closeTs = s.closeTimestamp;
27363
+ if (typeof pendingAt !== "number" || pendingAt <= 0)
27364
+ continue;
27365
+ if (typeof closeTs !== "number" || closeTs <= 0)
27366
+ continue;
27367
+ const minutes = (closeTs - pendingAt) / 60000;
27368
+ durations.push(minutes);
27369
+ const pnl = s.pnl.pnlPercentage;
27370
+ if (pnl > 0)
27371
+ winDurations.push(minutes);
27372
+ else if (pnl < 0)
27373
+ lossDurations.push(minutes);
27374
+ }
27375
+ if (durations.length > 0) {
27376
+ avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
27377
+ }
27378
+ if (winDurations.length > 0) {
27379
+ avgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
27380
+ }
27381
+ if (lossDurations.length > 0) {
27382
+ avgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
27383
+ }
27384
+ }
27385
+ // Median pnlPercentage — robust to outliers. Sort a copy (do not mutate signals).
27386
+ let medianPnl = null;
27387
+ if (signals.length > 0) {
27388
+ const sorted = signals.map((s) => s.pnl.pnlPercentage).sort((a, b) => a - b);
27389
+ const mid = sorted.length >> 1;
27390
+ medianPnl = sorted.length % 2 === 0
27391
+ ? (sorted[mid - 1] + sorted[mid]) / 2
27392
+ : sorted[mid];
26980
27393
  }
26981
27394
  // Expectancy — probabilities from observed win/loss counts (break-evens contribute 0).
26982
27395
  let expectancy = null;
@@ -26993,8 +27406,12 @@ class HeatmapStorage {
26993
27406
  expectancy = (lossCount / totalTrades) * avgLoss;
26994
27407
  }
26995
27408
  // Average only over signals that have the value — do not dilute the mean with zeros.
27409
+ // Extremes (peakProfitPnl / maxDrawdownPnl) are the best/worst observation
27410
+ // across all trades, surfacing tail behaviour the average hides.
26996
27411
  let avgPeakPnl = null;
26997
27412
  let avgFallPnl = null;
27413
+ let peakProfitPnl = null;
27414
+ let maxDrawdownPnl = null;
26998
27415
  if (signals.length > 0) {
26999
27416
  const peakValues = signals
27000
27417
  .map((s) => s.signal.peakProfit?.pnlPercentage)
@@ -27002,12 +27419,14 @@ class HeatmapStorage {
27002
27419
  const fallValues = signals
27003
27420
  .map((s) => s.signal.maxDrawdown?.pnlPercentage)
27004
27421
  .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;
27422
+ if (peakValues.length > 0) {
27423
+ avgPeakPnl = peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length;
27424
+ peakProfitPnl = Math.max(...peakValues);
27425
+ }
27426
+ if (fallValues.length > 0) {
27427
+ avgFallPnl = fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length;
27428
+ maxDrawdownPnl = Math.min(...fallValues);
27429
+ }
27011
27430
  }
27012
27431
  // Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
27013
27432
  // downsideDev = √( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
@@ -27083,6 +27502,25 @@ class HeatmapStorage {
27083
27502
  recoveryFactor = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, rawRec));
27084
27503
  }
27085
27504
  }
27505
+ // Annualized Sharpe — sharpeRatio × √tradesPerYear. Both inputs already
27506
+ // carry their own gates (sharpeRatio: N>=MIN_SIGNALS_FOR_RATIOS + STDDEV_EPSILON;
27507
+ // tradesPerYear: N>=MIN_SIGNALS_FOR_ANNUALIZATION + span>=MIN_CALENDAR_SPAN_DAYS
27508
+ // + raw frequency under MAX_TRADES_PER_YEAR), so we just propagate nulls.
27509
+ let annualizedSharpeRatio = null;
27510
+ if (sharpeRatio !== null && tradesPerYear !== null && tradesPerYear > 0) {
27511
+ annualizedSharpeRatio = sharpeRatio * Math.sqrt(tradesPerYear);
27512
+ }
27513
+ // Certainty Ratio = avgWin / |avgLoss|. Same gating shape as Backtest/Live:
27514
+ // N >= MIN_SIGNALS_FOR_RATIOS, AND |avgLoss| above STDDEV_EPSILON (float-artifact
27515
+ // losses near zero would otherwise produce spurious astronomical values).
27516
+ let certaintyRatio = null;
27517
+ if (canComputeRatios &&
27518
+ avgWin !== null &&
27519
+ avgLoss !== null &&
27520
+ avgLoss < 0 &&
27521
+ Math.abs(avgLoss) > STDDEV_EPSILON) {
27522
+ certaintyRatio = avgWin / Math.abs(avgLoss);
27523
+ }
27086
27524
  // Apply safe math checks
27087
27525
  if (isUnsafe(winRate))
27088
27526
  winRate = null;
@@ -27094,6 +27532,14 @@ class HeatmapStorage {
27094
27532
  stdDev = null;
27095
27533
  if (isUnsafe(sharpeRatio))
27096
27534
  sharpeRatio = null;
27535
+ if (isUnsafe(annualizedSharpeRatio))
27536
+ annualizedSharpeRatio = null;
27537
+ if (isUnsafe(certaintyRatio))
27538
+ certaintyRatio = null;
27539
+ if (isUnsafe(expectedYearlyReturns))
27540
+ expectedYearlyReturns = null;
27541
+ if (isUnsafe(tradesPerYear))
27542
+ tradesPerYear = null;
27097
27543
  if (isUnsafe(maxDrawdown))
27098
27544
  maxDrawdown = null;
27099
27545
  if (isUnsafe(profitFactor))
@@ -27108,6 +27554,22 @@ class HeatmapStorage {
27108
27554
  avgPeakPnl = null;
27109
27555
  if (isUnsafe(avgFallPnl))
27110
27556
  avgFallPnl = null;
27557
+ if (isUnsafe(peakProfitPnl))
27558
+ peakProfitPnl = null;
27559
+ if (isUnsafe(maxDrawdownPnl))
27560
+ maxDrawdownPnl = null;
27561
+ if (isUnsafe(avgDuration))
27562
+ avgDuration = null;
27563
+ if (isUnsafe(medianPnl))
27564
+ medianPnl = null;
27565
+ if (isUnsafe(avgConsecutiveWinPnl))
27566
+ avgConsecutiveWinPnl = null;
27567
+ if (isUnsafe(avgConsecutiveLossPnl))
27568
+ avgConsecutiveLossPnl = null;
27569
+ if (isUnsafe(avgWinDuration))
27570
+ avgWinDuration = null;
27571
+ if (isUnsafe(avgLossDuration))
27572
+ avgLossDuration = null;
27111
27573
  if (isUnsafe(sortinoRatio))
27112
27574
  sortinoRatio = null;
27113
27575
  if (isUnsafe(calmarRatio))
@@ -27133,9 +27595,21 @@ class HeatmapStorage {
27133
27595
  expectancy,
27134
27596
  avgPeakPnl,
27135
27597
  avgFallPnl,
27598
+ peakProfitPnl,
27599
+ maxDrawdownPnl,
27600
+ avgDuration,
27601
+ medianPnl,
27602
+ avgConsecutiveWinPnl,
27603
+ avgConsecutiveLossPnl,
27604
+ avgWinDuration,
27605
+ avgLossDuration,
27136
27606
  sortinoRatio,
27137
27607
  calmarRatio,
27138
27608
  recoveryFactor,
27609
+ annualizedSharpeRatio,
27610
+ certaintyRatio,
27611
+ expectedYearlyReturns,
27612
+ tradesPerYear,
27139
27613
  };
27140
27614
  }
27141
27615
  /**
@@ -27197,23 +27671,32 @@ class HeatmapStorage {
27197
27671
  let portfolioExpectancy = null;
27198
27672
  let portfolioCalmarRatio = null;
27199
27673
  let portfolioRecoveryFactor = null;
27200
- const allReturns = [];
27201
- // Parallel array of intra-trade troughs (≤ 0), aligned 1:1 with allReturns,
27202
- // used for mark-to-market DD in the pooled equity curve below.
27203
- const allFalls = [];
27674
+ let portfolioAnnualizedSharpeRatio = null;
27675
+ let portfolioCertaintyRatio = null;
27676
+ let portfolioExpectedYearlyReturns = null;
27677
+ let portfolioTradesPerYear = null;
27678
+ const pooledTrades = [];
27204
27679
  let poolFirstPendingAt = Infinity;
27205
27680
  let poolLastCloseAt = -Infinity;
27206
27681
  for (const signals of this.symbolData.values()) {
27207
27682
  for (const s of signals) {
27208
- allReturns.push(s.pnl.pnlPercentage);
27209
27683
  const fall = s.signal.maxDrawdown?.pnlPercentage;
27210
- allFalls.push(typeof fall === "number" ? fall : null);
27684
+ pooledTrades.push({
27685
+ r: s.pnl.pnlPercentage,
27686
+ fall: typeof fall === "number" ? fall : null,
27687
+ closeAt: s.closeTimestamp,
27688
+ });
27211
27689
  if (s.signal.pendingAt < poolFirstPendingAt)
27212
27690
  poolFirstPendingAt = s.signal.pendingAt;
27213
27691
  if (s.closeTimestamp > poolLastCloseAt)
27214
27692
  poolLastCloseAt = s.closeTimestamp;
27215
27693
  }
27216
27694
  }
27695
+ pooledTrades.sort((a, b) => a.closeAt - b.closeAt);
27696
+ const allReturns = pooledTrades.map((t) => t.r);
27697
+ // Parallel array of intra-trade troughs (≤ 0), aligned 1:1 with allReturns,
27698
+ // used for mark-to-market DD in the pooled equity curve below.
27699
+ const allFalls = pooledTrades.map((t) => t.fall);
27217
27700
  if (allReturns.length >= MIN_SIGNALS_FOR_RATIOS) {
27218
27701
  const portfolioAvg = allReturns.reduce((acc, r) => acc + r, 0) / allReturns.length;
27219
27702
  const portfolioVariance = allReturns.reduce((acc, r) => acc + Math.pow(r - portfolioAvg, 2), 0) /
@@ -27243,6 +27726,12 @@ class HeatmapStorage {
27243
27726
  if (wins.length > 0 || losses.length > 0) {
27244
27727
  portfolioExpectancy = (wins.length / total) * avgWin + (losses.length / total) * avgLoss;
27245
27728
  }
27729
+ // Pooled Certainty Ratio = pooledAvgWin / |pooledAvgLoss|. Same STDDEV_EPSILON
27730
+ // guard as per-symbol — protects against float-artifact losses producing
27731
+ // spuriously astronomical values.
27732
+ if (losses.length > 0 && Math.abs(avgLoss) > STDDEV_EPSILON && avgLoss < 0) {
27733
+ portfolioCertaintyRatio = avgWin / Math.abs(avgLoss);
27734
+ }
27246
27735
  // Pooled equity-curve max drawdown (compounded). MARK-TO-MARKET: each trade's
27247
27736
  // intra-trade trough (allFalls, ≤ 0) is applied before booking the realized close,
27248
27737
  // so deep round-trip dips are captured rather than understating DD.
@@ -27281,30 +27770,38 @@ class HeatmapStorage {
27281
27770
  // calendar span (≥ MIN_CALENDAR_SPAN_DAYS) and a non-clustered trade
27282
27771
  // frequency (≤ MAX_TRADES_PER_YEAR). Above MAX_EXPECTED_YEARLY_RETURNS → null
27283
27772
  // (don't surface the cap as a real figure). This is the numerator for Calmar.
27284
- let pooledExpectedYearlyReturns = null;
27285
27773
  const poolSpanDays = isFinite(poolFirstPendingAt) && isFinite(poolLastCloseAt)
27286
27774
  ? (poolLastCloseAt - poolFirstPendingAt) / (1000 * 60 * 60 * 24)
27287
27775
  : 0;
27288
27776
  if (poolSpanDays >= MIN_CALENDAR_SPAN_DAYS) {
27289
27777
  const rawTradesPerYear = (allReturns.length / poolSpanDays) * 365;
27290
27778
  if (rawTradesPerYear <= MAX_TRADES_PER_YEAR) {
27779
+ portfolioTradesPerYear = rawTradesPerYear;
27291
27780
  if (blown) {
27292
- pooledExpectedYearlyReturns = -100;
27781
+ portfolioExpectedYearlyReturns = -100;
27293
27782
  }
27294
27783
  else {
27295
27784
  const raw = (Math.pow(equityFinal, rawTradesPerYear / allReturns.length) - 1) * 100;
27296
- pooledExpectedYearlyReturns =
27785
+ portfolioExpectedYearlyReturns =
27297
27786
  Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS ? null : raw;
27298
27787
  }
27299
27788
  }
27300
27789
  }
27790
+ // Pooled Annualized Sharpe — pooledSharpe × √pooledTradesPerYear. Both
27791
+ // gates already enforced upstream; just propagate nulls.
27792
+ if (portfolioSharpeRatio !== null &&
27793
+ portfolioTradesPerYear !== null &&
27794
+ portfolioTradesPerYear > 0) {
27795
+ portfolioAnnualizedSharpeRatio =
27796
+ portfolioSharpeRatio * Math.sqrt(portfolioTradesPerYear);
27797
+ }
27301
27798
  // Pooled Calmar = annualized return / max drawdown — same formula and
27302
27799
  // gating as per-symbol Calmar. NULL when the annualized numerator is
27303
27800
  // unavailable (span/frequency gate, or over the yearly cap). This is what
27304
27801
  // distinguishes it from Recovery, which uses the compounded TOTAL return —
27305
27802
  // 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));
27803
+ if (maxDD > 0 && portfolioExpectedYearlyReturns !== null) {
27804
+ portfolioCalmarRatio = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, portfolioExpectedYearlyReturns / maxDD));
27308
27805
  }
27309
27806
  // Pooled Recovery Factor = compounded TOTAL return / max drawdown, clamped.
27310
27807
  // Time-independent (no annualization), so it needs no span gate — only a
@@ -27329,6 +27826,91 @@ class HeatmapStorage {
27329
27826
  if (validFall.length > 0 && fallTradesTotal > 0) {
27330
27827
  portfolioAvgFallPnl = validFall.reduce((acc, s) => acc + s.avgFallPnl * s.totalTrades, 0) / fallTradesTotal;
27331
27828
  }
27829
+ // Portfolio-wide extremes: best best-case and worst worst-case across
27830
+ // every per-symbol extreme. Skips symbols whose extreme is null (no
27831
+ // peakProfit/maxDrawdown snapshots) — they cannot vote in either direction.
27832
+ let portfolioPeakProfitPnl = null;
27833
+ let portfolioMaxDrawdownPnl = null;
27834
+ const peakExtremes = symbols
27835
+ .map((s) => s.peakProfitPnl)
27836
+ .filter((v) => typeof v === "number");
27837
+ const fallExtremes = symbols
27838
+ .map((s) => s.maxDrawdownPnl)
27839
+ .filter((v) => typeof v === "number");
27840
+ if (peakExtremes.length > 0) {
27841
+ portfolioPeakProfitPnl = Math.max(...peakExtremes);
27842
+ }
27843
+ if (fallExtremes.length > 0) {
27844
+ portfolioMaxDrawdownPnl = Math.min(...fallExtremes);
27845
+ }
27846
+ // Portfolio duration metrics — pooled means over every trade with valid
27847
+ // timestamps, regardless of symbol. A signal missing pendingAt/closeTimestamp
27848
+ // is excluded from its average (the same rule as per-symbol).
27849
+ let portfolioAvgDuration = null;
27850
+ let portfolioAvgWinDuration = null;
27851
+ let portfolioAvgLossDuration = null;
27852
+ {
27853
+ const durations = [];
27854
+ const winDurations = [];
27855
+ const lossDurations = [];
27856
+ for (const signals of this.symbolData.values()) {
27857
+ for (const s of signals) {
27858
+ const pendingAt = s.signal.pendingAt;
27859
+ const closeTs = s.closeTimestamp;
27860
+ if (typeof pendingAt !== "number" || pendingAt <= 0)
27861
+ continue;
27862
+ if (typeof closeTs !== "number" || closeTs <= 0)
27863
+ continue;
27864
+ const minutes = (closeTs - pendingAt) / 60000;
27865
+ durations.push(minutes);
27866
+ const pnl = s.pnl.pnlPercentage;
27867
+ if (pnl > 0)
27868
+ winDurations.push(minutes);
27869
+ else if (pnl < 0)
27870
+ lossDurations.push(minutes);
27871
+ }
27872
+ }
27873
+ if (durations.length > 0) {
27874
+ portfolioAvgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
27875
+ }
27876
+ if (winDurations.length > 0) {
27877
+ portfolioAvgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
27878
+ }
27879
+ if (lossDurations.length > 0) {
27880
+ portfolioAvgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
27881
+ }
27882
+ }
27883
+ // Portfolio median — pooled over allReturns (already collected for the
27884
+ // Sharpe block). Robust to outliers like the per-symbol counterpart.
27885
+ let portfolioMedianPnl = null;
27886
+ if (allReturns.length > 0) {
27887
+ const sortedAll = allReturns.slice().sort((a, b) => a - b);
27888
+ const mid = sortedAll.length >> 1;
27889
+ portfolioMedianPnl = sortedAll.length % 2 === 0
27890
+ ? (sortedAll[mid - 1] + sortedAll[mid]) / 2
27891
+ : sortedAll[mid];
27892
+ }
27893
+ // Portfolio streak averages — trade-count-weighted mean of per-symbol
27894
+ // averages. Concatenating streaks across symbols would be wrong: trades on
27895
+ // different symbols are not "consecutive" in any meaningful sense (different
27896
+ // markets, different timeframes). Weighting by totalTrades matches the
27897
+ // weighting used for portfolioAvgPeakPnl / portfolioAvgFallPnl.
27898
+ let portfolioAvgConsecutiveWinPnl = null;
27899
+ let portfolioAvgConsecutiveLossPnl = null;
27900
+ const validWinStreak = symbols.filter((s) => s.avgConsecutiveWinPnl !== null);
27901
+ const validLossStreak = symbols.filter((s) => s.avgConsecutiveLossPnl !== null);
27902
+ const winStreakWeight = validWinStreak.reduce((acc, s) => acc + s.totalTrades, 0);
27903
+ const lossStreakWeight = validLossStreak.reduce((acc, s) => acc + s.totalTrades, 0);
27904
+ if (validWinStreak.length > 0 && winStreakWeight > 0) {
27905
+ portfolioAvgConsecutiveWinPnl =
27906
+ validWinStreak.reduce((acc, s) => acc + s.avgConsecutiveWinPnl * s.totalTrades, 0) /
27907
+ winStreakWeight;
27908
+ }
27909
+ if (validLossStreak.length > 0 && lossStreakWeight > 0) {
27910
+ portfolioAvgConsecutiveLossPnl =
27911
+ validLossStreak.reduce((acc, s) => acc + s.avgConsecutiveLossPnl * s.totalTrades, 0) /
27912
+ lossStreakWeight;
27913
+ }
27332
27914
  // Apply safe math
27333
27915
  if (isUnsafe(portfolioTotalPnl))
27334
27916
  portfolioTotalPnl = null;
@@ -27338,6 +27920,10 @@ class HeatmapStorage {
27338
27920
  portfolioAvgPeakPnl = null;
27339
27921
  if (isUnsafe(portfolioAvgFallPnl))
27340
27922
  portfolioAvgFallPnl = null;
27923
+ if (isUnsafe(portfolioPeakProfitPnl))
27924
+ portfolioPeakProfitPnl = null;
27925
+ if (isUnsafe(portfolioMaxDrawdownPnl))
27926
+ portfolioMaxDrawdownPnl = null;
27341
27927
  if (isUnsafe(portfolioStdDev))
27342
27928
  portfolioStdDev = null;
27343
27929
  if (isUnsafe(portfolioSortinoRatio))
@@ -27348,6 +27934,26 @@ class HeatmapStorage {
27348
27934
  portfolioRecoveryFactor = null;
27349
27935
  if (isUnsafe(portfolioExpectancy))
27350
27936
  portfolioExpectancy = null;
27937
+ if (isUnsafe(portfolioAvgDuration))
27938
+ portfolioAvgDuration = null;
27939
+ if (isUnsafe(portfolioMedianPnl))
27940
+ portfolioMedianPnl = null;
27941
+ if (isUnsafe(portfolioAvgConsecutiveWinPnl))
27942
+ portfolioAvgConsecutiveWinPnl = null;
27943
+ if (isUnsafe(portfolioAvgConsecutiveLossPnl))
27944
+ portfolioAvgConsecutiveLossPnl = null;
27945
+ if (isUnsafe(portfolioAvgWinDuration))
27946
+ portfolioAvgWinDuration = null;
27947
+ if (isUnsafe(portfolioAvgLossDuration))
27948
+ portfolioAvgLossDuration = null;
27949
+ if (isUnsafe(portfolioAnnualizedSharpeRatio))
27950
+ portfolioAnnualizedSharpeRatio = null;
27951
+ if (isUnsafe(portfolioCertaintyRatio))
27952
+ portfolioCertaintyRatio = null;
27953
+ if (isUnsafe(portfolioExpectedYearlyReturns))
27954
+ portfolioExpectedYearlyReturns = null;
27955
+ if (isUnsafe(portfolioTradesPerYear))
27956
+ portfolioTradesPerYear = null;
27351
27957
  return {
27352
27958
  symbols,
27353
27959
  totalSymbols,
@@ -27356,11 +27962,23 @@ class HeatmapStorage {
27356
27962
  portfolioTotalTrades,
27357
27963
  portfolioAvgPeakPnl,
27358
27964
  portfolioAvgFallPnl,
27965
+ portfolioPeakProfitPnl,
27966
+ portfolioMaxDrawdownPnl,
27359
27967
  portfolioStdDev,
27360
27968
  portfolioSortinoRatio,
27361
27969
  portfolioCalmarRatio,
27362
27970
  portfolioRecoveryFactor,
27363
27971
  portfolioExpectancy,
27972
+ portfolioAvgDuration,
27973
+ portfolioMedianPnl,
27974
+ portfolioAvgConsecutiveWinPnl,
27975
+ portfolioAvgConsecutiveLossPnl,
27976
+ portfolioAvgWinDuration,
27977
+ portfolioAvgLossDuration,
27978
+ portfolioAnnualizedSharpeRatio,
27979
+ portfolioCertaintyRatio,
27980
+ portfolioExpectedYearlyReturns,
27981
+ portfolioTradesPerYear,
27364
27982
  };
27365
27983
  }
27366
27984
  /**
@@ -27409,32 +28027,53 @@ class HeatmapStorage {
27409
28027
  return [
27410
28028
  `# Portfolio Heatmap: ${strategyName}`,
27411
28029
  "",
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"}`,
28030
+ `**Total Symbols:** ${data.totalSymbols}`,
28031
+ `**Portfolio PNL:** ${data.portfolioTotalPnl !== null ? str(data.portfolioTotalPnl, "%") : "N/A"}`,
28032
+ `**Pooled Sharpe:** ${data.portfolioSharpeRatio !== null ? str(data.portfolioSharpeRatio) : "N/A"}`,
28033
+ `**Annualized Sharpe:** ${data.portfolioAnnualizedSharpeRatio !== null ? str(data.portfolioAnnualizedSharpeRatio) : "N/A"}`,
28034
+ `**Certainty Ratio:** ${data.portfolioCertaintyRatio !== null ? str(data.portfolioCertaintyRatio) : "N/A"}`,
28035
+ `**Expected Yearly Returns:** ${data.portfolioExpectedYearlyReturns !== null ? str(data.portfolioExpectedYearlyReturns, "%") : "N/A"}`,
28036
+ `**Trades Per Year:** ${data.portfolioTradesPerYear !== null ? data.portfolioTradesPerYear.toFixed(1) : "N/A"}`,
28037
+ `**Total Trades:** ${data.portfolioTotalTrades}`,
28038
+ `**Avg Peak PNL:** ${data.portfolioAvgPeakPnl !== null ? str(data.portfolioAvgPeakPnl, "%") : "N/A"}`,
28039
+ `**Avg Max Drawdown PNL:** ${data.portfolioAvgFallPnl !== null ? str(data.portfolioAvgFallPnl, "%") : "N/A"}`,
28040
+ `**Peak Profit PNL:** ${data.portfolioPeakProfitPnl !== null ? str(data.portfolioPeakProfitPnl, "%") : "N/A"}`,
28041
+ `**Max Drawdown PNL:** ${data.portfolioMaxDrawdownPnl !== null ? str(data.portfolioMaxDrawdownPnl, "%") : "N/A"}`,
28042
+ `**Median PNL:** ${data.portfolioMedianPnl !== null ? str(data.portfolioMedianPnl, "%") : "N/A"}`,
28043
+ `**Avg Duration:** ${data.portfolioAvgDuration !== null ? `${data.portfolioAvgDuration.toFixed(1)} min` : "N/A"}`,
28044
+ `**Avg Win Duration:** ${data.portfolioAvgWinDuration !== null ? `${data.portfolioAvgWinDuration.toFixed(1)} min` : "N/A"}`,
28045
+ `**Avg Loss Duration:** ${data.portfolioAvgLossDuration !== null ? `${data.portfolioAvgLossDuration.toFixed(1)} min` : "N/A"}`,
28046
+ `**Avg Consecutive Win PNL:** ${data.portfolioAvgConsecutiveWinPnl !== null ? str(data.portfolioAvgConsecutiveWinPnl, "%") : "N/A"}`,
28047
+ `**Avg Consecutive Loss PNL:** ${data.portfolioAvgConsecutiveLossPnl !== null ? str(data.portfolioAvgConsecutiveLossPnl, "%") : "N/A"}`,
28048
+ `**Standard Deviation Per Trade:** ${data.portfolioStdDev !== null ? str(data.portfolioStdDev, "%") : "N/A"}`,
28049
+ `**Sortino Ratio:** ${data.portfolioSortinoRatio !== null ? str(data.portfolioSortinoRatio) : "N/A"}`,
28050
+ `**Calmar Ratio:** ${data.portfolioCalmarRatio !== null ? str(data.portfolioCalmarRatio) : "N/A"}`,
28051
+ `**Recovery Factor:** ${data.portfolioRecoveryFactor !== null ? str(data.portfolioRecoveryFactor) : "N/A"}`,
28052
+ `**Expectancy:** ${data.portfolioExpectancy !== null ? str(data.portfolioExpectancy, "%") : "N/A"}`,
27423
28053
  "",
27424
28054
  table,
27425
28055
  "",
27426
28056
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
27427
28057
  `*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.*`,
28058
+ `*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.*`,
28059
+ `*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.*`,
28060
+ `*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.*`,
28061
+ `*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
28062
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals per symbol.*`,
27429
28063
  `*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
28064
  `*Profit Factor: below 1.0 means strategy is losing overall. Above 1.5 is considered good.*`,
27432
28065
  `*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
28066
  `*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.*`,
27434
- `*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.*`,
28067
+ `*Expectancy: per-trade expected value (winProb × avgWin + lossProb × avgLoss). Positive = profitable on average per trade. Break-even trades contribute 0.*`,
28068
+ `*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).*`,
28069
+ `*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.*`,
28070
+ `*Peak Profit PNL / Max Drawdown PNL: extremes — the best best-case and worst worst-case observed across all trades. Tail behaviour the averages hide.*`,
28071
+ `*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").*`,
28072
+ `*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).*`,
28073
+ `*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. The pooled curve walks trades chronologically by closeTimestamp; simultaneous cross-symbol drawdowns within the same minute are still serialised (one trade applied at a time), so genuine same-instant tail correlation is not modelled.*`,
27435
28074
  `*All metrics require 100+ signals per symbol to be statistically reliable. Annualized metrics assume the observed trading frequency persists year-round.*`,
27436
28075
  `*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.*`,
28076
+ `*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
28077
  ].join("\n");
27439
28078
  }
27440
28079
  /**