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