backtest-kit 2.2.26 → 2.3.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
@@ -700,6 +700,20 @@ async function writeFileAtomic(file, data, options = {}) {
700
700
 
701
701
  var _a$2;
702
702
  const BASE_WAIT_FOR_INIT_SYMBOL = Symbol("wait-for-init");
703
+ // Calculate step in milliseconds for candle close time validation
704
+ const INTERVAL_MINUTES$5 = {
705
+ "1m": 1,
706
+ "3m": 3,
707
+ "5m": 5,
708
+ "15m": 15,
709
+ "30m": 30,
710
+ "1h": 60,
711
+ "2h": 120,
712
+ "4h": 240,
713
+ "6h": 360,
714
+ "8h": 480,
715
+ };
716
+ const MS_PER_MINUTE$2 = 60000;
703
717
  const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_PERSIST_SIGNAL_ADAPTER = "PersistSignalUtils.usePersistSignalAdapter";
704
718
  const PERSIST_SIGNAL_UTILS_METHOD_NAME_READ_DATA = "PersistSignalUtils.readSignalData";
705
719
  const PERSIST_SIGNAL_UTILS_METHOD_NAME_WRITE_DATA = "PersistSignalUtils.writeSignalData";
@@ -1564,60 +1578,73 @@ class PersistCandleUtils {
1564
1578
  ]));
1565
1579
  /**
1566
1580
  * Reads cached candles for a specific exchange, symbol, and interval.
1567
- * Returns candles only if cache contains exactly the requested limit.
1581
+ * Returns candles only if cache contains ALL requested candles.
1582
+ *
1583
+ * Algorithm (matches ClientExchange.ts logic):
1584
+ * 1. Calculate expected timestamps: sinceTimestamp, sinceTimestamp + stepMs, ..., sinceTimestamp + (limit-1) * stepMs
1585
+ * 2. Try to read each expected candle by timestamp key
1586
+ * 3. If ANY candle is missing, return null (cache miss)
1587
+ * 4. If all candles found, return them in order
1568
1588
  *
1569
1589
  * @param symbol - Trading pair symbol
1570
1590
  * @param interval - Candle interval
1571
1591
  * @param exchangeName - Exchange identifier
1572
1592
  * @param limit - Number of candles requested
1573
- * @param sinceTimestamp - Start timestamp (inclusive)
1574
- * @param untilTimestamp - End timestamp (exclusive)
1593
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
1594
+ * @param _untilTimestamp - Unused, kept for API compatibility
1575
1595
  * @returns Promise resolving to array of candles or null if cache is incomplete
1576
1596
  */
1577
- this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, untilTimestamp) => {
1597
+ this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, _untilTimestamp) => {
1578
1598
  bt.loggerService.info("PersistCandleUtils.readCandlesData", {
1579
1599
  symbol,
1580
1600
  interval,
1581
1601
  exchangeName,
1582
1602
  limit,
1583
1603
  sinceTimestamp,
1584
- untilTimestamp,
1585
1604
  });
1586
1605
  const key = `${symbol}:${interval}:${exchangeName}`;
1587
1606
  const isInitial = !this.getCandlesStorage.has(key);
1588
1607
  const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
1589
1608
  await stateStorage.waitForInit(isInitial);
1590
- // Collect all cached candles within the time range
1609
+ const stepMs = INTERVAL_MINUTES$5[interval] * MS_PER_MINUTE$2;
1610
+ // Calculate expected timestamps and fetch each candle directly
1591
1611
  const cachedCandles = [];
1592
- for await (const timestamp of stateStorage.keys()) {
1593
- const ts = Number(timestamp);
1594
- if (ts >= sinceTimestamp && ts < untilTimestamp) {
1595
- try {
1596
- const candle = await stateStorage.readValue(timestamp);
1597
- cachedCandles.push(candle);
1598
- }
1599
- catch (error) {
1600
- const message = `PersistCandleUtils.readCandlesData found invalid candle symbol=${symbol} interval=${interval} timestamp=${timestamp}`;
1601
- const payload = {
1602
- error: functoolsKit.errorData(error),
1603
- message: functoolsKit.getErrorMessage(error),
1604
- };
1605
- bt.loggerService.warn(message, payload);
1606
- console.warn(message, payload);
1607
- errorEmitter.next(error);
1608
- continue;
1609
- }
1612
+ for (let i = 0; i < limit; i++) {
1613
+ const expectedTimestamp = sinceTimestamp + i * stepMs;
1614
+ const timestampKey = String(expectedTimestamp);
1615
+ if (await functoolsKit.not(stateStorage.hasValue(timestampKey))) {
1616
+ // Cache miss - candle not found
1617
+ return null;
1618
+ }
1619
+ try {
1620
+ const candle = await stateStorage.readValue(timestampKey);
1621
+ cachedCandles.push(candle);
1622
+ }
1623
+ catch (error) {
1624
+ // Invalid candle in cache - treat as cache miss
1625
+ const message = `PersistCandleUtils.readCandlesData found invalid candle symbol=${symbol} interval=${interval} timestamp=${expectedTimestamp}`;
1626
+ const payload = {
1627
+ error: functoolsKit.errorData(error),
1628
+ message: functoolsKit.getErrorMessage(error),
1629
+ };
1630
+ bt.loggerService.warn(message, payload);
1631
+ console.warn(message, payload);
1632
+ errorEmitter.next(error);
1633
+ return null;
1610
1634
  }
1611
1635
  }
1612
- // Sort by timestamp ascending
1613
- cachedCandles.sort((a, b) => a.timestamp - b.timestamp);
1614
1636
  return cachedCandles;
1615
1637
  };
1616
1638
  /**
1617
1639
  * Writes candles to cache with atomic file writes.
1618
1640
  * Each candle is stored as a separate JSON file named by its timestamp.
1619
1641
  *
1620
- * @param candles - Array of candle data to cache
1642
+ * The candles passed to this function should be validated candles from the adapter:
1643
+ * - First candle.timestamp equals aligned sinceTimestamp (openTime)
1644
+ * - Exact number of candles as requested
1645
+ * - All candles are fully closed (timestamp + stepMs < untilTimestamp)
1646
+ *
1647
+ * @param candles - Array of candle data to cache (validated by the caller)
1621
1648
  * @param symbol - Trading pair symbol
1622
1649
  * @param interval - Candle interval
1623
1650
  * @param exchangeName - Exchange identifier
@@ -1634,8 +1661,25 @@ class PersistCandleUtils {
1634
1661
  const isInitial = !this.getCandlesStorage.has(key);
1635
1662
  const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
1636
1663
  await stateStorage.waitForInit(isInitial);
1637
- // Write each candle as a separate file
1664
+ // Calculate step in milliseconds to determine candle close time
1665
+ const stepMs = INTERVAL_MINUTES$5[interval] * MS_PER_MINUTE$2;
1666
+ const now = Date.now();
1667
+ // Write each candle as a separate file, skipping incomplete candles
1638
1668
  for (const candle of candles) {
1669
+ // Skip incomplete candles: candle is complete when closeTime <= now
1670
+ // closeTime = timestamp + stepMs
1671
+ const candleCloseTime = candle.timestamp + stepMs;
1672
+ if (candleCloseTime > now) {
1673
+ bt.loggerService.debug("PersistCandleUtils.writeCandlesData: skipping incomplete candle", {
1674
+ symbol,
1675
+ interval,
1676
+ exchangeName,
1677
+ timestamp: candle.timestamp,
1678
+ closeTime: candleCloseTime,
1679
+ now,
1680
+ });
1681
+ continue;
1682
+ }
1639
1683
  if (await functoolsKit.not(stateStorage.hasValue(String(candle.timestamp)))) {
1640
1684
  await stateStorage.writeValue(String(candle.timestamp), candle);
1641
1685
  }
@@ -1793,6 +1837,27 @@ const INTERVAL_MINUTES$4 = {
1793
1837
  "6h": 360,
1794
1838
  "8h": 480,
1795
1839
  };
1840
+ /**
1841
+ * Aligns timestamp down to the nearest interval boundary.
1842
+ * For example, for 15m interval: 00:17 -> 00:15, 00:44 -> 00:30
1843
+ *
1844
+ * Candle timestamp convention:
1845
+ * - Candle timestamp = openTime (when candle opens)
1846
+ * - Candle with timestamp 00:00 covers period [00:00, 00:15) for 15m interval
1847
+ *
1848
+ * Adapter contract:
1849
+ * - Adapter must return candles with timestamp = openTime
1850
+ * - First returned candle.timestamp must equal aligned since
1851
+ * - Adapter must return exactly `limit` candles
1852
+ *
1853
+ * @param timestamp - Timestamp in milliseconds
1854
+ * @param intervalMinutes - Interval in minutes
1855
+ * @returns Aligned timestamp rounded down to interval boundary
1856
+ */
1857
+ const ALIGN_TO_INTERVAL_FN$1 = (timestamp, intervalMinutes) => {
1858
+ const intervalMs = intervalMinutes * MS_PER_MINUTE$1;
1859
+ return Math.floor(timestamp / intervalMs) * intervalMs;
1860
+ };
1796
1861
  /**
1797
1862
  * Validates that all candles have valid OHLCV data without anomalies.
1798
1863
  * Detects incomplete candles from Binance API by checking for abnormally low prices or volumes.
@@ -1856,18 +1921,24 @@ const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => {
1856
1921
  };
1857
1922
  /**
1858
1923
  * Attempts to read candles from cache.
1859
- * Validates cache consistency (no gaps in timestamps) before returning.
1924
+ *
1925
+ * Cache lookup calculates expected timestamps:
1926
+ * sinceTimestamp + i * stepMs for i = 0..limit-1
1927
+ * Returns all candles if found, null if any missing.
1860
1928
  *
1861
1929
  * @param dto - Data transfer object containing symbol, interval, and limit
1862
- * @param sinceTimestamp - Start timestamp in milliseconds
1863
- * @param untilTimestamp - End timestamp in milliseconds
1930
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
1931
+ * @param untilTimestamp - Unused, kept for API compatibility
1864
1932
  * @param self - Instance of ClientExchange
1865
- * @returns Cached candles array or null if cache miss or inconsistent
1933
+ * @returns Cached candles array (exactly limit) or null if cache miss
1866
1934
  */
1867
1935
  const READ_CANDLES_CACHE_FN$1 = functoolsKit.trycatch(async (dto, sinceTimestamp, untilTimestamp, self) => {
1936
+ // PersistCandleAdapter.readCandlesData calculates expected timestamps:
1937
+ // sinceTimestamp + i * stepMs for i = 0..limit-1
1938
+ // Returns all candles if found, null if any missing
1868
1939
  const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, self.params.exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
1869
1940
  // Return cached data only if we have exactly the requested limit
1870
- if (cachedCandles.length === dto.limit) {
1941
+ if (cachedCandles?.length === dto.limit) {
1871
1942
  self.params.logger.debug(`ClientExchange READ_CANDLES_CACHE_FN: cache hit for symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
1872
1943
  return cachedCandles;
1873
1944
  }
@@ -1889,7 +1960,12 @@ const READ_CANDLES_CACHE_FN$1 = functoolsKit.trycatch(async (dto, sinceTimestamp
1889
1960
  /**
1890
1961
  * Writes candles to cache with error handling.
1891
1962
  *
1892
- * @param candles - Array of candle data to cache
1963
+ * The candles passed to this function should be validated:
1964
+ * - First candle.timestamp equals aligned sinceTimestamp (openTime)
1965
+ * - Exact number of candles as requested (limit)
1966
+ * - Sequential timestamps: sinceTimestamp + i * stepMs
1967
+ *
1968
+ * @param candles - Array of validated candle data to cache
1893
1969
  * @param dto - Data transfer object containing symbol, interval, and limit
1894
1970
  * @param self - Instance of ClientExchange
1895
1971
  */
@@ -2016,6 +2092,13 @@ class ClientExchange {
2016
2092
  /**
2017
2093
  * Fetches historical candles backwards from execution context time.
2018
2094
  *
2095
+ * Algorithm:
2096
+ * 1. Align when down to interval boundary (e.g., 00:17 -> 00:15 for 15m)
2097
+ * 2. Calculate since = alignedWhen - limit * step
2098
+ * 3. Fetch candles starting from since
2099
+ * 4. Validate first candle timestamp matches since (adapter must return inclusive data)
2100
+ * 5. Slice to limit
2101
+ *
2019
2102
  * @param symbol - Trading pair symbol
2020
2103
  * @param interval - Candle interval
2021
2104
  * @param limit - Number of candles to fetch
@@ -2028,11 +2111,16 @@ class ClientExchange {
2028
2111
  limit,
2029
2112
  });
2030
2113
  const step = INTERVAL_MINUTES$4[interval];
2031
- const adjust = step * limit;
2032
- if (!adjust) {
2033
- throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
2114
+ if (!step) {
2115
+ throw new Error(`ClientExchange unknown interval=${interval}`);
2034
2116
  }
2035
- const since = new Date(this.params.execution.context.when.getTime() - adjust * MS_PER_MINUTE$1);
2117
+ const stepMs = step * MS_PER_MINUTE$1;
2118
+ // Align when down to interval boundary
2119
+ const whenTimestamp = this.params.execution.context.when.getTime();
2120
+ const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
2121
+ // Calculate since: go back limit candles from aligned when
2122
+ const sinceTimestamp = alignedWhen - limit * stepMs;
2123
+ const since = new Date(sinceTimestamp);
2036
2124
  let allData = [];
2037
2125
  // If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
2038
2126
  if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
@@ -2045,29 +2133,34 @@ class ClientExchange {
2045
2133
  remaining -= chunkLimit;
2046
2134
  if (remaining > 0) {
2047
2135
  // Move currentSince forward by the number of candles fetched
2048
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2136
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2049
2137
  }
2050
2138
  }
2051
2139
  }
2052
2140
  else {
2053
2141
  allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
2054
2142
  }
2055
- // Filter candles to strictly match the requested range
2056
- const whenTimestamp = this.params.execution.context.when.getTime();
2057
- const sinceTimestamp = since.getTime();
2058
- const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp &&
2059
- candle.timestamp < whenTimestamp);
2060
2143
  // Apply distinct by timestamp to remove duplicates
2061
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2062
- if (filteredData.length !== uniqueData.length) {
2063
- const msg = `ClientExchange Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
2144
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
2145
+ if (allData.length !== uniqueData.length) {
2146
+ const msg = `ClientExchange getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
2064
2147
  this.params.logger.warn(msg);
2065
2148
  console.warn(msg);
2066
2149
  }
2067
- if (uniqueData.length < limit) {
2068
- const msg = `ClientExchange Expected ${limit} candles, got ${uniqueData.length}`;
2069
- this.params.logger.warn(msg);
2070
- console.warn(msg);
2150
+ // Validate adapter returned data
2151
+ if (uniqueData.length === 0) {
2152
+ throw new Error(`ClientExchange getCandles: adapter returned empty array. ` +
2153
+ `Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
2154
+ }
2155
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
2156
+ throw new Error(`ClientExchange getCandles: first candle timestamp mismatch. ` +
2157
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
2158
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
2159
+ }
2160
+ if (uniqueData.length !== limit) {
2161
+ throw new Error(`ClientExchange getCandles: candle count mismatch. ` +
2162
+ `Expected ${limit} candles, got ${uniqueData.length}. ` +
2163
+ `Adapter must return exact number of candles requested.`);
2071
2164
  }
2072
2165
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
2073
2166
  return uniqueData;
@@ -2076,6 +2169,13 @@ class ClientExchange {
2076
2169
  * Fetches future candles forwards from execution context time.
2077
2170
  * Used in backtest mode to get candles for signal duration.
2078
2171
  *
2172
+ * Algorithm:
2173
+ * 1. Align when down to interval boundary (e.g., 00:17 -> 00:15 for 15m)
2174
+ * 2. since = alignedWhen (start from aligned when)
2175
+ * 3. Fetch candles starting from since
2176
+ * 4. Validate first candle timestamp matches since (adapter must return inclusive data)
2177
+ * 5. Slice to limit
2178
+ *
2079
2179
  * @param symbol - Trading pair symbol
2080
2180
  * @param interval - Candle interval
2081
2181
  * @param limit - Number of candles to fetch
@@ -2088,12 +2188,24 @@ class ClientExchange {
2088
2188
  interval,
2089
2189
  limit,
2090
2190
  });
2091
- const since = new Date(this.params.execution.context.when.getTime());
2092
- const now = Date.now();
2093
- // Вычисляем конечное время запроса
2191
+ if (!this.params.execution.context.backtest) {
2192
+ throw new Error(`ClientExchange getNextCandles: cannot fetch future candles in live mode`);
2193
+ }
2094
2194
  const step = INTERVAL_MINUTES$4[interval];
2095
- const endTime = since.getTime() + limit * step * MS_PER_MINUTE$1;
2096
- // Проверяем что запрошенный период не заходит за Date.now()
2195
+ if (!step) {
2196
+ throw new Error(`ClientExchange getNextCandles: unknown interval=${interval}`);
2197
+ }
2198
+ const stepMs = step * MS_PER_MINUTE$1;
2199
+ const now = Date.now();
2200
+ // Align when down to interval boundary
2201
+ const whenTimestamp = this.params.execution.context.when.getTime();
2202
+ const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
2203
+ // since = alignedWhen (start from aligned when, going forward)
2204
+ const sinceTimestamp = alignedWhen;
2205
+ const since = new Date(sinceTimestamp);
2206
+ // Calculate end time for Date.now() check
2207
+ const endTime = sinceTimestamp + limit * stepMs;
2208
+ // Check that requested period does not exceed Date.now()
2097
2209
  if (endTime > now) {
2098
2210
  return [];
2099
2211
  }
@@ -2109,27 +2221,34 @@ class ClientExchange {
2109
2221
  remaining -= chunkLimit;
2110
2222
  if (remaining > 0) {
2111
2223
  // Move currentSince forward by the number of candles fetched
2112
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2224
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2113
2225
  }
2114
2226
  }
2115
2227
  }
2116
2228
  else {
2117
2229
  allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
2118
2230
  }
2119
- // Filter candles to strictly match the requested range
2120
- const sinceTimestamp = since.getTime();
2121
- const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp < endTime);
2122
2231
  // Apply distinct by timestamp to remove duplicates
2123
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2124
- if (filteredData.length !== uniqueData.length) {
2125
- const msg = `ClientExchange getNextCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
2232
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
2233
+ if (allData.length !== uniqueData.length) {
2234
+ const msg = `ClientExchange getNextCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
2126
2235
  this.params.logger.warn(msg);
2127
2236
  console.warn(msg);
2128
2237
  }
2129
- if (uniqueData.length < limit) {
2130
- const msg = `ClientExchange getNextCandles: Expected ${limit} candles, got ${uniqueData.length}`;
2131
- this.params.logger.warn(msg);
2132
- console.warn(msg);
2238
+ // Validate adapter returned data
2239
+ if (uniqueData.length === 0) {
2240
+ throw new Error(`ClientExchange getNextCandles: adapter returned empty array. ` +
2241
+ `Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
2242
+ }
2243
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
2244
+ throw new Error(`ClientExchange getNextCandles: first candle timestamp mismatch. ` +
2245
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
2246
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
2247
+ }
2248
+ if (uniqueData.length !== limit) {
2249
+ throw new Error(`ClientExchange getNextCandles: candle count mismatch. ` +
2250
+ `Expected ${limit} candles, got ${uniqueData.length}. ` +
2251
+ `Adapter must return exact number of candles requested.`);
2133
2252
  }
2134
2253
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
2135
2254
  return uniqueData;
@@ -2204,6 +2323,12 @@ class ClientExchange {
2204
2323
  /**
2205
2324
  * Fetches raw candles with flexible date/limit parameters.
2206
2325
  *
2326
+ * Algorithm:
2327
+ * 1. Align all timestamps down to interval boundary
2328
+ * 2. Fetch candles starting from aligned since
2329
+ * 3. Validate first candle timestamp matches aligned since (adapter must return inclusive data)
2330
+ * 4. Slice to limit
2331
+ *
2207
2332
  * All modes respect execution context and prevent look-ahead bias.
2208
2333
  *
2209
2334
  * Parameter combinations:
@@ -2238,9 +2363,10 @@ class ClientExchange {
2238
2363
  if (!step) {
2239
2364
  throw new Error(`ClientExchange getRawCandles: unknown interval=${interval}`);
2240
2365
  }
2366
+ const stepMs = step * MS_PER_MINUTE$1;
2241
2367
  const whenTimestamp = this.params.execution.context.when.getTime();
2368
+ const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
2242
2369
  let sinceTimestamp;
2243
- let untilTimestamp;
2244
2370
  let calculatedLimit;
2245
2371
  // Case 1: all three parameters provided
2246
2372
  if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
@@ -2250,8 +2376,8 @@ class ClientExchange {
2250
2376
  if (eDate > whenTimestamp) {
2251
2377
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2252
2378
  }
2253
- sinceTimestamp = sDate;
2254
- untilTimestamp = eDate;
2379
+ // Align sDate down to interval boundary
2380
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
2255
2381
  calculatedLimit = limit;
2256
2382
  }
2257
2383
  // Case 2: sDate + eDate (no limit) - calculate limit from date range
@@ -2262,9 +2388,10 @@ class ClientExchange {
2262
2388
  if (eDate > whenTimestamp) {
2263
2389
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2264
2390
  }
2265
- sinceTimestamp = sDate;
2266
- untilTimestamp = eDate;
2267
- calculatedLimit = Math.ceil((eDate - sDate) / (step * MS_PER_MINUTE$1));
2391
+ // Align sDate down to interval boundary
2392
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
2393
+ const alignedEDate = ALIGN_TO_INTERVAL_FN$1(eDate, step);
2394
+ calculatedLimit = Math.ceil((alignedEDate - sinceTimestamp) / stepMs);
2268
2395
  if (calculatedLimit <= 0) {
2269
2396
  throw new Error(`ClientExchange getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
2270
2397
  }
@@ -2274,23 +2401,24 @@ class ClientExchange {
2274
2401
  if (eDate > whenTimestamp) {
2275
2402
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2276
2403
  }
2277
- untilTimestamp = eDate;
2278
- sinceTimestamp = eDate - limit * step * MS_PER_MINUTE$1;
2404
+ // Align eDate down and calculate sinceTimestamp
2405
+ const alignedEDate = ALIGN_TO_INTERVAL_FN$1(eDate, step);
2406
+ sinceTimestamp = alignedEDate - limit * stepMs;
2279
2407
  calculatedLimit = limit;
2280
2408
  }
2281
2409
  // Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
2282
2410
  else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
2283
- sinceTimestamp = sDate;
2284
- untilTimestamp = sDate + limit * step * MS_PER_MINUTE$1;
2285
- if (untilTimestamp > whenTimestamp) {
2286
- throw new Error(`ClientExchange getRawCandles: calculated endTimestamp (${untilTimestamp}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2411
+ // Align sDate down to interval boundary
2412
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
2413
+ const endTimestamp = sinceTimestamp + limit * stepMs;
2414
+ if (endTimestamp > whenTimestamp) {
2415
+ throw new Error(`ClientExchange getRawCandles: calculated endTimestamp (${endTimestamp}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2287
2416
  }
2288
2417
  calculatedLimit = limit;
2289
2418
  }
2290
2419
  // Case 5: Only limit - use execution.context.when as reference (backward like getCandles)
2291
2420
  else if (sDate === undefined && eDate === undefined && limit !== undefined) {
2292
- untilTimestamp = whenTimestamp;
2293
- sinceTimestamp = whenTimestamp - limit * step * MS_PER_MINUTE$1;
2421
+ sinceTimestamp = alignedWhen - limit * stepMs;
2294
2422
  calculatedLimit = limit;
2295
2423
  }
2296
2424
  // Invalid: no parameters or only sDate or only eDate
@@ -2311,27 +2439,34 @@ class ClientExchange {
2311
2439
  allData.push(...chunkData);
2312
2440
  remaining -= chunkLimit;
2313
2441
  if (remaining > 0) {
2314
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2442
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2315
2443
  }
2316
2444
  }
2317
2445
  }
2318
2446
  else {
2319
2447
  allData = await GET_CANDLES_FN({ symbol, interval, limit: calculatedLimit }, since, this);
2320
2448
  }
2321
- // Filter candles to strictly match the requested range
2322
- const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp &&
2323
- candle.timestamp < untilTimestamp);
2324
2449
  // Apply distinct by timestamp to remove duplicates
2325
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2326
- if (filteredData.length !== uniqueData.length) {
2327
- const msg = `ClientExchange getRawCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
2450
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
2451
+ if (allData.length !== uniqueData.length) {
2452
+ const msg = `ClientExchange getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
2328
2453
  this.params.logger.warn(msg);
2329
2454
  console.warn(msg);
2330
2455
  }
2331
- if (uniqueData.length < calculatedLimit) {
2332
- const msg = `ClientExchange getRawCandles: Expected ${calculatedLimit} candles, got ${uniqueData.length}`;
2333
- this.params.logger.warn(msg);
2334
- console.warn(msg);
2456
+ // Validate adapter returned data
2457
+ if (uniqueData.length === 0) {
2458
+ throw new Error(`ClientExchange getRawCandles: adapter returned empty array. ` +
2459
+ `Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
2460
+ }
2461
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
2462
+ throw new Error(`ClientExchange getRawCandles: first candle timestamp mismatch. ` +
2463
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
2464
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
2465
+ }
2466
+ if (uniqueData.length !== calculatedLimit) {
2467
+ throw new Error(`ClientExchange getRawCandles: candle count mismatch. ` +
2468
+ `Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
2469
+ `Adapter must return exact number of candles requested.`);
2335
2470
  }
2336
2471
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, calculatedLimit, uniqueData);
2337
2472
  return uniqueData;
@@ -13839,6 +13974,18 @@ const live_columns = [
13839
13974
  format: (data) => data.duration !== undefined ? `${data.duration}` : "N/A",
13840
13975
  isVisible: () => true,
13841
13976
  },
13977
+ {
13978
+ key: "pendingAt",
13979
+ label: "Pending At",
13980
+ format: (data) => data.pendingAt !== undefined ? new Date(data.pendingAt).toISOString() : "N/A",
13981
+ isVisible: () => true,
13982
+ },
13983
+ {
13984
+ key: "scheduledAt",
13985
+ label: "Scheduled At",
13986
+ format: (data) => data.scheduledAt !== undefined ? new Date(data.scheduledAt).toISOString() : "N/A",
13987
+ isVisible: () => true,
13988
+ },
13842
13989
  ];
13843
13990
 
13844
13991
  /**
@@ -13963,6 +14110,18 @@ const partial_columns = [
13963
14110
  format: (data) => data.note || "",
13964
14111
  isVisible: () => GLOBAL_CONFIG.CC_REPORT_SHOW_SIGNAL_NOTE,
13965
14112
  },
14113
+ {
14114
+ key: "pendingAt",
14115
+ label: "Pending At",
14116
+ format: (data) => (data.pendingAt ? new Date(data.pendingAt).toISOString() : "N/A"),
14117
+ isVisible: () => true,
14118
+ },
14119
+ {
14120
+ key: "scheduledAt",
14121
+ label: "Scheduled At",
14122
+ format: (data) => (data.scheduledAt ? new Date(data.scheduledAt).toISOString() : "N/A"),
14123
+ isVisible: () => true,
14124
+ },
13966
14125
  {
13967
14126
  key: "timestamp",
13968
14127
  label: "Timestamp",
@@ -14085,6 +14244,18 @@ const breakeven_columns = [
14085
14244
  format: (data) => data.note || "",
14086
14245
  isVisible: () => GLOBAL_CONFIG.CC_REPORT_SHOW_SIGNAL_NOTE,
14087
14246
  },
14247
+ {
14248
+ key: "pendingAt",
14249
+ label: "Pending At",
14250
+ format: (data) => (data.pendingAt ? new Date(data.pendingAt).toISOString() : "N/A"),
14251
+ isVisible: () => true,
14252
+ },
14253
+ {
14254
+ key: "scheduledAt",
14255
+ label: "Scheduled At",
14256
+ format: (data) => (data.scheduledAt ? new Date(data.scheduledAt).toISOString() : "N/A"),
14257
+ isVisible: () => true,
14258
+ },
14088
14259
  {
14089
14260
  key: "timestamp",
14090
14261
  label: "Timestamp",
@@ -14363,6 +14534,22 @@ const risk_columns = [
14363
14534
  format: (data) => data.rejectionNote,
14364
14535
  isVisible: () => true,
14365
14536
  },
14537
+ {
14538
+ key: "pendingAt",
14539
+ label: "Pending At",
14540
+ format: (data) => data.currentSignal.pendingAt !== undefined
14541
+ ? new Date(data.currentSignal.pendingAt).toISOString()
14542
+ : "N/A",
14543
+ isVisible: () => true,
14544
+ },
14545
+ {
14546
+ key: "scheduledAt",
14547
+ label: "Scheduled At",
14548
+ format: (data) => data.currentSignal.scheduledAt !== undefined
14549
+ ? new Date(data.currentSignal.scheduledAt).toISOString()
14550
+ : "N/A",
14551
+ isVisible: () => true,
14552
+ },
14366
14553
  {
14367
14554
  key: "timestamp",
14368
14555
  label: "Timestamp",
@@ -14513,6 +14700,18 @@ const schedule_columns = [
14513
14700
  format: (data) => data.cancelId ?? "N/A",
14514
14701
  isVisible: () => true,
14515
14702
  },
14703
+ {
14704
+ key: "pendingAt",
14705
+ label: "Pending At",
14706
+ format: (data) => data.pendingAt !== undefined ? new Date(data.pendingAt).toISOString() : "N/A",
14707
+ isVisible: () => true,
14708
+ },
14709
+ {
14710
+ key: "scheduledAt",
14711
+ label: "Scheduled At",
14712
+ format: (data) => data.scheduledAt !== undefined ? new Date(data.scheduledAt).toISOString() : "N/A",
14713
+ isVisible: () => true,
14714
+ },
14516
14715
  ];
14517
14716
 
14518
14717
  /**
@@ -15904,6 +16103,8 @@ let ReportStorage$6 = class ReportStorage {
15904
16103
  originalPriceTakeProfit: data.signal.originalPriceTakeProfit,
15905
16104
  originalPriceStopLoss: data.signal.originalPriceStopLoss,
15906
16105
  partialExecuted: data.signal.partialExecuted,
16106
+ pendingAt: data.signal.pendingAt,
16107
+ scheduledAt: data.signal.scheduledAt,
15907
16108
  });
15908
16109
  // Trim queue if exceeded MAX_EVENTS
15909
16110
  if (this._eventList.length > MAX_EVENTS$7) {
@@ -15934,6 +16135,8 @@ let ReportStorage$6 = class ReportStorage {
15934
16135
  percentTp: data.percentTp,
15935
16136
  percentSl: data.percentSl,
15936
16137
  pnl: data.pnl.pnlPercentage,
16138
+ pendingAt: data.signal.pendingAt,
16139
+ scheduledAt: data.signal.scheduledAt,
15937
16140
  };
15938
16141
  // Find the last active event with the same signalId
15939
16142
  const lastActiveIndex = this._eventList.findLastIndex((event) => event.action === "active" && event.signalId === data.signal.id);
@@ -15974,6 +16177,8 @@ let ReportStorage$6 = class ReportStorage {
15974
16177
  pnl: data.pnl.pnlPercentage,
15975
16178
  closeReason: data.closeReason,
15976
16179
  duration: durationMin,
16180
+ pendingAt: data.signal.pendingAt,
16181
+ scheduledAt: data.signal.scheduledAt,
15977
16182
  };
15978
16183
  this._eventList.unshift(newEvent);
15979
16184
  // Trim queue if exceeded MAX_EVENTS
@@ -16001,6 +16206,7 @@ let ReportStorage$6 = class ReportStorage {
16001
16206
  originalPriceTakeProfit: data.signal.originalPriceTakeProfit,
16002
16207
  originalPriceStopLoss: data.signal.originalPriceStopLoss,
16003
16208
  partialExecuted: data.signal.partialExecuted,
16209
+ scheduledAt: data.signal.scheduledAt,
16004
16210
  });
16005
16211
  // Trim queue if exceeded MAX_EVENTS
16006
16212
  if (this._eventList.length > MAX_EVENTS$7) {
@@ -16031,6 +16237,7 @@ let ReportStorage$6 = class ReportStorage {
16031
16237
  percentTp: data.percentTp,
16032
16238
  percentSl: data.percentSl,
16033
16239
  pnl: data.pnl.pnlPercentage,
16240
+ scheduledAt: data.signal.scheduledAt,
16034
16241
  };
16035
16242
  // Find the last waiting event with the same signalId
16036
16243
  const lastWaitingIndex = this._eventList.findLastIndex((event) => event.action === "waiting" && event.signalId === data.signal.id);
@@ -16067,6 +16274,7 @@ let ReportStorage$6 = class ReportStorage {
16067
16274
  originalPriceStopLoss: data.signal.originalPriceStopLoss,
16068
16275
  partialExecuted: data.signal.partialExecuted,
16069
16276
  cancelReason: data.reason,
16277
+ scheduledAt: data.signal.scheduledAt,
16070
16278
  });
16071
16279
  // Trim queue if exceeded MAX_EVENTS
16072
16280
  if (this._eventList.length > MAX_EVENTS$7) {
@@ -16558,6 +16766,7 @@ let ReportStorage$5 = class ReportStorage {
16558
16766
  originalPriceTakeProfit: data.signal.originalPriceTakeProfit,
16559
16767
  originalPriceStopLoss: data.signal.originalPriceStopLoss,
16560
16768
  partialExecuted: data.signal.partialExecuted,
16769
+ scheduledAt: data.signal.scheduledAt,
16561
16770
  });
16562
16771
  // Trim queue if exceeded MAX_EVENTS
16563
16772
  if (this._eventList.length > MAX_EVENTS$6) {
@@ -16587,6 +16796,8 @@ let ReportStorage$5 = class ReportStorage {
16587
16796
  originalPriceStopLoss: data.signal.originalPriceStopLoss,
16588
16797
  partialExecuted: data.signal.partialExecuted,
16589
16798
  duration: durationMin,
16799
+ pendingAt: data.signal.pendingAt,
16800
+ scheduledAt: data.signal.scheduledAt,
16590
16801
  };
16591
16802
  this._eventList.unshift(newEvent);
16592
16803
  // Trim queue if exceeded MAX_EVENTS
@@ -16620,6 +16831,7 @@ let ReportStorage$5 = class ReportStorage {
16620
16831
  duration: durationMin,
16621
16832
  cancelReason: data.reason,
16622
16833
  cancelId: data.cancelId,
16834
+ scheduledAt: data.signal.scheduledAt,
16623
16835
  };
16624
16836
  this._eventList.unshift(newEvent);
16625
16837
  // Trim queue if exceeded MAX_EVENTS
@@ -19761,6 +19973,8 @@ let ReportStorage$3 = class ReportStorage {
19761
19973
  originalPriceStopLoss: data.originalPriceStopLoss,
19762
19974
  partialExecuted: data.partialExecuted,
19763
19975
  note: data.note,
19976
+ pendingAt: data.pendingAt,
19977
+ scheduledAt: data.scheduledAt,
19764
19978
  backtest,
19765
19979
  });
19766
19980
  // Trim queue if exceeded MAX_EVENTS
@@ -19793,6 +20007,8 @@ let ReportStorage$3 = class ReportStorage {
19793
20007
  originalPriceStopLoss: data.originalPriceStopLoss,
19794
20008
  partialExecuted: data.partialExecuted,
19795
20009
  note: data.note,
20010
+ pendingAt: data.pendingAt,
20011
+ scheduledAt: data.scheduledAt,
19796
20012
  backtest,
19797
20013
  });
19798
20014
  // Trim queue if exceeded MAX_EVENTS
@@ -20890,6 +21106,8 @@ let ReportStorage$2 = class ReportStorage {
20890
21106
  originalPriceStopLoss: data.originalPriceStopLoss,
20891
21107
  partialExecuted: data.partialExecuted,
20892
21108
  note: data.note,
21109
+ pendingAt: data.pendingAt,
21110
+ scheduledAt: data.scheduledAt,
20893
21111
  backtest,
20894
21112
  });
20895
21113
  // Trim queue if exceeded MAX_EVENTS
@@ -23275,9 +23493,15 @@ class HeatReportService {
23275
23493
  signalId: data.signal?.id,
23276
23494
  position: data.signal?.position,
23277
23495
  note: data.signal?.note,
23496
+ priceOpen: data.signal?.priceOpen,
23497
+ priceTakeProfit: data.signal?.priceTakeProfit,
23498
+ priceStopLoss: data.signal?.priceStopLoss,
23499
+ originalPriceTakeProfit: data.signal?.originalPriceTakeProfit,
23500
+ originalPriceStopLoss: data.signal?.originalPriceStopLoss,
23278
23501
  pnl: data.pnl.pnlPercentage,
23279
23502
  closeReason: data.closeReason,
23280
23503
  openTime: data.signal?.pendingAt,
23504
+ scheduledAt: data.signal?.scheduledAt,
23281
23505
  closeTime: data.closeTimestamp,
23282
23506
  }, {
23283
23507
  symbol: data.symbol,
@@ -23686,6 +23910,8 @@ class RiskReportService {
23686
23910
  originalPriceStopLoss: data.currentSignal?.originalPriceStopLoss,
23687
23911
  partialExecuted: data.currentSignal?.partialExecuted,
23688
23912
  note: data.currentSignal?.note,
23913
+ pendingAt: data.currentSignal?.pendingAt,
23914
+ scheduledAt: data.currentSignal?.scheduledAt,
23689
23915
  minuteEstimatedTime: data.currentSignal?.minuteEstimatedTime,
23690
23916
  }, {
23691
23917
  symbol: data.symbol,
@@ -25628,6 +25854,7 @@ const GET_CONTEXT_METHOD_NAME = "exchange.getContext";
25628
25854
  const HAS_TRADE_CONTEXT_METHOD_NAME = "exchange.hasTradeContext";
25629
25855
  const GET_ORDER_BOOK_METHOD_NAME = "exchange.getOrderBook";
25630
25856
  const GET_RAW_CANDLES_METHOD_NAME = "exchange.getRawCandles";
25857
+ const GET_NEXT_CANDLES_METHOD_NAME = "exchange.getNextCandles";
25631
25858
  /**
25632
25859
  * Checks if trade context is active (execution and method contexts).
25633
25860
  *
@@ -25932,6 +26159,30 @@ async function getRawCandles(symbol, interval, limit, sDate, eDate) {
25932
26159
  }
25933
26160
  return await bt.exchangeConnectionService.getRawCandles(symbol, interval, limit, sDate, eDate);
25934
26161
  }
26162
+ /**
26163
+ * Fetches the set of candles after current time based on execution context.
26164
+ *
26165
+ * Uses the exchange's getNextCandles implementation to retrieve candles
26166
+ * that occur after the current context time.
26167
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
26168
+ * @param interval - Candle interval ("1m" | "3m" | "5m" | "15m" | "30m" | "1h" | "2h" | "4h" | "6h" | "8h")
26169
+ * @param limit - Number of candles to fetch
26170
+ * @returns Promise resolving to array of candle data
26171
+ */
26172
+ async function getNextCandles(symbol, interval, limit) {
26173
+ bt.loggerService.info(GET_NEXT_CANDLES_METHOD_NAME, {
26174
+ symbol,
26175
+ interval,
26176
+ limit,
26177
+ });
26178
+ if (!ExecutionContextService.hasContext()) {
26179
+ throw new Error("getNextCandles requires an execution context");
26180
+ }
26181
+ if (!MethodContextService.hasContext()) {
26182
+ throw new Error("getNextCandles requires a method context");
26183
+ }
26184
+ return await bt.exchangeConnectionService.getNextCandles(symbol, interval, limit);
26185
+ }
25935
26186
 
25936
26187
  const CANCEL_SCHEDULED_METHOD_NAME = "strategy.commitCancelScheduled";
25937
26188
  const CLOSE_PENDING_METHOD_NAME = "strategy.commitClosePending";
@@ -32865,6 +33116,16 @@ const EXCHANGE_METHOD_NAME_FORMAT_PRICE = "ExchangeUtils.formatPrice";
32865
33116
  const EXCHANGE_METHOD_NAME_GET_ORDER_BOOK = "ExchangeUtils.getOrderBook";
32866
33117
  const EXCHANGE_METHOD_NAME_GET_RAW_CANDLES = "ExchangeUtils.getRawCandles";
32867
33118
  const MS_PER_MINUTE = 60000;
33119
+ /**
33120
+ * Gets current timestamp from execution context if available.
33121
+ * Returns current Date() if no execution context exists (non-trading GUI).
33122
+ */
33123
+ const GET_TIMESTAMP_FN = async () => {
33124
+ if (ExecutionContextService.hasContext()) {
33125
+ return new Date(bt.executionContextService.context.when);
33126
+ }
33127
+ return new Date();
33128
+ };
32868
33129
  /**
32869
33130
  * Gets backtest mode flag from execution context if available.
32870
33131
  * Returns false if no execution context exists (live mode).
@@ -32921,6 +33182,27 @@ const INTERVAL_MINUTES$1 = {
32921
33182
  "6h": 360,
32922
33183
  "8h": 480,
32923
33184
  };
33185
+ /**
33186
+ * Aligns timestamp down to the nearest interval boundary.
33187
+ * For example, for 15m interval: 00:17 -> 00:15, 00:44 -> 00:30
33188
+ *
33189
+ * Candle timestamp convention:
33190
+ * - Candle timestamp = openTime (when candle opens)
33191
+ * - Candle with timestamp 00:00 covers period [00:00, 00:15) for 15m interval
33192
+ *
33193
+ * Adapter contract:
33194
+ * - Adapter must return candles with timestamp = openTime
33195
+ * - First returned candle.timestamp must equal aligned since
33196
+ * - Adapter must return exactly `limit` candles
33197
+ *
33198
+ * @param timestamp - Timestamp in milliseconds
33199
+ * @param intervalMinutes - Interval in minutes
33200
+ * @returns Aligned timestamp rounded down to interval boundary
33201
+ */
33202
+ const ALIGN_TO_INTERVAL_FN = (timestamp, intervalMinutes) => {
33203
+ const intervalMs = intervalMinutes * MS_PER_MINUTE;
33204
+ return Math.floor(timestamp / intervalMs) * intervalMs;
33205
+ };
32924
33206
  /**
32925
33207
  * Creates exchange instance with methods resolved once during construction.
32926
33208
  * Applies default implementations where schema methods are not provided.
@@ -32942,18 +33224,24 @@ const CREATE_EXCHANGE_INSTANCE_FN = (schema) => {
32942
33224
  };
32943
33225
  /**
32944
33226
  * Attempts to read candles from cache.
32945
- * Validates cache consistency (no gaps in timestamps) before returning.
33227
+ *
33228
+ * Cache lookup calculates expected timestamps:
33229
+ * sinceTimestamp + i * stepMs for i = 0..limit-1
33230
+ * Returns all candles if found, null if any missing.
32946
33231
  *
32947
33232
  * @param dto - Data transfer object containing symbol, interval, and limit
32948
- * @param sinceTimestamp - Start timestamp in milliseconds
32949
- * @param untilTimestamp - End timestamp in milliseconds
33233
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
33234
+ * @param untilTimestamp - Unused, kept for API compatibility
32950
33235
  * @param exchangeName - Exchange name
32951
- * @returns Cached candles array or null if cache miss or inconsistent
33236
+ * @returns Cached candles array (exactly limit) or null if cache miss
32952
33237
  */
32953
33238
  const READ_CANDLES_CACHE_FN = functoolsKit.trycatch(async (dto, sinceTimestamp, untilTimestamp, exchangeName) => {
33239
+ // PersistCandleAdapter.readCandlesData calculates expected timestamps:
33240
+ // sinceTimestamp + i * stepMs for i = 0..limit-1
33241
+ // Returns all candles if found, null if any missing
32954
33242
  const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
32955
33243
  // Return cached data only if we have exactly the requested limit
32956
- if (cachedCandles.length === dto.limit) {
33244
+ if (cachedCandles?.length === dto.limit) {
32957
33245
  bt.loggerService.debug(`ExchangeInstance READ_CANDLES_CACHE_FN: cache hit for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
32958
33246
  return cachedCandles;
32959
33247
  }
@@ -32975,7 +33263,12 @@ const READ_CANDLES_CACHE_FN = functoolsKit.trycatch(async (dto, sinceTimestamp,
32975
33263
  /**
32976
33264
  * Writes candles to cache with error handling.
32977
33265
  *
32978
- * @param candles - Array of candle data to cache
33266
+ * The candles passed to this function should be validated:
33267
+ * - First candle.timestamp equals aligned sinceTimestamp (openTime)
33268
+ * - Exact number of candles as requested (limit)
33269
+ * - Sequential timestamps: sinceTimestamp + i * stepMs
33270
+ *
33271
+ * @param candles - Array of validated candle data to cache
32979
33272
  * @param dto - Data transfer object containing symbol, interval, and limit
32980
33273
  * @param exchangeName - Exchange name
32981
33274
  */
@@ -33046,14 +33339,18 @@ class ExchangeInstance {
33046
33339
  });
33047
33340
  const getCandles = this._methods.getCandles;
33048
33341
  const step = INTERVAL_MINUTES$1[interval];
33049
- const adjust = step * limit;
33050
- if (!adjust) {
33051
- throw new Error(`ExchangeInstance unknown time adjust for interval=${interval}`);
33052
- }
33053
- const when = new Date(Date.now());
33054
- const since = new Date(when.getTime() - adjust * 60 * 1000);
33055
- const sinceTimestamp = since.getTime();
33056
- const untilTimestamp = sinceTimestamp + limit * step * 60 * 1000;
33342
+ if (!step) {
33343
+ throw new Error(`ExchangeInstance unknown interval=${interval}`);
33344
+ }
33345
+ const stepMs = step * MS_PER_MINUTE;
33346
+ // Align when down to interval boundary
33347
+ const when = await GET_TIMESTAMP_FN();
33348
+ const whenTimestamp = when.getTime();
33349
+ const alignedWhen = ALIGN_TO_INTERVAL_FN(whenTimestamp, step);
33350
+ // Calculate since: go back limit candles from aligned when
33351
+ const sinceTimestamp = alignedWhen - limit * stepMs;
33352
+ const since = new Date(sinceTimestamp);
33353
+ const untilTimestamp = alignedWhen;
33057
33354
  // Try to read from cache first
33058
33355
  const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
33059
33356
  if (cachedCandles !== null) {
@@ -33072,7 +33369,7 @@ class ExchangeInstance {
33072
33369
  remaining -= chunkLimit;
33073
33370
  if (remaining > 0) {
33074
33371
  // Move currentSince forward by the number of candles fetched
33075
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * 60 * 1000);
33372
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
33076
33373
  }
33077
33374
  }
33078
33375
  }
@@ -33080,17 +33377,25 @@ class ExchangeInstance {
33080
33377
  const isBacktest = await GET_BACKTEST_FN();
33081
33378
  allData = await getCandles(symbol, interval, since, limit, isBacktest);
33082
33379
  }
33083
- // Filter candles to strictly match the requested range
33084
- const whenTimestamp = when.getTime();
33085
- const stepMs = step * 60 * 1000;
33086
- const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp < whenTimestamp + stepMs);
33087
33380
  // Apply distinct by timestamp to remove duplicates
33088
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
33089
- if (filteredData.length !== uniqueData.length) {
33090
- bt.loggerService.warn(`ExchangeInstance Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`);
33381
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
33382
+ if (allData.length !== uniqueData.length) {
33383
+ bt.loggerService.warn(`ExchangeInstance getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
33091
33384
  }
33092
- if (uniqueData.length < limit) {
33093
- bt.loggerService.warn(`ExchangeInstance Expected ${limit} candles, got ${uniqueData.length}`);
33385
+ // Validate adapter returned data
33386
+ if (uniqueData.length === 0) {
33387
+ throw new Error(`ExchangeInstance getCandles: adapter returned empty array. ` +
33388
+ `Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
33389
+ }
33390
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
33391
+ throw new Error(`ExchangeInstance getCandles: first candle timestamp mismatch. ` +
33392
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
33393
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
33394
+ }
33395
+ if (uniqueData.length !== limit) {
33396
+ throw new Error(`ExchangeInstance getCandles: candle count mismatch. ` +
33397
+ `Expected ${limit} candles, got ${uniqueData.length}. ` +
33398
+ `Adapter must return exact number of candles requested.`);
33094
33399
  }
33095
33400
  // Write to cache after successful fetch
33096
33401
  await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
@@ -33213,8 +33518,8 @@ class ExchangeInstance {
33213
33518
  symbol,
33214
33519
  depth,
33215
33520
  });
33216
- const to = new Date(Date.now());
33217
- const from = new Date(to.getTime() - GLOBAL_CONFIG.CC_ORDER_BOOK_TIME_OFFSET_MINUTES * 60 * 1000);
33521
+ const to = await GET_TIMESTAMP_FN();
33522
+ const from = new Date(to.getTime() - GLOBAL_CONFIG.CC_ORDER_BOOK_TIME_OFFSET_MINUTES * MS_PER_MINUTE);
33218
33523
  const isBacktest = await GET_BACKTEST_FN();
33219
33524
  return await this._methods.getOrderBook(symbol, depth, from, to, isBacktest);
33220
33525
  };
@@ -33261,9 +33566,11 @@ class ExchangeInstance {
33261
33566
  if (!step) {
33262
33567
  throw new Error(`ExchangeInstance getRawCandles: unknown interval=${interval}`);
33263
33568
  }
33264
- const nowTimestamp = Date.now();
33569
+ const stepMs = step * MS_PER_MINUTE;
33570
+ const when = await GET_TIMESTAMP_FN();
33571
+ const nowTimestamp = when.getTime();
33572
+ const alignedNow = ALIGN_TO_INTERVAL_FN(nowTimestamp, step);
33265
33573
  let sinceTimestamp;
33266
- let untilTimestamp;
33267
33574
  let calculatedLimit;
33268
33575
  // Case 1: all three parameters provided
33269
33576
  if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
@@ -33273,8 +33580,8 @@ class ExchangeInstance {
33273
33580
  if (eDate > nowTimestamp) {
33274
33581
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33275
33582
  }
33276
- sinceTimestamp = sDate;
33277
- untilTimestamp = eDate;
33583
+ // Align sDate down to interval boundary
33584
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
33278
33585
  calculatedLimit = limit;
33279
33586
  }
33280
33587
  // Case 2: sDate + eDate (no limit) - calculate limit from date range
@@ -33285,9 +33592,10 @@ class ExchangeInstance {
33285
33592
  if (eDate > nowTimestamp) {
33286
33593
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33287
33594
  }
33288
- sinceTimestamp = sDate;
33289
- untilTimestamp = eDate;
33290
- calculatedLimit = Math.ceil((eDate - sDate) / (step * MS_PER_MINUTE));
33595
+ // Align sDate down to interval boundary
33596
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
33597
+ const alignedEDate = ALIGN_TO_INTERVAL_FN(eDate, step);
33598
+ calculatedLimit = Math.ceil((alignedEDate - sinceTimestamp) / stepMs);
33291
33599
  if (calculatedLimit <= 0) {
33292
33600
  throw new Error(`ExchangeInstance getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
33293
33601
  }
@@ -33297,23 +33605,24 @@ class ExchangeInstance {
33297
33605
  if (eDate > nowTimestamp) {
33298
33606
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33299
33607
  }
33300
- untilTimestamp = eDate;
33301
- sinceTimestamp = eDate - limit * step * MS_PER_MINUTE;
33608
+ // Align eDate down and calculate sinceTimestamp
33609
+ const alignedEDate = ALIGN_TO_INTERVAL_FN(eDate, step);
33610
+ sinceTimestamp = alignedEDate - limit * stepMs;
33302
33611
  calculatedLimit = limit;
33303
33612
  }
33304
33613
  // Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
33305
33614
  else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
33306
- sinceTimestamp = sDate;
33307
- untilTimestamp = sDate + limit * step * MS_PER_MINUTE;
33308
- if (untilTimestamp > nowTimestamp) {
33309
- throw new Error(`ExchangeInstance getRawCandles: calculated endTimestamp (${untilTimestamp}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33615
+ // Align sDate down to interval boundary
33616
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
33617
+ const endTimestamp = sinceTimestamp + limit * stepMs;
33618
+ if (endTimestamp > nowTimestamp) {
33619
+ throw new Error(`ExchangeInstance getRawCandles: calculated endTimestamp (${endTimestamp}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33310
33620
  }
33311
33621
  calculatedLimit = limit;
33312
33622
  }
33313
33623
  // Case 5: Only limit - use Date.now() as reference (backward)
33314
33624
  else if (sDate === undefined && eDate === undefined && limit !== undefined) {
33315
- untilTimestamp = nowTimestamp;
33316
- sinceTimestamp = nowTimestamp - limit * step * MS_PER_MINUTE;
33625
+ sinceTimestamp = alignedNow - limit * stepMs;
33317
33626
  calculatedLimit = limit;
33318
33627
  }
33319
33628
  // Invalid: no parameters or only sDate or only eDate
@@ -33323,6 +33632,7 @@ class ExchangeInstance {
33323
33632
  `Got: sDate=${sDate}, eDate=${eDate}, limit=${limit}`);
33324
33633
  }
33325
33634
  // Try to read from cache first
33635
+ const untilTimestamp = sinceTimestamp + calculatedLimit * stepMs;
33326
33636
  const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit: calculatedLimit }, sinceTimestamp, untilTimestamp, this.exchangeName);
33327
33637
  if (cachedCandles !== null) {
33328
33638
  return cachedCandles;
@@ -33341,23 +33651,32 @@ class ExchangeInstance {
33341
33651
  allData.push(...chunkData);
33342
33652
  remaining -= chunkLimit;
33343
33653
  if (remaining > 0) {
33344
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE);
33654
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
33345
33655
  }
33346
33656
  }
33347
33657
  }
33348
33658
  else {
33349
33659
  allData = await getCandles(symbol, interval, since, calculatedLimit, isBacktest);
33350
33660
  }
33351
- // Filter candles to strictly match the requested range
33352
- const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp &&
33353
- candle.timestamp < untilTimestamp);
33354
33661
  // Apply distinct by timestamp to remove duplicates
33355
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
33356
- if (filteredData.length !== uniqueData.length) {
33357
- bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`);
33662
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
33663
+ if (allData.length !== uniqueData.length) {
33664
+ bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
33665
+ }
33666
+ // Validate adapter returned data
33667
+ if (uniqueData.length === 0) {
33668
+ throw new Error(`ExchangeInstance getRawCandles: adapter returned empty array. ` +
33669
+ `Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
33670
+ }
33671
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
33672
+ throw new Error(`ExchangeInstance getRawCandles: first candle timestamp mismatch. ` +
33673
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
33674
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
33358
33675
  }
33359
- if (uniqueData.length < calculatedLimit) {
33360
- bt.loggerService.warn(`ExchangeInstance getRawCandles: Expected ${calculatedLimit} candles, got ${uniqueData.length}`);
33676
+ if (uniqueData.length !== calculatedLimit) {
33677
+ throw new Error(`ExchangeInstance getRawCandles: candle count mismatch. ` +
33678
+ `Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
33679
+ `Adapter must return exact number of candles requested.`);
33361
33680
  }
33362
33681
  // Write to cache after successful fetch
33363
33682
  await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);
@@ -35018,6 +35337,7 @@ exports.getDefaultConfig = getDefaultConfig;
35018
35337
  exports.getExchangeSchema = getExchangeSchema;
35019
35338
  exports.getFrameSchema = getFrameSchema;
35020
35339
  exports.getMode = getMode;
35340
+ exports.getNextCandles = getNextCandles;
35021
35341
  exports.getOrderBook = getOrderBook;
35022
35342
  exports.getRawCandles = getRawCandles;
35023
35343
  exports.getRiskSchema = getRiskSchema;