backtest-kit 7.7.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
@@ -3865,6 +3865,27 @@ class ClientExchange {
3865
3865
  const vwap = sumPriceVolume / totalVolume;
3866
3866
  return vwap;
3867
3867
  }
3868
+ /**
3869
+ * Returns the close price of the last completed candle for the given interval.
3870
+ *
3871
+ * Fetches a single candle for the requested interval and returns its close price.
3872
+ *
3873
+ * @param symbol - Trading pair symbol
3874
+ * @param interval - Candle time interval (e.g., "1m", "1h")
3875
+ * @returns Promise resolving to close price of the last candle
3876
+ * @throws Error if no candles available
3877
+ */
3878
+ async getClosePrice(symbol, interval) {
3879
+ this.params.logger.debug(`ClientExchange getClosePrice`, {
3880
+ symbol,
3881
+ interval,
3882
+ });
3883
+ const candles = await this.getCandles(symbol, interval, 1);
3884
+ if (candles.length === 0) {
3885
+ throw new Error(`ClientExchange getClosePrice: no candles data for symbol=${symbol}`);
3886
+ }
3887
+ return candles[candles.length - 1].close;
3888
+ }
3868
3889
  /**
3869
3890
  * Formats quantity according to exchange-specific rules for the given symbol.
3870
3891
  * Applies proper decimal precision and rounding based on symbol's lot size filters.
@@ -4270,6 +4291,22 @@ class ExchangeConnectionService {
4270
4291
  });
4271
4292
  return await this.getExchange(this.methodContextService.context.exchangeName).getAveragePrice(symbol);
4272
4293
  };
4294
+ /**
4295
+ * Returns the close price of the last completed candle for the given interval.
4296
+ *
4297
+ * Routes to exchange determined by methodContextService.context.exchangeName.
4298
+ *
4299
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
4300
+ * @param interval - Candle interval (e.g., "1h", "1d")
4301
+ * @returns Promise resolving to close price of the last candle
4302
+ */
4303
+ this.getClosePrice = async (symbol, interval) => {
4304
+ this.loggerService.log("exchangeConnectionService getClosePrice", {
4305
+ symbol,
4306
+ interval,
4307
+ });
4308
+ return await this.getExchange(this.methodContextService.context.exchangeName).getClosePrice(symbol, interval);
4309
+ };
4273
4310
  /**
4274
4311
  * Formats price according to exchange-specific precision rules.
4275
4312
  *
@@ -5508,6 +5545,10 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
5508
5545
  _peak: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5509
5546
  _fall: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5510
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
+ }
5511
5552
  // Валидируем сигнал перед возвратом
5512
5553
  validatePendingSignal(signalRow, currentPrice);
5513
5554
  return signalRow;
@@ -5557,6 +5598,10 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
5557
5598
  _peak: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5558
5599
  _fall: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5559
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
+ }
5560
5605
  // Валидируем сигнал перед возвратом
5561
5606
  validatePendingSignal(signalRow, currentPrice);
5562
5607
  return signalRow;
@@ -6269,6 +6314,10 @@ const ACTIVATE_SCHEDULED_SIGNAL_FN = async (self, scheduled, activationTimestamp
6269
6314
  _peak: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
6270
6315
  _fall: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
6271
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
+ }
6272
6321
  // Sync open: if external system rejects — cancel scheduled signal instead of opening
6273
6322
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(activationTime, activatedSignal.priceOpen, activatedSignal, self);
6274
6323
  if (!syncOpenAllowed) {
@@ -7152,6 +7201,10 @@ const ACTIVATE_SCHEDULED_SIGNAL_IN_BACKTEST_FN = async (self, scheduled, activat
7152
7201
  _peak: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
7153
7202
  _fall: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
7154
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
+ }
7155
7208
  // Sync open: if external system rejects — cancel scheduled signal instead of opening
7156
7209
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(activationTime, activatedSignal.priceOpen, activatedSignal, self);
7157
7210
  if (!syncOpenAllowed) {
@@ -7349,6 +7402,10 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles, fra
7349
7402
  _peak: { price: activatedSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
7350
7403
  _fall: { price: activatedSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
7351
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
+ }
7352
7409
  // Sync open: if external system rejects — cancel scheduled signal instead of opening
7353
7410
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(candle.timestamp, pendingSignal.priceOpen, pendingSignal, self);
7354
7411
  if (!syncOpenAllowed) {
@@ -8793,6 +8850,10 @@ class ClientStrategy {
8793
8850
  _peak: { price: activatedSignal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, pnlEntries: 0, priceOpen: 0 },
8794
8851
  _fall: { price: activatedSignal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, pnlEntries: 0, priceOpen: 0 },
8795
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
+ }
8796
8857
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(currentTime, currentPrice, pendingSignal, this);
8797
8858
  if (!syncOpenAllowed) {
8798
8859
  this.params.logger.info("ClientStrategy tick: user-activated signal rejected by sync", {
@@ -14860,6 +14921,34 @@ class ExchangeCoreService {
14860
14921
  backtest,
14861
14922
  });
14862
14923
  };
14924
+ /**
14925
+ * Returns the close price of the last completed candle for the given interval with execution context.
14926
+ *
14927
+ * @param symbol - Trading pair symbol
14928
+ * @param interval - Candle interval (e.g., "1m", "1h")
14929
+ * @param when - Timestamp for context (used in backtest mode)
14930
+ * @param backtest - Whether running in backtest mode
14931
+ * @returns Promise resolving to close price of the last candle
14932
+ */
14933
+ this.getClosePrice = async (symbol, interval, when, backtest) => {
14934
+ this.loggerService.log("exchangeCoreService getClosePrice", {
14935
+ symbol,
14936
+ interval,
14937
+ when,
14938
+ backtest,
14939
+ });
14940
+ if (!MethodContextService.hasContext()) {
14941
+ throw new Error("exchangeCoreService getClosePrice requires a method context");
14942
+ }
14943
+ await this.validate(this.methodContextService.context.exchangeName);
14944
+ return await ExecutionContextService.runInContext(async () => {
14945
+ return await this.exchangeConnectionService.getClosePrice(symbol, interval);
14946
+ }, {
14947
+ symbol,
14948
+ when,
14949
+ backtest,
14950
+ });
14951
+ };
14863
14952
  /**
14864
14953
  * Formats price with execution context.
14865
14954
  *
@@ -19328,6 +19417,24 @@ const heat_columns = [
19328
19417
  format: (data) => data.avgFallPnl !== null ? functoolsKit.str(data.avgFallPnl, "%") : "N/A",
19329
19418
  isVisible: () => true,
19330
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
+ },
19331
19438
  ];
19332
19439
 
19333
19440
  /**
@@ -20890,6 +20997,30 @@ const walker_strategy_columns = [
20890
20997
  : "N/A",
20891
20998
  isVisible: () => true,
20892
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
+ },
20893
21024
  {
20894
21025
  key: "firstEventTime",
20895
21026
  label: "First Event",
@@ -21439,13 +21570,13 @@ class ReportBase {
21439
21570
  * Waits for drain event if write buffer is full.
21440
21571
  * Times out after 15 seconds and returns TIMEOUT_SYMBOL.
21441
21572
  */
21442
- this[_d] = functoolsKit.timeout(async (line) => {
21573
+ this[_d] = functoolsKit.queued(functoolsKit.timeout(async (line) => {
21443
21574
  if (!this._stream.write(line)) {
21444
21575
  await new Promise((resolve) => {
21445
21576
  this._stream.once("drain", resolve);
21446
21577
  });
21447
21578
  }
21448
- }, 15000);
21579
+ }, 15000));
21449
21580
  LOGGER_SERVICE$3.debug(REPORT_BASE_METHOD_NAME_CTOR, {
21450
21581
  reportName: this.reportName,
21451
21582
  baseDir,
@@ -21733,6 +21864,9 @@ let ReportStorage$a = class ReportStorage {
21733
21864
  expectedYearlyReturns: null,
21734
21865
  avgPeakPnl: null,
21735
21866
  avgFallPnl: null,
21867
+ sortinoRatio: null,
21868
+ calmarRatio: null,
21869
+ recoveryFactor: null,
21736
21870
  };
21737
21871
  }
21738
21872
  const totalSignals = this._signalList.length;
@@ -21766,6 +21900,16 @@ let ReportStorage$a = class ReportStorage {
21766
21900
  // Calculate average peak and fall PNL across all signals
21767
21901
  const avgPeakPnl = this._signalList.reduce((sum, s) => sum + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / totalSignals;
21768
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;
21769
21913
  return {
21770
21914
  signalList: this._signalList,
21771
21915
  totalSignals,
@@ -21781,6 +21925,9 @@ let ReportStorage$a = class ReportStorage {
21781
21925
  expectedYearlyReturns: isUnsafe$3(expectedYearlyReturns) ? null : expectedYearlyReturns,
21782
21926
  avgPeakPnl: isUnsafe$3(avgPeakPnl) ? null : avgPeakPnl,
21783
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,
21784
21931
  };
21785
21932
  }
21786
21933
  /**
@@ -21827,6 +21974,9 @@ let ReportStorage$a = class ReportStorage {
21827
21974
  `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
21828
21975
  `**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
21829
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)`}`,
21830
21980
  ].join("\n");
21831
21981
  }
21832
21982
  /**
@@ -22427,6 +22577,9 @@ let ReportStorage$9 = class ReportStorage {
22427
22577
  expectedYearlyReturns: null,
22428
22578
  avgPeakPnl: null,
22429
22579
  avgFallPnl: null,
22580
+ sortinoRatio: null,
22581
+ calmarRatio: null,
22582
+ recoveryFactor: null,
22430
22583
  };
22431
22584
  }
22432
22585
  const closedEvents = this._eventList.filter((e) => e.action === "closed");
@@ -22476,6 +22629,21 @@ let ReportStorage$9 = class ReportStorage {
22476
22629
  const avgFallPnl = totalClosed > 0
22477
22630
  ? closedEvents.reduce((sum, e) => sum + (e.fallPnl || 0), 0) / totalClosed
22478
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;
22479
22647
  return {
22480
22648
  eventList: this._eventList,
22481
22649
  totalEvents: this._eventList.length,
@@ -22492,6 +22660,9 @@ let ReportStorage$9 = class ReportStorage {
22492
22660
  expectedYearlyReturns: isUnsafe$2(expectedYearlyReturns) ? null : expectedYearlyReturns,
22493
22661
  avgPeakPnl: isUnsafe$2(avgPeakPnl) ? null : avgPeakPnl,
22494
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,
22495
22666
  };
22496
22667
  }
22497
22668
  /**
@@ -22538,6 +22709,9 @@ let ReportStorage$9 = class ReportStorage {
22538
22709
  `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
22539
22710
  `**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
22540
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)`}`,
22541
22715
  ].join("\n");
22542
22716
  }
22543
22717
  /**
@@ -23983,6 +24157,9 @@ let ReportStorage$7 = class ReportStorage {
23983
24157
  "",
23984
24158
  `**Best ${results.metric}:** ${formatMetric(results.bestMetric)}`,
23985
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"}`,
23986
24163
  "",
23987
24164
  "## Top Strategies Comparison",
23988
24165
  "",
@@ -24449,6 +24626,27 @@ class HeatmapStorage {
24449
24626
  avgPeakPnl = signals.reduce((acc, s) => acc + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / signals.length;
24450
24627
  avgFallPnl = signals.reduce((acc, s) => acc + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / signals.length;
24451
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
+ }
24452
24650
  // Apply safe math checks
24453
24651
  if (isUnsafe(winRate))
24454
24652
  winRate = null;
@@ -24474,6 +24672,12 @@ class HeatmapStorage {
24474
24672
  avgPeakPnl = null;
24475
24673
  if (isUnsafe(avgFallPnl))
24476
24674
  avgFallPnl = null;
24675
+ if (isUnsafe(sortinoRatio))
24676
+ sortinoRatio = null;
24677
+ if (isUnsafe(calmarRatio))
24678
+ calmarRatio = null;
24679
+ if (isUnsafe(recoveryFactor))
24680
+ recoveryFactor = null;
24477
24681
  return {
24478
24682
  symbol,
24479
24683
  totalPnl,
@@ -24493,6 +24697,9 @@ class HeatmapStorage {
24493
24697
  expectancy,
24494
24698
  avgPeakPnl,
24495
24699
  avgFallPnl,
24700
+ sortinoRatio,
24701
+ calmarRatio,
24702
+ recoveryFactor,
24496
24703
  };
24497
24704
  }
24498
24705
  /**
@@ -29359,6 +29566,9 @@ class WalkerReportService {
29359
29566
  annualizedSharpeRatio: data.stats.annualizedSharpeRatio,
29360
29567
  certaintyRatio: data.stats.certaintyRatio,
29361
29568
  expectedYearlyReturns: data.stats.expectedYearlyReturns,
29569
+ sortinoRatio: data.stats.sortinoRatio,
29570
+ calmarRatio: data.stats.calmarRatio,
29571
+ recoveryFactor: data.stats.recoveryFactor,
29362
29572
  firstEventTime,
29363
29573
  lastEventTime,
29364
29574
  }, {
@@ -33548,6 +33758,7 @@ async function getBacktestTimeframe(symbol) {
33548
33758
 
33549
33759
  const EXCHANGE_METHOD_NAME_GET_CANDLES = "ExchangeUtils.getCandles";
33550
33760
  const EXCHANGE_METHOD_NAME_GET_AVERAGE_PRICE = "ExchangeUtils.getAveragePrice";
33761
+ const EXCHANGE_METHOD_NAME_GET_CLOSE_PRICE = "ExchangeUtils.getClosePrice";
33551
33762
  const EXCHANGE_METHOD_NAME_FORMAT_QUANTITY = "ExchangeUtils.formatQuantity";
33552
33763
  const EXCHANGE_METHOD_NAME_FORMAT_PRICE = "ExchangeUtils.formatPrice";
33553
33764
  const EXCHANGE_METHOD_NAME_GET_ORDER_BOOK = "ExchangeUtils.getOrderBook";
@@ -33905,6 +34116,35 @@ class ExchangeInstance {
33905
34116
  const vwap = sumPriceVolume / totalVolume;
33906
34117
  return vwap;
33907
34118
  };
34119
+ /**
34120
+ * Returns the close price of the last completed candle for the given interval.
34121
+ *
34122
+ * Fetches a single candle for the requested interval and returns its close price.
34123
+ *
34124
+ * @param symbol - Trading pair symbol
34125
+ * @param interval - Candle time interval (e.g., "1m", "1h")
34126
+ * @returns Promise resolving to close price of the last candle
34127
+ * @throws Error if no candles available
34128
+ *
34129
+ * @example
34130
+ * ```typescript
34131
+ * const instance = new ExchangeInstance("binance");
34132
+ * const close = await instance.getClosePrice("BTCUSDT", "1h");
34133
+ * console.log(close); // 50125.43
34134
+ * ```
34135
+ */
34136
+ this.getClosePrice = async (symbol, interval) => {
34137
+ backtest.loggerService.debug(`ExchangeInstance getClosePrice`, {
34138
+ exchangeName: this.exchangeName,
34139
+ symbol,
34140
+ interval,
34141
+ });
34142
+ const candles = await this.getCandles(symbol, interval, 1);
34143
+ if (candles.length === 0) {
34144
+ throw new Error(`ExchangeInstance getClosePrice: no candles data for symbol=${symbol}`);
34145
+ }
34146
+ return candles[candles.length - 1].close;
34147
+ };
33908
34148
  /**
33909
34149
  * Format quantity according to exchange precision rules.
33910
34150
  *
@@ -34262,6 +34502,28 @@ class ExchangeUtils {
34262
34502
  const instance = this._getInstance(context.exchangeName);
34263
34503
  return await instance.getAveragePrice(symbol);
34264
34504
  };
34505
+ /**
34506
+ * Returns the close price of the last completed candle for the given interval.
34507
+ *
34508
+ * @param symbol - Trading pair symbol
34509
+ * @param interval - Candle time interval (e.g., "1m", "1h")
34510
+ * @param context - Execution context with exchange name
34511
+ * @returns Promise resolving to close price of the last candle
34512
+ * @throws Error if no candles available
34513
+ *
34514
+ * @example
34515
+ * ```typescript
34516
+ * const close = await Exchange.getClosePrice("BTCUSDT", "1h", {
34517
+ * exchangeName: "binance"
34518
+ * });
34519
+ * console.log(close); // 50125.43
34520
+ * ```
34521
+ */
34522
+ this.getClosePrice = async (symbol, interval, context) => {
34523
+ backtest.exchangeValidationService.validate(context.exchangeName, EXCHANGE_METHOD_NAME_GET_CLOSE_PRICE);
34524
+ const instance = this._getInstance(context.exchangeName);
34525
+ return await instance.getClosePrice(symbol, interval);
34526
+ };
34265
34527
  /**
34266
34528
  * Format quantity according to exchange precision rules.
34267
34529
  *
@@ -34848,6 +35110,7 @@ function getActionSchema(actionName) {
34848
35110
 
34849
35111
  const GET_CANDLES_METHOD_NAME = "exchange.getCandles";
34850
35112
  const GET_AVERAGE_PRICE_METHOD_NAME = "exchange.getAveragePrice";
35113
+ const GET_CLOSE_PRICE_METHOD_NAME = "exchange.getClosePrice";
34851
35114
  const FORMAT_PRICE_METHOD_NAME = "exchange.formatPrice";
34852
35115
  const FORMAT_QUANTITY_METHOD_NAME = "exchange.formatQuantity";
34853
35116
  const GET_DATE_METHOD_NAME = "exchange.getDate";
@@ -34945,6 +35208,32 @@ async function getAveragePrice(symbol) {
34945
35208
  }
34946
35209
  return await backtest.exchangeConnectionService.getAveragePrice(symbol);
34947
35210
  }
35211
+ /**
35212
+ * Returns the close price of the last completed candle for the given interval.
35213
+ *
35214
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
35215
+ * @param interval - Candle interval ("1m" | "3m" | "5m" | "15m" | "30m" | "1h" | "2h" | "4h" | "6h" | "8h")
35216
+ * @returns Promise resolving to close price of the last candle
35217
+ *
35218
+ * @example
35219
+ * ```typescript
35220
+ * const close = await getClosePrice("BTCUSDT", "1h");
35221
+ * console.log(close); // 50125.43
35222
+ * ```
35223
+ */
35224
+ async function getClosePrice(symbol, interval) {
35225
+ backtest.loggerService.info(GET_CLOSE_PRICE_METHOD_NAME, {
35226
+ symbol,
35227
+ interval,
35228
+ });
35229
+ if (!ExecutionContextService.hasContext()) {
35230
+ throw new Error("getClosePrice requires an execution context");
35231
+ }
35232
+ if (!MethodContextService.hasContext()) {
35233
+ throw new Error("getClosePrice requires a method context");
35234
+ }
35235
+ return await backtest.exchangeConnectionService.getClosePrice(symbol, interval);
35236
+ }
34948
35237
  /**
34949
35238
  * Formats a price value according to exchange rules.
34950
35239
  *
@@ -61690,6 +61979,7 @@ exports.getAveragePrice = getAveragePrice;
61690
61979
  exports.getBacktestTimeframe = getBacktestTimeframe;
61691
61980
  exports.getBreakeven = getBreakeven;
61692
61981
  exports.getCandles = getCandles;
61982
+ exports.getClosePrice = getClosePrice;
61693
61983
  exports.getColumns = getColumns;
61694
61984
  exports.getConfig = getConfig;
61695
61985
  exports.getContext = getContext;