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