backtest-kit 7.8.0 → 8.1.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/README.md CHANGED
@@ -1742,6 +1742,49 @@ Install the skill once and get AI-assisted backtest-kit development inside Claud
1742
1742
  npx skills add https://github.com/backtest-kit/backtest-kit-skills
1743
1743
  ```
1744
1744
 
1745
+ ## 🧩 Strategy Examples
1746
+
1747
+ #### 🧠 Neural Network Strategy (Oct 2021)
1748
+
1749
+ > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/oct_2021.strategy)
1750
+
1751
+ Trains a feed-forward `TensorFlow` neural network (8→6→4→1 architecture) every 8 hours to predict where the next candle will close within its high-low range. When current price is below predicted price, opens a LONG with 1% trailing take-profit.
1752
+
1753
+ #### 🌲 Pine Script Range Breakout (Dec 2025)
1754
+
1755
+ > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/dec_2025.strategy)
1756
+
1757
+ Runs `btc_dec2025_range.pine` on 1h candles via `@backtest-kit/pinets`, extracting Bollinger Bands, range boundaries, and volume spikes. Signals fire only on confirmed breakouts when price hasn't already moved past the signal close.
1758
+
1759
+ #### 🔪 Signal Inversion Strategy (Jan 2026)
1760
+
1761
+ > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/jan_2026.strategy)
1762
+
1763
+ The strategy takes published signals from a real Telegram crypto channel (Crypto Yoda), enters at the same price zone and timestamp, but **inverts the direction** and uses the liquidity of the crowd that blindly follows the recommendation regardless of the contents of the order book.
1764
+
1765
+ #### 📰 AI News Sentiment (Feb 2026)
1766
+
1767
+ > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2026.strategy)
1768
+
1769
+ Every 4-8 hours, fetches live crypto/macro news via Tavily, passes headlines to Ollama (local LLM), and opens positions based on `bullish`/`bearish`/`wait` forecasts. Conflicting signals flip positions mid-trade. Achieved +16.99% during a -16.4% month.
1770
+
1771
+ #### 🪂 SHORT DCA Ladder (Mar 2026)
1772
+
1773
+ > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/mar_2026.strategy)
1774
+
1775
+ Opens a SHORT on every pending signal, then adds rungs (up to 10) whenever price spikes upward outside a ±1-5% band around last entry. Closes at 0.5% blended profit.
1776
+
1777
+ #### 🧗 LONG DCA Ladder (Apr 2026)
1778
+
1779
+ > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/apr_2026.strategy)
1780
+
1781
+ Same mechanics as SHORT version but LONG-biased with 3% profit target. Deployed 2.4 entries per trade on average, achieved +67.85% PNL on deployed capital with improved percentage drawdown (-2.59% vs -3.99% without DCA).
1782
+
1783
+ #### 🐍 Python EMA Crossover (Feb 2021)
1784
+
1785
+ > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2021.strategy)
1786
+
1787
+ Python-based (WASI) strategy that uses EMA(9) and EMA(21) crossover signals executed via WebAssembly. Trades trigger when fast EMA crosses slow EMA, confirmed by 4h range midpoint.
1745
1788
 
1746
1789
  ## 🤖 Are you a robot?
1747
1790
 
package/build/index.cjs CHANGED
@@ -590,6 +590,20 @@ const GLOBAL_CONFIG = {
590
590
  * Default: false (PPPL logic is only applied when it does not break the direction of exits, ensuring clearer profit/loss outcomes)
591
591
  */
592
592
  CC_ENABLE_PPPL_EVERYWHERE: false,
593
+ /**
594
+ * Enables long signals in strategies that are primarily designed for short signals.
595
+ * This allows the strategy to generate and manage long signals in addition to short signals, even if the original design was focused on short trading.
596
+ * This can help expand the strategy's applicability and take advantage of bullish market conditions, but may require additional logic to manage long signal behavior effectively.
597
+ *
598
+ * Default: false (long signals are only enabled in strategies that are designed for them, ensuring strategy logic is aligned with signal types)
599
+ */
600
+ CC_ENABLE_LONG_SIGNAL: true,
601
+ /**
602
+ * Enables short signals in strategies that are primarily designed for long signals.
603
+ * This allows the strategy to generate and manage short signals in addition to long signals, even if the original design was focused on long trading.
604
+ * This can help expand the strategy's applicability and take advantage of bearish market conditions, but may require additional logic to manage short signal behavior effectively.
605
+ */
606
+ CC_ENABLE_SHORT_SIGNAL: true,
593
607
  /**
594
608
  * Enables trailing logic (Trailing Take / Trailing Stop) without requiring absorption conditions.
595
609
  * Allows trailing mechanisms to be activated regardless of whether absorption has been detected.
@@ -4710,6 +4724,12 @@ const validateCommonSignal = (signal) => {
4710
4724
  if (signal.position !== "long" && signal.position !== "short") {
4711
4725
  errors.push(`position must be "long" or "short", got "${signal.position}"`);
4712
4726
  }
4727
+ if (signal.position === "long" && !GLOBAL_CONFIG.CC_ENABLE_LONG_SIGNAL) {
4728
+ errors.push(`Long signals are disabled (CC_ENABLE_LONG_SIGNAL=false)`);
4729
+ }
4730
+ if (signal.position === "short" && !GLOBAL_CONFIG.CC_ENABLE_SHORT_SIGNAL) {
4731
+ errors.push(`Short signals are disabled (CC_ENABLE_SHORT_SIGNAL=false)`);
4732
+ }
4713
4733
  }
4714
4734
  // ЗАЩИТА ОТ NaN/Infinity: все цены должны быть конечными числами
4715
4735
  {
@@ -5545,6 +5565,10 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
5545
5565
  _peak: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5546
5566
  _fall: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5547
5567
  };
5568
+ {
5569
+ const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(signalRow, signal.priceOpen);
5570
+ signalRow._fall = { price: signal.priceOpen, timestamp: currentTime, pnlPercentage, pnlCost, priceClose, priceOpen, pnlEntries };
5571
+ }
5548
5572
  // Валидируем сигнал перед возвратом
5549
5573
  validatePendingSignal(signalRow, currentPrice);
5550
5574
  return signalRow;
@@ -5594,6 +5618,10 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
5594
5618
  _peak: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5595
5619
  _fall: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5596
5620
  };
5621
+ {
5622
+ const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(signalRow, currentPrice);
5623
+ signalRow._fall = { price: currentPrice, timestamp: currentTime, pnlPercentage, pnlCost, priceClose, priceOpen, pnlEntries };
5624
+ }
5597
5625
  // Валидируем сигнал перед возвратом
5598
5626
  validatePendingSignal(signalRow, currentPrice);
5599
5627
  return signalRow;
@@ -6306,6 +6334,10 @@ const ACTIVATE_SCHEDULED_SIGNAL_FN = async (self, scheduled, activationTimestamp
6306
6334
  _peak: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
6307
6335
  _fall: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
6308
6336
  };
6337
+ {
6338
+ const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(activatedSignal, activatedSignal.priceOpen);
6339
+ activatedSignal._fall = { price: activatedSignal.priceOpen, timestamp: activationTime, pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen };
6340
+ }
6309
6341
  // Sync open: if external system rejects — cancel scheduled signal instead of opening
6310
6342
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(activationTime, activatedSignal.priceOpen, activatedSignal, self);
6311
6343
  if (!syncOpenAllowed) {
@@ -7189,6 +7221,10 @@ const ACTIVATE_SCHEDULED_SIGNAL_IN_BACKTEST_FN = async (self, scheduled, activat
7189
7221
  _peak: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
7190
7222
  _fall: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
7191
7223
  };
7224
+ {
7225
+ const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(activatedSignal, activatedSignal.priceOpen);
7226
+ activatedSignal._fall = { price: activatedSignal.priceOpen, timestamp: activationTime, pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen };
7227
+ }
7192
7228
  // Sync open: if external system rejects — cancel scheduled signal instead of opening
7193
7229
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(activationTime, activatedSignal.priceOpen, activatedSignal, self);
7194
7230
  if (!syncOpenAllowed) {
@@ -7386,6 +7422,10 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles, fra
7386
7422
  _peak: { price: activatedSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
7387
7423
  _fall: { price: activatedSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
7388
7424
  };
7425
+ {
7426
+ const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(pendingSignal, pendingSignal.priceOpen);
7427
+ pendingSignal._fall = { price: pendingSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage, pnlCost, priceClose, priceOpen, pnlEntries };
7428
+ }
7389
7429
  // Sync open: if external system rejects — cancel scheduled signal instead of opening
7390
7430
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(candle.timestamp, pendingSignal.priceOpen, pendingSignal, self);
7391
7431
  if (!syncOpenAllowed) {
@@ -8830,6 +8870,10 @@ class ClientStrategy {
8830
8870
  _peak: { price: activatedSignal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, pnlEntries: 0, priceOpen: 0 },
8831
8871
  _fall: { price: activatedSignal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, pnlEntries: 0, priceOpen: 0 },
8832
8872
  };
8873
+ {
8874
+ const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(pendingSignal, pendingSignal.priceOpen);
8875
+ pendingSignal._fall = { price: pendingSignal.priceOpen, timestamp: currentTime, pnlPercentage, pnlCost, priceClose, pnlEntries, priceOpen };
8876
+ }
8833
8877
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(currentTime, currentPrice, pendingSignal, this);
8834
8878
  if (!syncOpenAllowed) {
8835
8879
  this.params.logger.info("ClientStrategy tick: user-activated signal rejected by sync", {
@@ -19393,6 +19437,24 @@ const heat_columns = [
19393
19437
  format: (data) => data.avgFallPnl !== null ? functoolsKit.str(data.avgFallPnl, "%") : "N/A",
19394
19438
  isVisible: () => true,
19395
19439
  },
19440
+ {
19441
+ key: "sortinoRatio",
19442
+ label: "Sortino",
19443
+ format: (data) => data.sortinoRatio !== null ? functoolsKit.str(data.sortinoRatio) : "N/A",
19444
+ isVisible: () => true,
19445
+ },
19446
+ {
19447
+ key: "calmarRatio",
19448
+ label: "Calmar",
19449
+ format: (data) => data.calmarRatio !== null ? functoolsKit.str(data.calmarRatio) : "N/A",
19450
+ isVisible: () => true,
19451
+ },
19452
+ {
19453
+ key: "recoveryFactor",
19454
+ label: "Recovery",
19455
+ format: (data) => data.recoveryFactor !== null ? functoolsKit.str(data.recoveryFactor) : "N/A",
19456
+ isVisible: () => true,
19457
+ },
19396
19458
  ];
19397
19459
 
19398
19460
  /**
@@ -20955,6 +21017,30 @@ const walker_strategy_columns = [
20955
21017
  : "N/A",
20956
21018
  isVisible: () => true,
20957
21019
  },
21020
+ {
21021
+ key: "sortinoRatio",
21022
+ label: "Sortino",
21023
+ format: (data) => data.stats.sortinoRatio !== null
21024
+ ? `${data.stats.sortinoRatio.toFixed(3)}`
21025
+ : "N/A",
21026
+ isVisible: () => true,
21027
+ },
21028
+ {
21029
+ key: "calmarRatio",
21030
+ label: "Calmar",
21031
+ format: (data) => data.stats.calmarRatio !== null
21032
+ ? `${data.stats.calmarRatio.toFixed(3)}`
21033
+ : "N/A",
21034
+ isVisible: () => true,
21035
+ },
21036
+ {
21037
+ key: "recoveryFactor",
21038
+ label: "Recovery",
21039
+ format: (data) => data.stats.recoveryFactor !== null
21040
+ ? `${data.stats.recoveryFactor.toFixed(3)}`
21041
+ : "N/A",
21042
+ isVisible: () => true,
21043
+ },
20958
21044
  {
20959
21045
  key: "firstEventTime",
20960
21046
  label: "First Event",
@@ -21504,13 +21590,13 @@ class ReportBase {
21504
21590
  * Waits for drain event if write buffer is full.
21505
21591
  * Times out after 15 seconds and returns TIMEOUT_SYMBOL.
21506
21592
  */
21507
- this[_d] = functoolsKit.timeout(async (line) => {
21593
+ this[_d] = functoolsKit.queued(functoolsKit.timeout(async (line) => {
21508
21594
  if (!this._stream.write(line)) {
21509
21595
  await new Promise((resolve) => {
21510
21596
  this._stream.once("drain", resolve);
21511
21597
  });
21512
21598
  }
21513
- }, 15000);
21599
+ }, 15000));
21514
21600
  LOGGER_SERVICE$3.debug(REPORT_BASE_METHOD_NAME_CTOR, {
21515
21601
  reportName: this.reportName,
21516
21602
  baseDir,
@@ -21798,6 +21884,9 @@ let ReportStorage$a = class ReportStorage {
21798
21884
  expectedYearlyReturns: null,
21799
21885
  avgPeakPnl: null,
21800
21886
  avgFallPnl: null,
21887
+ sortinoRatio: null,
21888
+ calmarRatio: null,
21889
+ recoveryFactor: null,
21801
21890
  };
21802
21891
  }
21803
21892
  const totalSignals = this._signalList.length;
@@ -21831,6 +21920,16 @@ let ReportStorage$a = class ReportStorage {
21831
21920
  // Calculate average peak and fall PNL across all signals
21832
21921
  const avgPeakPnl = this._signalList.reduce((sum, s) => sum + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / totalSignals;
21833
21922
  const avgFallPnl = this._signalList.reduce((sum, s) => sum + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / totalSignals;
21923
+ // Downside per signal: maxDrawdown.pnlPercentage captures the worst intra-trade dip
21924
+ const fallReturns = this._signalList.map((s) => s.signal.maxDrawdown?.pnlPercentage ?? 0);
21925
+ // Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
21926
+ const fallVariance = fallReturns.reduce((sum, r) => sum + Math.pow(r, 2), 0) / totalSignals;
21927
+ const fallDeviation = Math.sqrt(fallVariance);
21928
+ const sortinoRatio = fallDeviation > 0 ? avgPnl / fallDeviation : 0;
21929
+ // Avg absolute peak drawdown per signal — used as denominator for Calmar and Recovery
21930
+ const avgAbsFall = fallReturns.reduce((sum, r) => sum + Math.abs(r), 0) / totalSignals;
21931
+ const calmarRatio = avgAbsFall > 0 ? expectedYearlyReturns / avgAbsFall : 0;
21932
+ const recoveryFactor = avgAbsFall > 0 ? totalPnl / avgAbsFall : 0;
21834
21933
  return {
21835
21934
  signalList: this._signalList,
21836
21935
  totalSignals,
@@ -21846,6 +21945,9 @@ let ReportStorage$a = class ReportStorage {
21846
21945
  expectedYearlyReturns: isUnsafe$3(expectedYearlyReturns) ? null : expectedYearlyReturns,
21847
21946
  avgPeakPnl: isUnsafe$3(avgPeakPnl) ? null : avgPeakPnl,
21848
21947
  avgFallPnl: isUnsafe$3(avgFallPnl) ? null : avgFallPnl,
21948
+ sortinoRatio: isUnsafe$3(sortinoRatio) ? null : sortinoRatio,
21949
+ calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
21950
+ recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
21849
21951
  };
21850
21952
  }
21851
21953
  /**
@@ -21892,6 +21994,9 @@ let ReportStorage$a = class ReportStorage {
21892
21994
  `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
21893
21995
  `**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
21894
21996
  `**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
21997
+ `**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
21998
+ `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
21999
+ `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
21895
22000
  ].join("\n");
21896
22001
  }
21897
22002
  /**
@@ -22492,6 +22597,9 @@ let ReportStorage$9 = class ReportStorage {
22492
22597
  expectedYearlyReturns: null,
22493
22598
  avgPeakPnl: null,
22494
22599
  avgFallPnl: null,
22600
+ sortinoRatio: null,
22601
+ calmarRatio: null,
22602
+ recoveryFactor: null,
22495
22603
  };
22496
22604
  }
22497
22605
  const closedEvents = this._eventList.filter((e) => e.action === "closed");
@@ -22541,6 +22649,21 @@ let ReportStorage$9 = class ReportStorage {
22541
22649
  const avgFallPnl = totalClosed > 0
22542
22650
  ? closedEvents.reduce((sum, e) => sum + (e.fallPnl || 0), 0) / totalClosed
22543
22651
  : 0;
22652
+ // Downside per signal: fallPnl captures the worst intra-trade dip (maxDrawdown.pnlPercentage)
22653
+ const fallReturns = closedEvents.map((e) => e.fallPnl || 0);
22654
+ // Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
22655
+ let sortinoRatio = 0;
22656
+ if (totalClosed > 0) {
22657
+ const fallVariance = fallReturns.reduce((sum, r) => sum + Math.pow(r, 2), 0) / totalClosed;
22658
+ const fallDeviation = Math.sqrt(fallVariance);
22659
+ sortinoRatio = fallDeviation > 0 ? avgPnl / fallDeviation : 0;
22660
+ }
22661
+ // Avg absolute peak drawdown per signal — denominator for Calmar and Recovery
22662
+ const avgAbsFall = totalClosed > 0
22663
+ ? fallReturns.reduce((sum, r) => sum + Math.abs(r), 0) / totalClosed
22664
+ : 0;
22665
+ const calmarRatio = avgAbsFall > 0 ? expectedYearlyReturns / avgAbsFall : 0;
22666
+ const recoveryFactor = avgAbsFall > 0 ? totalPnl / avgAbsFall : 0;
22544
22667
  return {
22545
22668
  eventList: this._eventList,
22546
22669
  totalEvents: this._eventList.length,
@@ -22557,6 +22680,9 @@ let ReportStorage$9 = class ReportStorage {
22557
22680
  expectedYearlyReturns: isUnsafe$2(expectedYearlyReturns) ? null : expectedYearlyReturns,
22558
22681
  avgPeakPnl: isUnsafe$2(avgPeakPnl) ? null : avgPeakPnl,
22559
22682
  avgFallPnl: isUnsafe$2(avgFallPnl) ? null : avgFallPnl,
22683
+ sortinoRatio: isUnsafe$2(sortinoRatio) ? null : sortinoRatio,
22684
+ calmarRatio: isUnsafe$2(calmarRatio) ? null : calmarRatio,
22685
+ recoveryFactor: isUnsafe$2(recoveryFactor) ? null : recoveryFactor,
22560
22686
  };
22561
22687
  }
22562
22688
  /**
@@ -22603,6 +22729,9 @@ let ReportStorage$9 = class ReportStorage {
22603
22729
  `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
22604
22730
  `**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
22605
22731
  `**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
22732
+ `**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
22733
+ `**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
22734
+ `**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
22606
22735
  ].join("\n");
22607
22736
  }
22608
22737
  /**
@@ -24048,6 +24177,9 @@ let ReportStorage$7 = class ReportStorage {
24048
24177
  "",
24049
24178
  `**Best ${results.metric}:** ${formatMetric(results.bestMetric)}`,
24050
24179
  `**Total Signals:** ${bestStrategySignals}`,
24180
+ `**Sortino Ratio:** ${results.bestStats?.sortinoRatio != null ? `${results.bestStats.sortinoRatio.toFixed(3)} (higher is better)` : "N/A"}`,
24181
+ `**Calmar Ratio:** ${results.bestStats?.calmarRatio != null ? `${results.bestStats.calmarRatio.toFixed(3)} (higher is better)` : "N/A"}`,
24182
+ `**Recovery Factor:** ${results.bestStats?.recoveryFactor != null ? `${results.bestStats.recoveryFactor.toFixed(3)} (higher is better)` : "N/A"}`,
24051
24183
  "",
24052
24184
  "## Top Strategies Comparison",
24053
24185
  "",
@@ -24514,6 +24646,27 @@ class HeatmapStorage {
24514
24646
  avgPeakPnl = signals.reduce((acc, s) => acc + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / signals.length;
24515
24647
  avgFallPnl = signals.reduce((acc, s) => acc + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / signals.length;
24516
24648
  }
24649
+ // Downside per signal: maxDrawdown.pnlPercentage captures the worst intra-trade dip
24650
+ const fallReturns = signals.map((s) => s.signal.maxDrawdown?.pnlPercentage ?? 0);
24651
+ // Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
24652
+ let sortinoRatio = null;
24653
+ if (signals.length > 0 && avgPnl !== null) {
24654
+ const fallVariance = fallReturns.reduce((acc, r) => acc + Math.pow(r, 2), 0) / signals.length;
24655
+ const fallDeviation = Math.sqrt(fallVariance);
24656
+ if (fallDeviation > 0) {
24657
+ sortinoRatio = avgPnl / fallDeviation;
24658
+ }
24659
+ }
24660
+ // Avg absolute peak drawdown per signal — denominator for Calmar and Recovery
24661
+ const avgAbsFall = signals.length > 0
24662
+ ? fallReturns.reduce((acc, r) => acc + Math.abs(r), 0) / signals.length
24663
+ : 0;
24664
+ let calmarRatio = null;
24665
+ let recoveryFactor = null;
24666
+ if (avgAbsFall > 0 && totalPnl !== null) {
24667
+ calmarRatio = totalPnl / avgAbsFall;
24668
+ recoveryFactor = totalPnl / avgAbsFall;
24669
+ }
24517
24670
  // Apply safe math checks
24518
24671
  if (isUnsafe(winRate))
24519
24672
  winRate = null;
@@ -24539,6 +24692,12 @@ class HeatmapStorage {
24539
24692
  avgPeakPnl = null;
24540
24693
  if (isUnsafe(avgFallPnl))
24541
24694
  avgFallPnl = null;
24695
+ if (isUnsafe(sortinoRatio))
24696
+ sortinoRatio = null;
24697
+ if (isUnsafe(calmarRatio))
24698
+ calmarRatio = null;
24699
+ if (isUnsafe(recoveryFactor))
24700
+ recoveryFactor = null;
24542
24701
  return {
24543
24702
  symbol,
24544
24703
  totalPnl,
@@ -24558,6 +24717,9 @@ class HeatmapStorage {
24558
24717
  expectancy,
24559
24718
  avgPeakPnl,
24560
24719
  avgFallPnl,
24720
+ sortinoRatio,
24721
+ calmarRatio,
24722
+ recoveryFactor,
24561
24723
  };
24562
24724
  }
24563
24725
  /**
@@ -29424,6 +29586,9 @@ class WalkerReportService {
29424
29586
  annualizedSharpeRatio: data.stats.annualizedSharpeRatio,
29425
29587
  certaintyRatio: data.stats.certaintyRatio,
29426
29588
  expectedYearlyReturns: data.stats.expectedYearlyReturns,
29589
+ sortinoRatio: data.stats.sortinoRatio,
29590
+ calmarRatio: data.stats.calmarRatio,
29591
+ recoveryFactor: data.stats.recoveryFactor,
29427
29592
  firstEventTime,
29428
29593
  lastEventTime,
29429
29594
  }, {
@@ -50738,7 +50903,8 @@ class DumpAdapter {
50738
50903
  const unClose = signalEmitter
50739
50904
  .filter(({ action }) => action === "closed")
50740
50905
  .connect(({ signal }) => handleDispose(signal.id));
50741
- return functoolsKit.compose(() => unCancel(), () => unClose());
50906
+ const unEnable = () => this.enable.clear();
50907
+ return functoolsKit.compose(() => unCancel(), () => unClose(), () => unEnable());
50742
50908
  });
50743
50909
  /**
50744
50910
  * Deactivates the adapter by unsubscribing from signal lifecycle events.
@@ -51425,7 +51591,7 @@ class ReportUtils {
51425
51591
  *
51426
51592
  * @returns Cleanup function that unsubscribes from all enabled services
51427
51593
  */
51428
- this.enable = ({ backtest: bt = false, breakeven = false, heat = false, live = false, partial = false, performance = false, risk = false, schedule = false, walker = false, strategy = false, sync = false, highest_profit = false, max_drawdown = false, } = WILDCARD_TARGET$2) => {
51594
+ this.enable = functoolsKit.singleshot(({ backtest: bt = false, breakeven = false, heat = false, live = false, partial = false, performance = false, risk = false, schedule = false, walker = false, strategy = false, sync = false, highest_profit = false, max_drawdown = false, } = WILDCARD_TARGET$2) => {
51429
51595
  LOGGER_SERVICE$2.debug(REPORT_UTILS_METHOD_NAME_ENABLE, {
51430
51596
  backtest: bt,
51431
51597
  breakeven,
@@ -51479,8 +51645,11 @@ class ReportUtils {
51479
51645
  if (max_drawdown) {
51480
51646
  unList.push(backtest.maxDrawdownReportService.subscribe());
51481
51647
  }
51648
+ {
51649
+ unList.push(() => this.enable.clear());
51650
+ }
51482
51651
  return functoolsKit.compose(...unList.map((un) => () => void un()));
51483
- };
51652
+ });
51484
51653
  /**
51485
51654
  * Disables report services selectively.
51486
51655
  *
@@ -51531,6 +51700,11 @@ class ReportUtils {
51531
51700
  strategy,
51532
51701
  sync,
51533
51702
  });
51703
+ if (this.enable.hasValue()) {
51704
+ const lastSubscription = this.enable();
51705
+ lastSubscription();
51706
+ return;
51707
+ }
51534
51708
  if (bt) {
51535
51709
  backtest.backtestReportService.unsubscribe();
51536
51710
  }
@@ -51692,7 +51866,7 @@ class MarkdownUtils {
51692
51866
  *
51693
51867
  * @returns Cleanup function that unsubscribes from all enabled services
51694
51868
  */
51695
- this.enable = ({ backtest: bt = false, breakeven = false, heat = false, live = false, partial = false, performance = false, strategy = false, risk = false, schedule = false, walker = false, sync = false, highest_profit = false, max_drawdown = false, } = WILDCARD_TARGET$1) => {
51869
+ this.enable = functoolsKit.singleshot(({ backtest: bt = false, breakeven = false, heat = false, live = false, partial = false, performance = false, strategy = false, risk = false, schedule = false, walker = false, sync = false, highest_profit = false, max_drawdown = false, } = WILDCARD_TARGET$1) => {
51696
51870
  LOGGER_SERVICE$1.debug(MARKDOWN_METHOD_NAME_ENABLE, {
51697
51871
  backtest: bt,
51698
51872
  breakeven,
@@ -51747,8 +51921,11 @@ class MarkdownUtils {
51747
51921
  if (max_drawdown) {
51748
51922
  unList.push(backtest.maxDrawdownMarkdownService.subscribe());
51749
51923
  }
51924
+ {
51925
+ unList.push(() => this.enable.clear());
51926
+ }
51750
51927
  return functoolsKit.compose(...unList.map((un) => () => void un()));
51751
- };
51928
+ });
51752
51929
  /**
51753
51930
  * Disables markdown report services selectively.
51754
51931
  *
@@ -51801,6 +51978,11 @@ class MarkdownUtils {
51801
51978
  sync,
51802
51979
  highest_profit,
51803
51980
  });
51981
+ if (this.enable.hasValue()) {
51982
+ const lastSubscription = this.enable();
51983
+ lastSubscription();
51984
+ return;
51985
+ }
51804
51986
  if (bt) {
51805
51987
  backtest.backtestMarkdownService.unsubscribe();
51806
51988
  }
package/build/index.mjs CHANGED
@@ -570,6 +570,20 @@ const GLOBAL_CONFIG = {
570
570
  * Default: false (PPPL logic is only applied when it does not break the direction of exits, ensuring clearer profit/loss outcomes)
571
571
  */
572
572
  CC_ENABLE_PPPL_EVERYWHERE: false,
573
+ /**
574
+ * Enables long signals in strategies that are primarily designed for short signals.
575
+ * This allows the strategy to generate and manage long signals in addition to short signals, even if the original design was focused on short trading.
576
+ * This can help expand the strategy's applicability and take advantage of bullish market conditions, but may require additional logic to manage long signal behavior effectively.
577
+ *
578
+ * Default: false (long signals are only enabled in strategies that are designed for them, ensuring strategy logic is aligned with signal types)
579
+ */
580
+ CC_ENABLE_LONG_SIGNAL: true,
581
+ /**
582
+ * Enables short signals in strategies that are primarily designed for long signals.
583
+ * This allows the strategy to generate and manage short signals in addition to long signals, even if the original design was focused on long trading.
584
+ * This can help expand the strategy's applicability and take advantage of bearish market conditions, but may require additional logic to manage short signal behavior effectively.
585
+ */
586
+ CC_ENABLE_SHORT_SIGNAL: true,
573
587
  /**
574
588
  * Enables trailing logic (Trailing Take / Trailing Stop) without requiring absorption conditions.
575
589
  * Allows trailing mechanisms to be activated regardless of whether absorption has been detected.
@@ -4690,6 +4704,12 @@ const validateCommonSignal = (signal) => {
4690
4704
  if (signal.position !== "long" && signal.position !== "short") {
4691
4705
  errors.push(`position must be "long" or "short", got "${signal.position}"`);
4692
4706
  }
4707
+ if (signal.position === "long" && !GLOBAL_CONFIG.CC_ENABLE_LONG_SIGNAL) {
4708
+ errors.push(`Long signals are disabled (CC_ENABLE_LONG_SIGNAL=false)`);
4709
+ }
4710
+ if (signal.position === "short" && !GLOBAL_CONFIG.CC_ENABLE_SHORT_SIGNAL) {
4711
+ errors.push(`Short signals are disabled (CC_ENABLE_SHORT_SIGNAL=false)`);
4712
+ }
4693
4713
  }
4694
4714
  // ЗАЩИТА ОТ NaN/Infinity: все цены должны быть конечными числами
4695
4715
  {
@@ -5525,6 +5545,10 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
5525
5545
  _peak: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5526
5546
  _fall: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5527
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
+ }
5528
5552
  // Валидируем сигнал перед возвратом
5529
5553
  validatePendingSignal(signalRow, currentPrice);
5530
5554
  return signalRow;
@@ -5574,6 +5598,10 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
5574
5598
  _peak: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5575
5599
  _fall: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
5576
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
+ }
5577
5605
  // Валидируем сигнал перед возвратом
5578
5606
  validatePendingSignal(signalRow, currentPrice);
5579
5607
  return signalRow;
@@ -6286,6 +6314,10 @@ const ACTIVATE_SCHEDULED_SIGNAL_FN = async (self, scheduled, activationTimestamp
6286
6314
  _peak: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
6287
6315
  _fall: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
6288
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
+ }
6289
6321
  // Sync open: if external system rejects — cancel scheduled signal instead of opening
6290
6322
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(activationTime, activatedSignal.priceOpen, activatedSignal, self);
6291
6323
  if (!syncOpenAllowed) {
@@ -7169,6 +7201,10 @@ const ACTIVATE_SCHEDULED_SIGNAL_IN_BACKTEST_FN = async (self, scheduled, activat
7169
7201
  _peak: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
7170
7202
  _fall: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
7171
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
+ }
7172
7208
  // Sync open: if external system rejects — cancel scheduled signal instead of opening
7173
7209
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(activationTime, activatedSignal.priceOpen, activatedSignal, self);
7174
7210
  if (!syncOpenAllowed) {
@@ -7366,6 +7402,10 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles, fra
7366
7402
  _peak: { price: activatedSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
7367
7403
  _fall: { price: activatedSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
7368
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
+ }
7369
7409
  // Sync open: if external system rejects — cancel scheduled signal instead of opening
7370
7410
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(candle.timestamp, pendingSignal.priceOpen, pendingSignal, self);
7371
7411
  if (!syncOpenAllowed) {
@@ -8810,6 +8850,10 @@ class ClientStrategy {
8810
8850
  _peak: { price: activatedSignal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, pnlEntries: 0, priceOpen: 0 },
8811
8851
  _fall: { price: activatedSignal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, pnlEntries: 0, priceOpen: 0 },
8812
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
+ }
8813
8857
  const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(currentTime, currentPrice, pendingSignal, this);
8814
8858
  if (!syncOpenAllowed) {
8815
8859
  this.params.logger.info("ClientStrategy tick: user-activated signal rejected by sync", {
@@ -19373,6 +19417,24 @@ const heat_columns = [
19373
19417
  format: (data) => data.avgFallPnl !== null ? str(data.avgFallPnl, "%") : "N/A",
19374
19418
  isVisible: () => true,
19375
19419
  },
19420
+ {
19421
+ key: "sortinoRatio",
19422
+ label: "Sortino",
19423
+ format: (data) => data.sortinoRatio !== null ? str(data.sortinoRatio) : "N/A",
19424
+ isVisible: () => true,
19425
+ },
19426
+ {
19427
+ key: "calmarRatio",
19428
+ label: "Calmar",
19429
+ format: (data) => data.calmarRatio !== null ? str(data.calmarRatio) : "N/A",
19430
+ isVisible: () => true,
19431
+ },
19432
+ {
19433
+ key: "recoveryFactor",
19434
+ label: "Recovery",
19435
+ format: (data) => data.recoveryFactor !== null ? str(data.recoveryFactor) : "N/A",
19436
+ isVisible: () => true,
19437
+ },
19376
19438
  ];
19377
19439
 
19378
19440
  /**
@@ -20935,6 +20997,30 @@ const walker_strategy_columns = [
20935
20997
  : "N/A",
20936
20998
  isVisible: () => true,
20937
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
+ },
20938
21024
  {
20939
21025
  key: "firstEventTime",
20940
21026
  label: "First Event",
@@ -21484,13 +21570,13 @@ class ReportBase {
21484
21570
  * Waits for drain event if write buffer is full.
21485
21571
  * Times out after 15 seconds and returns TIMEOUT_SYMBOL.
21486
21572
  */
21487
- this[_d] = timeout(async (line) => {
21573
+ this[_d] = queued(timeout(async (line) => {
21488
21574
  if (!this._stream.write(line)) {
21489
21575
  await new Promise((resolve) => {
21490
21576
  this._stream.once("drain", resolve);
21491
21577
  });
21492
21578
  }
21493
- }, 15000);
21579
+ }, 15000));
21494
21580
  LOGGER_SERVICE$3.debug(REPORT_BASE_METHOD_NAME_CTOR, {
21495
21581
  reportName: this.reportName,
21496
21582
  baseDir,
@@ -21778,6 +21864,9 @@ let ReportStorage$a = class ReportStorage {
21778
21864
  expectedYearlyReturns: null,
21779
21865
  avgPeakPnl: null,
21780
21866
  avgFallPnl: null,
21867
+ sortinoRatio: null,
21868
+ calmarRatio: null,
21869
+ recoveryFactor: null,
21781
21870
  };
21782
21871
  }
21783
21872
  const totalSignals = this._signalList.length;
@@ -21811,6 +21900,16 @@ let ReportStorage$a = class ReportStorage {
21811
21900
  // Calculate average peak and fall PNL across all signals
21812
21901
  const avgPeakPnl = this._signalList.reduce((sum, s) => sum + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / totalSignals;
21813
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;
21814
21913
  return {
21815
21914
  signalList: this._signalList,
21816
21915
  totalSignals,
@@ -21826,6 +21925,9 @@ let ReportStorage$a = class ReportStorage {
21826
21925
  expectedYearlyReturns: isUnsafe$3(expectedYearlyReturns) ? null : expectedYearlyReturns,
21827
21926
  avgPeakPnl: isUnsafe$3(avgPeakPnl) ? null : avgPeakPnl,
21828
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,
21829
21931
  };
21830
21932
  }
21831
21933
  /**
@@ -21872,6 +21974,9 @@ let ReportStorage$a = class ReportStorage {
21872
21974
  `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
21873
21975
  `**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
21874
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)`}`,
21875
21980
  ].join("\n");
21876
21981
  }
21877
21982
  /**
@@ -22472,6 +22577,9 @@ let ReportStorage$9 = class ReportStorage {
22472
22577
  expectedYearlyReturns: null,
22473
22578
  avgPeakPnl: null,
22474
22579
  avgFallPnl: null,
22580
+ sortinoRatio: null,
22581
+ calmarRatio: null,
22582
+ recoveryFactor: null,
22475
22583
  };
22476
22584
  }
22477
22585
  const closedEvents = this._eventList.filter((e) => e.action === "closed");
@@ -22521,6 +22629,21 @@ let ReportStorage$9 = class ReportStorage {
22521
22629
  const avgFallPnl = totalClosed > 0
22522
22630
  ? closedEvents.reduce((sum, e) => sum + (e.fallPnl || 0), 0) / totalClosed
22523
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;
22524
22647
  return {
22525
22648
  eventList: this._eventList,
22526
22649
  totalEvents: this._eventList.length,
@@ -22537,6 +22660,9 @@ let ReportStorage$9 = class ReportStorage {
22537
22660
  expectedYearlyReturns: isUnsafe$2(expectedYearlyReturns) ? null : expectedYearlyReturns,
22538
22661
  avgPeakPnl: isUnsafe$2(avgPeakPnl) ? null : avgPeakPnl,
22539
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,
22540
22666
  };
22541
22667
  }
22542
22668
  /**
@@ -22583,6 +22709,9 @@ let ReportStorage$9 = class ReportStorage {
22583
22709
  `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
22584
22710
  `**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
22585
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)`}`,
22586
22715
  ].join("\n");
22587
22716
  }
22588
22717
  /**
@@ -24028,6 +24157,9 @@ let ReportStorage$7 = class ReportStorage {
24028
24157
  "",
24029
24158
  `**Best ${results.metric}:** ${formatMetric(results.bestMetric)}`,
24030
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"}`,
24031
24163
  "",
24032
24164
  "## Top Strategies Comparison",
24033
24165
  "",
@@ -24494,6 +24626,27 @@ class HeatmapStorage {
24494
24626
  avgPeakPnl = signals.reduce((acc, s) => acc + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / signals.length;
24495
24627
  avgFallPnl = signals.reduce((acc, s) => acc + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / signals.length;
24496
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
+ }
24497
24650
  // Apply safe math checks
24498
24651
  if (isUnsafe(winRate))
24499
24652
  winRate = null;
@@ -24519,6 +24672,12 @@ class HeatmapStorage {
24519
24672
  avgPeakPnl = null;
24520
24673
  if (isUnsafe(avgFallPnl))
24521
24674
  avgFallPnl = null;
24675
+ if (isUnsafe(sortinoRatio))
24676
+ sortinoRatio = null;
24677
+ if (isUnsafe(calmarRatio))
24678
+ calmarRatio = null;
24679
+ if (isUnsafe(recoveryFactor))
24680
+ recoveryFactor = null;
24522
24681
  return {
24523
24682
  symbol,
24524
24683
  totalPnl,
@@ -24538,6 +24697,9 @@ class HeatmapStorage {
24538
24697
  expectancy,
24539
24698
  avgPeakPnl,
24540
24699
  avgFallPnl,
24700
+ sortinoRatio,
24701
+ calmarRatio,
24702
+ recoveryFactor,
24541
24703
  };
24542
24704
  }
24543
24705
  /**
@@ -29404,6 +29566,9 @@ class WalkerReportService {
29404
29566
  annualizedSharpeRatio: data.stats.annualizedSharpeRatio,
29405
29567
  certaintyRatio: data.stats.certaintyRatio,
29406
29568
  expectedYearlyReturns: data.stats.expectedYearlyReturns,
29569
+ sortinoRatio: data.stats.sortinoRatio,
29570
+ calmarRatio: data.stats.calmarRatio,
29571
+ recoveryFactor: data.stats.recoveryFactor,
29407
29572
  firstEventTime,
29408
29573
  lastEventTime,
29409
29574
  }, {
@@ -50718,7 +50883,8 @@ class DumpAdapter {
50718
50883
  const unClose = signalEmitter
50719
50884
  .filter(({ action }) => action === "closed")
50720
50885
  .connect(({ signal }) => handleDispose(signal.id));
50721
- return compose(() => unCancel(), () => unClose());
50886
+ const unEnable = () => this.enable.clear();
50887
+ return compose(() => unCancel(), () => unClose(), () => unEnable());
50722
50888
  });
50723
50889
  /**
50724
50890
  * Deactivates the adapter by unsubscribing from signal lifecycle events.
@@ -51405,7 +51571,7 @@ class ReportUtils {
51405
51571
  *
51406
51572
  * @returns Cleanup function that unsubscribes from all enabled services
51407
51573
  */
51408
- this.enable = ({ backtest: bt = false, breakeven = false, heat = false, live = false, partial = false, performance = false, risk = false, schedule = false, walker = false, strategy = false, sync = false, highest_profit = false, max_drawdown = false, } = WILDCARD_TARGET$2) => {
51574
+ this.enable = singleshot(({ backtest: bt = false, breakeven = false, heat = false, live = false, partial = false, performance = false, risk = false, schedule = false, walker = false, strategy = false, sync = false, highest_profit = false, max_drawdown = false, } = WILDCARD_TARGET$2) => {
51409
51575
  LOGGER_SERVICE$2.debug(REPORT_UTILS_METHOD_NAME_ENABLE, {
51410
51576
  backtest: bt,
51411
51577
  breakeven,
@@ -51459,8 +51625,11 @@ class ReportUtils {
51459
51625
  if (max_drawdown) {
51460
51626
  unList.push(backtest.maxDrawdownReportService.subscribe());
51461
51627
  }
51628
+ {
51629
+ unList.push(() => this.enable.clear());
51630
+ }
51462
51631
  return compose(...unList.map((un) => () => void un()));
51463
- };
51632
+ });
51464
51633
  /**
51465
51634
  * Disables report services selectively.
51466
51635
  *
@@ -51511,6 +51680,11 @@ class ReportUtils {
51511
51680
  strategy,
51512
51681
  sync,
51513
51682
  });
51683
+ if (this.enable.hasValue()) {
51684
+ const lastSubscription = this.enable();
51685
+ lastSubscription();
51686
+ return;
51687
+ }
51514
51688
  if (bt) {
51515
51689
  backtest.backtestReportService.unsubscribe();
51516
51690
  }
@@ -51672,7 +51846,7 @@ class MarkdownUtils {
51672
51846
  *
51673
51847
  * @returns Cleanup function that unsubscribes from all enabled services
51674
51848
  */
51675
- this.enable = ({ backtest: bt = false, breakeven = false, heat = false, live = false, partial = false, performance = false, strategy = false, risk = false, schedule = false, walker = false, sync = false, highest_profit = false, max_drawdown = false, } = WILDCARD_TARGET$1) => {
51849
+ this.enable = singleshot(({ backtest: bt = false, breakeven = false, heat = false, live = false, partial = false, performance = false, strategy = false, risk = false, schedule = false, walker = false, sync = false, highest_profit = false, max_drawdown = false, } = WILDCARD_TARGET$1) => {
51676
51850
  LOGGER_SERVICE$1.debug(MARKDOWN_METHOD_NAME_ENABLE, {
51677
51851
  backtest: bt,
51678
51852
  breakeven,
@@ -51727,8 +51901,11 @@ class MarkdownUtils {
51727
51901
  if (max_drawdown) {
51728
51902
  unList.push(backtest.maxDrawdownMarkdownService.subscribe());
51729
51903
  }
51904
+ {
51905
+ unList.push(() => this.enable.clear());
51906
+ }
51730
51907
  return compose(...unList.map((un) => () => void un()));
51731
- };
51908
+ });
51732
51909
  /**
51733
51910
  * Disables markdown report services selectively.
51734
51911
  *
@@ -51781,6 +51958,11 @@ class MarkdownUtils {
51781
51958
  sync,
51782
51959
  highest_profit,
51783
51960
  });
51961
+ if (this.enable.hasValue()) {
51962
+ const lastSubscription = this.enable();
51963
+ lastSubscription();
51964
+ return;
51965
+ }
51784
51966
  if (bt) {
51785
51967
  backtest.backtestMarkdownService.unsubscribe();
51786
51968
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backtest-kit",
3
- "version": "7.8.0",
3
+ "version": "8.1.0",
4
4
  "description": "A TypeScript library for trading system backtest",
5
5
  "author": {
6
6
  "name": "Petr Tripolsky",
package/types.d.ts CHANGED
@@ -4466,6 +4466,12 @@ interface BacktestStatisticsModel {
4466
4466
  avgPeakPnl: number | null;
4467
4467
  /** Average fall PNL percentage across all signals (_fall.pnlPercentage), null if unsafe. Lower (more negative) means deeper drawdowns. */
4468
4468
  avgFallPnl: number | null;
4469
+ /** Sortino Ratio (avgPnl / downside deviation — stdDev of losses only), null if unsafe. Higher is better. */
4470
+ sortinoRatio: number | null;
4471
+ /** Calmar Ratio (annualized expected return / max drawdown), null if unsafe. Higher is better. */
4472
+ calmarRatio: number | null;
4473
+ /** Recovery Factor (totalPnl / max drawdown), null if unsafe. Higher is better. */
4474
+ recoveryFactor: number | null;
4469
4475
  }
4470
4476
 
4471
4477
  /**
@@ -6478,6 +6484,20 @@ declare const GLOBAL_CONFIG: {
6478
6484
  * Default: false (PPPL logic is only applied when it does not break the direction of exits, ensuring clearer profit/loss outcomes)
6479
6485
  */
6480
6486
  CC_ENABLE_PPPL_EVERYWHERE: boolean;
6487
+ /**
6488
+ * Enables long signals in strategies that are primarily designed for short signals.
6489
+ * This allows the strategy to generate and manage long signals in addition to short signals, even if the original design was focused on short trading.
6490
+ * This can help expand the strategy's applicability and take advantage of bullish market conditions, but may require additional logic to manage long signal behavior effectively.
6491
+ *
6492
+ * Default: false (long signals are only enabled in strategies that are designed for them, ensuring strategy logic is aligned with signal types)
6493
+ */
6494
+ CC_ENABLE_LONG_SIGNAL: boolean;
6495
+ /**
6496
+ * Enables short signals in strategies that are primarily designed for long signals.
6497
+ * This allows the strategy to generate and manage short signals in addition to long signals, even if the original design was focused on long trading.
6498
+ * This can help expand the strategy's applicability and take advantage of bearish market conditions, but may require additional logic to manage short signal behavior effectively.
6499
+ */
6500
+ CC_ENABLE_SHORT_SIGNAL: boolean;
6481
6501
  /**
6482
6502
  * Enables trailing logic (Trailing Take / Trailing Stop) without requiring absorption conditions.
6483
6503
  * Allows trailing mechanisms to be activated regardless of whether absorption has been detected.
@@ -6626,6 +6646,8 @@ declare function getConfig(): {
6626
6646
  CC_ENABLE_CANDLE_FETCH_MUTEX: boolean;
6627
6647
  CC_ENABLE_DCA_EVERYWHERE: boolean;
6628
6648
  CC_ENABLE_PPPL_EVERYWHERE: boolean;
6649
+ CC_ENABLE_LONG_SIGNAL: boolean;
6650
+ CC_ENABLE_SHORT_SIGNAL: boolean;
6629
6651
  CC_ENABLE_TRAILING_EVERYWHERE: boolean;
6630
6652
  CC_POSITION_ENTRY_COST: number;
6631
6653
  };
@@ -6682,6 +6704,8 @@ declare function getDefaultConfig(): Readonly<{
6682
6704
  CC_ENABLE_CANDLE_FETCH_MUTEX: boolean;
6683
6705
  CC_ENABLE_DCA_EVERYWHERE: boolean;
6684
6706
  CC_ENABLE_PPPL_EVERYWHERE: boolean;
6707
+ CC_ENABLE_LONG_SIGNAL: boolean;
6708
+ CC_ENABLE_SHORT_SIGNAL: boolean;
6685
6709
  CC_ENABLE_TRAILING_EVERYWHERE: boolean;
6686
6710
  CC_POSITION_ENTRY_COST: number;
6687
6711
  }>;
@@ -10259,6 +10283,12 @@ interface IHeatmapRow {
10259
10283
  avgPeakPnl: number | null;
10260
10284
  /** Average fall PNL percentage across all trades (_fall.pnlPercentage). Closer to 0 is better. */
10261
10285
  avgFallPnl: number | null;
10286
+ /** Sortino Ratio (avgPnl / downside deviation — stdDev of losses only). Higher is better. */
10287
+ sortinoRatio: number | null;
10288
+ /** Calmar Ratio (totalPnl / maxDrawdown). Higher is better. */
10289
+ calmarRatio: number | null;
10290
+ /** Recovery Factor (totalPnl / maxDrawdown). Higher is better. */
10291
+ recoveryFactor: number | null;
10262
10292
  }
10263
10293
 
10264
10294
  /**
@@ -12131,6 +12161,12 @@ interface LiveStatisticsModel {
12131
12161
  avgPeakPnl: number | null;
12132
12162
  /** Average fall PNL percentage across all closed signals (_fall.pnlPercentage), null if unsafe. Closer to 0 is better. */
12133
12163
  avgFallPnl: number | null;
12164
+ /** Sortino Ratio (avgPnl / downside deviation — stdDev of losses only), null if unsafe. Higher is better. */
12165
+ sortinoRatio: number | null;
12166
+ /** Calmar Ratio (annualized expected return / max drawdown), null if unsafe. Higher is better. */
12167
+ calmarRatio: number | null;
12168
+ /** Recovery Factor (totalPnl / max drawdown), null if unsafe. Higher is better. */
12169
+ recoveryFactor: number | null;
12134
12170
  }
12135
12171
 
12136
12172
  /**
@@ -14593,7 +14629,7 @@ declare class ReportBase implements TReportBase {
14593
14629
  * Waits for drain event if write buffer is full.
14594
14630
  * Times out after 15 seconds and returns TIMEOUT_SYMBOL.
14595
14631
  */
14596
- [WRITE_SAFE_SYMBOL]: (line: string) => Promise<symbol | void>;
14632
+ [WRITE_SAFE_SYMBOL]: functools_kit.IWrappedQueuedFn<symbol | void, [line: string]>;
14597
14633
  /**
14598
14634
  * Initializes the JSONL file and write stream.
14599
14635
  * Safe to call multiple times - singleshot ensures one-time execution.
@@ -14717,7 +14753,7 @@ declare class ReportUtils {
14717
14753
  *
14718
14754
  * @returns Cleanup function that unsubscribes from all enabled services
14719
14755
  */
14720
- enable: ({ backtest: bt, breakeven, heat, live, partial, performance, risk, schedule, walker, strategy, sync, highest_profit, max_drawdown, }?: Partial<IReportTarget>) => (...args: any[]) => any;
14756
+ enable: (({ backtest: bt, breakeven, heat, live, partial, performance, risk, schedule, walker, strategy, sync, highest_profit, max_drawdown, }?: Partial<IReportTarget>) => (...args: any[]) => any) & functools_kit.ISingleshotClearable<({ backtest: bt, breakeven, heat, live, partial, performance, risk, schedule, walker, strategy, sync, highest_profit, max_drawdown, }?: Partial<IReportTarget>) => (...args: any[]) => any>;
14721
14757
  /**
14722
14758
  * Disables report services selectively.
14723
14759
  *
@@ -14834,7 +14870,7 @@ declare class MarkdownUtils {
14834
14870
  *
14835
14871
  * @returns Cleanup function that unsubscribes from all enabled services
14836
14872
  */
14837
- enable: ({ backtest: bt, breakeven, heat, live, partial, performance, strategy, risk, schedule, walker, sync, highest_profit, max_drawdown, }?: Partial<IMarkdownTarget>) => (...args: any[]) => any;
14873
+ enable: (({ backtest: bt, breakeven, heat, live, partial, performance, strategy, risk, schedule, walker, sync, highest_profit, max_drawdown, }?: Partial<IMarkdownTarget>) => (...args: any[]) => any) & functools_kit.ISingleshotClearable<({ backtest: bt, breakeven, heat, live, partial, performance, strategy, risk, schedule, walker, sync, highest_profit, max_drawdown, }?: Partial<IMarkdownTarget>) => (...args: any[]) => any>;
14838
14874
  /**
14839
14875
  * Disables markdown report services selectively.
14840
14876
  *