backtest-kit 7.8.0 → 8.0.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 +1804 -1761
- package/build/index.cjs +147 -2
- package/build/index.mjs +147 -2
- package/package.json +86 -86
- package/types.d.ts +19 -1
package/build/index.cjs
CHANGED
|
@@ -5545,6 +5545,10 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
|
|
|
5545
5545
|
_peak: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
5546
5546
|
_fall: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
5547
5547
|
};
|
|
5548
|
+
{
|
|
5549
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(signalRow, signal.priceOpen);
|
|
5550
|
+
signalRow._fall = { price: signal.priceOpen, timestamp: currentTime, pnlPercentage, pnlCost, priceClose, priceOpen, pnlEntries };
|
|
5551
|
+
}
|
|
5548
5552
|
// Валидируем сигнал перед возвратом
|
|
5549
5553
|
validatePendingSignal(signalRow, currentPrice);
|
|
5550
5554
|
return signalRow;
|
|
@@ -5594,6 +5598,10 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
|
|
|
5594
5598
|
_peak: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
5595
5599
|
_fall: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
5596
5600
|
};
|
|
5601
|
+
{
|
|
5602
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(signalRow, currentPrice);
|
|
5603
|
+
signalRow._fall = { price: currentPrice, timestamp: currentTime, pnlPercentage, pnlCost, priceClose, priceOpen, pnlEntries };
|
|
5604
|
+
}
|
|
5597
5605
|
// Валидируем сигнал перед возвратом
|
|
5598
5606
|
validatePendingSignal(signalRow, currentPrice);
|
|
5599
5607
|
return signalRow;
|
|
@@ -6306,6 +6314,10 @@ const ACTIVATE_SCHEDULED_SIGNAL_FN = async (self, scheduled, activationTimestamp
|
|
|
6306
6314
|
_peak: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
|
|
6307
6315
|
_fall: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
|
|
6308
6316
|
};
|
|
6317
|
+
{
|
|
6318
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(activatedSignal, activatedSignal.priceOpen);
|
|
6319
|
+
activatedSignal._fall = { price: activatedSignal.priceOpen, timestamp: activationTime, pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen };
|
|
6320
|
+
}
|
|
6309
6321
|
// Sync open: if external system rejects — cancel scheduled signal instead of opening
|
|
6310
6322
|
const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(activationTime, activatedSignal.priceOpen, activatedSignal, self);
|
|
6311
6323
|
if (!syncOpenAllowed) {
|
|
@@ -7189,6 +7201,10 @@ const ACTIVATE_SCHEDULED_SIGNAL_IN_BACKTEST_FN = async (self, scheduled, activat
|
|
|
7189
7201
|
_peak: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
|
|
7190
7202
|
_fall: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
|
|
7191
7203
|
};
|
|
7204
|
+
{
|
|
7205
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(activatedSignal, activatedSignal.priceOpen);
|
|
7206
|
+
activatedSignal._fall = { price: activatedSignal.priceOpen, timestamp: activationTime, pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen };
|
|
7207
|
+
}
|
|
7192
7208
|
// Sync open: if external system rejects — cancel scheduled signal instead of opening
|
|
7193
7209
|
const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(activationTime, activatedSignal.priceOpen, activatedSignal, self);
|
|
7194
7210
|
if (!syncOpenAllowed) {
|
|
@@ -7386,6 +7402,10 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles, fra
|
|
|
7386
7402
|
_peak: { price: activatedSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
7387
7403
|
_fall: { price: activatedSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
7388
7404
|
};
|
|
7405
|
+
{
|
|
7406
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(pendingSignal, pendingSignal.priceOpen);
|
|
7407
|
+
pendingSignal._fall = { price: pendingSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage, pnlCost, priceClose, priceOpen, pnlEntries };
|
|
7408
|
+
}
|
|
7389
7409
|
// Sync open: if external system rejects — cancel scheduled signal instead of opening
|
|
7390
7410
|
const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(candle.timestamp, pendingSignal.priceOpen, pendingSignal, self);
|
|
7391
7411
|
if (!syncOpenAllowed) {
|
|
@@ -8830,6 +8850,10 @@ class ClientStrategy {
|
|
|
8830
8850
|
_peak: { price: activatedSignal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, pnlEntries: 0, priceOpen: 0 },
|
|
8831
8851
|
_fall: { price: activatedSignal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, pnlEntries: 0, priceOpen: 0 },
|
|
8832
8852
|
};
|
|
8853
|
+
{
|
|
8854
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(pendingSignal, pendingSignal.priceOpen);
|
|
8855
|
+
pendingSignal._fall = { price: pendingSignal.priceOpen, timestamp: currentTime, pnlPercentage, pnlCost, priceClose, pnlEntries, priceOpen };
|
|
8856
|
+
}
|
|
8833
8857
|
const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(currentTime, currentPrice, pendingSignal, this);
|
|
8834
8858
|
if (!syncOpenAllowed) {
|
|
8835
8859
|
this.params.logger.info("ClientStrategy tick: user-activated signal rejected by sync", {
|
|
@@ -19393,6 +19417,24 @@ const heat_columns = [
|
|
|
19393
19417
|
format: (data) => data.avgFallPnl !== null ? functoolsKit.str(data.avgFallPnl, "%") : "N/A",
|
|
19394
19418
|
isVisible: () => true,
|
|
19395
19419
|
},
|
|
19420
|
+
{
|
|
19421
|
+
key: "sortinoRatio",
|
|
19422
|
+
label: "Sortino",
|
|
19423
|
+
format: (data) => data.sortinoRatio !== null ? functoolsKit.str(data.sortinoRatio) : "N/A",
|
|
19424
|
+
isVisible: () => true,
|
|
19425
|
+
},
|
|
19426
|
+
{
|
|
19427
|
+
key: "calmarRatio",
|
|
19428
|
+
label: "Calmar",
|
|
19429
|
+
format: (data) => data.calmarRatio !== null ? functoolsKit.str(data.calmarRatio) : "N/A",
|
|
19430
|
+
isVisible: () => true,
|
|
19431
|
+
},
|
|
19432
|
+
{
|
|
19433
|
+
key: "recoveryFactor",
|
|
19434
|
+
label: "Recovery",
|
|
19435
|
+
format: (data) => data.recoveryFactor !== null ? functoolsKit.str(data.recoveryFactor) : "N/A",
|
|
19436
|
+
isVisible: () => true,
|
|
19437
|
+
},
|
|
19396
19438
|
];
|
|
19397
19439
|
|
|
19398
19440
|
/**
|
|
@@ -20955,6 +20997,30 @@ const walker_strategy_columns = [
|
|
|
20955
20997
|
: "N/A",
|
|
20956
20998
|
isVisible: () => true,
|
|
20957
20999
|
},
|
|
21000
|
+
{
|
|
21001
|
+
key: "sortinoRatio",
|
|
21002
|
+
label: "Sortino",
|
|
21003
|
+
format: (data) => data.stats.sortinoRatio !== null
|
|
21004
|
+
? `${data.stats.sortinoRatio.toFixed(3)}`
|
|
21005
|
+
: "N/A",
|
|
21006
|
+
isVisible: () => true,
|
|
21007
|
+
},
|
|
21008
|
+
{
|
|
21009
|
+
key: "calmarRatio",
|
|
21010
|
+
label: "Calmar",
|
|
21011
|
+
format: (data) => data.stats.calmarRatio !== null
|
|
21012
|
+
? `${data.stats.calmarRatio.toFixed(3)}`
|
|
21013
|
+
: "N/A",
|
|
21014
|
+
isVisible: () => true,
|
|
21015
|
+
},
|
|
21016
|
+
{
|
|
21017
|
+
key: "recoveryFactor",
|
|
21018
|
+
label: "Recovery",
|
|
21019
|
+
format: (data) => data.stats.recoveryFactor !== null
|
|
21020
|
+
? `${data.stats.recoveryFactor.toFixed(3)}`
|
|
21021
|
+
: "N/A",
|
|
21022
|
+
isVisible: () => true,
|
|
21023
|
+
},
|
|
20958
21024
|
{
|
|
20959
21025
|
key: "firstEventTime",
|
|
20960
21026
|
label: "First Event",
|
|
@@ -21504,13 +21570,13 @@ class ReportBase {
|
|
|
21504
21570
|
* Waits for drain event if write buffer is full.
|
|
21505
21571
|
* Times out after 15 seconds and returns TIMEOUT_SYMBOL.
|
|
21506
21572
|
*/
|
|
21507
|
-
this[_d] = functoolsKit.timeout(async (line) => {
|
|
21573
|
+
this[_d] = functoolsKit.queued(functoolsKit.timeout(async (line) => {
|
|
21508
21574
|
if (!this._stream.write(line)) {
|
|
21509
21575
|
await new Promise((resolve) => {
|
|
21510
21576
|
this._stream.once("drain", resolve);
|
|
21511
21577
|
});
|
|
21512
21578
|
}
|
|
21513
|
-
}, 15000);
|
|
21579
|
+
}, 15000));
|
|
21514
21580
|
LOGGER_SERVICE$3.debug(REPORT_BASE_METHOD_NAME_CTOR, {
|
|
21515
21581
|
reportName: this.reportName,
|
|
21516
21582
|
baseDir,
|
|
@@ -21798,6 +21864,9 @@ let ReportStorage$a = class ReportStorage {
|
|
|
21798
21864
|
expectedYearlyReturns: null,
|
|
21799
21865
|
avgPeakPnl: null,
|
|
21800
21866
|
avgFallPnl: null,
|
|
21867
|
+
sortinoRatio: null,
|
|
21868
|
+
calmarRatio: null,
|
|
21869
|
+
recoveryFactor: null,
|
|
21801
21870
|
};
|
|
21802
21871
|
}
|
|
21803
21872
|
const totalSignals = this._signalList.length;
|
|
@@ -21831,6 +21900,16 @@ let ReportStorage$a = class ReportStorage {
|
|
|
21831
21900
|
// Calculate average peak and fall PNL across all signals
|
|
21832
21901
|
const avgPeakPnl = this._signalList.reduce((sum, s) => sum + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / totalSignals;
|
|
21833
21902
|
const avgFallPnl = this._signalList.reduce((sum, s) => sum + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / totalSignals;
|
|
21903
|
+
// Downside per signal: maxDrawdown.pnlPercentage captures the worst intra-trade dip
|
|
21904
|
+
const fallReturns = this._signalList.map((s) => s.signal.maxDrawdown?.pnlPercentage ?? 0);
|
|
21905
|
+
// Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
|
|
21906
|
+
const fallVariance = fallReturns.reduce((sum, r) => sum + Math.pow(r, 2), 0) / totalSignals;
|
|
21907
|
+
const fallDeviation = Math.sqrt(fallVariance);
|
|
21908
|
+
const sortinoRatio = fallDeviation > 0 ? avgPnl / fallDeviation : 0;
|
|
21909
|
+
// Avg absolute peak drawdown per signal — used as denominator for Calmar and Recovery
|
|
21910
|
+
const avgAbsFall = fallReturns.reduce((sum, r) => sum + Math.abs(r), 0) / totalSignals;
|
|
21911
|
+
const calmarRatio = avgAbsFall > 0 ? expectedYearlyReturns / avgAbsFall : 0;
|
|
21912
|
+
const recoveryFactor = avgAbsFall > 0 ? totalPnl / avgAbsFall : 0;
|
|
21834
21913
|
return {
|
|
21835
21914
|
signalList: this._signalList,
|
|
21836
21915
|
totalSignals,
|
|
@@ -21846,6 +21925,9 @@ let ReportStorage$a = class ReportStorage {
|
|
|
21846
21925
|
expectedYearlyReturns: isUnsafe$3(expectedYearlyReturns) ? null : expectedYearlyReturns,
|
|
21847
21926
|
avgPeakPnl: isUnsafe$3(avgPeakPnl) ? null : avgPeakPnl,
|
|
21848
21927
|
avgFallPnl: isUnsafe$3(avgFallPnl) ? null : avgFallPnl,
|
|
21928
|
+
sortinoRatio: isUnsafe$3(sortinoRatio) ? null : sortinoRatio,
|
|
21929
|
+
calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
|
|
21930
|
+
recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
|
|
21849
21931
|
};
|
|
21850
21932
|
}
|
|
21851
21933
|
/**
|
|
@@ -21892,6 +21974,9 @@ let ReportStorage$a = class ReportStorage {
|
|
|
21892
21974
|
`**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
|
|
21893
21975
|
`**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
|
|
21894
21976
|
`**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
|
|
21977
|
+
`**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
|
|
21978
|
+
`**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
|
|
21979
|
+
`**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
|
|
21895
21980
|
].join("\n");
|
|
21896
21981
|
}
|
|
21897
21982
|
/**
|
|
@@ -22492,6 +22577,9 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
22492
22577
|
expectedYearlyReturns: null,
|
|
22493
22578
|
avgPeakPnl: null,
|
|
22494
22579
|
avgFallPnl: null,
|
|
22580
|
+
sortinoRatio: null,
|
|
22581
|
+
calmarRatio: null,
|
|
22582
|
+
recoveryFactor: null,
|
|
22495
22583
|
};
|
|
22496
22584
|
}
|
|
22497
22585
|
const closedEvents = this._eventList.filter((e) => e.action === "closed");
|
|
@@ -22541,6 +22629,21 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
22541
22629
|
const avgFallPnl = totalClosed > 0
|
|
22542
22630
|
? closedEvents.reduce((sum, e) => sum + (e.fallPnl || 0), 0) / totalClosed
|
|
22543
22631
|
: 0;
|
|
22632
|
+
// Downside per signal: fallPnl captures the worst intra-trade dip (maxDrawdown.pnlPercentage)
|
|
22633
|
+
const fallReturns = closedEvents.map((e) => e.fallPnl || 0);
|
|
22634
|
+
// Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
|
|
22635
|
+
let sortinoRatio = 0;
|
|
22636
|
+
if (totalClosed > 0) {
|
|
22637
|
+
const fallVariance = fallReturns.reduce((sum, r) => sum + Math.pow(r, 2), 0) / totalClosed;
|
|
22638
|
+
const fallDeviation = Math.sqrt(fallVariance);
|
|
22639
|
+
sortinoRatio = fallDeviation > 0 ? avgPnl / fallDeviation : 0;
|
|
22640
|
+
}
|
|
22641
|
+
// Avg absolute peak drawdown per signal — denominator for Calmar and Recovery
|
|
22642
|
+
const avgAbsFall = totalClosed > 0
|
|
22643
|
+
? fallReturns.reduce((sum, r) => sum + Math.abs(r), 0) / totalClosed
|
|
22644
|
+
: 0;
|
|
22645
|
+
const calmarRatio = avgAbsFall > 0 ? expectedYearlyReturns / avgAbsFall : 0;
|
|
22646
|
+
const recoveryFactor = avgAbsFall > 0 ? totalPnl / avgAbsFall : 0;
|
|
22544
22647
|
return {
|
|
22545
22648
|
eventList: this._eventList,
|
|
22546
22649
|
totalEvents: this._eventList.length,
|
|
@@ -22557,6 +22660,9 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
22557
22660
|
expectedYearlyReturns: isUnsafe$2(expectedYearlyReturns) ? null : expectedYearlyReturns,
|
|
22558
22661
|
avgPeakPnl: isUnsafe$2(avgPeakPnl) ? null : avgPeakPnl,
|
|
22559
22662
|
avgFallPnl: isUnsafe$2(avgFallPnl) ? null : avgFallPnl,
|
|
22663
|
+
sortinoRatio: isUnsafe$2(sortinoRatio) ? null : sortinoRatio,
|
|
22664
|
+
calmarRatio: isUnsafe$2(calmarRatio) ? null : calmarRatio,
|
|
22665
|
+
recoveryFactor: isUnsafe$2(recoveryFactor) ? null : recoveryFactor,
|
|
22560
22666
|
};
|
|
22561
22667
|
}
|
|
22562
22668
|
/**
|
|
@@ -22603,6 +22709,9 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
22603
22709
|
`**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
|
|
22604
22710
|
`**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
|
|
22605
22711
|
`**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
|
|
22712
|
+
`**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
|
|
22713
|
+
`**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
|
|
22714
|
+
`**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
|
|
22606
22715
|
].join("\n");
|
|
22607
22716
|
}
|
|
22608
22717
|
/**
|
|
@@ -24048,6 +24157,9 @@ let ReportStorage$7 = class ReportStorage {
|
|
|
24048
24157
|
"",
|
|
24049
24158
|
`**Best ${results.metric}:** ${formatMetric(results.bestMetric)}`,
|
|
24050
24159
|
`**Total Signals:** ${bestStrategySignals}`,
|
|
24160
|
+
`**Sortino Ratio:** ${results.bestStats?.sortinoRatio != null ? `${results.bestStats.sortinoRatio.toFixed(3)} (higher is better)` : "N/A"}`,
|
|
24161
|
+
`**Calmar Ratio:** ${results.bestStats?.calmarRatio != null ? `${results.bestStats.calmarRatio.toFixed(3)} (higher is better)` : "N/A"}`,
|
|
24162
|
+
`**Recovery Factor:** ${results.bestStats?.recoveryFactor != null ? `${results.bestStats.recoveryFactor.toFixed(3)} (higher is better)` : "N/A"}`,
|
|
24051
24163
|
"",
|
|
24052
24164
|
"## Top Strategies Comparison",
|
|
24053
24165
|
"",
|
|
@@ -24514,6 +24626,27 @@ class HeatmapStorage {
|
|
|
24514
24626
|
avgPeakPnl = signals.reduce((acc, s) => acc + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / signals.length;
|
|
24515
24627
|
avgFallPnl = signals.reduce((acc, s) => acc + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / signals.length;
|
|
24516
24628
|
}
|
|
24629
|
+
// Downside per signal: maxDrawdown.pnlPercentage captures the worst intra-trade dip
|
|
24630
|
+
const fallReturns = signals.map((s) => s.signal.maxDrawdown?.pnlPercentage ?? 0);
|
|
24631
|
+
// Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
|
|
24632
|
+
let sortinoRatio = null;
|
|
24633
|
+
if (signals.length > 0 && avgPnl !== null) {
|
|
24634
|
+
const fallVariance = fallReturns.reduce((acc, r) => acc + Math.pow(r, 2), 0) / signals.length;
|
|
24635
|
+
const fallDeviation = Math.sqrt(fallVariance);
|
|
24636
|
+
if (fallDeviation > 0) {
|
|
24637
|
+
sortinoRatio = avgPnl / fallDeviation;
|
|
24638
|
+
}
|
|
24639
|
+
}
|
|
24640
|
+
// Avg absolute peak drawdown per signal — denominator for Calmar and Recovery
|
|
24641
|
+
const avgAbsFall = signals.length > 0
|
|
24642
|
+
? fallReturns.reduce((acc, r) => acc + Math.abs(r), 0) / signals.length
|
|
24643
|
+
: 0;
|
|
24644
|
+
let calmarRatio = null;
|
|
24645
|
+
let recoveryFactor = null;
|
|
24646
|
+
if (avgAbsFall > 0 && totalPnl !== null) {
|
|
24647
|
+
calmarRatio = totalPnl / avgAbsFall;
|
|
24648
|
+
recoveryFactor = totalPnl / avgAbsFall;
|
|
24649
|
+
}
|
|
24517
24650
|
// Apply safe math checks
|
|
24518
24651
|
if (isUnsafe(winRate))
|
|
24519
24652
|
winRate = null;
|
|
@@ -24539,6 +24672,12 @@ class HeatmapStorage {
|
|
|
24539
24672
|
avgPeakPnl = null;
|
|
24540
24673
|
if (isUnsafe(avgFallPnl))
|
|
24541
24674
|
avgFallPnl = null;
|
|
24675
|
+
if (isUnsafe(sortinoRatio))
|
|
24676
|
+
sortinoRatio = null;
|
|
24677
|
+
if (isUnsafe(calmarRatio))
|
|
24678
|
+
calmarRatio = null;
|
|
24679
|
+
if (isUnsafe(recoveryFactor))
|
|
24680
|
+
recoveryFactor = null;
|
|
24542
24681
|
return {
|
|
24543
24682
|
symbol,
|
|
24544
24683
|
totalPnl,
|
|
@@ -24558,6 +24697,9 @@ class HeatmapStorage {
|
|
|
24558
24697
|
expectancy,
|
|
24559
24698
|
avgPeakPnl,
|
|
24560
24699
|
avgFallPnl,
|
|
24700
|
+
sortinoRatio,
|
|
24701
|
+
calmarRatio,
|
|
24702
|
+
recoveryFactor,
|
|
24561
24703
|
};
|
|
24562
24704
|
}
|
|
24563
24705
|
/**
|
|
@@ -29424,6 +29566,9 @@ class WalkerReportService {
|
|
|
29424
29566
|
annualizedSharpeRatio: data.stats.annualizedSharpeRatio,
|
|
29425
29567
|
certaintyRatio: data.stats.certaintyRatio,
|
|
29426
29568
|
expectedYearlyReturns: data.stats.expectedYearlyReturns,
|
|
29569
|
+
sortinoRatio: data.stats.sortinoRatio,
|
|
29570
|
+
calmarRatio: data.stats.calmarRatio,
|
|
29571
|
+
recoveryFactor: data.stats.recoveryFactor,
|
|
29427
29572
|
firstEventTime,
|
|
29428
29573
|
lastEventTime,
|
|
29429
29574
|
}, {
|
package/build/index.mjs
CHANGED
|
@@ -5525,6 +5525,10 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
|
|
|
5525
5525
|
_peak: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
5526
5526
|
_fall: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
5527
5527
|
};
|
|
5528
|
+
{
|
|
5529
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(signalRow, signal.priceOpen);
|
|
5530
|
+
signalRow._fall = { price: signal.priceOpen, timestamp: currentTime, pnlPercentage, pnlCost, priceClose, priceOpen, pnlEntries };
|
|
5531
|
+
}
|
|
5528
5532
|
// Валидируем сигнал перед возвратом
|
|
5529
5533
|
validatePendingSignal(signalRow, currentPrice);
|
|
5530
5534
|
return signalRow;
|
|
@@ -5574,6 +5578,10 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
|
|
|
5574
5578
|
_peak: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
5575
5579
|
_fall: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
5576
5580
|
};
|
|
5581
|
+
{
|
|
5582
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(signalRow, currentPrice);
|
|
5583
|
+
signalRow._fall = { price: currentPrice, timestamp: currentTime, pnlPercentage, pnlCost, priceClose, priceOpen, pnlEntries };
|
|
5584
|
+
}
|
|
5577
5585
|
// Валидируем сигнал перед возвратом
|
|
5578
5586
|
validatePendingSignal(signalRow, currentPrice);
|
|
5579
5587
|
return signalRow;
|
|
@@ -6286,6 +6294,10 @@ const ACTIVATE_SCHEDULED_SIGNAL_FN = async (self, scheduled, activationTimestamp
|
|
|
6286
6294
|
_peak: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
|
|
6287
6295
|
_fall: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
|
|
6288
6296
|
};
|
|
6297
|
+
{
|
|
6298
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(activatedSignal, activatedSignal.priceOpen);
|
|
6299
|
+
activatedSignal._fall = { price: activatedSignal.priceOpen, timestamp: activationTime, pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen };
|
|
6300
|
+
}
|
|
6289
6301
|
// Sync open: if external system rejects — cancel scheduled signal instead of opening
|
|
6290
6302
|
const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(activationTime, activatedSignal.priceOpen, activatedSignal, self);
|
|
6291
6303
|
if (!syncOpenAllowed) {
|
|
@@ -7169,6 +7181,10 @@ const ACTIVATE_SCHEDULED_SIGNAL_IN_BACKTEST_FN = async (self, scheduled, activat
|
|
|
7169
7181
|
_peak: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
|
|
7170
7182
|
_fall: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
|
|
7171
7183
|
};
|
|
7184
|
+
{
|
|
7185
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(activatedSignal, activatedSignal.priceOpen);
|
|
7186
|
+
activatedSignal._fall = { price: activatedSignal.priceOpen, timestamp: activationTime, pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen };
|
|
7187
|
+
}
|
|
7172
7188
|
// Sync open: if external system rejects — cancel scheduled signal instead of opening
|
|
7173
7189
|
const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(activationTime, activatedSignal.priceOpen, activatedSignal, self);
|
|
7174
7190
|
if (!syncOpenAllowed) {
|
|
@@ -7366,6 +7382,10 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles, fra
|
|
|
7366
7382
|
_peak: { price: activatedSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
7367
7383
|
_fall: { price: activatedSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
7368
7384
|
};
|
|
7385
|
+
{
|
|
7386
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(pendingSignal, pendingSignal.priceOpen);
|
|
7387
|
+
pendingSignal._fall = { price: pendingSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage, pnlCost, priceClose, priceOpen, pnlEntries };
|
|
7388
|
+
}
|
|
7369
7389
|
// Sync open: if external system rejects — cancel scheduled signal instead of opening
|
|
7370
7390
|
const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(candle.timestamp, pendingSignal.priceOpen, pendingSignal, self);
|
|
7371
7391
|
if (!syncOpenAllowed) {
|
|
@@ -8810,6 +8830,10 @@ class ClientStrategy {
|
|
|
8810
8830
|
_peak: { price: activatedSignal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, pnlEntries: 0, priceOpen: 0 },
|
|
8811
8831
|
_fall: { price: activatedSignal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, pnlEntries: 0, priceOpen: 0 },
|
|
8812
8832
|
};
|
|
8833
|
+
{
|
|
8834
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(pendingSignal, pendingSignal.priceOpen);
|
|
8835
|
+
pendingSignal._fall = { price: pendingSignal.priceOpen, timestamp: currentTime, pnlPercentage, pnlCost, priceClose, pnlEntries, priceOpen };
|
|
8836
|
+
}
|
|
8813
8837
|
const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(currentTime, currentPrice, pendingSignal, this);
|
|
8814
8838
|
if (!syncOpenAllowed) {
|
|
8815
8839
|
this.params.logger.info("ClientStrategy tick: user-activated signal rejected by sync", {
|
|
@@ -19373,6 +19397,24 @@ const heat_columns = [
|
|
|
19373
19397
|
format: (data) => data.avgFallPnl !== null ? str(data.avgFallPnl, "%") : "N/A",
|
|
19374
19398
|
isVisible: () => true,
|
|
19375
19399
|
},
|
|
19400
|
+
{
|
|
19401
|
+
key: "sortinoRatio",
|
|
19402
|
+
label: "Sortino",
|
|
19403
|
+
format: (data) => data.sortinoRatio !== null ? str(data.sortinoRatio) : "N/A",
|
|
19404
|
+
isVisible: () => true,
|
|
19405
|
+
},
|
|
19406
|
+
{
|
|
19407
|
+
key: "calmarRatio",
|
|
19408
|
+
label: "Calmar",
|
|
19409
|
+
format: (data) => data.calmarRatio !== null ? str(data.calmarRatio) : "N/A",
|
|
19410
|
+
isVisible: () => true,
|
|
19411
|
+
},
|
|
19412
|
+
{
|
|
19413
|
+
key: "recoveryFactor",
|
|
19414
|
+
label: "Recovery",
|
|
19415
|
+
format: (data) => data.recoveryFactor !== null ? str(data.recoveryFactor) : "N/A",
|
|
19416
|
+
isVisible: () => true,
|
|
19417
|
+
},
|
|
19376
19418
|
];
|
|
19377
19419
|
|
|
19378
19420
|
/**
|
|
@@ -20935,6 +20977,30 @@ const walker_strategy_columns = [
|
|
|
20935
20977
|
: "N/A",
|
|
20936
20978
|
isVisible: () => true,
|
|
20937
20979
|
},
|
|
20980
|
+
{
|
|
20981
|
+
key: "sortinoRatio",
|
|
20982
|
+
label: "Sortino",
|
|
20983
|
+
format: (data) => data.stats.sortinoRatio !== null
|
|
20984
|
+
? `${data.stats.sortinoRatio.toFixed(3)}`
|
|
20985
|
+
: "N/A",
|
|
20986
|
+
isVisible: () => true,
|
|
20987
|
+
},
|
|
20988
|
+
{
|
|
20989
|
+
key: "calmarRatio",
|
|
20990
|
+
label: "Calmar",
|
|
20991
|
+
format: (data) => data.stats.calmarRatio !== null
|
|
20992
|
+
? `${data.stats.calmarRatio.toFixed(3)}`
|
|
20993
|
+
: "N/A",
|
|
20994
|
+
isVisible: () => true,
|
|
20995
|
+
},
|
|
20996
|
+
{
|
|
20997
|
+
key: "recoveryFactor",
|
|
20998
|
+
label: "Recovery",
|
|
20999
|
+
format: (data) => data.stats.recoveryFactor !== null
|
|
21000
|
+
? `${data.stats.recoveryFactor.toFixed(3)}`
|
|
21001
|
+
: "N/A",
|
|
21002
|
+
isVisible: () => true,
|
|
21003
|
+
},
|
|
20938
21004
|
{
|
|
20939
21005
|
key: "firstEventTime",
|
|
20940
21006
|
label: "First Event",
|
|
@@ -21484,13 +21550,13 @@ class ReportBase {
|
|
|
21484
21550
|
* Waits for drain event if write buffer is full.
|
|
21485
21551
|
* Times out after 15 seconds and returns TIMEOUT_SYMBOL.
|
|
21486
21552
|
*/
|
|
21487
|
-
this[_d] = timeout(async (line) => {
|
|
21553
|
+
this[_d] = queued(timeout(async (line) => {
|
|
21488
21554
|
if (!this._stream.write(line)) {
|
|
21489
21555
|
await new Promise((resolve) => {
|
|
21490
21556
|
this._stream.once("drain", resolve);
|
|
21491
21557
|
});
|
|
21492
21558
|
}
|
|
21493
|
-
}, 15000);
|
|
21559
|
+
}, 15000));
|
|
21494
21560
|
LOGGER_SERVICE$3.debug(REPORT_BASE_METHOD_NAME_CTOR, {
|
|
21495
21561
|
reportName: this.reportName,
|
|
21496
21562
|
baseDir,
|
|
@@ -21778,6 +21844,9 @@ let ReportStorage$a = class ReportStorage {
|
|
|
21778
21844
|
expectedYearlyReturns: null,
|
|
21779
21845
|
avgPeakPnl: null,
|
|
21780
21846
|
avgFallPnl: null,
|
|
21847
|
+
sortinoRatio: null,
|
|
21848
|
+
calmarRatio: null,
|
|
21849
|
+
recoveryFactor: null,
|
|
21781
21850
|
};
|
|
21782
21851
|
}
|
|
21783
21852
|
const totalSignals = this._signalList.length;
|
|
@@ -21811,6 +21880,16 @@ let ReportStorage$a = class ReportStorage {
|
|
|
21811
21880
|
// Calculate average peak and fall PNL across all signals
|
|
21812
21881
|
const avgPeakPnl = this._signalList.reduce((sum, s) => sum + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / totalSignals;
|
|
21813
21882
|
const avgFallPnl = this._signalList.reduce((sum, s) => sum + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / totalSignals;
|
|
21883
|
+
// Downside per signal: maxDrawdown.pnlPercentage captures the worst intra-trade dip
|
|
21884
|
+
const fallReturns = this._signalList.map((s) => s.signal.maxDrawdown?.pnlPercentage ?? 0);
|
|
21885
|
+
// Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
|
|
21886
|
+
const fallVariance = fallReturns.reduce((sum, r) => sum + Math.pow(r, 2), 0) / totalSignals;
|
|
21887
|
+
const fallDeviation = Math.sqrt(fallVariance);
|
|
21888
|
+
const sortinoRatio = fallDeviation > 0 ? avgPnl / fallDeviation : 0;
|
|
21889
|
+
// Avg absolute peak drawdown per signal — used as denominator for Calmar and Recovery
|
|
21890
|
+
const avgAbsFall = fallReturns.reduce((sum, r) => sum + Math.abs(r), 0) / totalSignals;
|
|
21891
|
+
const calmarRatio = avgAbsFall > 0 ? expectedYearlyReturns / avgAbsFall : 0;
|
|
21892
|
+
const recoveryFactor = avgAbsFall > 0 ? totalPnl / avgAbsFall : 0;
|
|
21814
21893
|
return {
|
|
21815
21894
|
signalList: this._signalList,
|
|
21816
21895
|
totalSignals,
|
|
@@ -21826,6 +21905,9 @@ let ReportStorage$a = class ReportStorage {
|
|
|
21826
21905
|
expectedYearlyReturns: isUnsafe$3(expectedYearlyReturns) ? null : expectedYearlyReturns,
|
|
21827
21906
|
avgPeakPnl: isUnsafe$3(avgPeakPnl) ? null : avgPeakPnl,
|
|
21828
21907
|
avgFallPnl: isUnsafe$3(avgFallPnl) ? null : avgFallPnl,
|
|
21908
|
+
sortinoRatio: isUnsafe$3(sortinoRatio) ? null : sortinoRatio,
|
|
21909
|
+
calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
|
|
21910
|
+
recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
|
|
21829
21911
|
};
|
|
21830
21912
|
}
|
|
21831
21913
|
/**
|
|
@@ -21872,6 +21954,9 @@ let ReportStorage$a = class ReportStorage {
|
|
|
21872
21954
|
`**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
|
|
21873
21955
|
`**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
|
|
21874
21956
|
`**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
|
|
21957
|
+
`**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
|
|
21958
|
+
`**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
|
|
21959
|
+
`**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
|
|
21875
21960
|
].join("\n");
|
|
21876
21961
|
}
|
|
21877
21962
|
/**
|
|
@@ -22472,6 +22557,9 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
22472
22557
|
expectedYearlyReturns: null,
|
|
22473
22558
|
avgPeakPnl: null,
|
|
22474
22559
|
avgFallPnl: null,
|
|
22560
|
+
sortinoRatio: null,
|
|
22561
|
+
calmarRatio: null,
|
|
22562
|
+
recoveryFactor: null,
|
|
22475
22563
|
};
|
|
22476
22564
|
}
|
|
22477
22565
|
const closedEvents = this._eventList.filter((e) => e.action === "closed");
|
|
@@ -22521,6 +22609,21 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
22521
22609
|
const avgFallPnl = totalClosed > 0
|
|
22522
22610
|
? closedEvents.reduce((sum, e) => sum + (e.fallPnl || 0), 0) / totalClosed
|
|
22523
22611
|
: 0;
|
|
22612
|
+
// Downside per signal: fallPnl captures the worst intra-trade dip (maxDrawdown.pnlPercentage)
|
|
22613
|
+
const fallReturns = closedEvents.map((e) => e.fallPnl || 0);
|
|
22614
|
+
// Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
|
|
22615
|
+
let sortinoRatio = 0;
|
|
22616
|
+
if (totalClosed > 0) {
|
|
22617
|
+
const fallVariance = fallReturns.reduce((sum, r) => sum + Math.pow(r, 2), 0) / totalClosed;
|
|
22618
|
+
const fallDeviation = Math.sqrt(fallVariance);
|
|
22619
|
+
sortinoRatio = fallDeviation > 0 ? avgPnl / fallDeviation : 0;
|
|
22620
|
+
}
|
|
22621
|
+
// Avg absolute peak drawdown per signal — denominator for Calmar and Recovery
|
|
22622
|
+
const avgAbsFall = totalClosed > 0
|
|
22623
|
+
? fallReturns.reduce((sum, r) => sum + Math.abs(r), 0) / totalClosed
|
|
22624
|
+
: 0;
|
|
22625
|
+
const calmarRatio = avgAbsFall > 0 ? expectedYearlyReturns / avgAbsFall : 0;
|
|
22626
|
+
const recoveryFactor = avgAbsFall > 0 ? totalPnl / avgAbsFall : 0;
|
|
22524
22627
|
return {
|
|
22525
22628
|
eventList: this._eventList,
|
|
22526
22629
|
totalEvents: this._eventList.length,
|
|
@@ -22537,6 +22640,9 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
22537
22640
|
expectedYearlyReturns: isUnsafe$2(expectedYearlyReturns) ? null : expectedYearlyReturns,
|
|
22538
22641
|
avgPeakPnl: isUnsafe$2(avgPeakPnl) ? null : avgPeakPnl,
|
|
22539
22642
|
avgFallPnl: isUnsafe$2(avgFallPnl) ? null : avgFallPnl,
|
|
22643
|
+
sortinoRatio: isUnsafe$2(sortinoRatio) ? null : sortinoRatio,
|
|
22644
|
+
calmarRatio: isUnsafe$2(calmarRatio) ? null : calmarRatio,
|
|
22645
|
+
recoveryFactor: isUnsafe$2(recoveryFactor) ? null : recoveryFactor,
|
|
22540
22646
|
};
|
|
22541
22647
|
}
|
|
22542
22648
|
/**
|
|
@@ -22583,6 +22689,9 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
22583
22689
|
`**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
|
|
22584
22690
|
`**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
|
|
22585
22691
|
`**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
|
|
22692
|
+
`**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
|
|
22693
|
+
`**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
|
|
22694
|
+
`**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
|
|
22586
22695
|
].join("\n");
|
|
22587
22696
|
}
|
|
22588
22697
|
/**
|
|
@@ -24028,6 +24137,9 @@ let ReportStorage$7 = class ReportStorage {
|
|
|
24028
24137
|
"",
|
|
24029
24138
|
`**Best ${results.metric}:** ${formatMetric(results.bestMetric)}`,
|
|
24030
24139
|
`**Total Signals:** ${bestStrategySignals}`,
|
|
24140
|
+
`**Sortino Ratio:** ${results.bestStats?.sortinoRatio != null ? `${results.bestStats.sortinoRatio.toFixed(3)} (higher is better)` : "N/A"}`,
|
|
24141
|
+
`**Calmar Ratio:** ${results.bestStats?.calmarRatio != null ? `${results.bestStats.calmarRatio.toFixed(3)} (higher is better)` : "N/A"}`,
|
|
24142
|
+
`**Recovery Factor:** ${results.bestStats?.recoveryFactor != null ? `${results.bestStats.recoveryFactor.toFixed(3)} (higher is better)` : "N/A"}`,
|
|
24031
24143
|
"",
|
|
24032
24144
|
"## Top Strategies Comparison",
|
|
24033
24145
|
"",
|
|
@@ -24494,6 +24606,27 @@ class HeatmapStorage {
|
|
|
24494
24606
|
avgPeakPnl = signals.reduce((acc, s) => acc + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / signals.length;
|
|
24495
24607
|
avgFallPnl = signals.reduce((acc, s) => acc + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / signals.length;
|
|
24496
24608
|
}
|
|
24609
|
+
// Downside per signal: maxDrawdown.pnlPercentage captures the worst intra-trade dip
|
|
24610
|
+
const fallReturns = signals.map((s) => s.signal.maxDrawdown?.pnlPercentage ?? 0);
|
|
24611
|
+
// Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
|
|
24612
|
+
let sortinoRatio = null;
|
|
24613
|
+
if (signals.length > 0 && avgPnl !== null) {
|
|
24614
|
+
const fallVariance = fallReturns.reduce((acc, r) => acc + Math.pow(r, 2), 0) / signals.length;
|
|
24615
|
+
const fallDeviation = Math.sqrt(fallVariance);
|
|
24616
|
+
if (fallDeviation > 0) {
|
|
24617
|
+
sortinoRatio = avgPnl / fallDeviation;
|
|
24618
|
+
}
|
|
24619
|
+
}
|
|
24620
|
+
// Avg absolute peak drawdown per signal — denominator for Calmar and Recovery
|
|
24621
|
+
const avgAbsFall = signals.length > 0
|
|
24622
|
+
? fallReturns.reduce((acc, r) => acc + Math.abs(r), 0) / signals.length
|
|
24623
|
+
: 0;
|
|
24624
|
+
let calmarRatio = null;
|
|
24625
|
+
let recoveryFactor = null;
|
|
24626
|
+
if (avgAbsFall > 0 && totalPnl !== null) {
|
|
24627
|
+
calmarRatio = totalPnl / avgAbsFall;
|
|
24628
|
+
recoveryFactor = totalPnl / avgAbsFall;
|
|
24629
|
+
}
|
|
24497
24630
|
// Apply safe math checks
|
|
24498
24631
|
if (isUnsafe(winRate))
|
|
24499
24632
|
winRate = null;
|
|
@@ -24519,6 +24652,12 @@ class HeatmapStorage {
|
|
|
24519
24652
|
avgPeakPnl = null;
|
|
24520
24653
|
if (isUnsafe(avgFallPnl))
|
|
24521
24654
|
avgFallPnl = null;
|
|
24655
|
+
if (isUnsafe(sortinoRatio))
|
|
24656
|
+
sortinoRatio = null;
|
|
24657
|
+
if (isUnsafe(calmarRatio))
|
|
24658
|
+
calmarRatio = null;
|
|
24659
|
+
if (isUnsafe(recoveryFactor))
|
|
24660
|
+
recoveryFactor = null;
|
|
24522
24661
|
return {
|
|
24523
24662
|
symbol,
|
|
24524
24663
|
totalPnl,
|
|
@@ -24538,6 +24677,9 @@ class HeatmapStorage {
|
|
|
24538
24677
|
expectancy,
|
|
24539
24678
|
avgPeakPnl,
|
|
24540
24679
|
avgFallPnl,
|
|
24680
|
+
sortinoRatio,
|
|
24681
|
+
calmarRatio,
|
|
24682
|
+
recoveryFactor,
|
|
24541
24683
|
};
|
|
24542
24684
|
}
|
|
24543
24685
|
/**
|
|
@@ -29404,6 +29546,9 @@ class WalkerReportService {
|
|
|
29404
29546
|
annualizedSharpeRatio: data.stats.annualizedSharpeRatio,
|
|
29405
29547
|
certaintyRatio: data.stats.certaintyRatio,
|
|
29406
29548
|
expectedYearlyReturns: data.stats.expectedYearlyReturns,
|
|
29549
|
+
sortinoRatio: data.stats.sortinoRatio,
|
|
29550
|
+
calmarRatio: data.stats.calmarRatio,
|
|
29551
|
+
recoveryFactor: data.stats.recoveryFactor,
|
|
29407
29552
|
firstEventTime,
|
|
29408
29553
|
lastEventTime,
|
|
29409
29554
|
}, {
|