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