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