backtest-kit 1.5.1 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.cjs CHANGED
@@ -25,7 +25,13 @@ const GLOBAL_CONFIG = {
25
25
  * Must be greater than trading fees to ensure profitable trades
26
26
  * Default: 0.3% (covers 2×0.1% fees + minimum profit margin)
27
27
  */
28
- CC_MIN_TAKEPROFIT_DISTANCE_PERCENT: 0.1,
28
+ CC_MIN_TAKEPROFIT_DISTANCE_PERCENT: 0.3,
29
+ /**
30
+ * Minimum StopLoss distance from priceOpen (percentage)
31
+ * Prevents signals from being immediately stopped out due to price volatility
32
+ * Default: 0.5% (buffer to avoid instant stop loss on normal market fluctuations)
33
+ */
34
+ CC_MIN_STOPLOSS_DISTANCE_PERCENT: 0.5,
29
35
  /**
30
36
  * Maximum StopLoss distance from priceOpen (percentage)
31
37
  * Prevents catastrophic losses from extreme StopLoss values
@@ -1821,18 +1827,30 @@ const VALIDATE_SIGNAL_FN = (signal, currentPrice, isScheduled) => {
1821
1827
  if (signal.priceStopLoss >= signal.priceOpen) {
1822
1828
  errors.push(`Long: priceStopLoss (${signal.priceStopLoss}) must be < priceOpen (${signal.priceOpen})`);
1823
1829
  }
1824
- // ЗАЩИТА ОТ EDGE CASE: для immediate сигналов проверяем что текущая цена не пробила SL/TP
1825
- // Для scheduled сигналов эта проверка избыточна т.к. priceOpen уже проверен выше
1826
- if (!isScheduled) {
1827
- // Текущая цена уже пробила StopLoss - позиция откроется и сразу закроется по SL
1828
- if (isFinite(currentPrice) && currentPrice < signal.priceStopLoss) {
1829
- errors.push(`Long: currentPrice (${currentPrice}) < priceStopLoss (${signal.priceStopLoss}). ` +
1830
- `Signal would be immediately cancelled. This signal is invalid.`);
1830
+ // ЗАЩИТА ОТ МОМЕНТАЛЬНОГО ЗАКРЫТИЯ: проверяем что позиция не закроется сразу после открытия
1831
+ if (!isScheduled && isFinite(currentPrice)) {
1832
+ // LONG: currentPrice должна быть МЕЖДУ SL и TP (не пробита ни одна граница)
1833
+ // SL < currentPrice < TP
1834
+ if (currentPrice <= signal.priceStopLoss) {
1835
+ errors.push(`Long immediate: currentPrice (${currentPrice}) <= priceStopLoss (${signal.priceStopLoss}). ` +
1836
+ `Signal would be immediately closed by stop loss. Cannot open position that is already stopped out.`);
1837
+ }
1838
+ if (currentPrice >= signal.priceTakeProfit) {
1839
+ errors.push(`Long immediate: currentPrice (${currentPrice}) >= priceTakeProfit (${signal.priceTakeProfit}). ` +
1840
+ `Signal would be immediately closed by take profit. The profit opportunity has already passed.`);
1841
+ }
1842
+ }
1843
+ // ЗАЩИТА ОТ МОМЕНТАЛЬНОГО ЗАКРЫТИЯ scheduled сигналов
1844
+ if (isScheduled && isFinite(signal.priceOpen)) {
1845
+ // LONG scheduled: priceOpen должен быть МЕЖДУ SL и TP
1846
+ // SL < priceOpen < TP
1847
+ if (signal.priceOpen <= signal.priceStopLoss) {
1848
+ errors.push(`Long scheduled: priceOpen (${signal.priceOpen}) <= priceStopLoss (${signal.priceStopLoss}). ` +
1849
+ `Signal would be immediately cancelled on activation. Cannot activate position that is already stopped out.`);
1831
1850
  }
1832
- // Текущая цена уже достигла TakeProfit - профит упущен
1833
- if (isFinite(currentPrice) && currentPrice > signal.priceTakeProfit) {
1834
- errors.push(`Long: currentPrice (${currentPrice}) > priceTakeProfit (${signal.priceTakeProfit}). ` +
1835
- `Signal is invalid - the profit opportunity has already passed.`);
1851
+ if (signal.priceOpen >= signal.priceTakeProfit) {
1852
+ errors.push(`Long scheduled: priceOpen (${signal.priceOpen}) >= priceTakeProfit (${signal.priceTakeProfit}). ` +
1853
+ `Signal would close immediately on activation. This is logically impossible for LONG position.`);
1836
1854
  }
1837
1855
  }
1838
1856
  // ЗАЩИТА ОТ МИКРО-ПРОФИТА: TakeProfit должен быть достаточно далеко, чтобы покрыть комиссии
@@ -1844,8 +1862,17 @@ const VALIDATE_SIGNAL_FN = (signal, currentPrice, isScheduled) => {
1844
1862
  `Current: TP=${signal.priceTakeProfit}, Open=${signal.priceOpen}`);
1845
1863
  }
1846
1864
  }
1865
+ // ЗАЩИТА ОТ СЛИШКОМ УЗКОГО STOPLOSS: минимальный буфер для избежания моментального закрытия
1866
+ if (GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
1867
+ const slDistancePercent = ((signal.priceOpen - signal.priceStopLoss) / signal.priceOpen) * 100;
1868
+ if (slDistancePercent < GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
1869
+ errors.push(`Long: StopLoss too close to priceOpen (${slDistancePercent.toFixed(3)}%). ` +
1870
+ `Minimum distance: ${GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT}% to avoid instant stop out on market volatility. ` +
1871
+ `Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`);
1872
+ }
1873
+ }
1847
1874
  // ЗАЩИТА ОТ ЭКСТРЕМАЛЬНОГО STOPLOSS: ограничиваем максимальный убыток
1848
- if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT && GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1875
+ if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1849
1876
  const slDistancePercent = ((signal.priceOpen - signal.priceStopLoss) / signal.priceOpen) * 100;
1850
1877
  if (slDistancePercent > GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1851
1878
  errors.push(`Long: StopLoss too far from priceOpen (${slDistancePercent.toFixed(3)}%). ` +
@@ -1862,18 +1889,30 @@ const VALIDATE_SIGNAL_FN = (signal, currentPrice, isScheduled) => {
1862
1889
  if (signal.priceStopLoss <= signal.priceOpen) {
1863
1890
  errors.push(`Short: priceStopLoss (${signal.priceStopLoss}) must be > priceOpen (${signal.priceOpen})`);
1864
1891
  }
1865
- // ЗАЩИТА ОТ EDGE CASE: для immediate сигналов проверяем что текущая цена не пробила SL/TP
1866
- // Для scheduled сигналов эта проверка избыточна т.к. priceOpen уже проверен выше
1867
- if (!isScheduled) {
1868
- // Текущая цена уже пробила StopLoss - позиция откроется и сразу закроется по SL
1869
- if (isFinite(currentPrice) && currentPrice > signal.priceStopLoss) {
1870
- errors.push(`Short: currentPrice (${currentPrice}) > priceStopLoss (${signal.priceStopLoss}). ` +
1871
- `Signal would be immediately cancelled. This signal is invalid.`);
1892
+ // ЗАЩИТА ОТ МОМЕНТАЛЬНОГО ЗАКРЫТИЯ: проверяем что позиция не закроется сразу после открытия
1893
+ if (!isScheduled && isFinite(currentPrice)) {
1894
+ // SHORT: currentPrice должна быть МЕЖДУ TP и SL (не пробита ни одна граница)
1895
+ // TP < currentPrice < SL
1896
+ if (currentPrice >= signal.priceStopLoss) {
1897
+ errors.push(`Short immediate: currentPrice (${currentPrice}) >= priceStopLoss (${signal.priceStopLoss}). ` +
1898
+ `Signal would be immediately closed by stop loss. Cannot open position that is already stopped out.`);
1872
1899
  }
1873
- // Текущая цена уже достигла TakeProfit - профит упущен
1874
- if (isFinite(currentPrice) && currentPrice < signal.priceTakeProfit) {
1875
- errors.push(`Short: currentPrice (${currentPrice}) < priceTakeProfit (${signal.priceTakeProfit}). ` +
1876
- `Signal is invalid - the profit opportunity has already passed.`);
1900
+ if (currentPrice <= signal.priceTakeProfit) {
1901
+ errors.push(`Short immediate: currentPrice (${currentPrice}) <= priceTakeProfit (${signal.priceTakeProfit}). ` +
1902
+ `Signal would be immediately closed by take profit. The profit opportunity has already passed.`);
1903
+ }
1904
+ }
1905
+ // ЗАЩИТА ОТ МОМЕНТАЛЬНОГО ЗАКРЫТИЯ scheduled сигналов
1906
+ if (isScheduled && isFinite(signal.priceOpen)) {
1907
+ // SHORT scheduled: priceOpen должен быть МЕЖДУ TP и SL
1908
+ // TP < priceOpen < SL
1909
+ if (signal.priceOpen >= signal.priceStopLoss) {
1910
+ errors.push(`Short scheduled: priceOpen (${signal.priceOpen}) >= priceStopLoss (${signal.priceStopLoss}). ` +
1911
+ `Signal would be immediately cancelled on activation. Cannot activate position that is already stopped out.`);
1912
+ }
1913
+ if (signal.priceOpen <= signal.priceTakeProfit) {
1914
+ errors.push(`Short scheduled: priceOpen (${signal.priceOpen}) <= priceTakeProfit (${signal.priceTakeProfit}). ` +
1915
+ `Signal would close immediately on activation. This is logically impossible for SHORT position.`);
1877
1916
  }
1878
1917
  }
1879
1918
  // ЗАЩИТА ОТ МИКРО-ПРОФИТА: TakeProfit должен быть достаточно далеко, чтобы покрыть комиссии
@@ -1885,8 +1924,17 @@ const VALIDATE_SIGNAL_FN = (signal, currentPrice, isScheduled) => {
1885
1924
  `Current: TP=${signal.priceTakeProfit}, Open=${signal.priceOpen}`);
1886
1925
  }
1887
1926
  }
1927
+ // ЗАЩИТА ОТ СЛИШКОМ УЗКОГО STOPLOSS: минимальный буфер для избежания моментального закрытия
1928
+ if (GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
1929
+ const slDistancePercent = ((signal.priceStopLoss - signal.priceOpen) / signal.priceOpen) * 100;
1930
+ if (slDistancePercent < GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
1931
+ errors.push(`Short: StopLoss too close to priceOpen (${slDistancePercent.toFixed(3)}%). ` +
1932
+ `Minimum distance: ${GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT}% to avoid instant stop out on market volatility. ` +
1933
+ `Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`);
1934
+ }
1935
+ }
1888
1936
  // ЗАЩИТА ОТ ЭКСТРЕМАЛЬНОГО STOPLOSS: ограничиваем максимальный убыток
1889
- if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT && GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1937
+ if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1890
1938
  const slDistancePercent = ((signal.priceStopLoss - signal.priceOpen) / signal.priceOpen) * 100;
1891
1939
  if (slDistancePercent > GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1892
1940
  errors.push(`Short: StopLoss too far from priceOpen (${slDistancePercent.toFixed(3)}%). ` +
@@ -2577,8 +2625,14 @@ const CLOSE_PENDING_SIGNAL_IN_BACKTEST_FN = async (self, signal, averagePrice, c
2577
2625
  const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) => {
2578
2626
  const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
2579
2627
  const maxTimeToWait = GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES * 60 * 1000;
2628
+ const bufferCandlesCount = candlesCount - 1;
2580
2629
  for (let i = 0; i < candles.length; i++) {
2581
2630
  const candle = candles[i];
2631
+ // КРИТИЧНО: Пропускаем первые bufferCandlesCount свечей (буфер для VWAP)
2632
+ // BacktestLogicPrivateService запросил свечи начиная с (when - bufferMinutes)
2633
+ if (i < bufferCandlesCount) {
2634
+ continue;
2635
+ }
2582
2636
  const recentCandles = candles.slice(Math.max(0, i - (candlesCount - 1)), i + 1);
2583
2637
  const averagePrice = GET_AVG_PRICE_FN(recentCandles);
2584
2638
  // КРИТИЧНО: Проверяем timeout ПЕРЕД проверкой цены
@@ -2644,11 +2698,21 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
2644
2698
  };
2645
2699
  const PROCESS_PENDING_SIGNAL_CANDLES_FN = async (self, signal, candles) => {
2646
2700
  const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
2647
- for (let i = candlesCount - 1; i < candles.length; i++) {
2648
- const recentCandles = candles.slice(i - (candlesCount - 1), i + 1);
2701
+ const bufferCandlesCount = candlesCount - 1;
2702
+ // КРИТИЧНО: проверяем TP/SL на КАЖДОЙ свече начиная после буфера
2703
+ // Первые bufferCandlesCount свечей - это буфер для VWAP
2704
+ for (let i = 0; i < candles.length; i++) {
2705
+ const currentCandle = candles[i];
2706
+ const currentCandleTimestamp = currentCandle.timestamp;
2707
+ // КРИТИЧНО: Пропускаем первые bufferCandlesCount свечей (буфер для VWAP)
2708
+ // BacktestLogicPrivateService запросил свечи начиная с (when - bufferMinutes)
2709
+ if (i < bufferCandlesCount) {
2710
+ continue;
2711
+ }
2712
+ // Берем последние candlesCount свечей для VWAP (включая буфер)
2713
+ const startIndex = Math.max(0, i - (candlesCount - 1));
2714
+ const recentCandles = candles.slice(startIndex, i + 1);
2649
2715
  const averagePrice = GET_AVG_PRICE_FN(recentCandles);
2650
- const currentCandleTimestamp = recentCandles[recentCandles.length - 1].timestamp;
2651
- const currentCandle = recentCandles[recentCandles.length - 1];
2652
2716
  let shouldClose = false;
2653
2717
  let closeReason;
2654
2718
  // Check time expiration FIRST (КРИТИЧНО!)
@@ -2948,9 +3012,10 @@ class ClientStrategy {
2948
3012
  * 4. If cancelled: returns closed result with closeReason "cancelled"
2949
3013
  *
2950
3014
  * For pending signals:
2951
- * 1. Iterates through candles checking VWAP against TP/SL on each timeframe
2952
- * 2. Starts from index 4 (needs 5 candles for VWAP calculation)
2953
- * 3. Returns closed result (either TP/SL or time_expired)
3015
+ * 1. Iterates through ALL candles starting from the first one
3016
+ * 2. Checks TP/SL using candle.high/low (immediate detection)
3017
+ * 3. VWAP calculated with dynamic window (1 to CC_AVG_PRICE_CANDLES_COUNT candles)
3018
+ * 4. Returns closed result (either TP/SL or time_expired)
2954
3019
  *
2955
3020
  * @param candles - Array of candles to process
2956
3021
  * @returns Promise resolving to closed signal result with PNL
@@ -4906,13 +4971,17 @@ class BacktestLogicPrivateService {
4906
4971
  minuteEstimatedTime: signal.minuteEstimatedTime,
4907
4972
  });
4908
4973
  // Запрашиваем минутные свечи для мониторинга активации/отмены
4909
- // КРИТИЧНО: запрашиваем CC_SCHEDULE_AWAIT_MINUTES для ожидания активации
4910
- // + minuteEstimatedTime для работы сигнала ПОСЛЕ активации
4911
- // +1 потому что when включается как первая свеча (timestamp начинается с when, а не after when)
4912
- const candlesNeeded = GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES + signal.minuteEstimatedTime + 1;
4974
+ // КРИТИЧНО: запрашиваем:
4975
+ // - CC_AVG_PRICE_CANDLES_COUNT-1 для буфера VWAP (ДО when)
4976
+ // - CC_SCHEDULE_AWAIT_MINUTES для ожидания активации
4977
+ // - minuteEstimatedTime для работы сигнала ПОСЛЕ активации
4978
+ // - +1 потому что when включается как первая свеча
4979
+ const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
4980
+ const bufferStartTime = new Date(when.getTime() - bufferMinutes * 60 * 1000);
4981
+ const candlesNeeded = bufferMinutes + GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES + signal.minuteEstimatedTime + 1;
4913
4982
  let candles;
4914
4983
  try {
4915
- candles = await this.exchangeGlobalService.getNextCandles(symbol, "1m", candlesNeeded, when, true);
4984
+ candles = await this.exchangeGlobalService.getNextCandles(symbol, "1m", candlesNeeded, bufferStartTime, true);
4916
4985
  }
4917
4986
  catch (error) {
4918
4987
  console.warn(`backtestLogicPrivateService getNextCandles failed for scheduled signal when=${when.toISOString()} symbol=${symbol} strategyName=${this.methodContextService.context.strategyName} exchangeName=${this.methodContextService.context.exchangeName}`);
@@ -4920,6 +4989,7 @@ class BacktestLogicPrivateService {
4920
4989
  symbol,
4921
4990
  signalId: signal.id,
4922
4991
  candlesNeeded,
4992
+ bufferMinutes,
4923
4993
  error: functoolsKit.errorData(error), message: functoolsKit.getErrorMessage(error),
4924
4994
  });
4925
4995
  await errorEmitter.next(error);
@@ -4992,16 +5062,23 @@ class BacktestLogicPrivateService {
4992
5062
  signalId: signal.id,
4993
5063
  minuteEstimatedTime: signal.minuteEstimatedTime,
4994
5064
  });
4995
- // Получаем свечи для бектеста
5065
+ // КРИТИЧНО: Получаем свечи включая буфер для VWAP
5066
+ // Сдвигаем начало назад на CC_AVG_PRICE_CANDLES_COUNT-1 минут для буфера VWAP
5067
+ // Запрашиваем minuteEstimatedTime + буфер свечей одним запросом
5068
+ const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
5069
+ const bufferStartTime = new Date(when.getTime() - bufferMinutes * 60 * 1000);
5070
+ const totalCandles = signal.minuteEstimatedTime + bufferMinutes;
4996
5071
  let candles;
4997
5072
  try {
4998
- candles = await this.exchangeGlobalService.getNextCandles(symbol, "1m", signal.minuteEstimatedTime, when, true);
5073
+ candles = await this.exchangeGlobalService.getNextCandles(symbol, "1m", totalCandles, bufferStartTime, true);
4999
5074
  }
5000
5075
  catch (error) {
5001
5076
  console.warn(`backtestLogicPrivateService getNextCandles failed for opened signal when=${when.toISOString()} symbol=${symbol} strategyName=${this.methodContextService.context.strategyName} exchangeName=${this.methodContextService.context.exchangeName}`);
5002
5077
  this.loggerService.warn("backtestLogicPrivateService getNextCandles failed for opened signal", {
5003
5078
  symbol,
5004
5079
  signalId: signal.id,
5080
+ totalCandles,
5081
+ bufferMinutes,
5005
5082
  error: functoolsKit.errorData(error), message: functoolsKit.getErrorMessage(error),
5006
5083
  });
5007
5084
  await errorEmitter.next(error);
package/build/index.mjs CHANGED
@@ -23,7 +23,13 @@ const GLOBAL_CONFIG = {
23
23
  * Must be greater than trading fees to ensure profitable trades
24
24
  * Default: 0.3% (covers 2×0.1% fees + minimum profit margin)
25
25
  */
26
- CC_MIN_TAKEPROFIT_DISTANCE_PERCENT: 0.1,
26
+ CC_MIN_TAKEPROFIT_DISTANCE_PERCENT: 0.3,
27
+ /**
28
+ * Minimum StopLoss distance from priceOpen (percentage)
29
+ * Prevents signals from being immediately stopped out due to price volatility
30
+ * Default: 0.5% (buffer to avoid instant stop loss on normal market fluctuations)
31
+ */
32
+ CC_MIN_STOPLOSS_DISTANCE_PERCENT: 0.5,
27
33
  /**
28
34
  * Maximum StopLoss distance from priceOpen (percentage)
29
35
  * Prevents catastrophic losses from extreme StopLoss values
@@ -1819,18 +1825,30 @@ const VALIDATE_SIGNAL_FN = (signal, currentPrice, isScheduled) => {
1819
1825
  if (signal.priceStopLoss >= signal.priceOpen) {
1820
1826
  errors.push(`Long: priceStopLoss (${signal.priceStopLoss}) must be < priceOpen (${signal.priceOpen})`);
1821
1827
  }
1822
- // ЗАЩИТА ОТ EDGE CASE: для immediate сигналов проверяем что текущая цена не пробила SL/TP
1823
- // Для scheduled сигналов эта проверка избыточна т.к. priceOpen уже проверен выше
1824
- if (!isScheduled) {
1825
- // Текущая цена уже пробила StopLoss - позиция откроется и сразу закроется по SL
1826
- if (isFinite(currentPrice) && currentPrice < signal.priceStopLoss) {
1827
- errors.push(`Long: currentPrice (${currentPrice}) < priceStopLoss (${signal.priceStopLoss}). ` +
1828
- `Signal would be immediately cancelled. This signal is invalid.`);
1828
+ // ЗАЩИТА ОТ МОМЕНТАЛЬНОГО ЗАКРЫТИЯ: проверяем что позиция не закроется сразу после открытия
1829
+ if (!isScheduled && isFinite(currentPrice)) {
1830
+ // LONG: currentPrice должна быть МЕЖДУ SL и TP (не пробита ни одна граница)
1831
+ // SL < currentPrice < TP
1832
+ if (currentPrice <= signal.priceStopLoss) {
1833
+ errors.push(`Long immediate: currentPrice (${currentPrice}) <= priceStopLoss (${signal.priceStopLoss}). ` +
1834
+ `Signal would be immediately closed by stop loss. Cannot open position that is already stopped out.`);
1835
+ }
1836
+ if (currentPrice >= signal.priceTakeProfit) {
1837
+ errors.push(`Long immediate: currentPrice (${currentPrice}) >= priceTakeProfit (${signal.priceTakeProfit}). ` +
1838
+ `Signal would be immediately closed by take profit. The profit opportunity has already passed.`);
1839
+ }
1840
+ }
1841
+ // ЗАЩИТА ОТ МОМЕНТАЛЬНОГО ЗАКРЫТИЯ scheduled сигналов
1842
+ if (isScheduled && isFinite(signal.priceOpen)) {
1843
+ // LONG scheduled: priceOpen должен быть МЕЖДУ SL и TP
1844
+ // SL < priceOpen < TP
1845
+ if (signal.priceOpen <= signal.priceStopLoss) {
1846
+ errors.push(`Long scheduled: priceOpen (${signal.priceOpen}) <= priceStopLoss (${signal.priceStopLoss}). ` +
1847
+ `Signal would be immediately cancelled on activation. Cannot activate position that is already stopped out.`);
1829
1848
  }
1830
- // Текущая цена уже достигла TakeProfit - профит упущен
1831
- if (isFinite(currentPrice) && currentPrice > signal.priceTakeProfit) {
1832
- errors.push(`Long: currentPrice (${currentPrice}) > priceTakeProfit (${signal.priceTakeProfit}). ` +
1833
- `Signal is invalid - the profit opportunity has already passed.`);
1849
+ if (signal.priceOpen >= signal.priceTakeProfit) {
1850
+ errors.push(`Long scheduled: priceOpen (${signal.priceOpen}) >= priceTakeProfit (${signal.priceTakeProfit}). ` +
1851
+ `Signal would close immediately on activation. This is logically impossible for LONG position.`);
1834
1852
  }
1835
1853
  }
1836
1854
  // ЗАЩИТА ОТ МИКРО-ПРОФИТА: TakeProfit должен быть достаточно далеко, чтобы покрыть комиссии
@@ -1842,8 +1860,17 @@ const VALIDATE_SIGNAL_FN = (signal, currentPrice, isScheduled) => {
1842
1860
  `Current: TP=${signal.priceTakeProfit}, Open=${signal.priceOpen}`);
1843
1861
  }
1844
1862
  }
1863
+ // ЗАЩИТА ОТ СЛИШКОМ УЗКОГО STOPLOSS: минимальный буфер для избежания моментального закрытия
1864
+ if (GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
1865
+ const slDistancePercent = ((signal.priceOpen - signal.priceStopLoss) / signal.priceOpen) * 100;
1866
+ if (slDistancePercent < GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
1867
+ errors.push(`Long: StopLoss too close to priceOpen (${slDistancePercent.toFixed(3)}%). ` +
1868
+ `Minimum distance: ${GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT}% to avoid instant stop out on market volatility. ` +
1869
+ `Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`);
1870
+ }
1871
+ }
1845
1872
  // ЗАЩИТА ОТ ЭКСТРЕМАЛЬНОГО STOPLOSS: ограничиваем максимальный убыток
1846
- if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT && GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1873
+ if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1847
1874
  const slDistancePercent = ((signal.priceOpen - signal.priceStopLoss) / signal.priceOpen) * 100;
1848
1875
  if (slDistancePercent > GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1849
1876
  errors.push(`Long: StopLoss too far from priceOpen (${slDistancePercent.toFixed(3)}%). ` +
@@ -1860,18 +1887,30 @@ const VALIDATE_SIGNAL_FN = (signal, currentPrice, isScheduled) => {
1860
1887
  if (signal.priceStopLoss <= signal.priceOpen) {
1861
1888
  errors.push(`Short: priceStopLoss (${signal.priceStopLoss}) must be > priceOpen (${signal.priceOpen})`);
1862
1889
  }
1863
- // ЗАЩИТА ОТ EDGE CASE: для immediate сигналов проверяем что текущая цена не пробила SL/TP
1864
- // Для scheduled сигналов эта проверка избыточна т.к. priceOpen уже проверен выше
1865
- if (!isScheduled) {
1866
- // Текущая цена уже пробила StopLoss - позиция откроется и сразу закроется по SL
1867
- if (isFinite(currentPrice) && currentPrice > signal.priceStopLoss) {
1868
- errors.push(`Short: currentPrice (${currentPrice}) > priceStopLoss (${signal.priceStopLoss}). ` +
1869
- `Signal would be immediately cancelled. This signal is invalid.`);
1890
+ // ЗАЩИТА ОТ МОМЕНТАЛЬНОГО ЗАКРЫТИЯ: проверяем что позиция не закроется сразу после открытия
1891
+ if (!isScheduled && isFinite(currentPrice)) {
1892
+ // SHORT: currentPrice должна быть МЕЖДУ TP и SL (не пробита ни одна граница)
1893
+ // TP < currentPrice < SL
1894
+ if (currentPrice >= signal.priceStopLoss) {
1895
+ errors.push(`Short immediate: currentPrice (${currentPrice}) >= priceStopLoss (${signal.priceStopLoss}). ` +
1896
+ `Signal would be immediately closed by stop loss. Cannot open position that is already stopped out.`);
1870
1897
  }
1871
- // Текущая цена уже достигла TakeProfit - профит упущен
1872
- if (isFinite(currentPrice) && currentPrice < signal.priceTakeProfit) {
1873
- errors.push(`Short: currentPrice (${currentPrice}) < priceTakeProfit (${signal.priceTakeProfit}). ` +
1874
- `Signal is invalid - the profit opportunity has already passed.`);
1898
+ if (currentPrice <= signal.priceTakeProfit) {
1899
+ errors.push(`Short immediate: currentPrice (${currentPrice}) <= priceTakeProfit (${signal.priceTakeProfit}). ` +
1900
+ `Signal would be immediately closed by take profit. The profit opportunity has already passed.`);
1901
+ }
1902
+ }
1903
+ // ЗАЩИТА ОТ МОМЕНТАЛЬНОГО ЗАКРЫТИЯ scheduled сигналов
1904
+ if (isScheduled && isFinite(signal.priceOpen)) {
1905
+ // SHORT scheduled: priceOpen должен быть МЕЖДУ TP и SL
1906
+ // TP < priceOpen < SL
1907
+ if (signal.priceOpen >= signal.priceStopLoss) {
1908
+ errors.push(`Short scheduled: priceOpen (${signal.priceOpen}) >= priceStopLoss (${signal.priceStopLoss}). ` +
1909
+ `Signal would be immediately cancelled on activation. Cannot activate position that is already stopped out.`);
1910
+ }
1911
+ if (signal.priceOpen <= signal.priceTakeProfit) {
1912
+ errors.push(`Short scheduled: priceOpen (${signal.priceOpen}) <= priceTakeProfit (${signal.priceTakeProfit}). ` +
1913
+ `Signal would close immediately on activation. This is logically impossible for SHORT position.`);
1875
1914
  }
1876
1915
  }
1877
1916
  // ЗАЩИТА ОТ МИКРО-ПРОФИТА: TakeProfit должен быть достаточно далеко, чтобы покрыть комиссии
@@ -1883,8 +1922,17 @@ const VALIDATE_SIGNAL_FN = (signal, currentPrice, isScheduled) => {
1883
1922
  `Current: TP=${signal.priceTakeProfit}, Open=${signal.priceOpen}`);
1884
1923
  }
1885
1924
  }
1925
+ // ЗАЩИТА ОТ СЛИШКОМ УЗКОГО STOPLOSS: минимальный буфер для избежания моментального закрытия
1926
+ if (GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
1927
+ const slDistancePercent = ((signal.priceStopLoss - signal.priceOpen) / signal.priceOpen) * 100;
1928
+ if (slDistancePercent < GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT) {
1929
+ errors.push(`Short: StopLoss too close to priceOpen (${slDistancePercent.toFixed(3)}%). ` +
1930
+ `Minimum distance: ${GLOBAL_CONFIG.CC_MIN_STOPLOSS_DISTANCE_PERCENT}% to avoid instant stop out on market volatility. ` +
1931
+ `Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`);
1932
+ }
1933
+ }
1886
1934
  // ЗАЩИТА ОТ ЭКСТРЕМАЛЬНОГО STOPLOSS: ограничиваем максимальный убыток
1887
- if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT && GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1935
+ if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1888
1936
  const slDistancePercent = ((signal.priceStopLoss - signal.priceOpen) / signal.priceOpen) * 100;
1889
1937
  if (slDistancePercent > GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1890
1938
  errors.push(`Short: StopLoss too far from priceOpen (${slDistancePercent.toFixed(3)}%). ` +
@@ -2575,8 +2623,14 @@ const CLOSE_PENDING_SIGNAL_IN_BACKTEST_FN = async (self, signal, averagePrice, c
2575
2623
  const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) => {
2576
2624
  const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
2577
2625
  const maxTimeToWait = GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES * 60 * 1000;
2626
+ const bufferCandlesCount = candlesCount - 1;
2578
2627
  for (let i = 0; i < candles.length; i++) {
2579
2628
  const candle = candles[i];
2629
+ // КРИТИЧНО: Пропускаем первые bufferCandlesCount свечей (буфер для VWAP)
2630
+ // BacktestLogicPrivateService запросил свечи начиная с (when - bufferMinutes)
2631
+ if (i < bufferCandlesCount) {
2632
+ continue;
2633
+ }
2580
2634
  const recentCandles = candles.slice(Math.max(0, i - (candlesCount - 1)), i + 1);
2581
2635
  const averagePrice = GET_AVG_PRICE_FN(recentCandles);
2582
2636
  // КРИТИЧНО: Проверяем timeout ПЕРЕД проверкой цены
@@ -2642,11 +2696,21 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
2642
2696
  };
2643
2697
  const PROCESS_PENDING_SIGNAL_CANDLES_FN = async (self, signal, candles) => {
2644
2698
  const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
2645
- for (let i = candlesCount - 1; i < candles.length; i++) {
2646
- const recentCandles = candles.slice(i - (candlesCount - 1), i + 1);
2699
+ const bufferCandlesCount = candlesCount - 1;
2700
+ // КРИТИЧНО: проверяем TP/SL на КАЖДОЙ свече начиная после буфера
2701
+ // Первые bufferCandlesCount свечей - это буфер для VWAP
2702
+ for (let i = 0; i < candles.length; i++) {
2703
+ const currentCandle = candles[i];
2704
+ const currentCandleTimestamp = currentCandle.timestamp;
2705
+ // КРИТИЧНО: Пропускаем первые bufferCandlesCount свечей (буфер для VWAP)
2706
+ // BacktestLogicPrivateService запросил свечи начиная с (when - bufferMinutes)
2707
+ if (i < bufferCandlesCount) {
2708
+ continue;
2709
+ }
2710
+ // Берем последние candlesCount свечей для VWAP (включая буфер)
2711
+ const startIndex = Math.max(0, i - (candlesCount - 1));
2712
+ const recentCandles = candles.slice(startIndex, i + 1);
2647
2713
  const averagePrice = GET_AVG_PRICE_FN(recentCandles);
2648
- const currentCandleTimestamp = recentCandles[recentCandles.length - 1].timestamp;
2649
- const currentCandle = recentCandles[recentCandles.length - 1];
2650
2714
  let shouldClose = false;
2651
2715
  let closeReason;
2652
2716
  // Check time expiration FIRST (КРИТИЧНО!)
@@ -2946,9 +3010,10 @@ class ClientStrategy {
2946
3010
  * 4. If cancelled: returns closed result with closeReason "cancelled"
2947
3011
  *
2948
3012
  * For pending signals:
2949
- * 1. Iterates through candles checking VWAP against TP/SL on each timeframe
2950
- * 2. Starts from index 4 (needs 5 candles for VWAP calculation)
2951
- * 3. Returns closed result (either TP/SL or time_expired)
3013
+ * 1. Iterates through ALL candles starting from the first one
3014
+ * 2. Checks TP/SL using candle.high/low (immediate detection)
3015
+ * 3. VWAP calculated with dynamic window (1 to CC_AVG_PRICE_CANDLES_COUNT candles)
3016
+ * 4. Returns closed result (either TP/SL or time_expired)
2952
3017
  *
2953
3018
  * @param candles - Array of candles to process
2954
3019
  * @returns Promise resolving to closed signal result with PNL
@@ -4904,13 +4969,17 @@ class BacktestLogicPrivateService {
4904
4969
  minuteEstimatedTime: signal.minuteEstimatedTime,
4905
4970
  });
4906
4971
  // Запрашиваем минутные свечи для мониторинга активации/отмены
4907
- // КРИТИЧНО: запрашиваем CC_SCHEDULE_AWAIT_MINUTES для ожидания активации
4908
- // + minuteEstimatedTime для работы сигнала ПОСЛЕ активации
4909
- // +1 потому что when включается как первая свеча (timestamp начинается с when, а не after when)
4910
- const candlesNeeded = GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES + signal.minuteEstimatedTime + 1;
4972
+ // КРИТИЧНО: запрашиваем:
4973
+ // - CC_AVG_PRICE_CANDLES_COUNT-1 для буфера VWAP (ДО when)
4974
+ // - CC_SCHEDULE_AWAIT_MINUTES для ожидания активации
4975
+ // - minuteEstimatedTime для работы сигнала ПОСЛЕ активации
4976
+ // - +1 потому что when включается как первая свеча
4977
+ const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
4978
+ const bufferStartTime = new Date(when.getTime() - bufferMinutes * 60 * 1000);
4979
+ const candlesNeeded = bufferMinutes + GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES + signal.minuteEstimatedTime + 1;
4911
4980
  let candles;
4912
4981
  try {
4913
- candles = await this.exchangeGlobalService.getNextCandles(symbol, "1m", candlesNeeded, when, true);
4982
+ candles = await this.exchangeGlobalService.getNextCandles(symbol, "1m", candlesNeeded, bufferStartTime, true);
4914
4983
  }
4915
4984
  catch (error) {
4916
4985
  console.warn(`backtestLogicPrivateService getNextCandles failed for scheduled signal when=${when.toISOString()} symbol=${symbol} strategyName=${this.methodContextService.context.strategyName} exchangeName=${this.methodContextService.context.exchangeName}`);
@@ -4918,6 +4987,7 @@ class BacktestLogicPrivateService {
4918
4987
  symbol,
4919
4988
  signalId: signal.id,
4920
4989
  candlesNeeded,
4990
+ bufferMinutes,
4921
4991
  error: errorData(error), message: getErrorMessage(error),
4922
4992
  });
4923
4993
  await errorEmitter.next(error);
@@ -4990,16 +5060,23 @@ class BacktestLogicPrivateService {
4990
5060
  signalId: signal.id,
4991
5061
  minuteEstimatedTime: signal.minuteEstimatedTime,
4992
5062
  });
4993
- // Получаем свечи для бектеста
5063
+ // КРИТИЧНО: Получаем свечи включая буфер для VWAP
5064
+ // Сдвигаем начало назад на CC_AVG_PRICE_CANDLES_COUNT-1 минут для буфера VWAP
5065
+ // Запрашиваем minuteEstimatedTime + буфер свечей одним запросом
5066
+ const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
5067
+ const bufferStartTime = new Date(when.getTime() - bufferMinutes * 60 * 1000);
5068
+ const totalCandles = signal.minuteEstimatedTime + bufferMinutes;
4994
5069
  let candles;
4995
5070
  try {
4996
- candles = await this.exchangeGlobalService.getNextCandles(symbol, "1m", signal.minuteEstimatedTime, when, true);
5071
+ candles = await this.exchangeGlobalService.getNextCandles(symbol, "1m", totalCandles, bufferStartTime, true);
4997
5072
  }
4998
5073
  catch (error) {
4999
5074
  console.warn(`backtestLogicPrivateService getNextCandles failed for opened signal when=${when.toISOString()} symbol=${symbol} strategyName=${this.methodContextService.context.strategyName} exchangeName=${this.methodContextService.context.exchangeName}`);
5000
5075
  this.loggerService.warn("backtestLogicPrivateService getNextCandles failed for opened signal", {
5001
5076
  symbol,
5002
5077
  signalId: signal.id,
5078
+ totalCandles,
5079
+ bufferMinutes,
5003
5080
  error: errorData(error), message: getErrorMessage(error),
5004
5081
  });
5005
5082
  await errorEmitter.next(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backtest-kit",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "A TypeScript library for trading system backtest",
5
5
  "author": {
6
6
  "name": "Petr Tripolsky",
package/types.d.ts CHANGED
@@ -19,6 +19,12 @@ declare const GLOBAL_CONFIG: {
19
19
  * Default: 0.3% (covers 2×0.1% fees + minimum profit margin)
20
20
  */
21
21
  CC_MIN_TAKEPROFIT_DISTANCE_PERCENT: number;
22
+ /**
23
+ * Minimum StopLoss distance from priceOpen (percentage)
24
+ * Prevents signals from being immediately stopped out due to price volatility
25
+ * Default: 0.5% (buffer to avoid instant stop loss on normal market fluctuations)
26
+ */
27
+ CC_MIN_STOPLOSS_DISTANCE_PERCENT: number;
22
28
  /**
23
29
  * Maximum StopLoss distance from priceOpen (percentage)
24
30
  * Prevents catastrophic losses from extreme StopLoss values