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.mjs CHANGED
@@ -3845,6 +3845,27 @@ class ClientExchange {
3845
3845
  const vwap = sumPriceVolume / totalVolume;
3846
3846
  return vwap;
3847
3847
  }
3848
+ /**
3849
+ * Returns the close price of the last completed candle for the given interval.
3850
+ *
3851
+ * Fetches a single candle for the requested interval and returns its close price.
3852
+ *
3853
+ * @param symbol - Trading pair symbol
3854
+ * @param interval - Candle time interval (e.g., "1m", "1h")
3855
+ * @returns Promise resolving to close price of the last candle
3856
+ * @throws Error if no candles available
3857
+ */
3858
+ async getClosePrice(symbol, interval) {
3859
+ this.params.logger.debug(`ClientExchange getClosePrice`, {
3860
+ symbol,
3861
+ interval,
3862
+ });
3863
+ const candles = await this.getCandles(symbol, interval, 1);
3864
+ if (candles.length === 0) {
3865
+ throw new Error(`ClientExchange getClosePrice: no candles data for symbol=${symbol}`);
3866
+ }
3867
+ return candles[candles.length - 1].close;
3868
+ }
3848
3869
  /**
3849
3870
  * Formats quantity according to exchange-specific rules for the given symbol.
3850
3871
  * Applies proper decimal precision and rounding based on symbol's lot size filters.
@@ -4250,6 +4271,22 @@ class ExchangeConnectionService {
4250
4271
  });
4251
4272
  return await this.getExchange(this.methodContextService.context.exchangeName).getAveragePrice(symbol);
4252
4273
  };
4274
+ /**
4275
+ * Returns the close price of the last completed candle for the given interval.
4276
+ *
4277
+ * Routes to exchange determined by methodContextService.context.exchangeName.
4278
+ *
4279
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
4280
+ * @param interval - Candle interval (e.g., "1h", "1d")
4281
+ * @returns Promise resolving to close price of the last candle
4282
+ */
4283
+ this.getClosePrice = async (symbol, interval) => {
4284
+ this.loggerService.log("exchangeConnectionService getClosePrice", {
4285
+ symbol,
4286
+ interval,
4287
+ });
4288
+ return await this.getExchange(this.methodContextService.context.exchangeName).getClosePrice(symbol, interval);
4289
+ };
4253
4290
  /**
4254
4291
  * Formats price according to exchange-specific precision rules.
4255
4292
  *
@@ -5488,6 +5525,10 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
5488
5525
  _peak: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5489
5526
  _fall: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5490
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
+ }
5491
5532
  // Валидируем сигнал перед возвратом
5492
5533
  validatePendingSignal(signalRow, currentPrice);
5493
5534
  return signalRow;
@@ -5537,6 +5578,10 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
5537
5578
  _peak: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5538
5579
  _fall: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5539
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
+ }
5540
5585
  // Валидируем сигнал перед возвратом
5541
5586
  validatePendingSignal(signalRow, currentPrice);
5542
5587
  return signalRow;
@@ -6249,6 +6294,10 @@ const ACTIVATE_SCHEDULED_SIGNAL_FN = async (self, scheduled, activationTimestamp
6249
6294
  _peak: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
6250
6295
  _fall: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
6251
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
+ }
6252
6301
  // Sync open: if external system rejects — cancel scheduled signal instead of opening
6253
6302
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(activationTime, activatedSignal.priceOpen, activatedSignal, self);
6254
6303
  if (!syncOpenAllowed) {
@@ -7132,6 +7181,10 @@ const ACTIVATE_SCHEDULED_SIGNAL_IN_BACKTEST_FN = async (self, scheduled, activat
7132
7181
  _peak: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
7133
7182
  _fall: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
7134
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
+ }
7135
7188
  // Sync open: if external system rejects — cancel scheduled signal instead of opening
7136
7189
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(activationTime, activatedSignal.priceOpen, activatedSignal, self);
7137
7190
  if (!syncOpenAllowed) {
@@ -7329,6 +7382,10 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles, fra
7329
7382
  _peak: { price: activatedSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
7330
7383
  _fall: { price: activatedSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
7331
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
+ }
7332
7389
  // Sync open: if external system rejects — cancel scheduled signal instead of opening
7333
7390
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(candle.timestamp, pendingSignal.priceOpen, pendingSignal, self);
7334
7391
  if (!syncOpenAllowed) {
@@ -8773,6 +8830,10 @@ class ClientStrategy {
8773
8830
  _peak: { price: activatedSignal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, pnlEntries: 0, priceOpen: 0 },
8774
8831
  _fall: { price: activatedSignal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, pnlEntries: 0, priceOpen: 0 },
8775
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
+ }
8776
8837
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(currentTime, currentPrice, pendingSignal, this);
8777
8838
  if (!syncOpenAllowed) {
8778
8839
  this.params.logger.info("ClientStrategy tick: user-activated signal rejected by sync", {
@@ -14840,6 +14901,34 @@ class ExchangeCoreService {
14840
14901
  backtest,
14841
14902
  });
14842
14903
  };
14904
+ /**
14905
+ * Returns the close price of the last completed candle for the given interval with execution context.
14906
+ *
14907
+ * @param symbol - Trading pair symbol
14908
+ * @param interval - Candle interval (e.g., "1m", "1h")
14909
+ * @param when - Timestamp for context (used in backtest mode)
14910
+ * @param backtest - Whether running in backtest mode
14911
+ * @returns Promise resolving to close price of the last candle
14912
+ */
14913
+ this.getClosePrice = async (symbol, interval, when, backtest) => {
14914
+ this.loggerService.log("exchangeCoreService getClosePrice", {
14915
+ symbol,
14916
+ interval,
14917
+ when,
14918
+ backtest,
14919
+ });
14920
+ if (!MethodContextService.hasContext()) {
14921
+ throw new Error("exchangeCoreService getClosePrice requires a method context");
14922
+ }
14923
+ await this.validate(this.methodContextService.context.exchangeName);
14924
+ return await ExecutionContextService.runInContext(async () => {
14925
+ return await this.exchangeConnectionService.getClosePrice(symbol, interval);
14926
+ }, {
14927
+ symbol,
14928
+ when,
14929
+ backtest,
14930
+ });
14931
+ };
14843
14932
  /**
14844
14933
  * Formats price with execution context.
14845
14934
  *
@@ -19308,6 +19397,24 @@ const heat_columns = [
19308
19397
  format: (data) => data.avgFallPnl !== null ? str(data.avgFallPnl, "%") : "N/A",
19309
19398
  isVisible: () => true,
19310
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
+ },
19311
19418
  ];
19312
19419
 
19313
19420
  /**
@@ -20870,6 +20977,30 @@ const walker_strategy_columns = [
20870
20977
  : "N/A",
20871
20978
  isVisible: () => true,
20872
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
+ },
20873
21004
  {
20874
21005
  key: "firstEventTime",
20875
21006
  label: "First Event",
@@ -21419,13 +21550,13 @@ class ReportBase {
21419
21550
  * Waits for drain event if write buffer is full.
21420
21551
  * Times out after 15 seconds and returns TIMEOUT_SYMBOL.
21421
21552
  */
21422
- this[_d] = timeout(async (line) => {
21553
+ this[_d] = queued(timeout(async (line) => {
21423
21554
  if (!this._stream.write(line)) {
21424
21555
  await new Promise((resolve) => {
21425
21556
  this._stream.once("drain", resolve);
21426
21557
  });
21427
21558
  }
21428
- }, 15000);
21559
+ }, 15000));
21429
21560
  LOGGER_SERVICE$3.debug(REPORT_BASE_METHOD_NAME_CTOR, {
21430
21561
  reportName: this.reportName,
21431
21562
  baseDir,
@@ -21713,6 +21844,9 @@ let ReportStorage$a = class ReportStorage {
21713
21844
  expectedYearlyReturns: null,
21714
21845
  avgPeakPnl: null,
21715
21846
  avgFallPnl: null,
21847
+ sortinoRatio: null,
21848
+ calmarRatio: null,
21849
+ recoveryFactor: null,
21716
21850
  };
21717
21851
  }
21718
21852
  const totalSignals = this._signalList.length;
@@ -21746,6 +21880,16 @@ let ReportStorage$a = class ReportStorage {
21746
21880
  // Calculate average peak and fall PNL across all signals
21747
21881
  const avgPeakPnl = this._signalList.reduce((sum, s) => sum + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / totalSignals;
21748
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;
21749
21893
  return {
21750
21894
  signalList: this._signalList,
21751
21895
  totalSignals,
@@ -21761,6 +21905,9 @@ let ReportStorage$a = class ReportStorage {
21761
21905
  expectedYearlyReturns: isUnsafe$3(expectedYearlyReturns) ? null : expectedYearlyReturns,
21762
21906
  avgPeakPnl: isUnsafe$3(avgPeakPnl) ? null : avgPeakPnl,
21763
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,
21764
21911
  };
21765
21912
  }
21766
21913
  /**
@@ -21807,6 +21954,9 @@ let ReportStorage$a = class ReportStorage {
21807
21954
  `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
21808
21955
  `**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
21809
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)`}`,
21810
21960
  ].join("\n");
21811
21961
  }
21812
21962
  /**
@@ -22407,6 +22557,9 @@ let ReportStorage$9 = class ReportStorage {
22407
22557
  expectedYearlyReturns: null,
22408
22558
  avgPeakPnl: null,
22409
22559
  avgFallPnl: null,
22560
+ sortinoRatio: null,
22561
+ calmarRatio: null,
22562
+ recoveryFactor: null,
22410
22563
  };
22411
22564
  }
22412
22565
  const closedEvents = this._eventList.filter((e) => e.action === "closed");
@@ -22456,6 +22609,21 @@ let ReportStorage$9 = class ReportStorage {
22456
22609
  const avgFallPnl = totalClosed > 0
22457
22610
  ? closedEvents.reduce((sum, e) => sum + (e.fallPnl || 0), 0) / totalClosed
22458
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;
22459
22627
  return {
22460
22628
  eventList: this._eventList,
22461
22629
  totalEvents: this._eventList.length,
@@ -22472,6 +22640,9 @@ let ReportStorage$9 = class ReportStorage {
22472
22640
  expectedYearlyReturns: isUnsafe$2(expectedYearlyReturns) ? null : expectedYearlyReturns,
22473
22641
  avgPeakPnl: isUnsafe$2(avgPeakPnl) ? null : avgPeakPnl,
22474
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,
22475
22646
  };
22476
22647
  }
22477
22648
  /**
@@ -22518,6 +22689,9 @@ let ReportStorage$9 = class ReportStorage {
22518
22689
  `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
22519
22690
  `**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
22520
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)`}`,
22521
22695
  ].join("\n");
22522
22696
  }
22523
22697
  /**
@@ -23963,6 +24137,9 @@ let ReportStorage$7 = class ReportStorage {
23963
24137
  "",
23964
24138
  `**Best ${results.metric}:** ${formatMetric(results.bestMetric)}`,
23965
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"}`,
23966
24143
  "",
23967
24144
  "## Top Strategies Comparison",
23968
24145
  "",
@@ -24429,6 +24606,27 @@ class HeatmapStorage {
24429
24606
  avgPeakPnl = signals.reduce((acc, s) => acc + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / signals.length;
24430
24607
  avgFallPnl = signals.reduce((acc, s) => acc + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / signals.length;
24431
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
+ }
24432
24630
  // Apply safe math checks
24433
24631
  if (isUnsafe(winRate))
24434
24632
  winRate = null;
@@ -24454,6 +24652,12 @@ class HeatmapStorage {
24454
24652
  avgPeakPnl = null;
24455
24653
  if (isUnsafe(avgFallPnl))
24456
24654
  avgFallPnl = null;
24655
+ if (isUnsafe(sortinoRatio))
24656
+ sortinoRatio = null;
24657
+ if (isUnsafe(calmarRatio))
24658
+ calmarRatio = null;
24659
+ if (isUnsafe(recoveryFactor))
24660
+ recoveryFactor = null;
24457
24661
  return {
24458
24662
  symbol,
24459
24663
  totalPnl,
@@ -24473,6 +24677,9 @@ class HeatmapStorage {
24473
24677
  expectancy,
24474
24678
  avgPeakPnl,
24475
24679
  avgFallPnl,
24680
+ sortinoRatio,
24681
+ calmarRatio,
24682
+ recoveryFactor,
24476
24683
  };
24477
24684
  }
24478
24685
  /**
@@ -29339,6 +29546,9 @@ class WalkerReportService {
29339
29546
  annualizedSharpeRatio: data.stats.annualizedSharpeRatio,
29340
29547
  certaintyRatio: data.stats.certaintyRatio,
29341
29548
  expectedYearlyReturns: data.stats.expectedYearlyReturns,
29549
+ sortinoRatio: data.stats.sortinoRatio,
29550
+ calmarRatio: data.stats.calmarRatio,
29551
+ recoveryFactor: data.stats.recoveryFactor,
29342
29552
  firstEventTime,
29343
29553
  lastEventTime,
29344
29554
  }, {
@@ -33528,6 +33738,7 @@ async function getBacktestTimeframe(symbol) {
33528
33738
 
33529
33739
  const EXCHANGE_METHOD_NAME_GET_CANDLES = "ExchangeUtils.getCandles";
33530
33740
  const EXCHANGE_METHOD_NAME_GET_AVERAGE_PRICE = "ExchangeUtils.getAveragePrice";
33741
+ const EXCHANGE_METHOD_NAME_GET_CLOSE_PRICE = "ExchangeUtils.getClosePrice";
33531
33742
  const EXCHANGE_METHOD_NAME_FORMAT_QUANTITY = "ExchangeUtils.formatQuantity";
33532
33743
  const EXCHANGE_METHOD_NAME_FORMAT_PRICE = "ExchangeUtils.formatPrice";
33533
33744
  const EXCHANGE_METHOD_NAME_GET_ORDER_BOOK = "ExchangeUtils.getOrderBook";
@@ -33885,6 +34096,35 @@ class ExchangeInstance {
33885
34096
  const vwap = sumPriceVolume / totalVolume;
33886
34097
  return vwap;
33887
34098
  };
34099
+ /**
34100
+ * Returns the close price of the last completed candle for the given interval.
34101
+ *
34102
+ * Fetches a single candle for the requested interval and returns its close price.
34103
+ *
34104
+ * @param symbol - Trading pair symbol
34105
+ * @param interval - Candle time interval (e.g., "1m", "1h")
34106
+ * @returns Promise resolving to close price of the last candle
34107
+ * @throws Error if no candles available
34108
+ *
34109
+ * @example
34110
+ * ```typescript
34111
+ * const instance = new ExchangeInstance("binance");
34112
+ * const close = await instance.getClosePrice("BTCUSDT", "1h");
34113
+ * console.log(close); // 50125.43
34114
+ * ```
34115
+ */
34116
+ this.getClosePrice = async (symbol, interval) => {
34117
+ backtest.loggerService.debug(`ExchangeInstance getClosePrice`, {
34118
+ exchangeName: this.exchangeName,
34119
+ symbol,
34120
+ interval,
34121
+ });
34122
+ const candles = await this.getCandles(symbol, interval, 1);
34123
+ if (candles.length === 0) {
34124
+ throw new Error(`ExchangeInstance getClosePrice: no candles data for symbol=${symbol}`);
34125
+ }
34126
+ return candles[candles.length - 1].close;
34127
+ };
33888
34128
  /**
33889
34129
  * Format quantity according to exchange precision rules.
33890
34130
  *
@@ -34242,6 +34482,28 @@ class ExchangeUtils {
34242
34482
  const instance = this._getInstance(context.exchangeName);
34243
34483
  return await instance.getAveragePrice(symbol);
34244
34484
  };
34485
+ /**
34486
+ * Returns the close price of the last completed candle for the given interval.
34487
+ *
34488
+ * @param symbol - Trading pair symbol
34489
+ * @param interval - Candle time interval (e.g., "1m", "1h")
34490
+ * @param context - Execution context with exchange name
34491
+ * @returns Promise resolving to close price of the last candle
34492
+ * @throws Error if no candles available
34493
+ *
34494
+ * @example
34495
+ * ```typescript
34496
+ * const close = await Exchange.getClosePrice("BTCUSDT", "1h", {
34497
+ * exchangeName: "binance"
34498
+ * });
34499
+ * console.log(close); // 50125.43
34500
+ * ```
34501
+ */
34502
+ this.getClosePrice = async (symbol, interval, context) => {
34503
+ backtest.exchangeValidationService.validate(context.exchangeName, EXCHANGE_METHOD_NAME_GET_CLOSE_PRICE);
34504
+ const instance = this._getInstance(context.exchangeName);
34505
+ return await instance.getClosePrice(symbol, interval);
34506
+ };
34245
34507
  /**
34246
34508
  * Format quantity according to exchange precision rules.
34247
34509
  *
@@ -34828,6 +35090,7 @@ function getActionSchema(actionName) {
34828
35090
 
34829
35091
  const GET_CANDLES_METHOD_NAME = "exchange.getCandles";
34830
35092
  const GET_AVERAGE_PRICE_METHOD_NAME = "exchange.getAveragePrice";
35093
+ const GET_CLOSE_PRICE_METHOD_NAME = "exchange.getClosePrice";
34831
35094
  const FORMAT_PRICE_METHOD_NAME = "exchange.formatPrice";
34832
35095
  const FORMAT_QUANTITY_METHOD_NAME = "exchange.formatQuantity";
34833
35096
  const GET_DATE_METHOD_NAME = "exchange.getDate";
@@ -34925,6 +35188,32 @@ async function getAveragePrice(symbol) {
34925
35188
  }
34926
35189
  return await backtest.exchangeConnectionService.getAveragePrice(symbol);
34927
35190
  }
35191
+ /**
35192
+ * Returns the close price of the last completed candle for the given interval.
35193
+ *
35194
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
35195
+ * @param interval - Candle interval ("1m" | "3m" | "5m" | "15m" | "30m" | "1h" | "2h" | "4h" | "6h" | "8h")
35196
+ * @returns Promise resolving to close price of the last candle
35197
+ *
35198
+ * @example
35199
+ * ```typescript
35200
+ * const close = await getClosePrice("BTCUSDT", "1h");
35201
+ * console.log(close); // 50125.43
35202
+ * ```
35203
+ */
35204
+ async function getClosePrice(symbol, interval) {
35205
+ backtest.loggerService.info(GET_CLOSE_PRICE_METHOD_NAME, {
35206
+ symbol,
35207
+ interval,
35208
+ });
35209
+ if (!ExecutionContextService.hasContext()) {
35210
+ throw new Error("getClosePrice requires an execution context");
35211
+ }
35212
+ if (!MethodContextService.hasContext()) {
35213
+ throw new Error("getClosePrice requires a method context");
35214
+ }
35215
+ return await backtest.exchangeConnectionService.getClosePrice(symbol, interval);
35216
+ }
34928
35217
  /**
34929
35218
  * Formats a price value according to exchange rules.
34930
35219
  *
@@ -61557,4 +61846,4 @@ const validateSignal = (signal, currentPrice) => {
61557
61846
  return !errors.length;
61558
61847
  };
61559
61848
 
61560
- export { ActionBase, Backtest, Breakeven, Broker, BrokerBase, Cache, Constant, Dump, Exchange, ExecutionContextService, Heat, HighestProfit, Interval, Live, Log, Markdown, MarkdownFileBase, MarkdownFolderBase, MarkdownWriter, MaxDrawdown, Memory, MemoryBacktest, MemoryBacktestAdapter, MemoryLive, MemoryLiveAdapter, MethodContextService, Notification, NotificationBacktest, NotificationLive, Partial, Performance, PersistBase, PersistBreakevenAdapter, PersistCandleAdapter, PersistIntervalAdapter, PersistLogAdapter, PersistMeasureAdapter, PersistMemoryAdapter, PersistNotificationAdapter, PersistPartialAdapter, PersistRecentAdapter, PersistRiskAdapter, PersistScheduleAdapter, PersistSessionAdapter, PersistSignalAdapter, PersistStateAdapter, PersistStorageAdapter, Position, PositionSize, Recent, RecentBacktest, RecentLive, Reflect$1 as Reflect, Report, ReportBase, ReportWriter, Risk, Schedule, Session, SessionBacktest, SessionLive, State, StateBacktest, StateBacktestAdapter, StateLive, StateLiveAdapter, Storage, StorageBacktest, StorageLive, Strategy, Sync, System, Walker, addActionSchema, addExchangeSchema, addFrameSchema, addRiskSchema, addSizingSchema, addStrategySchema, addWalkerSchema, alignToInterval, checkCandles, commitActivateScheduled, commitAverageBuy, commitBreakeven, commitCancelScheduled, commitClosePending, commitPartialLoss, commitPartialLossCost, commitPartialProfit, commitPartialProfitCost, commitSignalNotify, commitTrailingStop, commitTrailingStopCost, commitTrailingTake, commitTrailingTakeCost, createSignalState, dumpAgentAnswer, dumpError, dumpJson, dumpRecord, dumpTable, dumpText, emitters, formatPrice, formatQuantity, get, getActionSchema, getAggregatedTrades, getAveragePrice, getBacktestTimeframe, getBreakeven, getCandles, getColumns, getConfig, getContext, getDate, getDefaultColumns, getDefaultConfig, getEffectivePriceOpen, getExchangeSchema, getFrameSchema, getLatestSignal, getMaxDrawdownDistancePnlCost, getMaxDrawdownDistancePnlPercentage, getMinutesSinceLatestSignalCreated, getMode, getNextCandles, getOrderBook, getPendingSignal, getPositionActiveMinutes, getPositionCountdownMinutes, getPositionDrawdownMinutes, getPositionEffectivePrice, getPositionEntries, getPositionEntryOverlap, getPositionEstimateMinutes, getPositionHighestMaxDrawdownPnlCost, getPositionHighestMaxDrawdownPnlPercentage, getPositionHighestPnlCost, getPositionHighestPnlPercentage, getPositionHighestProfitBreakeven, getPositionHighestProfitDistancePnlCost, getPositionHighestProfitDistancePnlPercentage, getPositionHighestProfitMinutes, getPositionHighestProfitPrice, getPositionHighestProfitTimestamp, getPositionInvestedCost, getPositionInvestedCount, getPositionLevels, getPositionMaxDrawdownMinutes, getPositionMaxDrawdownPnlCost, getPositionMaxDrawdownPnlPercentage, getPositionMaxDrawdownPrice, getPositionMaxDrawdownTimestamp, getPositionPartialOverlap, getPositionPartials, getPositionPnlCost, getPositionPnlPercent, getPositionWaitingMinutes, getRawCandles, getRiskSchema, getScheduledSignal, getSessionData, getSignalState, getSizingSchema, getStrategySchema, getSymbol, getTimestamp, getTotalClosed, getTotalCostClosed, getTotalPercentClosed, getWalkerSchema, hasNoPendingSignal, hasNoScheduledSignal, hasTradeContext, investedCostToPercent, backtest as lib, listExchangeSchema, listFrameSchema, listMemory, listRiskSchema, listSizingSchema, listStrategySchema, listWalkerSchema, listenActivePing, listenActivePingOnce, listenBacktestProgress, listenBreakevenAvailable, listenBreakevenAvailableOnce, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenExit, listenHighestProfit, listenHighestProfitOnce, listenIdlePing, listenIdlePingOnce, listenMaxDrawdown, listenMaxDrawdownOnce, listenPartialLossAvailable, listenPartialLossAvailableOnce, listenPartialProfitAvailable, listenPartialProfitAvailableOnce, listenPerformance, listenRisk, listenRiskOnce, listenSchedulePing, listenSchedulePingOnce, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalNotify, listenSignalNotifyOnce, listenSignalOnce, listenStrategyCommit, listenStrategyCommitOnce, listenSync, listenSyncOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, listenWalkerProgress, overrideActionSchema, overrideExchangeSchema, overrideFrameSchema, overrideRiskSchema, overrideSizingSchema, overrideStrategySchema, overrideWalkerSchema, parseArgs, percentDiff, percentToCloseCost, percentValue, readMemory, removeMemory, roundTicks, runInMockContext, searchMemory, set, setColumns, setConfig, setLogger, setSessionData, setSignalState, shutdown, slPercentShiftToPrice, slPriceToPercentShift, stopStrategy, toProfitLossDto, tpPercentShiftToPrice, tpPriceToPercentShift, validate, validateCommonSignal, validatePendingSignal, validateScheduledSignal, validateSignal, waitForCandle, warmCandles, writeMemory };
61849
+ export { ActionBase, Backtest, Breakeven, Broker, BrokerBase, Cache, Constant, Dump, Exchange, ExecutionContextService, Heat, HighestProfit, Interval, Live, Log, Markdown, MarkdownFileBase, MarkdownFolderBase, MarkdownWriter, MaxDrawdown, Memory, MemoryBacktest, MemoryBacktestAdapter, MemoryLive, MemoryLiveAdapter, MethodContextService, Notification, NotificationBacktest, NotificationLive, Partial, Performance, PersistBase, PersistBreakevenAdapter, PersistCandleAdapter, PersistIntervalAdapter, PersistLogAdapter, PersistMeasureAdapter, PersistMemoryAdapter, PersistNotificationAdapter, PersistPartialAdapter, PersistRecentAdapter, PersistRiskAdapter, PersistScheduleAdapter, PersistSessionAdapter, PersistSignalAdapter, PersistStateAdapter, PersistStorageAdapter, Position, PositionSize, Recent, RecentBacktest, RecentLive, Reflect$1 as Reflect, Report, ReportBase, ReportWriter, Risk, Schedule, Session, SessionBacktest, SessionLive, State, StateBacktest, StateBacktestAdapter, StateLive, StateLiveAdapter, Storage, StorageBacktest, StorageLive, Strategy, Sync, System, Walker, addActionSchema, addExchangeSchema, addFrameSchema, addRiskSchema, addSizingSchema, addStrategySchema, addWalkerSchema, alignToInterval, checkCandles, commitActivateScheduled, commitAverageBuy, commitBreakeven, commitCancelScheduled, commitClosePending, commitPartialLoss, commitPartialLossCost, commitPartialProfit, commitPartialProfitCost, commitSignalNotify, commitTrailingStop, commitTrailingStopCost, commitTrailingTake, commitTrailingTakeCost, createSignalState, dumpAgentAnswer, dumpError, dumpJson, dumpRecord, dumpTable, dumpText, emitters, formatPrice, formatQuantity, get, getActionSchema, getAggregatedTrades, getAveragePrice, getBacktestTimeframe, getBreakeven, getCandles, getClosePrice, getColumns, getConfig, getContext, getDate, getDefaultColumns, getDefaultConfig, getEffectivePriceOpen, getExchangeSchema, getFrameSchema, getLatestSignal, getMaxDrawdownDistancePnlCost, getMaxDrawdownDistancePnlPercentage, getMinutesSinceLatestSignalCreated, getMode, getNextCandles, getOrderBook, getPendingSignal, getPositionActiveMinutes, getPositionCountdownMinutes, getPositionDrawdownMinutes, getPositionEffectivePrice, getPositionEntries, getPositionEntryOverlap, getPositionEstimateMinutes, getPositionHighestMaxDrawdownPnlCost, getPositionHighestMaxDrawdownPnlPercentage, getPositionHighestPnlCost, getPositionHighestPnlPercentage, getPositionHighestProfitBreakeven, getPositionHighestProfitDistancePnlCost, getPositionHighestProfitDistancePnlPercentage, getPositionHighestProfitMinutes, getPositionHighestProfitPrice, getPositionHighestProfitTimestamp, getPositionInvestedCost, getPositionInvestedCount, getPositionLevels, getPositionMaxDrawdownMinutes, getPositionMaxDrawdownPnlCost, getPositionMaxDrawdownPnlPercentage, getPositionMaxDrawdownPrice, getPositionMaxDrawdownTimestamp, getPositionPartialOverlap, getPositionPartials, getPositionPnlCost, getPositionPnlPercent, getPositionWaitingMinutes, getRawCandles, getRiskSchema, getScheduledSignal, getSessionData, getSignalState, getSizingSchema, getStrategySchema, getSymbol, getTimestamp, getTotalClosed, getTotalCostClosed, getTotalPercentClosed, getWalkerSchema, hasNoPendingSignal, hasNoScheduledSignal, hasTradeContext, investedCostToPercent, backtest as lib, listExchangeSchema, listFrameSchema, listMemory, listRiskSchema, listSizingSchema, listStrategySchema, listWalkerSchema, listenActivePing, listenActivePingOnce, listenBacktestProgress, listenBreakevenAvailable, listenBreakevenAvailableOnce, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenExit, listenHighestProfit, listenHighestProfitOnce, listenIdlePing, listenIdlePingOnce, listenMaxDrawdown, listenMaxDrawdownOnce, listenPartialLossAvailable, listenPartialLossAvailableOnce, listenPartialProfitAvailable, listenPartialProfitAvailableOnce, listenPerformance, listenRisk, listenRiskOnce, listenSchedulePing, listenSchedulePingOnce, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalNotify, listenSignalNotifyOnce, listenSignalOnce, listenStrategyCommit, listenStrategyCommitOnce, listenSync, listenSyncOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, listenWalkerProgress, overrideActionSchema, overrideExchangeSchema, overrideFrameSchema, overrideRiskSchema, overrideSizingSchema, overrideStrategySchema, overrideWalkerSchema, parseArgs, percentDiff, percentToCloseCost, percentValue, readMemory, removeMemory, roundTicks, runInMockContext, searchMemory, set, setColumns, setConfig, setLogger, setSessionData, setSignalState, shutdown, slPercentShiftToPrice, slPriceToPercentShift, stopStrategy, toProfitLossDto, tpPercentShiftToPrice, tpPriceToPercentShift, validate, validateCommonSignal, validatePendingSignal, validateScheduledSignal, validateSignal, waitForCandle, warmCandles, writeMemory };