backtest-kit 1.5.0 → 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
@@ -38,6 +44,15 @@ const GLOBAL_CONFIG = {
38
44
  * Default: 1440 minutes (1 day)
39
45
  */
40
46
  CC_MAX_SIGNAL_LIFETIME_MINUTES: 1440,
47
+ /**
48
+ * Maximum time allowed for signal generation (in seconds).
49
+ * Prevents long-running or stuck signal generation routines from blocking
50
+ * execution or consuming resources indefinitely. If generation exceeds this
51
+ * threshold the attempt should be aborted, logged and optionally retried.
52
+ *
53
+ * Default: 180 seconds (3 minutes)
54
+ */
55
+ CC_MAX_SIGNAL_GENERATION_SECONDS: 180,
41
56
  /**
42
57
  * Number of retries for getCandles function
43
58
  * Default: 3 retries
@@ -1755,6 +1770,7 @@ const INTERVAL_MINUTES$1 = {
1755
1770
  "30m": 30,
1756
1771
  "1h": 60,
1757
1772
  };
1773
+ const TIMEOUT_SYMBOL = Symbol('timeout');
1758
1774
  const VALIDATE_SIGNAL_FN = (signal, currentPrice, isScheduled) => {
1759
1775
  const errors = [];
1760
1776
  // ПРОВЕРКА ОБЯЗАТЕЛЬНЫХ ПОЛЕЙ ISignalRow
@@ -1811,18 +1827,30 @@ const VALIDATE_SIGNAL_FN = (signal, currentPrice, isScheduled) => {
1811
1827
  if (signal.priceStopLoss >= signal.priceOpen) {
1812
1828
  errors.push(`Long: priceStopLoss (${signal.priceStopLoss}) must be < priceOpen (${signal.priceOpen})`);
1813
1829
  }
1814
- // ЗАЩИТА ОТ EDGE CASE: для immediate сигналов проверяем что текущая цена не пробила SL/TP
1815
- // Для scheduled сигналов эта проверка избыточна т.к. priceOpen уже проверен выше
1816
- if (!isScheduled) {
1817
- // Текущая цена уже пробила StopLoss - позиция откроется и сразу закроется по SL
1818
- if (isFinite(currentPrice) && currentPrice < signal.priceStopLoss) {
1819
- errors.push(`Long: currentPrice (${currentPrice}) < priceStopLoss (${signal.priceStopLoss}). ` +
1820
- `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.`);
1821
1837
  }
1822
- // Текущая цена уже достигла TakeProfit - профит упущен
1823
- if (isFinite(currentPrice) && currentPrice > signal.priceTakeProfit) {
1824
- errors.push(`Long: currentPrice (${currentPrice}) > priceTakeProfit (${signal.priceTakeProfit}). ` +
1825
- `Signal is invalid - the profit opportunity has already passed.`);
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.`);
1850
+ }
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.`);
1826
1854
  }
1827
1855
  }
1828
1856
  // ЗАЩИТА ОТ МИКРО-ПРОФИТА: TakeProfit должен быть достаточно далеко, чтобы покрыть комиссии
@@ -1834,8 +1862,17 @@ const VALIDATE_SIGNAL_FN = (signal, currentPrice, isScheduled) => {
1834
1862
  `Current: TP=${signal.priceTakeProfit}, Open=${signal.priceOpen}`);
1835
1863
  }
1836
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
+ }
1837
1874
  // ЗАЩИТА ОТ ЭКСТРЕМАЛЬНОГО STOPLOSS: ограничиваем максимальный убыток
1838
- if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT && GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1875
+ if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1839
1876
  const slDistancePercent = ((signal.priceOpen - signal.priceStopLoss) / signal.priceOpen) * 100;
1840
1877
  if (slDistancePercent > GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1841
1878
  errors.push(`Long: StopLoss too far from priceOpen (${slDistancePercent.toFixed(3)}%). ` +
@@ -1852,18 +1889,30 @@ const VALIDATE_SIGNAL_FN = (signal, currentPrice, isScheduled) => {
1852
1889
  if (signal.priceStopLoss <= signal.priceOpen) {
1853
1890
  errors.push(`Short: priceStopLoss (${signal.priceStopLoss}) must be > priceOpen (${signal.priceOpen})`);
1854
1891
  }
1855
- // ЗАЩИТА ОТ EDGE CASE: для immediate сигналов проверяем что текущая цена не пробила SL/TP
1856
- // Для scheduled сигналов эта проверка избыточна т.к. priceOpen уже проверен выше
1857
- if (!isScheduled) {
1858
- // Текущая цена уже пробила StopLoss - позиция откроется и сразу закроется по SL
1859
- if (isFinite(currentPrice) && currentPrice > signal.priceStopLoss) {
1860
- errors.push(`Short: currentPrice (${currentPrice}) > priceStopLoss (${signal.priceStopLoss}). ` +
1861
- `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.`);
1899
+ }
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.`);
1862
1912
  }
1863
- // Текущая цена уже достигла TakeProfit - профит упущен
1864
- if (isFinite(currentPrice) && currentPrice < signal.priceTakeProfit) {
1865
- errors.push(`Short: currentPrice (${currentPrice}) < priceTakeProfit (${signal.priceTakeProfit}). ` +
1866
- `Signal is invalid - the profit opportunity has already passed.`);
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.`);
1867
1916
  }
1868
1917
  }
1869
1918
  // ЗАЩИТА ОТ МИКРО-ПРОФИТА: TakeProfit должен быть достаточно далеко, чтобы покрыть комиссии
@@ -1875,8 +1924,17 @@ const VALIDATE_SIGNAL_FN = (signal, currentPrice, isScheduled) => {
1875
1924
  `Current: TP=${signal.priceTakeProfit}, Open=${signal.priceOpen}`);
1876
1925
  }
1877
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
+ }
1878
1936
  // ЗАЩИТА ОТ ЭКСТРЕМАЛЬНОГО STOPLOSS: ограничиваем максимальный убыток
1879
- if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT && GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1937
+ if (GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1880
1938
  const slDistancePercent = ((signal.priceStopLoss - signal.priceOpen) / signal.priceOpen) * 100;
1881
1939
  if (slDistancePercent > GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
1882
1940
  errors.push(`Short: StopLoss too far from priceOpen (${slDistancePercent.toFixed(3)}%). ` +
@@ -1938,7 +1996,14 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
1938
1996
  }))) {
1939
1997
  return null;
1940
1998
  }
1941
- const signal = await self.params.getSignal(self.params.execution.context.symbol, self.params.execution.context.when);
1999
+ const timeoutMs = GLOBAL_CONFIG.CC_MAX_SIGNAL_GENERATION_SECONDS * 1000;
2000
+ const signal = await Promise.race([
2001
+ self.params.getSignal(self.params.execution.context.symbol, self.params.execution.context.when),
2002
+ functoolsKit.sleep(timeoutMs).then(() => TIMEOUT_SYMBOL),
2003
+ ]);
2004
+ if (typeof signal === "symbol") {
2005
+ throw new Error(`Timeout for ${self.params.method.context.strategyName} symbol=${self.params.execution.context.symbol}`);
2006
+ }
1942
2007
  if (!signal) {
1943
2008
  return null;
1944
2009
  }
@@ -2224,6 +2289,8 @@ const RETURN_SCHEDULED_SIGNAL_ACTIVE_FN = async (self, scheduled, currentPrice)
2224
2289
  strategyName: self.params.method.context.strategyName,
2225
2290
  exchangeName: self.params.method.context.exchangeName,
2226
2291
  symbol: self.params.execution.context.symbol,
2292
+ percentTp: 0,
2293
+ percentSl: 0,
2227
2294
  };
2228
2295
  if (self.params.callbacks?.onTick) {
2229
2296
  self.params.callbacks.onTick(self.params.execution.context.symbol, result, self.params.execution.context.backtest);
@@ -2350,6 +2417,8 @@ const CLOSE_PENDING_SIGNAL_FN = async (self, signal, currentPrice, closeReason)
2350
2417
  return result;
2351
2418
  };
2352
2419
  const RETURN_PENDING_SIGNAL_ACTIVE_FN = async (self, signal, currentPrice) => {
2420
+ let percentTp = 0;
2421
+ let percentSl = 0;
2353
2422
  // Calculate percentage of path to TP/SL for partial fill/loss callbacks
2354
2423
  {
2355
2424
  if (signal.position === "long") {
@@ -2359,18 +2428,20 @@ const RETURN_PENDING_SIGNAL_ACTIVE_FN = async (self, signal, currentPrice) => {
2359
2428
  // Moving towards TP
2360
2429
  const tpDistance = signal.priceTakeProfit - signal.priceOpen;
2361
2430
  const progressPercent = (currentDistance / tpDistance) * 100;
2362
- await self.params.partial.profit(self.params.execution.context.symbol, signal, currentPrice, Math.min(progressPercent, 100), self.params.execution.context.backtest, self.params.execution.context.when);
2431
+ percentTp = Math.min(progressPercent, 100);
2432
+ await self.params.partial.profit(self.params.execution.context.symbol, signal, currentPrice, percentTp, self.params.execution.context.backtest, self.params.execution.context.when);
2363
2433
  if (self.params.callbacks?.onPartialProfit) {
2364
- self.params.callbacks.onPartialProfit(self.params.execution.context.symbol, signal, currentPrice, Math.min(progressPercent, 100), self.params.execution.context.backtest);
2434
+ self.params.callbacks.onPartialProfit(self.params.execution.context.symbol, signal, currentPrice, percentTp, self.params.execution.context.backtest);
2365
2435
  }
2366
2436
  }
2367
2437
  else if (currentDistance < 0) {
2368
2438
  // Moving towards SL
2369
2439
  const slDistance = signal.priceOpen - signal.priceStopLoss;
2370
2440
  const progressPercent = (Math.abs(currentDistance) / slDistance) * 100;
2371
- await self.params.partial.loss(self.params.execution.context.symbol, signal, currentPrice, Math.min(progressPercent, 100), self.params.execution.context.backtest, self.params.execution.context.when);
2441
+ percentSl = Math.min(progressPercent, 100);
2442
+ await self.params.partial.loss(self.params.execution.context.symbol, signal, currentPrice, percentSl, self.params.execution.context.backtest, self.params.execution.context.when);
2372
2443
  if (self.params.callbacks?.onPartialLoss) {
2373
- self.params.callbacks.onPartialLoss(self.params.execution.context.symbol, signal, currentPrice, Math.min(progressPercent, 100), self.params.execution.context.backtest);
2444
+ self.params.callbacks.onPartialLoss(self.params.execution.context.symbol, signal, currentPrice, percentSl, self.params.execution.context.backtest);
2374
2445
  }
2375
2446
  }
2376
2447
  }
@@ -2381,18 +2452,20 @@ const RETURN_PENDING_SIGNAL_ACTIVE_FN = async (self, signal, currentPrice) => {
2381
2452
  // Moving towards TP
2382
2453
  const tpDistance = signal.priceOpen - signal.priceTakeProfit;
2383
2454
  const progressPercent = (currentDistance / tpDistance) * 100;
2384
- await self.params.partial.profit(self.params.execution.context.symbol, signal, currentPrice, Math.min(progressPercent, 100), self.params.execution.context.backtest, self.params.execution.context.when);
2455
+ percentTp = Math.min(progressPercent, 100);
2456
+ await self.params.partial.profit(self.params.execution.context.symbol, signal, currentPrice, percentTp, self.params.execution.context.backtest, self.params.execution.context.when);
2385
2457
  if (self.params.callbacks?.onPartialProfit) {
2386
- self.params.callbacks.onPartialProfit(self.params.execution.context.symbol, signal, currentPrice, Math.min(progressPercent, 100), self.params.execution.context.backtest);
2458
+ self.params.callbacks.onPartialProfit(self.params.execution.context.symbol, signal, currentPrice, percentTp, self.params.execution.context.backtest);
2387
2459
  }
2388
2460
  }
2389
2461
  if (currentDistance < 0) {
2390
2462
  // Moving towards SL
2391
2463
  const slDistance = signal.priceStopLoss - signal.priceOpen;
2392
2464
  const progressPercent = (Math.abs(currentDistance) / slDistance) * 100;
2393
- await self.params.partial.loss(self.params.execution.context.symbol, signal, currentPrice, Math.min(progressPercent, 100), self.params.execution.context.backtest, self.params.execution.context.when);
2465
+ percentSl = Math.min(progressPercent, 100);
2466
+ await self.params.partial.loss(self.params.execution.context.symbol, signal, currentPrice, percentSl, self.params.execution.context.backtest, self.params.execution.context.when);
2394
2467
  if (self.params.callbacks?.onPartialLoss) {
2395
- self.params.callbacks.onPartialLoss(self.params.execution.context.symbol, signal, currentPrice, Math.min(progressPercent, 100), self.params.execution.context.backtest);
2468
+ self.params.callbacks.onPartialLoss(self.params.execution.context.symbol, signal, currentPrice, percentSl, self.params.execution.context.backtest);
2396
2469
  }
2397
2470
  }
2398
2471
  }
@@ -2404,6 +2477,8 @@ const RETURN_PENDING_SIGNAL_ACTIVE_FN = async (self, signal, currentPrice) => {
2404
2477
  strategyName: self.params.method.context.strategyName,
2405
2478
  exchangeName: self.params.method.context.exchangeName,
2406
2479
  symbol: self.params.execution.context.symbol,
2480
+ percentTp,
2481
+ percentSl,
2407
2482
  };
2408
2483
  if (self.params.callbacks?.onTick) {
2409
2484
  self.params.callbacks.onTick(self.params.execution.context.symbol, result, self.params.execution.context.backtest);
@@ -2550,8 +2625,14 @@ const CLOSE_PENDING_SIGNAL_IN_BACKTEST_FN = async (self, signal, averagePrice, c
2550
2625
  const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) => {
2551
2626
  const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
2552
2627
  const maxTimeToWait = GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES * 60 * 1000;
2628
+ const bufferCandlesCount = candlesCount - 1;
2553
2629
  for (let i = 0; i < candles.length; i++) {
2554
2630
  const candle = candles[i];
2631
+ // КРИТИЧНО: Пропускаем первые bufferCandlesCount свечей (буфер для VWAP)
2632
+ // BacktestLogicPrivateService запросил свечи начиная с (when - bufferMinutes)
2633
+ if (i < bufferCandlesCount) {
2634
+ continue;
2635
+ }
2555
2636
  const recentCandles = candles.slice(Math.max(0, i - (candlesCount - 1)), i + 1);
2556
2637
  const averagePrice = GET_AVG_PRICE_FN(recentCandles);
2557
2638
  // КРИТИЧНО: Проверяем timeout ПЕРЕД проверкой цены
@@ -2617,11 +2698,21 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
2617
2698
  };
2618
2699
  const PROCESS_PENDING_SIGNAL_CANDLES_FN = async (self, signal, candles) => {
2619
2700
  const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
2620
- for (let i = candlesCount - 1; i < candles.length; i++) {
2621
- 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);
2622
2715
  const averagePrice = GET_AVG_PRICE_FN(recentCandles);
2623
- const currentCandleTimestamp = recentCandles[recentCandles.length - 1].timestamp;
2624
- const currentCandle = recentCandles[recentCandles.length - 1];
2625
2716
  let shouldClose = false;
2626
2717
  let closeReason;
2627
2718
  // Check time expiration FIRST (КРИТИЧНО!)
@@ -2921,9 +3012,10 @@ class ClientStrategy {
2921
3012
  * 4. If cancelled: returns closed result with closeReason "cancelled"
2922
3013
  *
2923
3014
  * For pending signals:
2924
- * 1. Iterates through candles checking VWAP against TP/SL on each timeframe
2925
- * 2. Starts from index 4 (needs 5 candles for VWAP calculation)
2926
- * 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)
2927
3019
  *
2928
3020
  * @param candles - Array of candles to process
2929
3021
  * @returns Promise resolving to closed signal result with PNL
@@ -3000,6 +3092,8 @@ class ClientStrategy {
3000
3092
  action: "active",
3001
3093
  signal: scheduled,
3002
3094
  currentPrice: lastPrice,
3095
+ percentSl: 0,
3096
+ percentTp: 0,
3003
3097
  strategyName: this.params.method.context.strategyName,
3004
3098
  exchangeName: this.params.method.context.exchangeName,
3005
3099
  symbol: this.params.execution.context.symbol,
@@ -4877,13 +4971,17 @@ class BacktestLogicPrivateService {
4877
4971
  minuteEstimatedTime: signal.minuteEstimatedTime,
4878
4972
  });
4879
4973
  // Запрашиваем минутные свечи для мониторинга активации/отмены
4880
- // КРИТИЧНО: запрашиваем CC_SCHEDULE_AWAIT_MINUTES для ожидания активации
4881
- // + minuteEstimatedTime для работы сигнала ПОСЛЕ активации
4882
- // +1 потому что when включается как первая свеча (timestamp начинается с when, а не after when)
4883
- 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;
4884
4982
  let candles;
4885
4983
  try {
4886
- candles = await this.exchangeGlobalService.getNextCandles(symbol, "1m", candlesNeeded, when, true);
4984
+ candles = await this.exchangeGlobalService.getNextCandles(symbol, "1m", candlesNeeded, bufferStartTime, true);
4887
4985
  }
4888
4986
  catch (error) {
4889
4987
  console.warn(`backtestLogicPrivateService getNextCandles failed for scheduled signal when=${when.toISOString()} symbol=${symbol} strategyName=${this.methodContextService.context.strategyName} exchangeName=${this.methodContextService.context.exchangeName}`);
@@ -4891,6 +4989,7 @@ class BacktestLogicPrivateService {
4891
4989
  symbol,
4892
4990
  signalId: signal.id,
4893
4991
  candlesNeeded,
4992
+ bufferMinutes,
4894
4993
  error: functoolsKit.errorData(error), message: functoolsKit.getErrorMessage(error),
4895
4994
  });
4896
4995
  await errorEmitter.next(error);
@@ -4963,16 +5062,23 @@ class BacktestLogicPrivateService {
4963
5062
  signalId: signal.id,
4964
5063
  minuteEstimatedTime: signal.minuteEstimatedTime,
4965
5064
  });
4966
- // Получаем свечи для бектеста
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;
4967
5071
  let candles;
4968
5072
  try {
4969
- candles = await this.exchangeGlobalService.getNextCandles(symbol, "1m", signal.minuteEstimatedTime, when, true);
5073
+ candles = await this.exchangeGlobalService.getNextCandles(symbol, "1m", totalCandles, bufferStartTime, true);
4970
5074
  }
4971
5075
  catch (error) {
4972
5076
  console.warn(`backtestLogicPrivateService getNextCandles failed for opened signal when=${when.toISOString()} symbol=${symbol} strategyName=${this.methodContextService.context.strategyName} exchangeName=${this.methodContextService.context.exchangeName}`);
4973
5077
  this.loggerService.warn("backtestLogicPrivateService getNextCandles failed for opened signal", {
4974
5078
  symbol,
4975
5079
  signalId: signal.id,
5080
+ totalCandles,
5081
+ bufferMinutes,
4976
5082
  error: functoolsKit.errorData(error), message: functoolsKit.getErrorMessage(error),
4977
5083
  });
4978
5084
  await errorEmitter.next(error);
@@ -5848,14 +5954,33 @@ let ReportStorage$4 = class ReportStorage {
5848
5954
  async getReport(strategyName) {
5849
5955
  const stats = await this.getData();
5850
5956
  if (stats.totalSignals === 0) {
5851
- return functoolsKit.str.newline(`# Backtest Report: ${strategyName}`, "", "No signals closed yet.");
5957
+ return [
5958
+ `# Backtest Report: ${strategyName}`,
5959
+ "",
5960
+ "No signals closed yet."
5961
+ ].join("\n");
5852
5962
  }
5853
5963
  const header = columns$4.map((col) => col.label);
5854
5964
  const separator = columns$4.map(() => "---");
5855
5965
  const rows = this._signalList.map((closedSignal) => columns$4.map((col) => col.format(closedSignal)));
5856
5966
  const tableData = [header, separator, ...rows];
5857
- const table = functoolsKit.str.newline(tableData.map(row => `| ${row.join(" | ")} |`));
5858
- return functoolsKit.str.newline(`# Backtest Report: ${strategyName}`, "", table, "", `**Total signals:** ${stats.totalSignals}`, `**Closed signals:** ${stats.totalSignals}`, `**Win rate:** ${stats.winRate === null ? "N/A" : `${stats.winRate.toFixed(2)}% (${stats.winCount}W / ${stats.lossCount}L) (higher is better)`}`, `**Average PNL:** ${stats.avgPnl === null ? "N/A" : `${stats.avgPnl > 0 ? "+" : ""}${stats.avgPnl.toFixed(2)}% (higher is better)`}`, `**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`, `**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`, `**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`, `**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`, `**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`, `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`);
5967
+ const table = tableData.map(row => `| ${row.join(" | ")} |`).join("\n");
5968
+ return [
5969
+ `# Backtest Report: ${strategyName}`,
5970
+ "",
5971
+ table,
5972
+ "",
5973
+ `**Total signals:** ${stats.totalSignals}`,
5974
+ `**Closed signals:** ${stats.totalSignals}`,
5975
+ `**Win rate:** ${stats.winRate === null ? "N/A" : `${stats.winRate.toFixed(2)}% (${stats.winCount}W / ${stats.lossCount}L) (higher is better)`}`,
5976
+ `**Average PNL:** ${stats.avgPnl === null ? "N/A" : `${stats.avgPnl > 0 ? "+" : ""}${stats.avgPnl.toFixed(2)}% (higher is better)`}`,
5977
+ `**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`,
5978
+ `**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`,
5979
+ `**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`,
5980
+ `**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`,
5981
+ `**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`,
5982
+ `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
5983
+ ].join("\n");
5859
5984
  }
5860
5985
  /**
5861
5986
  * Saves strategy report to disk.
@@ -6137,6 +6262,16 @@ const columns$3 = [
6137
6262
  label: "Stop Loss",
6138
6263
  format: (data) => data.stopLoss !== undefined ? `${data.stopLoss.toFixed(8)} USD` : "N/A",
6139
6264
  },
6265
+ {
6266
+ key: "percentTp",
6267
+ label: "% to TP",
6268
+ format: (data) => data.percentTp !== undefined ? `${data.percentTp.toFixed(2)}%` : "N/A",
6269
+ },
6270
+ {
6271
+ key: "percentSl",
6272
+ label: "% to SL",
6273
+ format: (data) => data.percentSl !== undefined ? `${data.percentSl.toFixed(2)}%` : "N/A",
6274
+ },
6140
6275
  {
6141
6276
  key: "pnl",
6142
6277
  label: "PNL (net)",
@@ -6239,6 +6374,8 @@ let ReportStorage$3 = class ReportStorage {
6239
6374
  openPrice: data.signal.priceOpen,
6240
6375
  takeProfit: data.signal.priceTakeProfit,
6241
6376
  stopLoss: data.signal.priceStopLoss,
6377
+ percentTp: data.percentTp,
6378
+ percentSl: data.percentSl,
6242
6379
  };
6243
6380
  // Replace existing event or add new one
6244
6381
  if (existingIndex !== -1) {
@@ -6380,14 +6517,33 @@ let ReportStorage$3 = class ReportStorage {
6380
6517
  async getReport(strategyName) {
6381
6518
  const stats = await this.getData();
6382
6519
  if (stats.totalEvents === 0) {
6383
- return functoolsKit.str.newline(`# Live Trading Report: ${strategyName}`, "", "No events recorded yet.");
6520
+ return [
6521
+ `# Live Trading Report: ${strategyName}`,
6522
+ "",
6523
+ "No events recorded yet."
6524
+ ].join("\n");
6384
6525
  }
6385
6526
  const header = columns$3.map((col) => col.label);
6386
6527
  const separator = columns$3.map(() => "---");
6387
6528
  const rows = this._eventList.map((event) => columns$3.map((col) => col.format(event)));
6388
6529
  const tableData = [header, separator, ...rows];
6389
- const table = functoolsKit.str.newline(tableData.map(row => `| ${row.join(" | ")} |`));
6390
- return functoolsKit.str.newline(`# Live Trading Report: ${strategyName}`, "", table, "", `**Total events:** ${stats.totalEvents}`, `**Closed signals:** ${stats.totalClosed}`, `**Win rate:** ${stats.winRate === null ? "N/A" : `${stats.winRate.toFixed(2)}% (${stats.winCount}W / ${stats.lossCount}L) (higher is better)`}`, `**Average PNL:** ${stats.avgPnl === null ? "N/A" : `${stats.avgPnl > 0 ? "+" : ""}${stats.avgPnl.toFixed(2)}% (higher is better)`}`, `**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`, `**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`, `**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`, `**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`, `**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`, `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`);
6530
+ const table = tableData.map(row => `| ${row.join(" | ")} |`).join("\n");
6531
+ return [
6532
+ `# Live Trading Report: ${strategyName}`,
6533
+ "",
6534
+ table,
6535
+ "",
6536
+ `**Total events:** ${stats.totalEvents}`,
6537
+ `**Closed signals:** ${stats.totalClosed}`,
6538
+ `**Win rate:** ${stats.winRate === null ? "N/A" : `${stats.winRate.toFixed(2)}% (${stats.winCount}W / ${stats.lossCount}L) (higher is better)`}`,
6539
+ `**Average PNL:** ${stats.avgPnl === null ? "N/A" : `${stats.avgPnl > 0 ? "+" : ""}${stats.avgPnl.toFixed(2)}% (higher is better)`}`,
6540
+ `**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`,
6541
+ `**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`,
6542
+ `**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`,
6543
+ `**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`,
6544
+ `**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`,
6545
+ `**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
6546
+ ].join("\n");
6391
6547
  }
6392
6548
  /**
6393
6549
  * Saves strategy report to disk.
@@ -6784,14 +6940,28 @@ let ReportStorage$2 = class ReportStorage {
6784
6940
  async getReport(strategyName) {
6785
6941
  const stats = await this.getData();
6786
6942
  if (stats.totalEvents === 0) {
6787
- return functoolsKit.str.newline(`# Scheduled Signals Report: ${strategyName}`, "", "No scheduled signals recorded yet.");
6943
+ return [
6944
+ `# Scheduled Signals Report: ${strategyName}`,
6945
+ "",
6946
+ "No scheduled signals recorded yet."
6947
+ ].join("\n");
6788
6948
  }
6789
6949
  const header = columns$2.map((col) => col.label);
6790
6950
  const separator = columns$2.map(() => "---");
6791
6951
  const rows = this._eventList.map((event) => columns$2.map((col) => col.format(event)));
6792
6952
  const tableData = [header, separator, ...rows];
6793
- const table = functoolsKit.str.newline(tableData.map((row) => `| ${row.join(" | ")} |`));
6794
- return functoolsKit.str.newline(`# Scheduled Signals Report: ${strategyName}`, "", table, "", `**Total events:** ${stats.totalEvents}`, `**Scheduled signals:** ${stats.totalScheduled}`, `**Cancelled signals:** ${stats.totalCancelled}`, `**Cancellation rate:** ${stats.cancellationRate === null ? "N/A" : `${stats.cancellationRate.toFixed(2)}% (lower is better)`}`, `**Average wait time (cancelled):** ${stats.avgWaitTime === null ? "N/A" : `${stats.avgWaitTime.toFixed(2)} minutes`}`);
6953
+ const table = tableData.map((row) => `| ${row.join(" | ")} |`).join("\n");
6954
+ return [
6955
+ `# Scheduled Signals Report: ${strategyName}`,
6956
+ "",
6957
+ table,
6958
+ "",
6959
+ `**Total events:** ${stats.totalEvents}`,
6960
+ `**Scheduled signals:** ${stats.totalScheduled}`,
6961
+ `**Cancelled signals:** ${stats.totalCancelled}`,
6962
+ `**Cancellation rate:** ${stats.cancellationRate === null ? "N/A" : `${stats.cancellationRate.toFixed(2)}% (lower is better)`}`,
6963
+ `**Average wait time (cancelled):** ${stats.avgWaitTime === null ? "N/A" : `${stats.avgWaitTime.toFixed(2)} minutes`}`
6964
+ ].join("\n");
6795
6965
  }
6796
6966
  /**
6797
6967
  * Saves strategy report to disk.
@@ -7109,7 +7279,11 @@ class PerformanceStorage {
7109
7279
  async getReport(strategyName) {
7110
7280
  const stats = await this.getData(strategyName);
7111
7281
  if (stats.totalEvents === 0) {
7112
- return functoolsKit.str.newline(`# Performance Report: ${strategyName}`, "", "No performance metrics recorded yet.");
7282
+ return [
7283
+ `# Performance Report: ${strategyName}`,
7284
+ "",
7285
+ "No performance metrics recorded yet."
7286
+ ].join("\n");
7113
7287
  }
7114
7288
  // Sort metrics by total duration (descending) to show bottlenecks first
7115
7289
  const sortedMetrics = Object.values(stats.metricStats).sort((a, b) => b.totalDuration - a.totalDuration);
@@ -7146,13 +7320,29 @@ class PerformanceStorage {
7146
7320
  metric.maxWaitTime.toFixed(2),
7147
7321
  ]);
7148
7322
  const summaryTableData = [summaryHeader, summarySeparator, ...summaryRows];
7149
- const summaryTable = functoolsKit.str.newline(summaryTableData.map((row) => `| ${row.join(" | ")} |`));
7323
+ const summaryTable = summaryTableData.map((row) => `| ${row.join(" | ")} |`).join("\n");
7150
7324
  // Calculate percentage of total time for each metric
7151
7325
  const percentages = sortedMetrics.map((metric) => {
7152
7326
  const pct = (metric.totalDuration / stats.totalDuration) * 100;
7153
7327
  return `- **${metric.metricType}**: ${pct.toFixed(1)}% (${metric.totalDuration.toFixed(2)}ms total)`;
7154
7328
  });
7155
- return functoolsKit.str.newline(`# Performance Report: ${strategyName}`, "", `**Total events:** ${stats.totalEvents}`, `**Total execution time:** ${stats.totalDuration.toFixed(2)}ms`, `**Number of metric types:** ${Object.keys(stats.metricStats).length}`, "", "## Time Distribution", "", functoolsKit.str.newline(percentages), "", "## Detailed Metrics", "", summaryTable, "", "**Note:** All durations are in milliseconds. P95/P99 represent 95th and 99th percentile response times. Wait times show the interval between consecutive events of the same type.");
7329
+ return [
7330
+ `# Performance Report: ${strategyName}`,
7331
+ "",
7332
+ `**Total events:** ${stats.totalEvents}`,
7333
+ `**Total execution time:** ${stats.totalDuration.toFixed(2)}ms`,
7334
+ `**Number of metric types:** ${Object.keys(stats.metricStats).length}`,
7335
+ "",
7336
+ "## Time Distribution",
7337
+ "",
7338
+ percentages.join("\n"),
7339
+ "",
7340
+ "## Detailed Metrics",
7341
+ "",
7342
+ summaryTable,
7343
+ "",
7344
+ "**Note:** All durations are in milliseconds. P95/P99 represent 95th and 99th percentile response times. Wait times show the interval between consecutive events of the same type."
7345
+ ].join("\n");
7156
7346
  }
7157
7347
  /**
7158
7348
  * Saves performance report to disk.
@@ -7556,7 +7746,7 @@ let ReportStorage$1 = class ReportStorage {
7556
7746
  // Build table rows
7557
7747
  const rows = topStrategies.map((result, index) => columns.map((col) => col.format(result, index)));
7558
7748
  const tableData = [header, separator, ...rows];
7559
- return functoolsKit.str.newline(tableData.map((row) => `| ${row.join(" | ")} |`));
7749
+ return tableData.map((row) => `| ${row.join(" | ")} |`).join("\n");
7560
7750
  }
7561
7751
  /**
7562
7752
  * Generates PNL table showing all closed signals across all strategies (View).
@@ -7593,7 +7783,7 @@ let ReportStorage$1 = class ReportStorage {
7593
7783
  // Build table rows
7594
7784
  const rows = allSignals.map((signal) => pnlColumns.map((col) => col.format(signal)));
7595
7785
  const tableData = [header, separator, ...rows];
7596
- return functoolsKit.str.newline(tableData.map((row) => `| ${row.join(" | ")} |`));
7786
+ return tableData.map((row) => `| ${row.join(" | ")} |`).join("\n");
7597
7787
  }
7598
7788
  /**
7599
7789
  * Generates markdown report with all strategy results (View).
@@ -7608,7 +7798,30 @@ let ReportStorage$1 = class ReportStorage {
7608
7798
  const results = await this.getData(symbol, metric, context);
7609
7799
  // Get total signals for best strategy
7610
7800
  const bestStrategySignals = results.bestStats?.totalSignals ?? 0;
7611
- return functoolsKit.str.newline(`# Walker Comparison Report: ${results.walkerName}`, "", `**Symbol:** ${results.symbol}`, `**Exchange:** ${results.exchangeName}`, `**Frame:** ${results.frameName}`, `**Optimization Metric:** ${results.metric}`, `**Strategies Tested:** ${results.totalStrategies}`, "", `## Best Strategy: ${results.bestStrategy}`, "", `**Best ${results.metric}:** ${formatMetric(results.bestMetric)}`, `**Total Signals:** ${bestStrategySignals}`, "", "## Top Strategies Comparison", "", this.getComparisonTable(metric, 10), "", "## All Signals (PNL Table)", "", this.getPnlTable(), "", "**Note:** Higher values are better for all metrics except Standard Deviation (lower is better).");
7801
+ return [
7802
+ `# Walker Comparison Report: ${results.walkerName}`,
7803
+ "",
7804
+ `**Symbol:** ${results.symbol}`,
7805
+ `**Exchange:** ${results.exchangeName}`,
7806
+ `**Frame:** ${results.frameName}`,
7807
+ `**Optimization Metric:** ${results.metric}`,
7808
+ `**Strategies Tested:** ${results.totalStrategies}`,
7809
+ "",
7810
+ `## Best Strategy: ${results.bestStrategy}`,
7811
+ "",
7812
+ `**Best ${results.metric}:** ${formatMetric(results.bestMetric)}`,
7813
+ `**Total Signals:** ${bestStrategySignals}`,
7814
+ "",
7815
+ "## Top Strategies Comparison",
7816
+ "",
7817
+ this.getComparisonTable(metric, 10),
7818
+ "",
7819
+ "## All Signals (PNL Table)",
7820
+ "",
7821
+ this.getPnlTable(),
7822
+ "",
7823
+ "**Note:** Higher values are better for all metrics except Standard Deviation (lower is better)."
7824
+ ].join("\n");
7612
7825
  }
7613
7826
  /**
7614
7827
  * Saves walker report to disk.
@@ -8122,14 +8335,24 @@ class HeatmapStorage {
8122
8335
  async getReport(strategyName) {
8123
8336
  const data = await this.getData();
8124
8337
  if (data.symbols.length === 0) {
8125
- return functoolsKit.str.newline(`# Portfolio Heatmap: ${strategyName}`, "", "*No data available*");
8338
+ return [
8339
+ `# Portfolio Heatmap: ${strategyName}`,
8340
+ "",
8341
+ "*No data available*"
8342
+ ].join("\n");
8126
8343
  }
8127
8344
  const header = columns$1.map((col) => col.label);
8128
8345
  const separator = columns$1.map(() => "---");
8129
8346
  const rows = data.symbols.map((row) => columns$1.map((col) => col.format(row)));
8130
8347
  const tableData = [header, separator, ...rows];
8131
- const table = functoolsKit.str.newline(tableData.map((row) => `| ${row.join(" | ")} |`));
8132
- return functoolsKit.str.newline(`# Portfolio Heatmap: ${strategyName}`, "", `**Total Symbols:** ${data.totalSymbols} | **Portfolio PNL:** ${data.portfolioTotalPnl !== null ? functoolsKit.str(data.portfolioTotalPnl, "%+.2f%%") : "N/A"} | **Portfolio Sharpe:** ${data.portfolioSharpeRatio !== null ? functoolsKit.str(data.portfolioSharpeRatio, "%.2f") : "N/A"} | **Total Trades:** ${data.portfolioTotalTrades}`, "", table);
8348
+ const table = tableData.map((row) => `| ${row.join(" | ")} |`).join("\n");
8349
+ return [
8350
+ `# Portfolio Heatmap: ${strategyName}`,
8351
+ "",
8352
+ `**Total Symbols:** ${data.totalSymbols} | **Portfolio PNL:** ${data.portfolioTotalPnl !== null ? functoolsKit.str(data.portfolioTotalPnl, "%+.2f%%") : "N/A"} | **Portfolio Sharpe:** ${data.portfolioSharpeRatio !== null ? functoolsKit.str(data.portfolioSharpeRatio, "%.2f") : "N/A"} | **Total Trades:** ${data.portfolioTotalTrades}`,
8353
+ "",
8354
+ table
8355
+ ].join("\n");
8133
8356
  }
8134
8357
  /**
8135
8358
  * Saves heatmap report to disk.
@@ -10687,14 +10910,26 @@ class ReportStorage {
10687
10910
  async getReport(symbol, strategyName) {
10688
10911
  const stats = await this.getData();
10689
10912
  if (stats.totalEvents === 0) {
10690
- return functoolsKit.str.newline(`# Partial Profit/Loss Report: ${symbol}:${strategyName}`, "", "No partial profit/loss events recorded yet.");
10913
+ return [
10914
+ `# Partial Profit/Loss Report: ${symbol}:${strategyName}`,
10915
+ "",
10916
+ "No partial profit/loss events recorded yet."
10917
+ ].join("\n");
10691
10918
  }
10692
10919
  const header = columns.map((col) => col.label);
10693
10920
  const separator = columns.map(() => "---");
10694
10921
  const rows = this._eventList.map((event) => columns.map((col) => col.format(event)));
10695
10922
  const tableData = [header, separator, ...rows];
10696
- const table = functoolsKit.str.newline(tableData.map((row) => `| ${row.join(" | ")} |`));
10697
- return functoolsKit.str.newline(`# Partial Profit/Loss Report: ${symbol}:${strategyName}`, "", table, "", `**Total events:** ${stats.totalEvents}`, `**Profit events:** ${stats.totalProfit}`, `**Loss events:** ${stats.totalLoss}`);
10923
+ const table = tableData.map((row) => `| ${row.join(" | ")} |`).join("\n");
10924
+ return [
10925
+ `# Partial Profit/Loss Report: ${symbol}:${strategyName}`,
10926
+ "",
10927
+ table,
10928
+ "",
10929
+ `**Total events:** ${stats.totalEvents}`,
10930
+ `**Profit events:** ${stats.totalProfit}`,
10931
+ `**Loss events:** ${stats.totalLoss}`
10932
+ ].join("\n");
10698
10933
  }
10699
10934
  /**
10700
10935
  * Saves symbol-strategy report to disk.