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.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createActivator } from 'di-kit';
2
2
  import { scoped } from 'di-scoped';
3
- import { Subject, makeExtendable, singleshot, getErrorMessage, memoize, errorData, not, trycatch, retry, queued, sleep, randomString, str, isObject, ToolRegistry, typo, and, resolveDocuments, timeout, TIMEOUT_SYMBOL as TIMEOUT_SYMBOL$1, compose, singlerun } from 'functools-kit';
3
+ import { Subject, makeExtendable, singleshot, getErrorMessage, memoize, not, errorData, trycatch, retry, queued, sleep, randomString, str, isObject, ToolRegistry, typo, and, resolveDocuments, timeout, TIMEOUT_SYMBOL as TIMEOUT_SYMBOL$1, compose, singlerun } from 'functools-kit';
4
4
  import * as fs from 'fs/promises';
5
5
  import fs__default from 'fs/promises';
6
6
  import path, { join, dirname } from 'path';
@@ -1558,73 +1558,73 @@ class PersistCandleUtils {
1558
1558
  ]));
1559
1559
  /**
1560
1560
  * Reads cached candles for a specific exchange, symbol, and interval.
1561
- * Returns candles only if cache contains exactly the requested limit.
1561
+ * Returns candles only if cache contains ALL requested candles.
1562
1562
  *
1563
- * Boundary semantics (EXCLUSIVE):
1564
- * - sinceTimestamp: candle.timestamp must be > sinceTimestamp
1565
- * - untilTimestamp: candle.timestamp + stepMs must be < untilTimestamp
1566
- * - Only fully closed candles within the exclusive range are returned
1563
+ * Algorithm (matches ClientExchange.ts logic):
1564
+ * 1. Calculate expected timestamps: sinceTimestamp, sinceTimestamp + stepMs, ..., sinceTimestamp + (limit-1) * stepMs
1565
+ * 2. Try to read each expected candle by timestamp key
1566
+ * 3. If ANY candle is missing, return null (cache miss)
1567
+ * 4. If all candles found, return them in order
1567
1568
  *
1568
1569
  * @param symbol - Trading pair symbol
1569
1570
  * @param interval - Candle interval
1570
1571
  * @param exchangeName - Exchange identifier
1571
1572
  * @param limit - Number of candles requested
1572
- * @param sinceTimestamp - Exclusive start timestamp in milliseconds
1573
- * @param untilTimestamp - Exclusive end timestamp in milliseconds
1573
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
1574
+ * @param _untilTimestamp - Unused, kept for API compatibility
1574
1575
  * @returns Promise resolving to array of candles or null if cache is incomplete
1575
1576
  */
1576
- this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, untilTimestamp) => {
1577
+ this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, _untilTimestamp) => {
1577
1578
  bt.loggerService.info("PersistCandleUtils.readCandlesData", {
1578
1579
  symbol,
1579
1580
  interval,
1580
1581
  exchangeName,
1581
1582
  limit,
1582
1583
  sinceTimestamp,
1583
- untilTimestamp,
1584
1584
  });
1585
1585
  const key = `${symbol}:${interval}:${exchangeName}`;
1586
1586
  const isInitial = !this.getCandlesStorage.has(key);
1587
1587
  const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
1588
1588
  await stateStorage.waitForInit(isInitial);
1589
1589
  const stepMs = INTERVAL_MINUTES$5[interval] * MS_PER_MINUTE$2;
1590
- // Collect all cached candles within the time range using EXCLUSIVE boundaries
1590
+ // Calculate expected timestamps and fetch each candle directly
1591
1591
  const cachedCandles = [];
1592
- for await (const timestamp of stateStorage.keys()) {
1593
- const ts = Number(timestamp);
1594
- // EXCLUSIVE boundaries:
1595
- // - candle.timestamp > sinceTimestamp
1596
- // - candle.timestamp + stepMs < untilTimestamp (fully closed before untilTimestamp)
1597
- if (ts > sinceTimestamp && ts + stepMs < untilTimestamp) {
1598
- try {
1599
- const candle = await stateStorage.readValue(timestamp);
1600
- cachedCandles.push(candle);
1601
- }
1602
- catch (error) {
1603
- const message = `PersistCandleUtils.readCandlesData found invalid candle symbol=${symbol} interval=${interval} timestamp=${timestamp}`;
1604
- const payload = {
1605
- error: errorData(error),
1606
- message: getErrorMessage(error),
1607
- };
1608
- bt.loggerService.warn(message, payload);
1609
- console.warn(message, payload);
1610
- errorEmitter.next(error);
1611
- continue;
1612
- }
1592
+ for (let i = 0; i < limit; i++) {
1593
+ const expectedTimestamp = sinceTimestamp + i * stepMs;
1594
+ const timestampKey = String(expectedTimestamp);
1595
+ if (await not(stateStorage.hasValue(timestampKey))) {
1596
+ // Cache miss - candle not found
1597
+ return null;
1598
+ }
1599
+ try {
1600
+ const candle = await stateStorage.readValue(timestampKey);
1601
+ cachedCandles.push(candle);
1602
+ }
1603
+ catch (error) {
1604
+ // Invalid candle in cache - treat as cache miss
1605
+ const message = `PersistCandleUtils.readCandlesData found invalid candle symbol=${symbol} interval=${interval} timestamp=${expectedTimestamp}`;
1606
+ const payload = {
1607
+ error: errorData(error),
1608
+ message: getErrorMessage(error),
1609
+ };
1610
+ bt.loggerService.warn(message, payload);
1611
+ console.warn(message, payload);
1612
+ errorEmitter.next(error);
1613
+ return null;
1613
1614
  }
1614
1615
  }
1615
- // Sort by timestamp ascending
1616
- cachedCandles.sort((a, b) => a.timestamp - b.timestamp);
1617
1616
  return cachedCandles;
1618
1617
  };
1619
1618
  /**
1620
1619
  * Writes candles to cache with atomic file writes.
1621
1620
  * Each candle is stored as a separate JSON file named by its timestamp.
1622
1621
  *
1623
- * The candles passed to this function must already be filtered using EXCLUSIVE boundaries:
1624
- * - candle.timestamp > sinceTimestamp
1625
- * - candle.timestamp + stepMs < untilTimestamp
1622
+ * The candles passed to this function should be validated candles from the adapter:
1623
+ * - First candle.timestamp equals aligned sinceTimestamp (openTime)
1624
+ * - Exact number of candles as requested
1625
+ * - All candles are fully closed (timestamp + stepMs < untilTimestamp)
1626
1626
  *
1627
- * @param candles - Array of candle data to cache (already filtered with exclusive boundaries)
1627
+ * @param candles - Array of candle data to cache (validated by the caller)
1628
1628
  * @param symbol - Trading pair symbol
1629
1629
  * @param interval - Candle interval
1630
1630
  * @param exchangeName - Exchange identifier
@@ -1641,8 +1641,25 @@ class PersistCandleUtils {
1641
1641
  const isInitial = !this.getCandlesStorage.has(key);
1642
1642
  const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
1643
1643
  await stateStorage.waitForInit(isInitial);
1644
- // Write each candle as a separate file
1644
+ // Calculate step in milliseconds to determine candle close time
1645
+ const stepMs = INTERVAL_MINUTES$5[interval] * MS_PER_MINUTE$2;
1646
+ const now = Date.now();
1647
+ // Write each candle as a separate file, skipping incomplete candles
1645
1648
  for (const candle of candles) {
1649
+ // Skip incomplete candles: candle is complete when closeTime <= now
1650
+ // closeTime = timestamp + stepMs
1651
+ const candleCloseTime = candle.timestamp + stepMs;
1652
+ if (candleCloseTime > now) {
1653
+ bt.loggerService.debug("PersistCandleUtils.writeCandlesData: skipping incomplete candle", {
1654
+ symbol,
1655
+ interval,
1656
+ exchangeName,
1657
+ timestamp: candle.timestamp,
1658
+ closeTime: candleCloseTime,
1659
+ now,
1660
+ });
1661
+ continue;
1662
+ }
1646
1663
  if (await not(stateStorage.hasValue(String(candle.timestamp)))) {
1647
1664
  await stateStorage.writeValue(String(candle.timestamp), candle);
1648
1665
  }
@@ -1800,6 +1817,27 @@ const INTERVAL_MINUTES$4 = {
1800
1817
  "6h": 360,
1801
1818
  "8h": 480,
1802
1819
  };
1820
+ /**
1821
+ * Aligns timestamp down to the nearest interval boundary.
1822
+ * For example, for 15m interval: 00:17 -> 00:15, 00:44 -> 00:30
1823
+ *
1824
+ * Candle timestamp convention:
1825
+ * - Candle timestamp = openTime (when candle opens)
1826
+ * - Candle with timestamp 00:00 covers period [00:00, 00:15) for 15m interval
1827
+ *
1828
+ * Adapter contract:
1829
+ * - Adapter must return candles with timestamp = openTime
1830
+ * - First returned candle.timestamp must equal aligned since
1831
+ * - Adapter must return exactly `limit` candles
1832
+ *
1833
+ * @param timestamp - Timestamp in milliseconds
1834
+ * @param intervalMinutes - Interval in minutes
1835
+ * @returns Aligned timestamp rounded down to interval boundary
1836
+ */
1837
+ const ALIGN_TO_INTERVAL_FN$1 = (timestamp, intervalMinutes) => {
1838
+ const intervalMs = intervalMinutes * MS_PER_MINUTE$1;
1839
+ return Math.floor(timestamp / intervalMs) * intervalMs;
1840
+ };
1803
1841
  /**
1804
1842
  * Validates that all candles have valid OHLCV data without anomalies.
1805
1843
  * Detects incomplete candles from Binance API by checking for abnormally low prices or volumes.
@@ -1863,25 +1901,24 @@ const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => {
1863
1901
  };
1864
1902
  /**
1865
1903
  * Attempts to read candles from cache.
1866
- * Validates cache consistency (no gaps in timestamps) before returning.
1867
1904
  *
1868
- * Boundary semantics:
1869
- * - sinceTimestamp: EXCLUSIVE lower bound (candle.timestamp > sinceTimestamp)
1870
- * - untilTimestamp: EXCLUSIVE upper bound (candle.timestamp + stepMs < untilTimestamp)
1871
- * - Only fully closed candles within the exclusive range are returned
1905
+ * Cache lookup calculates expected timestamps:
1906
+ * sinceTimestamp + i * stepMs for i = 0..limit-1
1907
+ * Returns all candles if found, null if any missing.
1872
1908
  *
1873
1909
  * @param dto - Data transfer object containing symbol, interval, and limit
1874
- * @param sinceTimestamp - Exclusive start timestamp in milliseconds
1875
- * @param untilTimestamp - Exclusive end timestamp in milliseconds
1910
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
1911
+ * @param untilTimestamp - Unused, kept for API compatibility
1876
1912
  * @param self - Instance of ClientExchange
1877
- * @returns Cached candles array or null if cache miss or inconsistent
1913
+ * @returns Cached candles array (exactly limit) or null if cache miss
1878
1914
  */
1879
1915
  const READ_CANDLES_CACHE_FN$1 = trycatch(async (dto, sinceTimestamp, untilTimestamp, self) => {
1880
- // PersistCandleAdapter.readCandlesData uses EXCLUSIVE boundaries:
1881
- // Returns candles where: timestamp > sinceTimestamp AND timestamp + stepMs < untilTimestamp
1916
+ // PersistCandleAdapter.readCandlesData calculates expected timestamps:
1917
+ // sinceTimestamp + i * stepMs for i = 0..limit-1
1918
+ // Returns all candles if found, null if any missing
1882
1919
  const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, self.params.exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
1883
1920
  // Return cached data only if we have exactly the requested limit
1884
- if (cachedCandles.length === dto.limit) {
1921
+ if (cachedCandles?.length === dto.limit) {
1885
1922
  self.params.logger.debug(`ClientExchange READ_CANDLES_CACHE_FN: cache hit for symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
1886
1923
  return cachedCandles;
1887
1924
  }
@@ -1903,11 +1940,12 @@ const READ_CANDLES_CACHE_FN$1 = trycatch(async (dto, sinceTimestamp, untilTimest
1903
1940
  /**
1904
1941
  * Writes candles to cache with error handling.
1905
1942
  *
1906
- * The candles passed to this function must already be filtered using EXCLUSIVE boundaries:
1907
- * - candle.timestamp > sinceTimestamp
1908
- * - candle.timestamp + stepMs < untilTimestamp
1943
+ * The candles passed to this function should be validated:
1944
+ * - First candle.timestamp equals aligned sinceTimestamp (openTime)
1945
+ * - Exact number of candles as requested (limit)
1946
+ * - Sequential timestamps: sinceTimestamp + i * stepMs
1909
1947
  *
1910
- * @param candles - Array of candle data to cache (already filtered with exclusive boundaries)
1948
+ * @param candles - Array of validated candle data to cache
1911
1949
  * @param dto - Data transfer object containing symbol, interval, and limit
1912
1950
  * @param self - Instance of ClientExchange
1913
1951
  */
@@ -2034,6 +2072,13 @@ class ClientExchange {
2034
2072
  /**
2035
2073
  * Fetches historical candles backwards from execution context time.
2036
2074
  *
2075
+ * Algorithm:
2076
+ * 1. Align when down to interval boundary (e.g., 00:17 -> 00:15 for 15m)
2077
+ * 2. Calculate since = alignedWhen - limit * step
2078
+ * 3. Fetch candles starting from since
2079
+ * 4. Validate first candle timestamp matches since (adapter must return inclusive data)
2080
+ * 5. Slice to limit
2081
+ *
2037
2082
  * @param symbol - Trading pair symbol
2038
2083
  * @param interval - Candle interval
2039
2084
  * @param limit - Number of candles to fetch
@@ -2046,11 +2091,16 @@ class ClientExchange {
2046
2091
  limit,
2047
2092
  });
2048
2093
  const step = INTERVAL_MINUTES$4[interval];
2049
- const adjust = step * limit;
2050
- if (!adjust) {
2051
- throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
2094
+ if (!step) {
2095
+ throw new Error(`ClientExchange unknown interval=${interval}`);
2052
2096
  }
2053
- const since = new Date(this.params.execution.context.when.getTime() - adjust * MS_PER_MINUTE$1);
2097
+ const stepMs = step * MS_PER_MINUTE$1;
2098
+ // Align when down to interval boundary
2099
+ const whenTimestamp = this.params.execution.context.when.getTime();
2100
+ const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
2101
+ // Calculate since: go back limit candles from aligned when
2102
+ const sinceTimestamp = alignedWhen - limit * stepMs;
2103
+ const since = new Date(sinceTimestamp);
2054
2104
  let allData = [];
2055
2105
  // If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
2056
2106
  if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
@@ -2063,39 +2113,34 @@ class ClientExchange {
2063
2113
  remaining -= chunkLimit;
2064
2114
  if (remaining > 0) {
2065
2115
  // Move currentSince forward by the number of candles fetched
2066
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2116
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2067
2117
  }
2068
2118
  }
2069
2119
  }
2070
2120
  else {
2071
2121
  allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
2072
2122
  }
2073
- // Filter candles to strictly match the requested range
2074
- const whenTimestamp = this.params.execution.context.when.getTime();
2075
- const sinceTimestamp = since.getTime();
2076
- const stepMs = step * MS_PER_MINUTE$1;
2077
- const filteredData = allData.filter((candle) => {
2078
- // EXCLUSIVE boundaries:
2079
- // - candle.timestamp > sinceTimestamp (exclude exact boundary)
2080
- // - candle.timestamp + stepMs < whenTimestamp (fully closed before "when")
2081
- if (candle.timestamp <= sinceTimestamp) {
2082
- return false;
2083
- }
2084
- // Check against current time (when)
2085
- // Only allow candles that have fully CLOSED before "when"
2086
- return candle.timestamp + stepMs < whenTimestamp;
2087
- });
2088
2123
  // Apply distinct by timestamp to remove duplicates
2089
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2090
- if (filteredData.length !== uniqueData.length) {
2091
- const msg = `ClientExchange Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
2124
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
2125
+ if (allData.length !== uniqueData.length) {
2126
+ const msg = `ClientExchange getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
2092
2127
  this.params.logger.warn(msg);
2093
2128
  console.warn(msg);
2094
2129
  }
2095
- if (uniqueData.length < limit) {
2096
- const msg = `ClientExchange Expected ${limit} candles, got ${uniqueData.length}`;
2097
- this.params.logger.warn(msg);
2098
- console.warn(msg);
2130
+ // Validate adapter returned data
2131
+ if (uniqueData.length === 0) {
2132
+ throw new Error(`ClientExchange getCandles: adapter returned empty array. ` +
2133
+ `Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
2134
+ }
2135
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
2136
+ throw new Error(`ClientExchange getCandles: first candle timestamp mismatch. ` +
2137
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
2138
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
2139
+ }
2140
+ if (uniqueData.length !== limit) {
2141
+ throw new Error(`ClientExchange getCandles: candle count mismatch. ` +
2142
+ `Expected ${limit} candles, got ${uniqueData.length}. ` +
2143
+ `Adapter must return exact number of candles requested.`);
2099
2144
  }
2100
2145
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
2101
2146
  return uniqueData;
@@ -2104,6 +2149,13 @@ class ClientExchange {
2104
2149
  * Fetches future candles forwards from execution context time.
2105
2150
  * Used in backtest mode to get candles for signal duration.
2106
2151
  *
2152
+ * Algorithm:
2153
+ * 1. Align when down to interval boundary (e.g., 00:17 -> 00:15 for 15m)
2154
+ * 2. since = alignedWhen (start from aligned when)
2155
+ * 3. Fetch candles starting from since
2156
+ * 4. Validate first candle timestamp matches since (adapter must return inclusive data)
2157
+ * 5. Slice to limit
2158
+ *
2107
2159
  * @param symbol - Trading pair symbol
2108
2160
  * @param interval - Candle interval
2109
2161
  * @param limit - Number of candles to fetch
@@ -2119,12 +2171,21 @@ class ClientExchange {
2119
2171
  if (!this.params.execution.context.backtest) {
2120
2172
  throw new Error(`ClientExchange getNextCandles: cannot fetch future candles in live mode`);
2121
2173
  }
2122
- const since = new Date(this.params.execution.context.when.getTime());
2123
- const now = Date.now();
2124
- // Вычисляем конечное время запроса
2125
2174
  const step = INTERVAL_MINUTES$4[interval];
2126
- const endTime = since.getTime() + limit * step * MS_PER_MINUTE$1;
2127
- // Проверяем что запрошенный период не заходит за Date.now()
2175
+ if (!step) {
2176
+ throw new Error(`ClientExchange getNextCandles: unknown interval=${interval}`);
2177
+ }
2178
+ const stepMs = step * MS_PER_MINUTE$1;
2179
+ const now = Date.now();
2180
+ // Align when down to interval boundary
2181
+ const whenTimestamp = this.params.execution.context.when.getTime();
2182
+ const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
2183
+ // since = alignedWhen (start from aligned when, going forward)
2184
+ const sinceTimestamp = alignedWhen;
2185
+ const since = new Date(sinceTimestamp);
2186
+ // Calculate end time for Date.now() check
2187
+ const endTime = sinceTimestamp + limit * stepMs;
2188
+ // Check that requested period does not exceed Date.now()
2128
2189
  if (endTime > now) {
2129
2190
  return [];
2130
2191
  }
@@ -2140,29 +2201,34 @@ class ClientExchange {
2140
2201
  remaining -= chunkLimit;
2141
2202
  if (remaining > 0) {
2142
2203
  // Move currentSince forward by the number of candles fetched
2143
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2204
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2144
2205
  }
2145
2206
  }
2146
2207
  }
2147
2208
  else {
2148
2209
  allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
2149
2210
  }
2150
- // Filter candles to strictly match the requested range
2151
- const sinceTimestamp = since.getTime();
2152
- const stepMs = step * MS_PER_MINUTE$1;
2153
- const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
2154
- candle.timestamp + stepMs < endTime);
2155
2211
  // Apply distinct by timestamp to remove duplicates
2156
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2157
- if (filteredData.length !== uniqueData.length) {
2158
- const msg = `ClientExchange getNextCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
2212
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
2213
+ if (allData.length !== uniqueData.length) {
2214
+ const msg = `ClientExchange getNextCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
2159
2215
  this.params.logger.warn(msg);
2160
2216
  console.warn(msg);
2161
2217
  }
2162
- if (uniqueData.length < limit) {
2163
- const msg = `ClientExchange getNextCandles: Expected ${limit} candles, got ${uniqueData.length}`;
2164
- this.params.logger.warn(msg);
2165
- console.warn(msg);
2218
+ // Validate adapter returned data
2219
+ if (uniqueData.length === 0) {
2220
+ throw new Error(`ClientExchange getNextCandles: adapter returned empty array. ` +
2221
+ `Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
2222
+ }
2223
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
2224
+ throw new Error(`ClientExchange getNextCandles: first candle timestamp mismatch. ` +
2225
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
2226
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
2227
+ }
2228
+ if (uniqueData.length !== limit) {
2229
+ throw new Error(`ClientExchange getNextCandles: candle count mismatch. ` +
2230
+ `Expected ${limit} candles, got ${uniqueData.length}. ` +
2231
+ `Adapter must return exact number of candles requested.`);
2166
2232
  }
2167
2233
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
2168
2234
  return uniqueData;
@@ -2237,6 +2303,12 @@ class ClientExchange {
2237
2303
  /**
2238
2304
  * Fetches raw candles with flexible date/limit parameters.
2239
2305
  *
2306
+ * Algorithm:
2307
+ * 1. Align all timestamps down to interval boundary
2308
+ * 2. Fetch candles starting from aligned since
2309
+ * 3. Validate first candle timestamp matches aligned since (adapter must return inclusive data)
2310
+ * 4. Slice to limit
2311
+ *
2240
2312
  * All modes respect execution context and prevent look-ahead bias.
2241
2313
  *
2242
2314
  * Parameter combinations:
@@ -2271,9 +2343,10 @@ class ClientExchange {
2271
2343
  if (!step) {
2272
2344
  throw new Error(`ClientExchange getRawCandles: unknown interval=${interval}`);
2273
2345
  }
2346
+ const stepMs = step * MS_PER_MINUTE$1;
2274
2347
  const whenTimestamp = this.params.execution.context.when.getTime();
2348
+ const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
2275
2349
  let sinceTimestamp;
2276
- let untilTimestamp;
2277
2350
  let calculatedLimit;
2278
2351
  // Case 1: all three parameters provided
2279
2352
  if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
@@ -2283,8 +2356,8 @@ class ClientExchange {
2283
2356
  if (eDate > whenTimestamp) {
2284
2357
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2285
2358
  }
2286
- sinceTimestamp = sDate;
2287
- untilTimestamp = eDate;
2359
+ // Align sDate down to interval boundary
2360
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
2288
2361
  calculatedLimit = limit;
2289
2362
  }
2290
2363
  // Case 2: sDate + eDate (no limit) - calculate limit from date range
@@ -2295,9 +2368,10 @@ class ClientExchange {
2295
2368
  if (eDate > whenTimestamp) {
2296
2369
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2297
2370
  }
2298
- sinceTimestamp = sDate;
2299
- untilTimestamp = eDate;
2300
- calculatedLimit = Math.ceil((eDate - sDate) / (step * MS_PER_MINUTE$1));
2371
+ // Align sDate down to interval boundary
2372
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
2373
+ const alignedEDate = ALIGN_TO_INTERVAL_FN$1(eDate, step);
2374
+ calculatedLimit = Math.ceil((alignedEDate - sinceTimestamp) / stepMs);
2301
2375
  if (calculatedLimit <= 0) {
2302
2376
  throw new Error(`ClientExchange getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
2303
2377
  }
@@ -2307,23 +2381,24 @@ class ClientExchange {
2307
2381
  if (eDate > whenTimestamp) {
2308
2382
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2309
2383
  }
2310
- untilTimestamp = eDate;
2311
- sinceTimestamp = eDate - limit * step * MS_PER_MINUTE$1;
2384
+ // Align eDate down and calculate sinceTimestamp
2385
+ const alignedEDate = ALIGN_TO_INTERVAL_FN$1(eDate, step);
2386
+ sinceTimestamp = alignedEDate - limit * stepMs;
2312
2387
  calculatedLimit = limit;
2313
2388
  }
2314
2389
  // Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
2315
2390
  else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
2316
- sinceTimestamp = sDate;
2317
- untilTimestamp = sDate + limit * step * MS_PER_MINUTE$1;
2318
- if (untilTimestamp > whenTimestamp) {
2319
- throw new Error(`ClientExchange getRawCandles: calculated endTimestamp (${untilTimestamp}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2391
+ // Align sDate down to interval boundary
2392
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
2393
+ const endTimestamp = sinceTimestamp + limit * stepMs;
2394
+ if (endTimestamp > whenTimestamp) {
2395
+ throw new Error(`ClientExchange getRawCandles: calculated endTimestamp (${endTimestamp}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2320
2396
  }
2321
2397
  calculatedLimit = limit;
2322
2398
  }
2323
2399
  // Case 5: Only limit - use execution.context.when as reference (backward like getCandles)
2324
2400
  else if (sDate === undefined && eDate === undefined && limit !== undefined) {
2325
- untilTimestamp = whenTimestamp;
2326
- sinceTimestamp = whenTimestamp - limit * step * MS_PER_MINUTE$1;
2401
+ sinceTimestamp = alignedWhen - limit * stepMs;
2327
2402
  calculatedLimit = limit;
2328
2403
  }
2329
2404
  // Invalid: no parameters or only sDate or only eDate
@@ -2344,29 +2419,34 @@ class ClientExchange {
2344
2419
  allData.push(...chunkData);
2345
2420
  remaining -= chunkLimit;
2346
2421
  if (remaining > 0) {
2347
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2422
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2348
2423
  }
2349
2424
  }
2350
2425
  }
2351
2426
  else {
2352
2427
  allData = await GET_CANDLES_FN({ symbol, interval, limit: calculatedLimit }, since, this);
2353
2428
  }
2354
- // Filter candles to strictly match the requested range
2355
- // Only include candles that have fully CLOSED before untilTimestamp
2356
- const stepMs = step * MS_PER_MINUTE$1;
2357
- const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
2358
- candle.timestamp + stepMs < untilTimestamp);
2359
2429
  // Apply distinct by timestamp to remove duplicates
2360
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2361
- if (filteredData.length !== uniqueData.length) {
2362
- const msg = `ClientExchange getRawCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
2430
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
2431
+ if (allData.length !== uniqueData.length) {
2432
+ const msg = `ClientExchange getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
2363
2433
  this.params.logger.warn(msg);
2364
2434
  console.warn(msg);
2365
2435
  }
2366
- if (uniqueData.length < calculatedLimit) {
2367
- const msg = `ClientExchange getRawCandles: Expected ${calculatedLimit} candles, got ${uniqueData.length}`;
2368
- this.params.logger.warn(msg);
2369
- console.warn(msg);
2436
+ // Validate adapter returned data
2437
+ if (uniqueData.length === 0) {
2438
+ throw new Error(`ClientExchange getRawCandles: adapter returned empty array. ` +
2439
+ `Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
2440
+ }
2441
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
2442
+ throw new Error(`ClientExchange getRawCandles: first candle timestamp mismatch. ` +
2443
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
2444
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
2445
+ }
2446
+ if (uniqueData.length !== calculatedLimit) {
2447
+ throw new Error(`ClientExchange getRawCandles: candle count mismatch. ` +
2448
+ `Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
2449
+ `Adapter must return exact number of candles requested.`);
2370
2450
  }
2371
2451
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, calculatedLimit, uniqueData);
2372
2452
  return uniqueData;
@@ -12323,13 +12403,6 @@ class WalkerSchemaService {
12323
12403
  }
12324
12404
  }
12325
12405
 
12326
- /**
12327
- * Компенсация для exclusive boundaries при фильтрации свечей.
12328
- * ClientExchange.getNextCandles использует фильтр:
12329
- * timestamp > since && timestamp + stepMs < endTime
12330
- * который исключает первую и последнюю свечи из запрошенного диапазона.
12331
- */
12332
- const CANDLE_EXCLUSIVE_BOUNDARY_OFFSET = 2;
12333
12406
  /**
12334
12407
  * Private service for backtest orchestration using async generators.
12335
12408
  *
@@ -12595,7 +12668,7 @@ class BacktestLogicPrivateService {
12595
12668
  // Запрашиваем minuteEstimatedTime + буфер свечей одним запросом
12596
12669
  const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
12597
12670
  const bufferStartTime = new Date(when.getTime() - bufferMinutes * 60 * 1000);
12598
- const totalCandles = signal.minuteEstimatedTime + GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT + CANDLE_EXCLUSIVE_BOUNDARY_OFFSET;
12671
+ const totalCandles = signal.minuteEstimatedTime + GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
12599
12672
  let candles;
12600
12673
  try {
12601
12674
  candles = await this.exchangeCoreService.getNextCandles(symbol, "1m", totalCandles, bufferStartTime, true);
@@ -33089,6 +33162,27 @@ const INTERVAL_MINUTES$1 = {
33089
33162
  "6h": 360,
33090
33163
  "8h": 480,
33091
33164
  };
33165
+ /**
33166
+ * Aligns timestamp down to the nearest interval boundary.
33167
+ * For example, for 15m interval: 00:17 -> 00:15, 00:44 -> 00:30
33168
+ *
33169
+ * Candle timestamp convention:
33170
+ * - Candle timestamp = openTime (when candle opens)
33171
+ * - Candle with timestamp 00:00 covers period [00:00, 00:15) for 15m interval
33172
+ *
33173
+ * Adapter contract:
33174
+ * - Adapter must return candles with timestamp = openTime
33175
+ * - First returned candle.timestamp must equal aligned since
33176
+ * - Adapter must return exactly `limit` candles
33177
+ *
33178
+ * @param timestamp - Timestamp in milliseconds
33179
+ * @param intervalMinutes - Interval in minutes
33180
+ * @returns Aligned timestamp rounded down to interval boundary
33181
+ */
33182
+ const ALIGN_TO_INTERVAL_FN = (timestamp, intervalMinutes) => {
33183
+ const intervalMs = intervalMinutes * MS_PER_MINUTE;
33184
+ return Math.floor(timestamp / intervalMs) * intervalMs;
33185
+ };
33092
33186
  /**
33093
33187
  * Creates exchange instance with methods resolved once during construction.
33094
33188
  * Applies default implementations where schema methods are not provided.
@@ -33110,25 +33204,24 @@ const CREATE_EXCHANGE_INSTANCE_FN = (schema) => {
33110
33204
  };
33111
33205
  /**
33112
33206
  * Attempts to read candles from cache.
33113
- * Validates cache consistency (no gaps in timestamps) before returning.
33114
33207
  *
33115
- * Boundary semantics:
33116
- * - sinceTimestamp: EXCLUSIVE lower bound (candle.timestamp > sinceTimestamp)
33117
- * - untilTimestamp: EXCLUSIVE upper bound (candle.timestamp + stepMs < untilTimestamp)
33118
- * - Only fully closed candles within the exclusive range are returned
33208
+ * Cache lookup calculates expected timestamps:
33209
+ * sinceTimestamp + i * stepMs for i = 0..limit-1
33210
+ * Returns all candles if found, null if any missing.
33119
33211
  *
33120
33212
  * @param dto - Data transfer object containing symbol, interval, and limit
33121
- * @param sinceTimestamp - Exclusive start timestamp in milliseconds
33122
- * @param untilTimestamp - Exclusive end timestamp in milliseconds
33213
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
33214
+ * @param untilTimestamp - Unused, kept for API compatibility
33123
33215
  * @param exchangeName - Exchange name
33124
- * @returns Cached candles array or null if cache miss or inconsistent
33216
+ * @returns Cached candles array (exactly limit) or null if cache miss
33125
33217
  */
33126
33218
  const READ_CANDLES_CACHE_FN = trycatch(async (dto, sinceTimestamp, untilTimestamp, exchangeName) => {
33127
- // PersistCandleAdapter.readCandlesData uses EXCLUSIVE boundaries:
33128
- // Returns candles where: timestamp > sinceTimestamp AND timestamp + stepMs < untilTimestamp
33219
+ // PersistCandleAdapter.readCandlesData calculates expected timestamps:
33220
+ // sinceTimestamp + i * stepMs for i = 0..limit-1
33221
+ // Returns all candles if found, null if any missing
33129
33222
  const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
33130
33223
  // Return cached data only if we have exactly the requested limit
33131
- if (cachedCandles.length === dto.limit) {
33224
+ if (cachedCandles?.length === dto.limit) {
33132
33225
  bt.loggerService.debug(`ExchangeInstance READ_CANDLES_CACHE_FN: cache hit for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
33133
33226
  return cachedCandles;
33134
33227
  }
@@ -33150,11 +33243,12 @@ const READ_CANDLES_CACHE_FN = trycatch(async (dto, sinceTimestamp, untilTimestam
33150
33243
  /**
33151
33244
  * Writes candles to cache with error handling.
33152
33245
  *
33153
- * The candles passed to this function must already be filtered using EXCLUSIVE boundaries:
33154
- * - candle.timestamp > sinceTimestamp
33155
- * - candle.timestamp + stepMs < untilTimestamp
33246
+ * The candles passed to this function should be validated:
33247
+ * - First candle.timestamp equals aligned sinceTimestamp (openTime)
33248
+ * - Exact number of candles as requested (limit)
33249
+ * - Sequential timestamps: sinceTimestamp + i * stepMs
33156
33250
  *
33157
- * @param candles - Array of candle data to cache (already filtered with exclusive boundaries)
33251
+ * @param candles - Array of validated candle data to cache
33158
33252
  * @param dto - Data transfer object containing symbol, interval, and limit
33159
33253
  * @param exchangeName - Exchange name
33160
33254
  */
@@ -33225,14 +33319,18 @@ class ExchangeInstance {
33225
33319
  });
33226
33320
  const getCandles = this._methods.getCandles;
33227
33321
  const step = INTERVAL_MINUTES$1[interval];
33228
- const adjust = step * limit;
33229
- if (!adjust) {
33230
- throw new Error(`ExchangeInstance unknown time adjust for interval=${interval}`);
33322
+ if (!step) {
33323
+ throw new Error(`ExchangeInstance unknown interval=${interval}`);
33231
33324
  }
33325
+ const stepMs = step * MS_PER_MINUTE;
33326
+ // Align when down to interval boundary
33232
33327
  const when = await GET_TIMESTAMP_FN();
33233
- const since = new Date(when.getTime() - adjust * MS_PER_MINUTE);
33234
- const sinceTimestamp = since.getTime();
33235
- const untilTimestamp = sinceTimestamp + limit * step * MS_PER_MINUTE;
33328
+ const whenTimestamp = when.getTime();
33329
+ const alignedWhen = ALIGN_TO_INTERVAL_FN(whenTimestamp, step);
33330
+ // Calculate since: go back limit candles from aligned when
33331
+ const sinceTimestamp = alignedWhen - limit * stepMs;
33332
+ const since = new Date(sinceTimestamp);
33333
+ const untilTimestamp = alignedWhen;
33236
33334
  // Try to read from cache first
33237
33335
  const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
33238
33336
  if (cachedCandles !== null) {
@@ -33251,7 +33349,7 @@ class ExchangeInstance {
33251
33349
  remaining -= chunkLimit;
33252
33350
  if (remaining > 0) {
33253
33351
  // Move currentSince forward by the number of candles fetched
33254
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE);
33352
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
33255
33353
  }
33256
33354
  }
33257
33355
  }
@@ -33259,27 +33357,25 @@ class ExchangeInstance {
33259
33357
  const isBacktest = await GET_BACKTEST_FN();
33260
33358
  allData = await getCandles(symbol, interval, since, limit, isBacktest);
33261
33359
  }
33262
- // Filter candles to strictly match the requested range
33263
- const whenTimestamp = when.getTime();
33264
- const stepMs = step * MS_PER_MINUTE;
33265
- const filteredData = allData.filter((candle) => {
33266
- // EXCLUSIVE boundaries:
33267
- // - candle.timestamp > sinceTimestamp (exclude exact boundary)
33268
- // - candle.timestamp + stepMs < whenTimestamp (fully closed before "when")
33269
- if (candle.timestamp <= sinceTimestamp) {
33270
- return false;
33271
- }
33272
- // Check against current time (when)
33273
- // Only allow candles that have fully CLOSED before "when"
33274
- return candle.timestamp + stepMs < whenTimestamp;
33275
- });
33276
33360
  // Apply distinct by timestamp to remove duplicates
33277
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
33278
- if (filteredData.length !== uniqueData.length) {
33279
- bt.loggerService.warn(`ExchangeInstance Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`);
33361
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
33362
+ if (allData.length !== uniqueData.length) {
33363
+ bt.loggerService.warn(`ExchangeInstance getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
33364
+ }
33365
+ // Validate adapter returned data
33366
+ if (uniqueData.length === 0) {
33367
+ throw new Error(`ExchangeInstance getCandles: adapter returned empty array. ` +
33368
+ `Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
33280
33369
  }
33281
- if (uniqueData.length < limit) {
33282
- bt.loggerService.warn(`ExchangeInstance Expected ${limit} candles, got ${uniqueData.length}`);
33370
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
33371
+ throw new Error(`ExchangeInstance getCandles: first candle timestamp mismatch. ` +
33372
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
33373
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
33374
+ }
33375
+ if (uniqueData.length !== limit) {
33376
+ throw new Error(`ExchangeInstance getCandles: candle count mismatch. ` +
33377
+ `Expected ${limit} candles, got ${uniqueData.length}. ` +
33378
+ `Adapter must return exact number of candles requested.`);
33283
33379
  }
33284
33380
  // Write to cache after successful fetch
33285
33381
  await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
@@ -33450,10 +33546,11 @@ class ExchangeInstance {
33450
33546
  if (!step) {
33451
33547
  throw new Error(`ExchangeInstance getRawCandles: unknown interval=${interval}`);
33452
33548
  }
33549
+ const stepMs = step * MS_PER_MINUTE;
33453
33550
  const when = await GET_TIMESTAMP_FN();
33454
33551
  const nowTimestamp = when.getTime();
33552
+ const alignedNow = ALIGN_TO_INTERVAL_FN(nowTimestamp, step);
33455
33553
  let sinceTimestamp;
33456
- let untilTimestamp;
33457
33554
  let calculatedLimit;
33458
33555
  // Case 1: all three parameters provided
33459
33556
  if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
@@ -33463,8 +33560,8 @@ class ExchangeInstance {
33463
33560
  if (eDate > nowTimestamp) {
33464
33561
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33465
33562
  }
33466
- sinceTimestamp = sDate;
33467
- untilTimestamp = eDate;
33563
+ // Align sDate down to interval boundary
33564
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
33468
33565
  calculatedLimit = limit;
33469
33566
  }
33470
33567
  // Case 2: sDate + eDate (no limit) - calculate limit from date range
@@ -33475,9 +33572,10 @@ class ExchangeInstance {
33475
33572
  if (eDate > nowTimestamp) {
33476
33573
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33477
33574
  }
33478
- sinceTimestamp = sDate;
33479
- untilTimestamp = eDate;
33480
- calculatedLimit = Math.ceil((eDate - sDate) / (step * MS_PER_MINUTE));
33575
+ // Align sDate down to interval boundary
33576
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
33577
+ const alignedEDate = ALIGN_TO_INTERVAL_FN(eDate, step);
33578
+ calculatedLimit = Math.ceil((alignedEDate - sinceTimestamp) / stepMs);
33481
33579
  if (calculatedLimit <= 0) {
33482
33580
  throw new Error(`ExchangeInstance getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
33483
33581
  }
@@ -33487,23 +33585,24 @@ class ExchangeInstance {
33487
33585
  if (eDate > nowTimestamp) {
33488
33586
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33489
33587
  }
33490
- untilTimestamp = eDate;
33491
- sinceTimestamp = eDate - limit * step * MS_PER_MINUTE;
33588
+ // Align eDate down and calculate sinceTimestamp
33589
+ const alignedEDate = ALIGN_TO_INTERVAL_FN(eDate, step);
33590
+ sinceTimestamp = alignedEDate - limit * stepMs;
33492
33591
  calculatedLimit = limit;
33493
33592
  }
33494
33593
  // Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
33495
33594
  else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
33496
- sinceTimestamp = sDate;
33497
- untilTimestamp = sDate + limit * step * MS_PER_MINUTE;
33498
- if (untilTimestamp > nowTimestamp) {
33499
- throw new Error(`ExchangeInstance getRawCandles: calculated endTimestamp (${untilTimestamp}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33595
+ // Align sDate down to interval boundary
33596
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
33597
+ const endTimestamp = sinceTimestamp + limit * stepMs;
33598
+ if (endTimestamp > nowTimestamp) {
33599
+ throw new Error(`ExchangeInstance getRawCandles: calculated endTimestamp (${endTimestamp}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33500
33600
  }
33501
33601
  calculatedLimit = limit;
33502
33602
  }
33503
33603
  // Case 5: Only limit - use Date.now() as reference (backward)
33504
33604
  else if (sDate === undefined && eDate === undefined && limit !== undefined) {
33505
- untilTimestamp = nowTimestamp;
33506
- sinceTimestamp = nowTimestamp - limit * step * MS_PER_MINUTE;
33605
+ sinceTimestamp = alignedNow - limit * stepMs;
33507
33606
  calculatedLimit = limit;
33508
33607
  }
33509
33608
  // Invalid: no parameters or only sDate or only eDate
@@ -33513,6 +33612,7 @@ class ExchangeInstance {
33513
33612
  `Got: sDate=${sDate}, eDate=${eDate}, limit=${limit}`);
33514
33613
  }
33515
33614
  // Try to read from cache first
33615
+ const untilTimestamp = sinceTimestamp + calculatedLimit * stepMs;
33516
33616
  const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit: calculatedLimit }, sinceTimestamp, untilTimestamp, this.exchangeName);
33517
33617
  if (cachedCandles !== null) {
33518
33618
  return cachedCandles;
@@ -33531,25 +33631,32 @@ class ExchangeInstance {
33531
33631
  allData.push(...chunkData);
33532
33632
  remaining -= chunkLimit;
33533
33633
  if (remaining > 0) {
33534
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE);
33634
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
33535
33635
  }
33536
33636
  }
33537
33637
  }
33538
33638
  else {
33539
33639
  allData = await getCandles(symbol, interval, since, calculatedLimit, isBacktest);
33540
33640
  }
33541
- // Filter candles to strictly match the requested range
33542
- // Only include candles that have fully CLOSED before untilTimestamp
33543
- const stepMs = step * MS_PER_MINUTE;
33544
- const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
33545
- candle.timestamp + stepMs < untilTimestamp);
33546
33641
  // Apply distinct by timestamp to remove duplicates
33547
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
33548
- if (filteredData.length !== uniqueData.length) {
33549
- bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`);
33550
- }
33551
- if (uniqueData.length < calculatedLimit) {
33552
- bt.loggerService.warn(`ExchangeInstance getRawCandles: Expected ${calculatedLimit} candles, got ${uniqueData.length}`);
33642
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
33643
+ if (allData.length !== uniqueData.length) {
33644
+ bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
33645
+ }
33646
+ // Validate adapter returned data
33647
+ if (uniqueData.length === 0) {
33648
+ throw new Error(`ExchangeInstance getRawCandles: adapter returned empty array. ` +
33649
+ `Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
33650
+ }
33651
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
33652
+ throw new Error(`ExchangeInstance getRawCandles: first candle timestamp mismatch. ` +
33653
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
33654
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
33655
+ }
33656
+ if (uniqueData.length !== calculatedLimit) {
33657
+ throw new Error(`ExchangeInstance getRawCandles: candle count mismatch. ` +
33658
+ `Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
33659
+ `Adapter must return exact number of candles requested.`);
33553
33660
  }
33554
33661
  // Write to cache after successful fetch
33555
33662
  await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);