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/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
  }, {