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.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
@@ -23894,13 +23978,19 @@ let ReportStorage$a = class ReportStorage {
23894
23978
  // mark-to-market low); equity then moves to the realized close.
23895
23979
  // If equity (at trough or close) goes ≤ 0 (e.g. leveraged loss < -100%) — account
23896
23980
  // blown, fix DD at 100% and stop walking the curve.
23981
+ // Walk the equity curve in chronological close order. Storage is
23982
+ // newest-first (unshift on addSignal); reverse-storage iteration normally
23983
+ // gives chronological order, but explicitly sorting by closeTimestamp
23984
+ // removes the dependency on insertion-order matching close-order (which
23985
+ // can break under crash recovery, signal backfill, or disk replays).
23986
+ const orderedSignals = [...validSignals].sort((a, b) => a.closeTimestamp - b.closeTimestamp);
23897
23987
  let equity = 1;
23898
23988
  let peak = 1;
23899
23989
  let equityMaxDrawdown = 0;
23900
23990
  let blown = false;
23901
- for (let i = validSignals.length - 1; i >= 0; i--) {
23991
+ for (const s of orderedSignals) {
23902
23992
  // Intra-trade trough — mark-to-market low while the position was open.
23903
- const fallPct = validSignals[i].signal.maxDrawdown?.pnlPercentage;
23993
+ const fallPct = s.signal.maxDrawdown?.pnlPercentage;
23904
23994
  if (typeof fallPct === "number" && fallPct < 0) {
23905
23995
  const trough = equity * (1 + fallPct / 100);
23906
23996
  if (trough <= 0) {
@@ -23913,7 +24003,7 @@ let ReportStorage$a = class ReportStorage {
23913
24003
  equityMaxDrawdown = troughDd;
23914
24004
  }
23915
24005
  // Realized close — book the final per-trade result.
23916
- equity *= 1 + validSignals[i].pnl.pnlPercentage / 100;
24006
+ equity *= 1 + s.pnl.pnlPercentage / 100;
23917
24007
  if (equity <= 0) {
23918
24008
  equityMaxDrawdown = 100;
23919
24009
  blown = true;
@@ -23966,6 +24056,101 @@ let ReportStorage$a = class ReportStorage {
23966
24056
  const expectancy = canComputeRatios && totalSignals > 0
23967
24057
  ? (wins.length / totalSignals) * avgWin + (losses.length / totalSignals) * avgLoss
23968
24058
  : null;
24059
+ // Median pnlPercentage — robust to outliers; reveals skew when avgPnl is
24060
+ // dragged by a whale trade. Sort a copy (do not mutate validSignals).
24061
+ let medianPnl = null;
24062
+ if (returns.length > 0) {
24063
+ const sortedReturns = returns.slice().sort((a, b) => a - b);
24064
+ const mid = sortedReturns.length >> 1;
24065
+ medianPnl = sortedReturns.length % 2 === 0
24066
+ ? (sortedReturns[mid - 1] + sortedReturns[mid]) / 2
24067
+ : sortedReturns[mid];
24068
+ }
24069
+ // Trade duration metrics in minutes (synchronized with strategy
24070
+ // `minuteEstimatedTime`). validSignals already requires pendingAt > 0 and
24071
+ // closeTimestamp > 0, so every signal here contributes a valid duration.
24072
+ let avgDuration = null;
24073
+ let avgWinDuration = null;
24074
+ let avgLossDuration = null;
24075
+ if (totalSignals > 0) {
24076
+ const durations = [];
24077
+ const winDurations = [];
24078
+ const lossDurations = [];
24079
+ for (const s of validSignals) {
24080
+ const minutes = (s.closeTimestamp - s.signal.pendingAt) / 60000;
24081
+ durations.push(minutes);
24082
+ const pnl = s.pnl.pnlPercentage;
24083
+ if (pnl > 0)
24084
+ winDurations.push(minutes);
24085
+ else if (pnl < 0)
24086
+ lossDurations.push(minutes);
24087
+ }
24088
+ avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
24089
+ if (winDurations.length > 0) {
24090
+ avgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
24091
+ }
24092
+ if (lossDurations.length > 0) {
24093
+ avgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
24094
+ }
24095
+ }
24096
+ // Consecutive streak averages: sum the per-streak pnl, then mean across
24097
+ // streaks. Storage is newest-first, so iterate in reverse for chronological
24098
+ // streaks. Break-even (pnl=0) closes both runs (neither a win nor a loss).
24099
+ let avgConsecutiveWinPnl = null;
24100
+ let avgConsecutiveLossPnl = null;
24101
+ {
24102
+ const winStreakSums = [];
24103
+ const lossStreakSums = [];
24104
+ let curWin = 0;
24105
+ let curLoss = 0;
24106
+ let curWinSum = 0;
24107
+ let curLossSum = 0;
24108
+ for (let i = validSignals.length - 1; i >= 0; i--) {
24109
+ const pnl = validSignals[i].pnl.pnlPercentage;
24110
+ if (pnl > 0) {
24111
+ if (curLoss > 0) {
24112
+ lossStreakSums.push(curLossSum);
24113
+ curLoss = 0;
24114
+ curLossSum = 0;
24115
+ }
24116
+ curWin++;
24117
+ curWinSum += pnl;
24118
+ }
24119
+ else if (pnl < 0) {
24120
+ if (curWin > 0) {
24121
+ winStreakSums.push(curWinSum);
24122
+ curWin = 0;
24123
+ curWinSum = 0;
24124
+ }
24125
+ curLoss++;
24126
+ curLossSum += pnl;
24127
+ }
24128
+ else {
24129
+ if (curWin > 0) {
24130
+ winStreakSums.push(curWinSum);
24131
+ curWin = 0;
24132
+ curWinSum = 0;
24133
+ }
24134
+ if (curLoss > 0) {
24135
+ lossStreakSums.push(curLossSum);
24136
+ curLoss = 0;
24137
+ curLossSum = 0;
24138
+ }
24139
+ }
24140
+ }
24141
+ if (curWin > 0)
24142
+ winStreakSums.push(curWinSum);
24143
+ if (curLoss > 0)
24144
+ lossStreakSums.push(curLossSum);
24145
+ if (winStreakSums.length > 0) {
24146
+ avgConsecutiveWinPnl =
24147
+ winStreakSums.reduce((a, b) => a + b, 0) / winStreakSums.length;
24148
+ }
24149
+ if (lossStreakSums.length > 0) {
24150
+ avgConsecutiveLossPnl =
24151
+ lossStreakSums.reduce((a, b) => a + b, 0) / lossStreakSums.length;
24152
+ }
24153
+ }
23969
24154
  // Average peak/fall PNL — over validSignals; only signals that actually have the
23970
24155
  // value contribute (no zero dilution from missing peakProfit/maxDrawdown).
23971
24156
  const peakValues = validSignals
@@ -24031,6 +24216,12 @@ let ReportStorage$a = class ReportStorage {
24031
24216
  calmarRatio: isUnsafe$4(calmarRatio) ? null : calmarRatio,
24032
24217
  recoveryFactor: isUnsafe$4(recoveryFactor) ? null : recoveryFactor,
24033
24218
  expectancy: isUnsafe$4(expectancy) ? null : expectancy,
24219
+ avgDuration: isUnsafe$4(avgDuration) ? null : avgDuration,
24220
+ medianPnl: isUnsafe$4(medianPnl) ? null : medianPnl,
24221
+ avgConsecutiveWinPnl: isUnsafe$4(avgConsecutiveWinPnl) ? null : avgConsecutiveWinPnl,
24222
+ avgConsecutiveLossPnl: isUnsafe$4(avgConsecutiveLossPnl) ? null : avgConsecutiveLossPnl,
24223
+ avgWinDuration: isUnsafe$4(avgWinDuration) ? null : avgWinDuration,
24224
+ avgLossDuration: isUnsafe$4(avgLossDuration) ? null : avgLossDuration,
24034
24225
  };
24035
24226
  }
24036
24227
  /**
@@ -24081,6 +24272,12 @@ let ReportStorage$a = class ReportStorage {
24081
24272
  `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
24082
24273
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
24083
24274
  `**Expectancy:** ${stats.expectancy === null ? "N/A" : `${stats.expectancy > 0 ? "+" : ""}${stats.expectancy.toFixed(3)}% (higher is better)`}`,
24275
+ `**Median PNL:** ${stats.medianPnl === null ? "N/A" : `${stats.medianPnl > 0 ? "+" : ""}${stats.medianPnl.toFixed(3)}% (closer to avgPnl = symmetric distribution)`}`,
24276
+ `**Avg Duration:** ${stats.avgDuration === null ? "N/A" : `${stats.avgDuration.toFixed(1)} min`}`,
24277
+ `**Avg Win Duration:** ${stats.avgWinDuration === null ? "N/A" : `${stats.avgWinDuration.toFixed(1)} min`}`,
24278
+ `**Avg Loss Duration:** ${stats.avgLossDuration === null ? "N/A" : `${stats.avgLossDuration.toFixed(1)} min`}`,
24279
+ `**Avg Consecutive Win PNL:** ${stats.avgConsecutiveWinPnl === null ? "N/A" : `${stats.avgConsecutiveWinPnl > 0 ? "+" : ""}${stats.avgConsecutiveWinPnl.toFixed(3)}% (higher is better)`}`,
24280
+ `**Avg Consecutive Loss PNL:** ${stats.avgConsecutiveLossPnl === null ? "N/A" : `${stats.avgConsecutiveLossPnl.toFixed(3)}% (closer to 0 is better)`}`,
24084
24281
  "",
24085
24282
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
24086
24283
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
@@ -24718,6 +24915,12 @@ let ReportStorage$9 = class ReportStorage {
24718
24915
  calmarRatio: null,
24719
24916
  recoveryFactor: null,
24720
24917
  expectancy: null,
24918
+ avgDuration: null,
24919
+ medianPnl: null,
24920
+ avgConsecutiveWinPnl: null,
24921
+ avgConsecutiveLossPnl: null,
24922
+ avgWinDuration: null,
24923
+ avgLossDuration: null,
24721
24924
  };
24722
24925
  }
24723
24926
  const closedEvents = this._eventList.filter((e) => e.action === "closed");
@@ -24807,6 +25010,104 @@ let ReportStorage$9 = class ReportStorage {
24807
25010
  // trades contribute 0 (excluded from both probabilities).
24808
25011
  expectancy = (wins.length / totalClosed) * avgWin + (losses.length / totalClosed) * avgLoss;
24809
25012
  }
25013
+ // Median pnl — robust to outliers; reveals skew when avgPnl is dragged
25014
+ // by a whale trade. Sort a copy (do not mutate returns).
25015
+ let medianPnl = null;
25016
+ if (returns.length > 0) {
25017
+ const sortedReturns = returns.slice().sort((a, b) => a - b);
25018
+ const mid = sortedReturns.length >> 1;
25019
+ medianPnl = sortedReturns.length % 2 === 0
25020
+ ? (sortedReturns[mid - 1] + sortedReturns[mid]) / 2
25021
+ : sortedReturns[mid];
25022
+ }
25023
+ // Trade duration metrics in minutes (synchronized with strategy
25024
+ // `minuteEstimatedTime`). Source: e.timestamp (close) - (e.pendingAt ?? e.timestamp).
25025
+ // validClosed already guarantees e.timestamp > 0; if pendingAt is missing the
25026
+ // event contributes a 0-minute duration, matching the validation fallback.
25027
+ let avgDuration = null;
25028
+ let avgWinDuration = null;
25029
+ let avgLossDuration = null;
25030
+ if (totalClosed > 0) {
25031
+ const durations = [];
25032
+ const winDurations = [];
25033
+ const lossDurations = [];
25034
+ for (const e of validClosed) {
25035
+ const closeTs = e.timestamp;
25036
+ const openTs = e.pendingAt ?? e.timestamp;
25037
+ const minutes = (closeTs - openTs) / 60000;
25038
+ durations.push(minutes);
25039
+ const pnl = e.pnl;
25040
+ if (pnl > 0)
25041
+ winDurations.push(minutes);
25042
+ else if (pnl < 0)
25043
+ lossDurations.push(minutes);
25044
+ }
25045
+ avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
25046
+ if (winDurations.length > 0) {
25047
+ avgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
25048
+ }
25049
+ if (lossDurations.length > 0) {
25050
+ avgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
25051
+ }
25052
+ }
25053
+ // Consecutive streak averages: sum the per-streak pnl, then mean across
25054
+ // streaks. validClosed is newest-first (events unshifted), so iterate in
25055
+ // reverse for chronological streaks. Break-even (pnl=0) closes both runs.
25056
+ let avgConsecutiveWinPnl = null;
25057
+ let avgConsecutiveLossPnl = null;
25058
+ {
25059
+ const winStreakSums = [];
25060
+ const lossStreakSums = [];
25061
+ let curWin = 0;
25062
+ let curLoss = 0;
25063
+ let curWinSum = 0;
25064
+ let curLossSum = 0;
25065
+ for (let i = validClosed.length - 1; i >= 0; i--) {
25066
+ const pnl = validClosed[i].pnl;
25067
+ if (pnl > 0) {
25068
+ if (curLoss > 0) {
25069
+ lossStreakSums.push(curLossSum);
25070
+ curLoss = 0;
25071
+ curLossSum = 0;
25072
+ }
25073
+ curWin++;
25074
+ curWinSum += pnl;
25075
+ }
25076
+ else if (pnl < 0) {
25077
+ if (curWin > 0) {
25078
+ winStreakSums.push(curWinSum);
25079
+ curWin = 0;
25080
+ curWinSum = 0;
25081
+ }
25082
+ curLoss++;
25083
+ curLossSum += pnl;
25084
+ }
25085
+ else {
25086
+ if (curWin > 0) {
25087
+ winStreakSums.push(curWinSum);
25088
+ curWin = 0;
25089
+ curWinSum = 0;
25090
+ }
25091
+ if (curLoss > 0) {
25092
+ lossStreakSums.push(curLossSum);
25093
+ curLoss = 0;
25094
+ curLossSum = 0;
25095
+ }
25096
+ }
25097
+ }
25098
+ if (curWin > 0)
25099
+ winStreakSums.push(curWinSum);
25100
+ if (curLoss > 0)
25101
+ lossStreakSums.push(curLossSum);
25102
+ if (winStreakSums.length > 0) {
25103
+ avgConsecutiveWinPnl =
25104
+ winStreakSums.reduce((a, b) => a + b, 0) / winStreakSums.length;
25105
+ }
25106
+ if (lossStreakSums.length > 0) {
25107
+ avgConsecutiveLossPnl =
25108
+ lossStreakSums.reduce((a, b) => a + b, 0) / lossStreakSums.length;
25109
+ }
25110
+ }
24810
25111
  // Average only over signals that have the value — do not dilute the mean with zeros.
24811
25112
  // Use validClosed to keep all metric denominators consistent.
24812
25113
  const peakValues = validClosed
@@ -24847,14 +25148,20 @@ let ReportStorage$9 = class ReportStorage {
24847
25148
  // snapshot, ≤ 0) is applied as a trough BEFORE booking the realized close. Without it
24848
25149
  // the curve only steps at close, so a trade that dipped to -18% and recovered to +2%
24849
25150
  // would register zero drawdown — understating DD and inflating Calmar/Recovery.
24850
- const chronological = [];
24851
- for (let i = validClosed.length - 1; i >= 0; i--) {
24852
- const fall = validClosed[i].fallPnl;
24853
- chronological.push({
24854
- r: validClosed[i].pnl,
24855
- fall: typeof fall === "number" ? fall : null,
24856
- });
24857
- }
25151
+ // Walk the equity curve in chronological close order. Reverse-storage
25152
+ // iteration (newest-first storage reverse) normally yields chronological
25153
+ // order for live ingest, but explicitly sorting by event.timestamp removes
25154
+ // the dependency on insertion-order matching close-order. This matters
25155
+ // under crash recovery (events reloaded from disk in arbitrary order) and
25156
+ // when ingest latency reorders closed events relative to wall-clock time.
25157
+ const chronological = validClosed
25158
+ .map((e) => ({
25159
+ r: e.pnl,
25160
+ fall: typeof e.fallPnl === "number" ? e.fallPnl : null,
25161
+ ts: e.timestamp,
25162
+ }))
25163
+ .sort((a, b) => a.ts - b.ts)
25164
+ .map(({ r, fall }) => ({ r, fall }));
24858
25165
  let equity = 1;
24859
25166
  let peak = 1;
24860
25167
  let equityMaxDrawdown = 0;
@@ -24932,6 +25239,12 @@ let ReportStorage$9 = class ReportStorage {
24932
25239
  calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
24933
25240
  recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
24934
25241
  expectancy: isUnsafe$3(expectancy) ? null : expectancy,
25242
+ avgDuration: isUnsafe$3(avgDuration) ? null : avgDuration,
25243
+ medianPnl: isUnsafe$3(medianPnl) ? null : medianPnl,
25244
+ avgConsecutiveWinPnl: isUnsafe$3(avgConsecutiveWinPnl) ? null : avgConsecutiveWinPnl,
25245
+ avgConsecutiveLossPnl: isUnsafe$3(avgConsecutiveLossPnl) ? null : avgConsecutiveLossPnl,
25246
+ avgWinDuration: isUnsafe$3(avgWinDuration) ? null : avgWinDuration,
25247
+ avgLossDuration: isUnsafe$3(avgLossDuration) ? null : avgLossDuration,
24935
25248
  };
24936
25249
  }
24937
25250
  /**
@@ -24982,6 +25295,12 @@ let ReportStorage$9 = class ReportStorage {
24982
25295
  `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
24983
25296
  `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
24984
25297
  `**Expectancy:** ${stats.expectancy === null ? "N/A" : `${stats.expectancy > 0 ? "+" : ""}${stats.expectancy.toFixed(3)}% (higher is better)`}`,
25298
+ `**Median PNL:** ${stats.medianPnl === null ? "N/A" : `${stats.medianPnl > 0 ? "+" : ""}${stats.medianPnl.toFixed(3)}% (closer to avgPnl = symmetric distribution)`}`,
25299
+ `**Avg Duration:** ${stats.avgDuration === null ? "N/A" : `${stats.avgDuration.toFixed(1)} min`}`,
25300
+ `**Avg Win Duration:** ${stats.avgWinDuration === null ? "N/A" : `${stats.avgWinDuration.toFixed(1)} min`}`,
25301
+ `**Avg Loss Duration:** ${stats.avgLossDuration === null ? "N/A" : `${stats.avgLossDuration.toFixed(1)} min`}`,
25302
+ `**Avg Consecutive Win PNL:** ${stats.avgConsecutiveWinPnl === null ? "N/A" : `${stats.avgConsecutiveWinPnl > 0 ? "+" : ""}${stats.avgConsecutiveWinPnl.toFixed(3)}% (higher is better)`}`,
25303
+ `**Avg Consecutive Loss PNL:** ${stats.avgConsecutiveLossPnl === null ? "N/A" : `${stats.avgConsecutiveLossPnl.toFixed(3)}% (closer to 0 is better)`}`,
24985
25304
  "",
24986
25305
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
24987
25306
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
@@ -26916,11 +27235,18 @@ class HeatmapStorage {
26916
27235
  let equityFinal = 1;
26917
27236
  let blown = false;
26918
27237
  if (signals.length > 0) {
27238
+ // Walk the per-symbol equity curve in chronological close order.
27239
+ // Storage is newest-first (unshift on addSignal), but if signals were
27240
+ // ingested out-of-order (e.g. Live + crash recovery loading from disk in
27241
+ // arbitrary order, or a backfill replay), reverse-storage iteration
27242
+ // would misplace peak/trough and silently distort maxDrawdown. Sorting
27243
+ // by closeTimestamp explicitly removes that dependency.
27244
+ const ordered = [...signals].sort((a, b) => a.closeTimestamp - b.closeTimestamp);
26919
27245
  let equity = 1;
26920
27246
  let peak = 1;
26921
27247
  let maxDD = 0;
26922
- for (let i = signals.length - 1; i >= 0; i--) {
26923
- const fallPct = signals[i].signal.maxDrawdown?.pnlPercentage;
27248
+ for (const s of ordered) {
27249
+ const fallPct = s.signal.maxDrawdown?.pnlPercentage;
26924
27250
  if (typeof fallPct === "number" && fallPct < 0) {
26925
27251
  const trough = equity * (1 + fallPct / 100);
26926
27252
  if (trough <= 0) {
@@ -26932,7 +27258,7 @@ class HeatmapStorage {
26932
27258
  if (troughDd > maxDD)
26933
27259
  maxDD = troughDd;
26934
27260
  }
26935
- equity *= 1 + signals[i].pnl.pnlPercentage / 100;
27261
+ equity *= 1 + s.pnl.pnlPercentage / 100;
26936
27262
  if (equity <= 0) {
26937
27263
  maxDD = 100;
26938
27264
  blown = true;
@@ -26977,26 +27303,113 @@ class HeatmapStorage {
26977
27303
  .filter((s) => s.pnl.pnlPercentage < 0)
26978
27304
  .reduce((acc, s) => acc + s.pnl.pnlPercentage, 0) / lossCount;
26979
27305
  }
26980
- // Calculate Win/Loss Streaks
27306
+ // Calculate Win/Loss Streaks AND per-streak pnl sums.
27307
+ // A streak is a run of same-signed trades; break-even (pnl=0) ends both runs.
27308
+ // The sign sequence is invariant under reversal, so iterating signals (newest
27309
+ // first) gives the same streak boundaries as chronological order.
26981
27310
  let maxWinStreak = 0;
26982
27311
  let maxLossStreak = 0;
26983
27312
  let currentWinStreak = 0;
26984
27313
  let currentLossStreak = 0;
27314
+ let currentWinStreakSum = 0;
27315
+ let currentLossStreakSum = 0;
27316
+ const winStreakSums = [];
27317
+ const lossStreakSums = [];
26985
27318
  for (const signal of signals) {
26986
- if (signal.pnl.pnlPercentage > 0) {
27319
+ const pnl = signal.pnl.pnlPercentage;
27320
+ if (pnl > 0) {
27321
+ if (currentLossStreak > 0) {
27322
+ lossStreakSums.push(currentLossStreakSum);
27323
+ currentLossStreak = 0;
27324
+ currentLossStreakSum = 0;
27325
+ }
26987
27326
  currentWinStreak++;
26988
- currentLossStreak = 0;
27327
+ currentWinStreakSum += pnl;
26989
27328
  if (currentWinStreak > maxWinStreak) {
26990
27329
  maxWinStreak = currentWinStreak;
26991
27330
  }
26992
27331
  }
26993
- else if (signal.pnl.pnlPercentage < 0) {
27332
+ else if (pnl < 0) {
27333
+ if (currentWinStreak > 0) {
27334
+ winStreakSums.push(currentWinStreakSum);
27335
+ currentWinStreak = 0;
27336
+ currentWinStreakSum = 0;
27337
+ }
26994
27338
  currentLossStreak++;
26995
- currentWinStreak = 0;
27339
+ currentLossStreakSum += pnl;
26996
27340
  if (currentLossStreak > maxLossStreak) {
26997
27341
  maxLossStreak = currentLossStreak;
26998
27342
  }
26999
27343
  }
27344
+ else {
27345
+ // Break-even closes both runs (it's neither a win nor a loss).
27346
+ if (currentWinStreak > 0) {
27347
+ winStreakSums.push(currentWinStreakSum);
27348
+ currentWinStreak = 0;
27349
+ currentWinStreakSum = 0;
27350
+ }
27351
+ if (currentLossStreak > 0) {
27352
+ lossStreakSums.push(currentLossStreakSum);
27353
+ currentLossStreak = 0;
27354
+ currentLossStreakSum = 0;
27355
+ }
27356
+ }
27357
+ }
27358
+ // Flush trailing streak.
27359
+ if (currentWinStreak > 0)
27360
+ winStreakSums.push(currentWinStreakSum);
27361
+ if (currentLossStreak > 0)
27362
+ lossStreakSums.push(currentLossStreakSum);
27363
+ let avgConsecutiveWinPnl = winStreakSums.length > 0
27364
+ ? winStreakSums.reduce((a, b) => a + b, 0) / winStreakSums.length
27365
+ : null;
27366
+ let avgConsecutiveLossPnl = lossStreakSums.length > 0
27367
+ ? lossStreakSums.reduce((a, b) => a + b, 0) / lossStreakSums.length
27368
+ : null;
27369
+ // Trade duration metrics. Source: closeTimestamp - signal.pendingAt, in minutes
27370
+ // (synchronized with strategy `minuteEstimatedTime`). A signal missing either
27371
+ // timestamp is excluded from the corresponding average — silent zeros would
27372
+ // otherwise pull the mean towards zero.
27373
+ let avgDuration = null;
27374
+ let avgWinDuration = null;
27375
+ let avgLossDuration = null;
27376
+ {
27377
+ const durations = [];
27378
+ const winDurations = [];
27379
+ const lossDurations = [];
27380
+ for (const s of signals) {
27381
+ const pendingAt = s.signal.pendingAt;
27382
+ const closeTs = s.closeTimestamp;
27383
+ if (typeof pendingAt !== "number" || pendingAt <= 0)
27384
+ continue;
27385
+ if (typeof closeTs !== "number" || closeTs <= 0)
27386
+ continue;
27387
+ const minutes = (closeTs - pendingAt) / 60000;
27388
+ durations.push(minutes);
27389
+ const pnl = s.pnl.pnlPercentage;
27390
+ if (pnl > 0)
27391
+ winDurations.push(minutes);
27392
+ else if (pnl < 0)
27393
+ lossDurations.push(minutes);
27394
+ }
27395
+ if (durations.length > 0) {
27396
+ avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
27397
+ }
27398
+ if (winDurations.length > 0) {
27399
+ avgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
27400
+ }
27401
+ if (lossDurations.length > 0) {
27402
+ avgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
27403
+ }
27404
+ }
27405
+ // Median pnlPercentage — robust to outliers. Sort a copy (do not mutate signals).
27406
+ let medianPnl = null;
27407
+ if (signals.length > 0) {
27408
+ const sorted = signals.map((s) => s.pnl.pnlPercentage).sort((a, b) => a - b);
27409
+ const mid = sorted.length >> 1;
27410
+ medianPnl = sorted.length % 2 === 0
27411
+ ? (sorted[mid - 1] + sorted[mid]) / 2
27412
+ : sorted[mid];
27000
27413
  }
27001
27414
  // Expectancy — probabilities from observed win/loss counts (break-evens contribute 0).
27002
27415
  let expectancy = null;
@@ -27013,8 +27426,12 @@ class HeatmapStorage {
27013
27426
  expectancy = (lossCount / totalTrades) * avgLoss;
27014
27427
  }
27015
27428
  // Average only over signals that have the value — do not dilute the mean with zeros.
27429
+ // Extremes (peakProfitPnl / maxDrawdownPnl) are the best/worst observation
27430
+ // across all trades, surfacing tail behaviour the average hides.
27016
27431
  let avgPeakPnl = null;
27017
27432
  let avgFallPnl = null;
27433
+ let peakProfitPnl = null;
27434
+ let maxDrawdownPnl = null;
27018
27435
  if (signals.length > 0) {
27019
27436
  const peakValues = signals
27020
27437
  .map((s) => s.signal.peakProfit?.pnlPercentage)
@@ -27022,12 +27439,14 @@ class HeatmapStorage {
27022
27439
  const fallValues = signals
27023
27440
  .map((s) => s.signal.maxDrawdown?.pnlPercentage)
27024
27441
  .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;
27442
+ if (peakValues.length > 0) {
27443
+ avgPeakPnl = peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length;
27444
+ peakProfitPnl = Math.max(...peakValues);
27445
+ }
27446
+ if (fallValues.length > 0) {
27447
+ avgFallPnl = fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length;
27448
+ maxDrawdownPnl = Math.min(...fallValues);
27449
+ }
27031
27450
  }
27032
27451
  // Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
27033
27452
  // downsideDev = √( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
@@ -27103,6 +27522,25 @@ class HeatmapStorage {
27103
27522
  recoveryFactor = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, rawRec));
27104
27523
  }
27105
27524
  }
27525
+ // Annualized Sharpe — sharpeRatio × √tradesPerYear. Both inputs already
27526
+ // carry their own gates (sharpeRatio: N>=MIN_SIGNALS_FOR_RATIOS + STDDEV_EPSILON;
27527
+ // tradesPerYear: N>=MIN_SIGNALS_FOR_ANNUALIZATION + span>=MIN_CALENDAR_SPAN_DAYS
27528
+ // + raw frequency under MAX_TRADES_PER_YEAR), so we just propagate nulls.
27529
+ let annualizedSharpeRatio = null;
27530
+ if (sharpeRatio !== null && tradesPerYear !== null && tradesPerYear > 0) {
27531
+ annualizedSharpeRatio = sharpeRatio * Math.sqrt(tradesPerYear);
27532
+ }
27533
+ // Certainty Ratio = avgWin / |avgLoss|. Same gating shape as Backtest/Live:
27534
+ // N >= MIN_SIGNALS_FOR_RATIOS, AND |avgLoss| above STDDEV_EPSILON (float-artifact
27535
+ // losses near zero would otherwise produce spurious astronomical values).
27536
+ let certaintyRatio = null;
27537
+ if (canComputeRatios &&
27538
+ avgWin !== null &&
27539
+ avgLoss !== null &&
27540
+ avgLoss < 0 &&
27541
+ Math.abs(avgLoss) > STDDEV_EPSILON) {
27542
+ certaintyRatio = avgWin / Math.abs(avgLoss);
27543
+ }
27106
27544
  // Apply safe math checks
27107
27545
  if (isUnsafe(winRate))
27108
27546
  winRate = null;
@@ -27114,6 +27552,14 @@ class HeatmapStorage {
27114
27552
  stdDev = null;
27115
27553
  if (isUnsafe(sharpeRatio))
27116
27554
  sharpeRatio = null;
27555
+ if (isUnsafe(annualizedSharpeRatio))
27556
+ annualizedSharpeRatio = null;
27557
+ if (isUnsafe(certaintyRatio))
27558
+ certaintyRatio = null;
27559
+ if (isUnsafe(expectedYearlyReturns))
27560
+ expectedYearlyReturns = null;
27561
+ if (isUnsafe(tradesPerYear))
27562
+ tradesPerYear = null;
27117
27563
  if (isUnsafe(maxDrawdown))
27118
27564
  maxDrawdown = null;
27119
27565
  if (isUnsafe(profitFactor))
@@ -27128,6 +27574,22 @@ class HeatmapStorage {
27128
27574
  avgPeakPnl = null;
27129
27575
  if (isUnsafe(avgFallPnl))
27130
27576
  avgFallPnl = null;
27577
+ if (isUnsafe(peakProfitPnl))
27578
+ peakProfitPnl = null;
27579
+ if (isUnsafe(maxDrawdownPnl))
27580
+ maxDrawdownPnl = null;
27581
+ if (isUnsafe(avgDuration))
27582
+ avgDuration = null;
27583
+ if (isUnsafe(medianPnl))
27584
+ medianPnl = null;
27585
+ if (isUnsafe(avgConsecutiveWinPnl))
27586
+ avgConsecutiveWinPnl = null;
27587
+ if (isUnsafe(avgConsecutiveLossPnl))
27588
+ avgConsecutiveLossPnl = null;
27589
+ if (isUnsafe(avgWinDuration))
27590
+ avgWinDuration = null;
27591
+ if (isUnsafe(avgLossDuration))
27592
+ avgLossDuration = null;
27131
27593
  if (isUnsafe(sortinoRatio))
27132
27594
  sortinoRatio = null;
27133
27595
  if (isUnsafe(calmarRatio))
@@ -27153,9 +27615,21 @@ class HeatmapStorage {
27153
27615
  expectancy,
27154
27616
  avgPeakPnl,
27155
27617
  avgFallPnl,
27618
+ peakProfitPnl,
27619
+ maxDrawdownPnl,
27620
+ avgDuration,
27621
+ medianPnl,
27622
+ avgConsecutiveWinPnl,
27623
+ avgConsecutiveLossPnl,
27624
+ avgWinDuration,
27625
+ avgLossDuration,
27156
27626
  sortinoRatio,
27157
27627
  calmarRatio,
27158
27628
  recoveryFactor,
27629
+ annualizedSharpeRatio,
27630
+ certaintyRatio,
27631
+ expectedYearlyReturns,
27632
+ tradesPerYear,
27159
27633
  };
27160
27634
  }
27161
27635
  /**
@@ -27217,23 +27691,32 @@ class HeatmapStorage {
27217
27691
  let portfolioExpectancy = null;
27218
27692
  let portfolioCalmarRatio = null;
27219
27693
  let portfolioRecoveryFactor = null;
27220
- const allReturns = [];
27221
- // Parallel array of intra-trade troughs (≤ 0), aligned 1:1 with allReturns,
27222
- // used for mark-to-market DD in the pooled equity curve below.
27223
- const allFalls = [];
27694
+ let portfolioAnnualizedSharpeRatio = null;
27695
+ let portfolioCertaintyRatio = null;
27696
+ let portfolioExpectedYearlyReturns = null;
27697
+ let portfolioTradesPerYear = null;
27698
+ const pooledTrades = [];
27224
27699
  let poolFirstPendingAt = Infinity;
27225
27700
  let poolLastCloseAt = -Infinity;
27226
27701
  for (const signals of this.symbolData.values()) {
27227
27702
  for (const s of signals) {
27228
- allReturns.push(s.pnl.pnlPercentage);
27229
27703
  const fall = s.signal.maxDrawdown?.pnlPercentage;
27230
- allFalls.push(typeof fall === "number" ? fall : null);
27704
+ pooledTrades.push({
27705
+ r: s.pnl.pnlPercentage,
27706
+ fall: typeof fall === "number" ? fall : null,
27707
+ closeAt: s.closeTimestamp,
27708
+ });
27231
27709
  if (s.signal.pendingAt < poolFirstPendingAt)
27232
27710
  poolFirstPendingAt = s.signal.pendingAt;
27233
27711
  if (s.closeTimestamp > poolLastCloseAt)
27234
27712
  poolLastCloseAt = s.closeTimestamp;
27235
27713
  }
27236
27714
  }
27715
+ pooledTrades.sort((a, b) => a.closeAt - b.closeAt);
27716
+ const allReturns = pooledTrades.map((t) => t.r);
27717
+ // Parallel array of intra-trade troughs (≤ 0), aligned 1:1 with allReturns,
27718
+ // used for mark-to-market DD in the pooled equity curve below.
27719
+ const allFalls = pooledTrades.map((t) => t.fall);
27237
27720
  if (allReturns.length >= MIN_SIGNALS_FOR_RATIOS) {
27238
27721
  const portfolioAvg = allReturns.reduce((acc, r) => acc + r, 0) / allReturns.length;
27239
27722
  const portfolioVariance = allReturns.reduce((acc, r) => acc + Math.pow(r - portfolioAvg, 2), 0) /
@@ -27263,6 +27746,12 @@ class HeatmapStorage {
27263
27746
  if (wins.length > 0 || losses.length > 0) {
27264
27747
  portfolioExpectancy = (wins.length / total) * avgWin + (losses.length / total) * avgLoss;
27265
27748
  }
27749
+ // Pooled Certainty Ratio = pooledAvgWin / |pooledAvgLoss|. Same STDDEV_EPSILON
27750
+ // guard as per-symbol — protects against float-artifact losses producing
27751
+ // spuriously astronomical values.
27752
+ if (losses.length > 0 && Math.abs(avgLoss) > STDDEV_EPSILON && avgLoss < 0) {
27753
+ portfolioCertaintyRatio = avgWin / Math.abs(avgLoss);
27754
+ }
27266
27755
  // Pooled equity-curve max drawdown (compounded). MARK-TO-MARKET: each trade's
27267
27756
  // intra-trade trough (allFalls, ≤ 0) is applied before booking the realized close,
27268
27757
  // so deep round-trip dips are captured rather than understating DD.
@@ -27301,30 +27790,38 @@ class HeatmapStorage {
27301
27790
  // calendar span (≥ MIN_CALENDAR_SPAN_DAYS) and a non-clustered trade
27302
27791
  // frequency (≤ MAX_TRADES_PER_YEAR). Above MAX_EXPECTED_YEARLY_RETURNS → null
27303
27792
  // (don't surface the cap as a real figure). This is the numerator for Calmar.
27304
- let pooledExpectedYearlyReturns = null;
27305
27793
  const poolSpanDays = isFinite(poolFirstPendingAt) && isFinite(poolLastCloseAt)
27306
27794
  ? (poolLastCloseAt - poolFirstPendingAt) / (1000 * 60 * 60 * 24)
27307
27795
  : 0;
27308
27796
  if (poolSpanDays >= MIN_CALENDAR_SPAN_DAYS) {
27309
27797
  const rawTradesPerYear = (allReturns.length / poolSpanDays) * 365;
27310
27798
  if (rawTradesPerYear <= MAX_TRADES_PER_YEAR) {
27799
+ portfolioTradesPerYear = rawTradesPerYear;
27311
27800
  if (blown) {
27312
- pooledExpectedYearlyReturns = -100;
27801
+ portfolioExpectedYearlyReturns = -100;
27313
27802
  }
27314
27803
  else {
27315
27804
  const raw = (Math.pow(equityFinal, rawTradesPerYear / allReturns.length) - 1) * 100;
27316
- pooledExpectedYearlyReturns =
27805
+ portfolioExpectedYearlyReturns =
27317
27806
  Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS ? null : raw;
27318
27807
  }
27319
27808
  }
27320
27809
  }
27810
+ // Pooled Annualized Sharpe — pooledSharpe × √pooledTradesPerYear. Both
27811
+ // gates already enforced upstream; just propagate nulls.
27812
+ if (portfolioSharpeRatio !== null &&
27813
+ portfolioTradesPerYear !== null &&
27814
+ portfolioTradesPerYear > 0) {
27815
+ portfolioAnnualizedSharpeRatio =
27816
+ portfolioSharpeRatio * Math.sqrt(portfolioTradesPerYear);
27817
+ }
27321
27818
  // Pooled Calmar = annualized return / max drawdown — same formula and
27322
27819
  // gating as per-symbol Calmar. NULL when the annualized numerator is
27323
27820
  // unavailable (span/frequency gate, or over the yearly cap). This is what
27324
27821
  // distinguishes it from Recovery, which uses the compounded TOTAL return —
27325
27822
  // 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));
27823
+ if (maxDD > 0 && portfolioExpectedYearlyReturns !== null) {
27824
+ portfolioCalmarRatio = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, portfolioExpectedYearlyReturns / maxDD));
27328
27825
  }
27329
27826
  // Pooled Recovery Factor = compounded TOTAL return / max drawdown, clamped.
27330
27827
  // Time-independent (no annualization), so it needs no span gate — only a
@@ -27349,6 +27846,91 @@ class HeatmapStorage {
27349
27846
  if (validFall.length > 0 && fallTradesTotal > 0) {
27350
27847
  portfolioAvgFallPnl = validFall.reduce((acc, s) => acc + s.avgFallPnl * s.totalTrades, 0) / fallTradesTotal;
27351
27848
  }
27849
+ // Portfolio-wide extremes: best best-case and worst worst-case across
27850
+ // every per-symbol extreme. Skips symbols whose extreme is null (no
27851
+ // peakProfit/maxDrawdown snapshots) — they cannot vote in either direction.
27852
+ let portfolioPeakProfitPnl = null;
27853
+ let portfolioMaxDrawdownPnl = null;
27854
+ const peakExtremes = symbols
27855
+ .map((s) => s.peakProfitPnl)
27856
+ .filter((v) => typeof v === "number");
27857
+ const fallExtremes = symbols
27858
+ .map((s) => s.maxDrawdownPnl)
27859
+ .filter((v) => typeof v === "number");
27860
+ if (peakExtremes.length > 0) {
27861
+ portfolioPeakProfitPnl = Math.max(...peakExtremes);
27862
+ }
27863
+ if (fallExtremes.length > 0) {
27864
+ portfolioMaxDrawdownPnl = Math.min(...fallExtremes);
27865
+ }
27866
+ // Portfolio duration metrics — pooled means over every trade with valid
27867
+ // timestamps, regardless of symbol. A signal missing pendingAt/closeTimestamp
27868
+ // is excluded from its average (the same rule as per-symbol).
27869
+ let portfolioAvgDuration = null;
27870
+ let portfolioAvgWinDuration = null;
27871
+ let portfolioAvgLossDuration = null;
27872
+ {
27873
+ const durations = [];
27874
+ const winDurations = [];
27875
+ const lossDurations = [];
27876
+ for (const signals of this.symbolData.values()) {
27877
+ for (const s of signals) {
27878
+ const pendingAt = s.signal.pendingAt;
27879
+ const closeTs = s.closeTimestamp;
27880
+ if (typeof pendingAt !== "number" || pendingAt <= 0)
27881
+ continue;
27882
+ if (typeof closeTs !== "number" || closeTs <= 0)
27883
+ continue;
27884
+ const minutes = (closeTs - pendingAt) / 60000;
27885
+ durations.push(minutes);
27886
+ const pnl = s.pnl.pnlPercentage;
27887
+ if (pnl > 0)
27888
+ winDurations.push(minutes);
27889
+ else if (pnl < 0)
27890
+ lossDurations.push(minutes);
27891
+ }
27892
+ }
27893
+ if (durations.length > 0) {
27894
+ portfolioAvgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
27895
+ }
27896
+ if (winDurations.length > 0) {
27897
+ portfolioAvgWinDuration = winDurations.reduce((a, b) => a + b, 0) / winDurations.length;
27898
+ }
27899
+ if (lossDurations.length > 0) {
27900
+ portfolioAvgLossDuration = lossDurations.reduce((a, b) => a + b, 0) / lossDurations.length;
27901
+ }
27902
+ }
27903
+ // Portfolio median — pooled over allReturns (already collected for the
27904
+ // Sharpe block). Robust to outliers like the per-symbol counterpart.
27905
+ let portfolioMedianPnl = null;
27906
+ if (allReturns.length > 0) {
27907
+ const sortedAll = allReturns.slice().sort((a, b) => a - b);
27908
+ const mid = sortedAll.length >> 1;
27909
+ portfolioMedianPnl = sortedAll.length % 2 === 0
27910
+ ? (sortedAll[mid - 1] + sortedAll[mid]) / 2
27911
+ : sortedAll[mid];
27912
+ }
27913
+ // Portfolio streak averages — trade-count-weighted mean of per-symbol
27914
+ // averages. Concatenating streaks across symbols would be wrong: trades on
27915
+ // different symbols are not "consecutive" in any meaningful sense (different
27916
+ // markets, different timeframes). Weighting by totalTrades matches the
27917
+ // weighting used for portfolioAvgPeakPnl / portfolioAvgFallPnl.
27918
+ let portfolioAvgConsecutiveWinPnl = null;
27919
+ let portfolioAvgConsecutiveLossPnl = null;
27920
+ const validWinStreak = symbols.filter((s) => s.avgConsecutiveWinPnl !== null);
27921
+ const validLossStreak = symbols.filter((s) => s.avgConsecutiveLossPnl !== null);
27922
+ const winStreakWeight = validWinStreak.reduce((acc, s) => acc + s.totalTrades, 0);
27923
+ const lossStreakWeight = validLossStreak.reduce((acc, s) => acc + s.totalTrades, 0);
27924
+ if (validWinStreak.length > 0 && winStreakWeight > 0) {
27925
+ portfolioAvgConsecutiveWinPnl =
27926
+ validWinStreak.reduce((acc, s) => acc + s.avgConsecutiveWinPnl * s.totalTrades, 0) /
27927
+ winStreakWeight;
27928
+ }
27929
+ if (validLossStreak.length > 0 && lossStreakWeight > 0) {
27930
+ portfolioAvgConsecutiveLossPnl =
27931
+ validLossStreak.reduce((acc, s) => acc + s.avgConsecutiveLossPnl * s.totalTrades, 0) /
27932
+ lossStreakWeight;
27933
+ }
27352
27934
  // Apply safe math
27353
27935
  if (isUnsafe(portfolioTotalPnl))
27354
27936
  portfolioTotalPnl = null;
@@ -27358,6 +27940,10 @@ class HeatmapStorage {
27358
27940
  portfolioAvgPeakPnl = null;
27359
27941
  if (isUnsafe(portfolioAvgFallPnl))
27360
27942
  portfolioAvgFallPnl = null;
27943
+ if (isUnsafe(portfolioPeakProfitPnl))
27944
+ portfolioPeakProfitPnl = null;
27945
+ if (isUnsafe(portfolioMaxDrawdownPnl))
27946
+ portfolioMaxDrawdownPnl = null;
27361
27947
  if (isUnsafe(portfolioStdDev))
27362
27948
  portfolioStdDev = null;
27363
27949
  if (isUnsafe(portfolioSortinoRatio))
@@ -27368,6 +27954,26 @@ class HeatmapStorage {
27368
27954
  portfolioRecoveryFactor = null;
27369
27955
  if (isUnsafe(portfolioExpectancy))
27370
27956
  portfolioExpectancy = null;
27957
+ if (isUnsafe(portfolioAvgDuration))
27958
+ portfolioAvgDuration = null;
27959
+ if (isUnsafe(portfolioMedianPnl))
27960
+ portfolioMedianPnl = null;
27961
+ if (isUnsafe(portfolioAvgConsecutiveWinPnl))
27962
+ portfolioAvgConsecutiveWinPnl = null;
27963
+ if (isUnsafe(portfolioAvgConsecutiveLossPnl))
27964
+ portfolioAvgConsecutiveLossPnl = null;
27965
+ if (isUnsafe(portfolioAvgWinDuration))
27966
+ portfolioAvgWinDuration = null;
27967
+ if (isUnsafe(portfolioAvgLossDuration))
27968
+ portfolioAvgLossDuration = null;
27969
+ if (isUnsafe(portfolioAnnualizedSharpeRatio))
27970
+ portfolioAnnualizedSharpeRatio = null;
27971
+ if (isUnsafe(portfolioCertaintyRatio))
27972
+ portfolioCertaintyRatio = null;
27973
+ if (isUnsafe(portfolioExpectedYearlyReturns))
27974
+ portfolioExpectedYearlyReturns = null;
27975
+ if (isUnsafe(portfolioTradesPerYear))
27976
+ portfolioTradesPerYear = null;
27371
27977
  return {
27372
27978
  symbols,
27373
27979
  totalSymbols,
@@ -27376,11 +27982,23 @@ class HeatmapStorage {
27376
27982
  portfolioTotalTrades,
27377
27983
  portfolioAvgPeakPnl,
27378
27984
  portfolioAvgFallPnl,
27985
+ portfolioPeakProfitPnl,
27986
+ portfolioMaxDrawdownPnl,
27379
27987
  portfolioStdDev,
27380
27988
  portfolioSortinoRatio,
27381
27989
  portfolioCalmarRatio,
27382
27990
  portfolioRecoveryFactor,
27383
27991
  portfolioExpectancy,
27992
+ portfolioAvgDuration,
27993
+ portfolioMedianPnl,
27994
+ portfolioAvgConsecutiveWinPnl,
27995
+ portfolioAvgConsecutiveLossPnl,
27996
+ portfolioAvgWinDuration,
27997
+ portfolioAvgLossDuration,
27998
+ portfolioAnnualizedSharpeRatio,
27999
+ portfolioCertaintyRatio,
28000
+ portfolioExpectedYearlyReturns,
28001
+ portfolioTradesPerYear,
27384
28002
  };
27385
28003
  }
27386
28004
  /**
@@ -27429,32 +28047,53 @@ class HeatmapStorage {
27429
28047
  return [
27430
28048
  `# Portfolio Heatmap: ${strategyName}`,
27431
28049
  "",
27432
- `**Total Symbols:** ${data.totalSymbols}
27433
- **Portfolio PNL:** ${data.portfolioTotalPnl !== null ? functoolsKit.str(data.portfolioTotalPnl, "%") : "N/A"}
27434
- **Pooled Sharpe:** ${data.portfolioSharpeRatio !== null ? functoolsKit.str(data.portfolioSharpeRatio) : "N/A"}
27435
- **Total Trades:** ${data.portfolioTotalTrades}
27436
- **Avg Peak PNL:** ${data.portfolioAvgPeakPnl !== null ? functoolsKit.str(data.portfolioAvgPeakPnl, "%") : "N/A"}
27437
- **Avg Max Drawdown PNL:** ${data.portfolioAvgFallPnl !== null ? functoolsKit.str(data.portfolioAvgFallPnl, "%") : "N/A"}`,
27438
- `**Standard Deviation Per Trade:** ${data.portfolioStdDev !== null ? functoolsKit.str(data.portfolioStdDev, "%") : "N/A"}
27439
- **Sortino Ratio:** ${data.portfolioSortinoRatio !== null ? functoolsKit.str(data.portfolioSortinoRatio) : "N/A"}
27440
- **Calmar Ratio:** ${data.portfolioCalmarRatio !== null ? functoolsKit.str(data.portfolioCalmarRatio) : "N/A"}
27441
- **Recovery Factor:** ${data.portfolioRecoveryFactor !== null ? functoolsKit.str(data.portfolioRecoveryFactor) : "N/A"}
27442
- **Expectancy:** ${data.portfolioExpectancy !== null ? functoolsKit.str(data.portfolioExpectancy, "%") : "N/A"}`,
28050
+ `**Total Symbols:** ${data.totalSymbols}`,
28051
+ `**Portfolio PNL:** ${data.portfolioTotalPnl !== null ? functoolsKit.str(data.portfolioTotalPnl, "%") : "N/A"}`,
28052
+ `**Pooled Sharpe:** ${data.portfolioSharpeRatio !== null ? functoolsKit.str(data.portfolioSharpeRatio) : "N/A"}`,
28053
+ `**Annualized Sharpe:** ${data.portfolioAnnualizedSharpeRatio !== null ? functoolsKit.str(data.portfolioAnnualizedSharpeRatio) : "N/A"}`,
28054
+ `**Certainty Ratio:** ${data.portfolioCertaintyRatio !== null ? functoolsKit.str(data.portfolioCertaintyRatio) : "N/A"}`,
28055
+ `**Expected Yearly Returns:** ${data.portfolioExpectedYearlyReturns !== null ? functoolsKit.str(data.portfolioExpectedYearlyReturns, "%") : "N/A"}`,
28056
+ `**Trades Per Year:** ${data.portfolioTradesPerYear !== null ? data.portfolioTradesPerYear.toFixed(1) : "N/A"}`,
28057
+ `**Total Trades:** ${data.portfolioTotalTrades}`,
28058
+ `**Avg Peak PNL:** ${data.portfolioAvgPeakPnl !== null ? functoolsKit.str(data.portfolioAvgPeakPnl, "%") : "N/A"}`,
28059
+ `**Avg Max Drawdown PNL:** ${data.portfolioAvgFallPnl !== null ? functoolsKit.str(data.portfolioAvgFallPnl, "%") : "N/A"}`,
28060
+ `**Peak Profit PNL:** ${data.portfolioPeakProfitPnl !== null ? functoolsKit.str(data.portfolioPeakProfitPnl, "%") : "N/A"}`,
28061
+ `**Max Drawdown PNL:** ${data.portfolioMaxDrawdownPnl !== null ? functoolsKit.str(data.portfolioMaxDrawdownPnl, "%") : "N/A"}`,
28062
+ `**Median PNL:** ${data.portfolioMedianPnl !== null ? functoolsKit.str(data.portfolioMedianPnl, "%") : "N/A"}`,
28063
+ `**Avg Duration:** ${data.portfolioAvgDuration !== null ? `${data.portfolioAvgDuration.toFixed(1)} min` : "N/A"}`,
28064
+ `**Avg Win Duration:** ${data.portfolioAvgWinDuration !== null ? `${data.portfolioAvgWinDuration.toFixed(1)} min` : "N/A"}`,
28065
+ `**Avg Loss Duration:** ${data.portfolioAvgLossDuration !== null ? `${data.portfolioAvgLossDuration.toFixed(1)} min` : "N/A"}`,
28066
+ `**Avg Consecutive Win PNL:** ${data.portfolioAvgConsecutiveWinPnl !== null ? functoolsKit.str(data.portfolioAvgConsecutiveWinPnl, "%") : "N/A"}`,
28067
+ `**Avg Consecutive Loss PNL:** ${data.portfolioAvgConsecutiveLossPnl !== null ? functoolsKit.str(data.portfolioAvgConsecutiveLossPnl, "%") : "N/A"}`,
28068
+ `**Standard Deviation Per Trade:** ${data.portfolioStdDev !== null ? functoolsKit.str(data.portfolioStdDev, "%") : "N/A"}`,
28069
+ `**Sortino Ratio:** ${data.portfolioSortinoRatio !== null ? functoolsKit.str(data.portfolioSortinoRatio) : "N/A"}`,
28070
+ `**Calmar Ratio:** ${data.portfolioCalmarRatio !== null ? functoolsKit.str(data.portfolioCalmarRatio) : "N/A"}`,
28071
+ `**Recovery Factor:** ${data.portfolioRecoveryFactor !== null ? functoolsKit.str(data.portfolioRecoveryFactor) : "N/A"}`,
28072
+ `**Expectancy:** ${data.portfolioExpectancy !== null ? functoolsKit.str(data.portfolioExpectancy, "%") : "N/A"}`,
27443
28073
  "",
27444
28074
  table,
27445
28075
  "",
27446
28076
  `*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
27447
28077
  `*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.*`,
28078
+ `*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.*`,
28079
+ `*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.*`,
28080
+ `*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.*`,
28081
+ `*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).*`,
27448
28082
  `*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals per symbol.*`,
27449
28083
  `*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".*`,
27450
- `*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
27451
28084
  `*Profit Factor: below 1.0 means strategy is losing overall. Above 1.5 is considered good.*`,
27452
28085
  `*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}.*`,
27453
28086
  `*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.*`,
27454
- `*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.*`,
28087
+ `*Expectancy: per-trade expected value (winProb × avgWin + lossProb × avgLoss). Positive = profitable on average per trade. Break-even trades contribute 0.*`,
28088
+ `*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).*`,
28089
+ `*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.*`,
28090
+ `*Peak Profit PNL / Max Drawdown PNL: extremes — the best best-case and worst worst-case observed across all trades. Tail behaviour the averages hide.*`,
28091
+ `*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").*`,
28092
+ `*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).*`,
28093
+ `*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.*`,
27455
28094
  `*All metrics require 100+ signals per symbol to be statistically reliable. Annualized metrics assume the observed trading frequency persists year-round.*`,
27456
28095
  `*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.*`,
27457
- `*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.*`,
28096
+ `*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.*`,
27458
28097
  ].join("\n");
27459
28098
  }
27460
28099
  /**