backtest-kit 2.3.1 → 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
@@ -1578,73 +1578,73 @@ class PersistCandleUtils {
1578
1578
  ]));
1579
1579
  /**
1580
1580
  * Reads cached candles for a specific exchange, symbol, and interval.
1581
- * Returns candles only if cache contains exactly the requested limit.
1581
+ * Returns candles only if cache contains ALL requested candles.
1582
1582
  *
1583
- * Boundary semantics (EXCLUSIVE):
1584
- * - sinceTimestamp: candle.timestamp must be > sinceTimestamp
1585
- * - untilTimestamp: candle.timestamp + stepMs must be < untilTimestamp
1586
- * - Only fully closed candles within the exclusive range are returned
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
1587
1588
  *
1588
1589
  * @param symbol - Trading pair symbol
1589
1590
  * @param interval - Candle interval
1590
1591
  * @param exchangeName - Exchange identifier
1591
1592
  * @param limit - Number of candles requested
1592
- * @param sinceTimestamp - Exclusive start timestamp in milliseconds
1593
- * @param untilTimestamp - Exclusive end timestamp in milliseconds
1593
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
1594
+ * @param _untilTimestamp - Unused, kept for API compatibility
1594
1595
  * @returns Promise resolving to array of candles or null if cache is incomplete
1595
1596
  */
1596
- this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, untilTimestamp) => {
1597
+ this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, _untilTimestamp) => {
1597
1598
  bt.loggerService.info("PersistCandleUtils.readCandlesData", {
1598
1599
  symbol,
1599
1600
  interval,
1600
1601
  exchangeName,
1601
1602
  limit,
1602
1603
  sinceTimestamp,
1603
- untilTimestamp,
1604
1604
  });
1605
1605
  const key = `${symbol}:${interval}:${exchangeName}`;
1606
1606
  const isInitial = !this.getCandlesStorage.has(key);
1607
1607
  const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
1608
1608
  await stateStorage.waitForInit(isInitial);
1609
1609
  const stepMs = INTERVAL_MINUTES$5[interval] * MS_PER_MINUTE$2;
1610
- // Collect all cached candles within the time range using EXCLUSIVE boundaries
1610
+ // Calculate expected timestamps and fetch each candle directly
1611
1611
  const cachedCandles = [];
1612
- for await (const timestamp of stateStorage.keys()) {
1613
- const ts = Number(timestamp);
1614
- // EXCLUSIVE boundaries:
1615
- // - candle.timestamp > sinceTimestamp
1616
- // - candle.timestamp + stepMs < untilTimestamp (fully closed before untilTimestamp)
1617
- if (ts > sinceTimestamp && ts + stepMs < untilTimestamp) {
1618
- try {
1619
- const candle = await stateStorage.readValue(timestamp);
1620
- cachedCandles.push(candle);
1621
- }
1622
- catch (error) {
1623
- const message = `PersistCandleUtils.readCandlesData found invalid candle symbol=${symbol} interval=${interval} timestamp=${timestamp}`;
1624
- const payload = {
1625
- error: functoolsKit.errorData(error),
1626
- message: functoolsKit.getErrorMessage(error),
1627
- };
1628
- bt.loggerService.warn(message, payload);
1629
- console.warn(message, payload);
1630
- errorEmitter.next(error);
1631
- continue;
1632
- }
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;
1633
1634
  }
1634
1635
  }
1635
- // Sort by timestamp ascending
1636
- cachedCandles.sort((a, b) => a.timestamp - b.timestamp);
1637
1636
  return cachedCandles;
1638
1637
  };
1639
1638
  /**
1640
1639
  * Writes candles to cache with atomic file writes.
1641
1640
  * Each candle is stored as a separate JSON file named by its timestamp.
1642
1641
  *
1643
- * The candles passed to this function must already be filtered using EXCLUSIVE boundaries:
1644
- * - candle.timestamp > sinceTimestamp
1645
- * - candle.timestamp + stepMs < untilTimestamp
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
1646
  *
1647
- * @param candles - Array of candle data to cache (already filtered with exclusive boundaries)
1647
+ * @param candles - Array of candle data to cache (validated by the caller)
1648
1648
  * @param symbol - Trading pair symbol
1649
1649
  * @param interval - Candle interval
1650
1650
  * @param exchangeName - Exchange identifier
@@ -1661,8 +1661,25 @@ class PersistCandleUtils {
1661
1661
  const isInitial = !this.getCandlesStorage.has(key);
1662
1662
  const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
1663
1663
  await stateStorage.waitForInit(isInitial);
1664
- // 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
1665
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
+ }
1666
1683
  if (await functoolsKit.not(stateStorage.hasValue(String(candle.timestamp)))) {
1667
1684
  await stateStorage.writeValue(String(candle.timestamp), candle);
1668
1685
  }
@@ -1820,6 +1837,27 @@ const INTERVAL_MINUTES$4 = {
1820
1837
  "6h": 360,
1821
1838
  "8h": 480,
1822
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
+ };
1823
1861
  /**
1824
1862
  * Validates that all candles have valid OHLCV data without anomalies.
1825
1863
  * Detects incomplete candles from Binance API by checking for abnormally low prices or volumes.
@@ -1883,25 +1921,24 @@ const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => {
1883
1921
  };
1884
1922
  /**
1885
1923
  * Attempts to read candles from cache.
1886
- * Validates cache consistency (no gaps in timestamps) before returning.
1887
1924
  *
1888
- * Boundary semantics:
1889
- * - sinceTimestamp: EXCLUSIVE lower bound (candle.timestamp > sinceTimestamp)
1890
- * - untilTimestamp: EXCLUSIVE upper bound (candle.timestamp + stepMs < untilTimestamp)
1891
- * - Only fully closed candles within the exclusive range are returned
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.
1892
1928
  *
1893
1929
  * @param dto - Data transfer object containing symbol, interval, and limit
1894
- * @param sinceTimestamp - Exclusive start timestamp in milliseconds
1895
- * @param untilTimestamp - Exclusive end timestamp in milliseconds
1930
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
1931
+ * @param untilTimestamp - Unused, kept for API compatibility
1896
1932
  * @param self - Instance of ClientExchange
1897
- * @returns Cached candles array or null if cache miss or inconsistent
1933
+ * @returns Cached candles array (exactly limit) or null if cache miss
1898
1934
  */
1899
1935
  const READ_CANDLES_CACHE_FN$1 = functoolsKit.trycatch(async (dto, sinceTimestamp, untilTimestamp, self) => {
1900
- // PersistCandleAdapter.readCandlesData uses EXCLUSIVE boundaries:
1901
- // Returns candles where: timestamp > sinceTimestamp AND timestamp + stepMs < untilTimestamp
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
1902
1939
  const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, self.params.exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
1903
1940
  // Return cached data only if we have exactly the requested limit
1904
- if (cachedCandles.length === dto.limit) {
1941
+ if (cachedCandles?.length === dto.limit) {
1905
1942
  self.params.logger.debug(`ClientExchange READ_CANDLES_CACHE_FN: cache hit for symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
1906
1943
  return cachedCandles;
1907
1944
  }
@@ -1923,11 +1960,12 @@ const READ_CANDLES_CACHE_FN$1 = functoolsKit.trycatch(async (dto, sinceTimestamp
1923
1960
  /**
1924
1961
  * Writes candles to cache with error handling.
1925
1962
  *
1926
- * The candles passed to this function must already be filtered using EXCLUSIVE boundaries:
1927
- * - candle.timestamp > sinceTimestamp
1928
- * - candle.timestamp + stepMs < untilTimestamp
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
1929
1967
  *
1930
- * @param candles - Array of candle data to cache (already filtered with exclusive boundaries)
1968
+ * @param candles - Array of validated candle data to cache
1931
1969
  * @param dto - Data transfer object containing symbol, interval, and limit
1932
1970
  * @param self - Instance of ClientExchange
1933
1971
  */
@@ -2054,6 +2092,13 @@ class ClientExchange {
2054
2092
  /**
2055
2093
  * Fetches historical candles backwards from execution context time.
2056
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
+ *
2057
2102
  * @param symbol - Trading pair symbol
2058
2103
  * @param interval - Candle interval
2059
2104
  * @param limit - Number of candles to fetch
@@ -2066,11 +2111,16 @@ class ClientExchange {
2066
2111
  limit,
2067
2112
  });
2068
2113
  const step = INTERVAL_MINUTES$4[interval];
2069
- const adjust = step * limit;
2070
- if (!adjust) {
2071
- throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
2114
+ if (!step) {
2115
+ throw new Error(`ClientExchange unknown interval=${interval}`);
2072
2116
  }
2073
- 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);
2074
2124
  let allData = [];
2075
2125
  // If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
2076
2126
  if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
@@ -2083,39 +2133,34 @@ class ClientExchange {
2083
2133
  remaining -= chunkLimit;
2084
2134
  if (remaining > 0) {
2085
2135
  // Move currentSince forward by the number of candles fetched
2086
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2136
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2087
2137
  }
2088
2138
  }
2089
2139
  }
2090
2140
  else {
2091
2141
  allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
2092
2142
  }
2093
- // Filter candles to strictly match the requested range
2094
- const whenTimestamp = this.params.execution.context.when.getTime();
2095
- const sinceTimestamp = since.getTime();
2096
- const stepMs = step * MS_PER_MINUTE$1;
2097
- const filteredData = allData.filter((candle) => {
2098
- // EXCLUSIVE boundaries:
2099
- // - candle.timestamp > sinceTimestamp (exclude exact boundary)
2100
- // - candle.timestamp + stepMs < whenTimestamp (fully closed before "when")
2101
- if (candle.timestamp <= sinceTimestamp) {
2102
- return false;
2103
- }
2104
- // Check against current time (when)
2105
- // Only allow candles that have fully CLOSED before "when"
2106
- return candle.timestamp + stepMs < whenTimestamp;
2107
- });
2108
2143
  // Apply distinct by timestamp to remove duplicates
2109
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2110
- if (filteredData.length !== uniqueData.length) {
2111
- 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`;
2112
2147
  this.params.logger.warn(msg);
2113
2148
  console.warn(msg);
2114
2149
  }
2115
- if (uniqueData.length < limit) {
2116
- const msg = `ClientExchange Expected ${limit} candles, got ${uniqueData.length}`;
2117
- this.params.logger.warn(msg);
2118
- 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.`);
2119
2164
  }
2120
2165
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
2121
2166
  return uniqueData;
@@ -2124,6 +2169,13 @@ class ClientExchange {
2124
2169
  * Fetches future candles forwards from execution context time.
2125
2170
  * Used in backtest mode to get candles for signal duration.
2126
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
+ *
2127
2179
  * @param symbol - Trading pair symbol
2128
2180
  * @param interval - Candle interval
2129
2181
  * @param limit - Number of candles to fetch
@@ -2139,12 +2191,21 @@ class ClientExchange {
2139
2191
  if (!this.params.execution.context.backtest) {
2140
2192
  throw new Error(`ClientExchange getNextCandles: cannot fetch future candles in live mode`);
2141
2193
  }
2142
- const since = new Date(this.params.execution.context.when.getTime());
2143
- const now = Date.now();
2144
- // Вычисляем конечное время запроса
2145
2194
  const step = INTERVAL_MINUTES$4[interval];
2146
- const endTime = since.getTime() + limit * step * MS_PER_MINUTE$1;
2147
- // Проверяем что запрошенный период не заходит за 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()
2148
2209
  if (endTime > now) {
2149
2210
  return [];
2150
2211
  }
@@ -2160,29 +2221,34 @@ class ClientExchange {
2160
2221
  remaining -= chunkLimit;
2161
2222
  if (remaining > 0) {
2162
2223
  // Move currentSince forward by the number of candles fetched
2163
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2224
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2164
2225
  }
2165
2226
  }
2166
2227
  }
2167
2228
  else {
2168
2229
  allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
2169
2230
  }
2170
- // Filter candles to strictly match the requested range
2171
- const sinceTimestamp = since.getTime();
2172
- const stepMs = step * MS_PER_MINUTE$1;
2173
- const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
2174
- candle.timestamp + stepMs < endTime);
2175
2231
  // Apply distinct by timestamp to remove duplicates
2176
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2177
- if (filteredData.length !== uniqueData.length) {
2178
- 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`;
2179
2235
  this.params.logger.warn(msg);
2180
2236
  console.warn(msg);
2181
2237
  }
2182
- if (uniqueData.length < limit) {
2183
- const msg = `ClientExchange getNextCandles: Expected ${limit} candles, got ${uniqueData.length}`;
2184
- this.params.logger.warn(msg);
2185
- 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.`);
2186
2252
  }
2187
2253
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
2188
2254
  return uniqueData;
@@ -2257,6 +2323,12 @@ class ClientExchange {
2257
2323
  /**
2258
2324
  * Fetches raw candles with flexible date/limit parameters.
2259
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
+ *
2260
2332
  * All modes respect execution context and prevent look-ahead bias.
2261
2333
  *
2262
2334
  * Parameter combinations:
@@ -2291,9 +2363,10 @@ class ClientExchange {
2291
2363
  if (!step) {
2292
2364
  throw new Error(`ClientExchange getRawCandles: unknown interval=${interval}`);
2293
2365
  }
2366
+ const stepMs = step * MS_PER_MINUTE$1;
2294
2367
  const whenTimestamp = this.params.execution.context.when.getTime();
2368
+ const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
2295
2369
  let sinceTimestamp;
2296
- let untilTimestamp;
2297
2370
  let calculatedLimit;
2298
2371
  // Case 1: all three parameters provided
2299
2372
  if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
@@ -2303,8 +2376,8 @@ class ClientExchange {
2303
2376
  if (eDate > whenTimestamp) {
2304
2377
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2305
2378
  }
2306
- sinceTimestamp = sDate;
2307
- untilTimestamp = eDate;
2379
+ // Align sDate down to interval boundary
2380
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
2308
2381
  calculatedLimit = limit;
2309
2382
  }
2310
2383
  // Case 2: sDate + eDate (no limit) - calculate limit from date range
@@ -2315,9 +2388,10 @@ class ClientExchange {
2315
2388
  if (eDate > whenTimestamp) {
2316
2389
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2317
2390
  }
2318
- sinceTimestamp = sDate;
2319
- untilTimestamp = eDate;
2320
- 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);
2321
2395
  if (calculatedLimit <= 0) {
2322
2396
  throw new Error(`ClientExchange getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
2323
2397
  }
@@ -2327,23 +2401,24 @@ class ClientExchange {
2327
2401
  if (eDate > whenTimestamp) {
2328
2402
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2329
2403
  }
2330
- untilTimestamp = eDate;
2331
- 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;
2332
2407
  calculatedLimit = limit;
2333
2408
  }
2334
2409
  // Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
2335
2410
  else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
2336
- sinceTimestamp = sDate;
2337
- untilTimestamp = sDate + limit * step * MS_PER_MINUTE$1;
2338
- if (untilTimestamp > whenTimestamp) {
2339
- 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.`);
2340
2416
  }
2341
2417
  calculatedLimit = limit;
2342
2418
  }
2343
2419
  // Case 5: Only limit - use execution.context.when as reference (backward like getCandles)
2344
2420
  else if (sDate === undefined && eDate === undefined && limit !== undefined) {
2345
- untilTimestamp = whenTimestamp;
2346
- sinceTimestamp = whenTimestamp - limit * step * MS_PER_MINUTE$1;
2421
+ sinceTimestamp = alignedWhen - limit * stepMs;
2347
2422
  calculatedLimit = limit;
2348
2423
  }
2349
2424
  // Invalid: no parameters or only sDate or only eDate
@@ -2364,29 +2439,34 @@ class ClientExchange {
2364
2439
  allData.push(...chunkData);
2365
2440
  remaining -= chunkLimit;
2366
2441
  if (remaining > 0) {
2367
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2442
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2368
2443
  }
2369
2444
  }
2370
2445
  }
2371
2446
  else {
2372
2447
  allData = await GET_CANDLES_FN({ symbol, interval, limit: calculatedLimit }, since, this);
2373
2448
  }
2374
- // Filter candles to strictly match the requested range
2375
- // Only include candles that have fully CLOSED before untilTimestamp
2376
- const stepMs = step * MS_PER_MINUTE$1;
2377
- const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
2378
- candle.timestamp + stepMs < untilTimestamp);
2379
2449
  // Apply distinct by timestamp to remove duplicates
2380
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2381
- if (filteredData.length !== uniqueData.length) {
2382
- 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`;
2383
2453
  this.params.logger.warn(msg);
2384
2454
  console.warn(msg);
2385
2455
  }
2386
- if (uniqueData.length < calculatedLimit) {
2387
- const msg = `ClientExchange getRawCandles: Expected ${calculatedLimit} candles, got ${uniqueData.length}`;
2388
- this.params.logger.warn(msg);
2389
- 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.`);
2390
2470
  }
2391
2471
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, calculatedLimit, uniqueData);
2392
2472
  return uniqueData;
@@ -12343,13 +12423,6 @@ class WalkerSchemaService {
12343
12423
  }
12344
12424
  }
12345
12425
 
12346
- /**
12347
- * Компенсация для exclusive boundaries при фильтрации свечей.
12348
- * ClientExchange.getNextCandles использует фильтр:
12349
- * timestamp > since && timestamp + stepMs < endTime
12350
- * который исключает первую и последнюю свечи из запрошенного диапазона.
12351
- */
12352
- const CANDLE_EXCLUSIVE_BOUNDARY_OFFSET = 2;
12353
12426
  /**
12354
12427
  * Private service for backtest orchestration using async generators.
12355
12428
  *
@@ -12615,7 +12688,7 @@ class BacktestLogicPrivateService {
12615
12688
  // Запрашиваем minuteEstimatedTime + буфер свечей одним запросом
12616
12689
  const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
12617
12690
  const bufferStartTime = new Date(when.getTime() - bufferMinutes * 60 * 1000);
12618
- const totalCandles = signal.minuteEstimatedTime + GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT + CANDLE_EXCLUSIVE_BOUNDARY_OFFSET;
12691
+ const totalCandles = signal.minuteEstimatedTime + GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
12619
12692
  let candles;
12620
12693
  try {
12621
12694
  candles = await this.exchangeCoreService.getNextCandles(symbol, "1m", totalCandles, bufferStartTime, true);
@@ -33109,6 +33182,27 @@ const INTERVAL_MINUTES$1 = {
33109
33182
  "6h": 360,
33110
33183
  "8h": 480,
33111
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
+ };
33112
33206
  /**
33113
33207
  * Creates exchange instance with methods resolved once during construction.
33114
33208
  * Applies default implementations where schema methods are not provided.
@@ -33130,25 +33224,24 @@ const CREATE_EXCHANGE_INSTANCE_FN = (schema) => {
33130
33224
  };
33131
33225
  /**
33132
33226
  * Attempts to read candles from cache.
33133
- * Validates cache consistency (no gaps in timestamps) before returning.
33134
33227
  *
33135
- * Boundary semantics:
33136
- * - sinceTimestamp: EXCLUSIVE lower bound (candle.timestamp > sinceTimestamp)
33137
- * - untilTimestamp: EXCLUSIVE upper bound (candle.timestamp + stepMs < untilTimestamp)
33138
- * - Only fully closed candles within the exclusive range are returned
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.
33139
33231
  *
33140
33232
  * @param dto - Data transfer object containing symbol, interval, and limit
33141
- * @param sinceTimestamp - Exclusive start timestamp in milliseconds
33142
- * @param untilTimestamp - Exclusive end timestamp in milliseconds
33233
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
33234
+ * @param untilTimestamp - Unused, kept for API compatibility
33143
33235
  * @param exchangeName - Exchange name
33144
- * @returns Cached candles array or null if cache miss or inconsistent
33236
+ * @returns Cached candles array (exactly limit) or null if cache miss
33145
33237
  */
33146
33238
  const READ_CANDLES_CACHE_FN = functoolsKit.trycatch(async (dto, sinceTimestamp, untilTimestamp, exchangeName) => {
33147
- // PersistCandleAdapter.readCandlesData uses EXCLUSIVE boundaries:
33148
- // Returns candles where: timestamp > sinceTimestamp AND timestamp + stepMs < untilTimestamp
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
33149
33242
  const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
33150
33243
  // Return cached data only if we have exactly the requested limit
33151
- if (cachedCandles.length === dto.limit) {
33244
+ if (cachedCandles?.length === dto.limit) {
33152
33245
  bt.loggerService.debug(`ExchangeInstance READ_CANDLES_CACHE_FN: cache hit for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
33153
33246
  return cachedCandles;
33154
33247
  }
@@ -33170,11 +33263,12 @@ const READ_CANDLES_CACHE_FN = functoolsKit.trycatch(async (dto, sinceTimestamp,
33170
33263
  /**
33171
33264
  * Writes candles to cache with error handling.
33172
33265
  *
33173
- * The candles passed to this function must already be filtered using EXCLUSIVE boundaries:
33174
- * - candle.timestamp > sinceTimestamp
33175
- * - candle.timestamp + stepMs < untilTimestamp
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
33176
33270
  *
33177
- * @param candles - Array of candle data to cache (already filtered with exclusive boundaries)
33271
+ * @param candles - Array of validated candle data to cache
33178
33272
  * @param dto - Data transfer object containing symbol, interval, and limit
33179
33273
  * @param exchangeName - Exchange name
33180
33274
  */
@@ -33245,14 +33339,18 @@ class ExchangeInstance {
33245
33339
  });
33246
33340
  const getCandles = this._methods.getCandles;
33247
33341
  const step = INTERVAL_MINUTES$1[interval];
33248
- const adjust = step * limit;
33249
- if (!adjust) {
33250
- throw new Error(`ExchangeInstance unknown time adjust for interval=${interval}`);
33342
+ if (!step) {
33343
+ throw new Error(`ExchangeInstance unknown interval=${interval}`);
33251
33344
  }
33345
+ const stepMs = step * MS_PER_MINUTE;
33346
+ // Align when down to interval boundary
33252
33347
  const when = await GET_TIMESTAMP_FN();
33253
- const since = new Date(when.getTime() - adjust * MS_PER_MINUTE);
33254
- const sinceTimestamp = since.getTime();
33255
- const untilTimestamp = sinceTimestamp + limit * step * MS_PER_MINUTE;
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;
33256
33354
  // Try to read from cache first
33257
33355
  const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
33258
33356
  if (cachedCandles !== null) {
@@ -33271,7 +33369,7 @@ class ExchangeInstance {
33271
33369
  remaining -= chunkLimit;
33272
33370
  if (remaining > 0) {
33273
33371
  // Move currentSince forward by the number of candles fetched
33274
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE);
33372
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
33275
33373
  }
33276
33374
  }
33277
33375
  }
@@ -33279,27 +33377,25 @@ class ExchangeInstance {
33279
33377
  const isBacktest = await GET_BACKTEST_FN();
33280
33378
  allData = await getCandles(symbol, interval, since, limit, isBacktest);
33281
33379
  }
33282
- // Filter candles to strictly match the requested range
33283
- const whenTimestamp = when.getTime();
33284
- const stepMs = step * MS_PER_MINUTE;
33285
- const filteredData = allData.filter((candle) => {
33286
- // EXCLUSIVE boundaries:
33287
- // - candle.timestamp > sinceTimestamp (exclude exact boundary)
33288
- // - candle.timestamp + stepMs < whenTimestamp (fully closed before "when")
33289
- if (candle.timestamp <= sinceTimestamp) {
33290
- return false;
33291
- }
33292
- // Check against current time (when)
33293
- // Only allow candles that have fully CLOSED before "when"
33294
- return candle.timestamp + stepMs < whenTimestamp;
33295
- });
33296
33380
  // Apply distinct by timestamp to remove duplicates
33297
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
33298
- if (filteredData.length !== uniqueData.length) {
33299
- 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`);
33384
+ }
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}.`);
33300
33389
  }
33301
- if (uniqueData.length < limit) {
33302
- bt.loggerService.warn(`ExchangeInstance Expected ${limit} candles, got ${uniqueData.length}`);
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.`);
33303
33399
  }
33304
33400
  // Write to cache after successful fetch
33305
33401
  await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
@@ -33470,10 +33566,11 @@ class ExchangeInstance {
33470
33566
  if (!step) {
33471
33567
  throw new Error(`ExchangeInstance getRawCandles: unknown interval=${interval}`);
33472
33568
  }
33569
+ const stepMs = step * MS_PER_MINUTE;
33473
33570
  const when = await GET_TIMESTAMP_FN();
33474
33571
  const nowTimestamp = when.getTime();
33572
+ const alignedNow = ALIGN_TO_INTERVAL_FN(nowTimestamp, step);
33475
33573
  let sinceTimestamp;
33476
- let untilTimestamp;
33477
33574
  let calculatedLimit;
33478
33575
  // Case 1: all three parameters provided
33479
33576
  if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
@@ -33483,8 +33580,8 @@ class ExchangeInstance {
33483
33580
  if (eDate > nowTimestamp) {
33484
33581
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33485
33582
  }
33486
- sinceTimestamp = sDate;
33487
- untilTimestamp = eDate;
33583
+ // Align sDate down to interval boundary
33584
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
33488
33585
  calculatedLimit = limit;
33489
33586
  }
33490
33587
  // Case 2: sDate + eDate (no limit) - calculate limit from date range
@@ -33495,9 +33592,10 @@ class ExchangeInstance {
33495
33592
  if (eDate > nowTimestamp) {
33496
33593
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33497
33594
  }
33498
- sinceTimestamp = sDate;
33499
- untilTimestamp = eDate;
33500
- 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);
33501
33599
  if (calculatedLimit <= 0) {
33502
33600
  throw new Error(`ExchangeInstance getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
33503
33601
  }
@@ -33507,23 +33605,24 @@ class ExchangeInstance {
33507
33605
  if (eDate > nowTimestamp) {
33508
33606
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33509
33607
  }
33510
- untilTimestamp = eDate;
33511
- 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;
33512
33611
  calculatedLimit = limit;
33513
33612
  }
33514
33613
  // Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
33515
33614
  else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
33516
- sinceTimestamp = sDate;
33517
- untilTimestamp = sDate + limit * step * MS_PER_MINUTE;
33518
- if (untilTimestamp > nowTimestamp) {
33519
- 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.`);
33520
33620
  }
33521
33621
  calculatedLimit = limit;
33522
33622
  }
33523
33623
  // Case 5: Only limit - use Date.now() as reference (backward)
33524
33624
  else if (sDate === undefined && eDate === undefined && limit !== undefined) {
33525
- untilTimestamp = nowTimestamp;
33526
- sinceTimestamp = nowTimestamp - limit * step * MS_PER_MINUTE;
33625
+ sinceTimestamp = alignedNow - limit * stepMs;
33527
33626
  calculatedLimit = limit;
33528
33627
  }
33529
33628
  // Invalid: no parameters or only sDate or only eDate
@@ -33533,6 +33632,7 @@ class ExchangeInstance {
33533
33632
  `Got: sDate=${sDate}, eDate=${eDate}, limit=${limit}`);
33534
33633
  }
33535
33634
  // Try to read from cache first
33635
+ const untilTimestamp = sinceTimestamp + calculatedLimit * stepMs;
33536
33636
  const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit: calculatedLimit }, sinceTimestamp, untilTimestamp, this.exchangeName);
33537
33637
  if (cachedCandles !== null) {
33538
33638
  return cachedCandles;
@@ -33551,25 +33651,32 @@ class ExchangeInstance {
33551
33651
  allData.push(...chunkData);
33552
33652
  remaining -= chunkLimit;
33553
33653
  if (remaining > 0) {
33554
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE);
33654
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
33555
33655
  }
33556
33656
  }
33557
33657
  }
33558
33658
  else {
33559
33659
  allData = await getCandles(symbol, interval, since, calculatedLimit, isBacktest);
33560
33660
  }
33561
- // Filter candles to strictly match the requested range
33562
- // Only include candles that have fully CLOSED before untilTimestamp
33563
- const stepMs = step * MS_PER_MINUTE;
33564
- const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
33565
- candle.timestamp + stepMs < untilTimestamp);
33566
33661
  // Apply distinct by timestamp to remove duplicates
33567
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
33568
- if (filteredData.length !== uniqueData.length) {
33569
- bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`);
33570
- }
33571
- if (uniqueData.length < calculatedLimit) {
33572
- bt.loggerService.warn(`ExchangeInstance getRawCandles: Expected ${calculatedLimit} candles, got ${uniqueData.length}`);
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.`);
33675
+ }
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.`);
33573
33680
  }
33574
33681
  // Write to cache after successful fetch
33575
33682
  await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);