backtest-kit 2.2.26 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.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';
@@ -680,6 +680,20 @@ async function writeFileAtomic(file, data, options = {}) {
680
680
 
681
681
  var _a$2;
682
682
  const BASE_WAIT_FOR_INIT_SYMBOL = Symbol("wait-for-init");
683
+ // Calculate step in milliseconds for candle close time validation
684
+ const INTERVAL_MINUTES$5 = {
685
+ "1m": 1,
686
+ "3m": 3,
687
+ "5m": 5,
688
+ "15m": 15,
689
+ "30m": 30,
690
+ "1h": 60,
691
+ "2h": 120,
692
+ "4h": 240,
693
+ "6h": 360,
694
+ "8h": 480,
695
+ };
696
+ const MS_PER_MINUTE$2 = 60000;
683
697
  const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_PERSIST_SIGNAL_ADAPTER = "PersistSignalUtils.usePersistSignalAdapter";
684
698
  const PERSIST_SIGNAL_UTILS_METHOD_NAME_READ_DATA = "PersistSignalUtils.readSignalData";
685
699
  const PERSIST_SIGNAL_UTILS_METHOD_NAME_WRITE_DATA = "PersistSignalUtils.writeSignalData";
@@ -1544,60 +1558,73 @@ class PersistCandleUtils {
1544
1558
  ]));
1545
1559
  /**
1546
1560
  * Reads cached candles for a specific exchange, symbol, and interval.
1547
- * Returns candles only if cache contains exactly the requested limit.
1561
+ * Returns candles only if cache contains ALL requested candles.
1562
+ *
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
1548
1568
  *
1549
1569
  * @param symbol - Trading pair symbol
1550
1570
  * @param interval - Candle interval
1551
1571
  * @param exchangeName - Exchange identifier
1552
1572
  * @param limit - Number of candles requested
1553
- * @param sinceTimestamp - Start timestamp (inclusive)
1554
- * @param untilTimestamp - End timestamp (exclusive)
1573
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
1574
+ * @param _untilTimestamp - Unused, kept for API compatibility
1555
1575
  * @returns Promise resolving to array of candles or null if cache is incomplete
1556
1576
  */
1557
- this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, untilTimestamp) => {
1577
+ this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, _untilTimestamp) => {
1558
1578
  bt.loggerService.info("PersistCandleUtils.readCandlesData", {
1559
1579
  symbol,
1560
1580
  interval,
1561
1581
  exchangeName,
1562
1582
  limit,
1563
1583
  sinceTimestamp,
1564
- untilTimestamp,
1565
1584
  });
1566
1585
  const key = `${symbol}:${interval}:${exchangeName}`;
1567
1586
  const isInitial = !this.getCandlesStorage.has(key);
1568
1587
  const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
1569
1588
  await stateStorage.waitForInit(isInitial);
1570
- // Collect all cached candles within the time range
1589
+ const stepMs = INTERVAL_MINUTES$5[interval] * MS_PER_MINUTE$2;
1590
+ // Calculate expected timestamps and fetch each candle directly
1571
1591
  const cachedCandles = [];
1572
- for await (const timestamp of stateStorage.keys()) {
1573
- const ts = Number(timestamp);
1574
- if (ts >= sinceTimestamp && ts < untilTimestamp) {
1575
- try {
1576
- const candle = await stateStorage.readValue(timestamp);
1577
- cachedCandles.push(candle);
1578
- }
1579
- catch (error) {
1580
- const message = `PersistCandleUtils.readCandlesData found invalid candle symbol=${symbol} interval=${interval} timestamp=${timestamp}`;
1581
- const payload = {
1582
- error: errorData(error),
1583
- message: getErrorMessage(error),
1584
- };
1585
- bt.loggerService.warn(message, payload);
1586
- console.warn(message, payload);
1587
- errorEmitter.next(error);
1588
- continue;
1589
- }
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;
1590
1614
  }
1591
1615
  }
1592
- // Sort by timestamp ascending
1593
- cachedCandles.sort((a, b) => a.timestamp - b.timestamp);
1594
1616
  return cachedCandles;
1595
1617
  };
1596
1618
  /**
1597
1619
  * Writes candles to cache with atomic file writes.
1598
1620
  * Each candle is stored as a separate JSON file named by its timestamp.
1599
1621
  *
1600
- * @param candles - Array of candle data to cache
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
+ *
1627
+ * @param candles - Array of candle data to cache (validated by the caller)
1601
1628
  * @param symbol - Trading pair symbol
1602
1629
  * @param interval - Candle interval
1603
1630
  * @param exchangeName - Exchange identifier
@@ -1614,8 +1641,25 @@ class PersistCandleUtils {
1614
1641
  const isInitial = !this.getCandlesStorage.has(key);
1615
1642
  const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
1616
1643
  await stateStorage.waitForInit(isInitial);
1617
- // 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
1618
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
+ }
1619
1663
  if (await not(stateStorage.hasValue(String(candle.timestamp)))) {
1620
1664
  await stateStorage.writeValue(String(candle.timestamp), candle);
1621
1665
  }
@@ -1773,6 +1817,27 @@ const INTERVAL_MINUTES$4 = {
1773
1817
  "6h": 360,
1774
1818
  "8h": 480,
1775
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
+ };
1776
1841
  /**
1777
1842
  * Validates that all candles have valid OHLCV data without anomalies.
1778
1843
  * Detects incomplete candles from Binance API by checking for abnormally low prices or volumes.
@@ -1836,18 +1901,24 @@ const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => {
1836
1901
  };
1837
1902
  /**
1838
1903
  * Attempts to read candles from cache.
1839
- * Validates cache consistency (no gaps in timestamps) before returning.
1904
+ *
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.
1840
1908
  *
1841
1909
  * @param dto - Data transfer object containing symbol, interval, and limit
1842
- * @param sinceTimestamp - Start timestamp in milliseconds
1843
- * @param untilTimestamp - End timestamp in milliseconds
1910
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
1911
+ * @param untilTimestamp - Unused, kept for API compatibility
1844
1912
  * @param self - Instance of ClientExchange
1845
- * @returns Cached candles array or null if cache miss or inconsistent
1913
+ * @returns Cached candles array (exactly limit) or null if cache miss
1846
1914
  */
1847
1915
  const READ_CANDLES_CACHE_FN$1 = trycatch(async (dto, sinceTimestamp, untilTimestamp, self) => {
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
1848
1919
  const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, self.params.exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
1849
1920
  // Return cached data only if we have exactly the requested limit
1850
- if (cachedCandles.length === dto.limit) {
1921
+ if (cachedCandles?.length === dto.limit) {
1851
1922
  self.params.logger.debug(`ClientExchange READ_CANDLES_CACHE_FN: cache hit for symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
1852
1923
  return cachedCandles;
1853
1924
  }
@@ -1869,7 +1940,12 @@ const READ_CANDLES_CACHE_FN$1 = trycatch(async (dto, sinceTimestamp, untilTimest
1869
1940
  /**
1870
1941
  * Writes candles to cache with error handling.
1871
1942
  *
1872
- * @param candles - Array of candle data to cache
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
1947
+ *
1948
+ * @param candles - Array of validated candle data to cache
1873
1949
  * @param dto - Data transfer object containing symbol, interval, and limit
1874
1950
  * @param self - Instance of ClientExchange
1875
1951
  */
@@ -1996,6 +2072,13 @@ class ClientExchange {
1996
2072
  /**
1997
2073
  * Fetches historical candles backwards from execution context time.
1998
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
+ *
1999
2082
  * @param symbol - Trading pair symbol
2000
2083
  * @param interval - Candle interval
2001
2084
  * @param limit - Number of candles to fetch
@@ -2008,11 +2091,16 @@ class ClientExchange {
2008
2091
  limit,
2009
2092
  });
2010
2093
  const step = INTERVAL_MINUTES$4[interval];
2011
- const adjust = step * limit;
2012
- if (!adjust) {
2013
- throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
2094
+ if (!step) {
2095
+ throw new Error(`ClientExchange unknown interval=${interval}`);
2014
2096
  }
2015
- 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);
2016
2104
  let allData = [];
2017
2105
  // If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
2018
2106
  if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
@@ -2025,29 +2113,34 @@ class ClientExchange {
2025
2113
  remaining -= chunkLimit;
2026
2114
  if (remaining > 0) {
2027
2115
  // Move currentSince forward by the number of candles fetched
2028
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2116
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2029
2117
  }
2030
2118
  }
2031
2119
  }
2032
2120
  else {
2033
2121
  allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
2034
2122
  }
2035
- // Filter candles to strictly match the requested range
2036
- const whenTimestamp = this.params.execution.context.when.getTime();
2037
- const sinceTimestamp = since.getTime();
2038
- const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp &&
2039
- candle.timestamp < whenTimestamp);
2040
2123
  // Apply distinct by timestamp to remove duplicates
2041
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2042
- if (filteredData.length !== uniqueData.length) {
2043
- 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`;
2044
2127
  this.params.logger.warn(msg);
2045
2128
  console.warn(msg);
2046
2129
  }
2047
- if (uniqueData.length < limit) {
2048
- const msg = `ClientExchange Expected ${limit} candles, got ${uniqueData.length}`;
2049
- this.params.logger.warn(msg);
2050
- 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.`);
2051
2144
  }
2052
2145
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
2053
2146
  return uniqueData;
@@ -2056,6 +2149,13 @@ class ClientExchange {
2056
2149
  * Fetches future candles forwards from execution context time.
2057
2150
  * Used in backtest mode to get candles for signal duration.
2058
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
+ *
2059
2159
  * @param symbol - Trading pair symbol
2060
2160
  * @param interval - Candle interval
2061
2161
  * @param limit - Number of candles to fetch
@@ -2068,12 +2168,24 @@ class ClientExchange {
2068
2168
  interval,
2069
2169
  limit,
2070
2170
  });
2071
- const since = new Date(this.params.execution.context.when.getTime());
2072
- const now = Date.now();
2073
- // Вычисляем конечное время запроса
2171
+ if (!this.params.execution.context.backtest) {
2172
+ throw new Error(`ClientExchange getNextCandles: cannot fetch future candles in live mode`);
2173
+ }
2074
2174
  const step = INTERVAL_MINUTES$4[interval];
2075
- const endTime = since.getTime() + limit * step * MS_PER_MINUTE$1;
2076
- // Проверяем что запрошенный период не заходит за 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()
2077
2189
  if (endTime > now) {
2078
2190
  return [];
2079
2191
  }
@@ -2089,27 +2201,34 @@ class ClientExchange {
2089
2201
  remaining -= chunkLimit;
2090
2202
  if (remaining > 0) {
2091
2203
  // Move currentSince forward by the number of candles fetched
2092
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2204
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2093
2205
  }
2094
2206
  }
2095
2207
  }
2096
2208
  else {
2097
2209
  allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
2098
2210
  }
2099
- // Filter candles to strictly match the requested range
2100
- const sinceTimestamp = since.getTime();
2101
- const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp < endTime);
2102
2211
  // Apply distinct by timestamp to remove duplicates
2103
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2104
- if (filteredData.length !== uniqueData.length) {
2105
- 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`;
2106
2215
  this.params.logger.warn(msg);
2107
2216
  console.warn(msg);
2108
2217
  }
2109
- if (uniqueData.length < limit) {
2110
- const msg = `ClientExchange getNextCandles: Expected ${limit} candles, got ${uniqueData.length}`;
2111
- this.params.logger.warn(msg);
2112
- 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.`);
2113
2232
  }
2114
2233
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
2115
2234
  return uniqueData;
@@ -2184,6 +2303,12 @@ class ClientExchange {
2184
2303
  /**
2185
2304
  * Fetches raw candles with flexible date/limit parameters.
2186
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
+ *
2187
2312
  * All modes respect execution context and prevent look-ahead bias.
2188
2313
  *
2189
2314
  * Parameter combinations:
@@ -2218,9 +2343,10 @@ class ClientExchange {
2218
2343
  if (!step) {
2219
2344
  throw new Error(`ClientExchange getRawCandles: unknown interval=${interval}`);
2220
2345
  }
2346
+ const stepMs = step * MS_PER_MINUTE$1;
2221
2347
  const whenTimestamp = this.params.execution.context.when.getTime();
2348
+ const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
2222
2349
  let sinceTimestamp;
2223
- let untilTimestamp;
2224
2350
  let calculatedLimit;
2225
2351
  // Case 1: all three parameters provided
2226
2352
  if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
@@ -2230,8 +2356,8 @@ class ClientExchange {
2230
2356
  if (eDate > whenTimestamp) {
2231
2357
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2232
2358
  }
2233
- sinceTimestamp = sDate;
2234
- untilTimestamp = eDate;
2359
+ // Align sDate down to interval boundary
2360
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
2235
2361
  calculatedLimit = limit;
2236
2362
  }
2237
2363
  // Case 2: sDate + eDate (no limit) - calculate limit from date range
@@ -2242,9 +2368,10 @@ class ClientExchange {
2242
2368
  if (eDate > whenTimestamp) {
2243
2369
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2244
2370
  }
2245
- sinceTimestamp = sDate;
2246
- untilTimestamp = eDate;
2247
- 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);
2248
2375
  if (calculatedLimit <= 0) {
2249
2376
  throw new Error(`ClientExchange getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
2250
2377
  }
@@ -2254,23 +2381,24 @@ class ClientExchange {
2254
2381
  if (eDate > whenTimestamp) {
2255
2382
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2256
2383
  }
2257
- untilTimestamp = eDate;
2258
- 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;
2259
2387
  calculatedLimit = limit;
2260
2388
  }
2261
2389
  // Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
2262
2390
  else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
2263
- sinceTimestamp = sDate;
2264
- untilTimestamp = sDate + limit * step * MS_PER_MINUTE$1;
2265
- if (untilTimestamp > whenTimestamp) {
2266
- 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.`);
2267
2396
  }
2268
2397
  calculatedLimit = limit;
2269
2398
  }
2270
2399
  // Case 5: Only limit - use execution.context.when as reference (backward like getCandles)
2271
2400
  else if (sDate === undefined && eDate === undefined && limit !== undefined) {
2272
- untilTimestamp = whenTimestamp;
2273
- sinceTimestamp = whenTimestamp - limit * step * MS_PER_MINUTE$1;
2401
+ sinceTimestamp = alignedWhen - limit * stepMs;
2274
2402
  calculatedLimit = limit;
2275
2403
  }
2276
2404
  // Invalid: no parameters or only sDate or only eDate
@@ -2291,27 +2419,34 @@ class ClientExchange {
2291
2419
  allData.push(...chunkData);
2292
2420
  remaining -= chunkLimit;
2293
2421
  if (remaining > 0) {
2294
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2422
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2295
2423
  }
2296
2424
  }
2297
2425
  }
2298
2426
  else {
2299
2427
  allData = await GET_CANDLES_FN({ symbol, interval, limit: calculatedLimit }, since, this);
2300
2428
  }
2301
- // Filter candles to strictly match the requested range
2302
- const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp &&
2303
- candle.timestamp < untilTimestamp);
2304
2429
  // Apply distinct by timestamp to remove duplicates
2305
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2306
- if (filteredData.length !== uniqueData.length) {
2307
- 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`;
2308
2433
  this.params.logger.warn(msg);
2309
2434
  console.warn(msg);
2310
2435
  }
2311
- if (uniqueData.length < calculatedLimit) {
2312
- const msg = `ClientExchange getRawCandles: Expected ${calculatedLimit} candles, got ${uniqueData.length}`;
2313
- this.params.logger.warn(msg);
2314
- 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.`);
2315
2450
  }
2316
2451
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, calculatedLimit, uniqueData);
2317
2452
  return uniqueData;
@@ -13819,6 +13954,18 @@ const live_columns = [
13819
13954
  format: (data) => data.duration !== undefined ? `${data.duration}` : "N/A",
13820
13955
  isVisible: () => true,
13821
13956
  },
13957
+ {
13958
+ key: "pendingAt",
13959
+ label: "Pending At",
13960
+ format: (data) => data.pendingAt !== undefined ? new Date(data.pendingAt).toISOString() : "N/A",
13961
+ isVisible: () => true,
13962
+ },
13963
+ {
13964
+ key: "scheduledAt",
13965
+ label: "Scheduled At",
13966
+ format: (data) => data.scheduledAt !== undefined ? new Date(data.scheduledAt).toISOString() : "N/A",
13967
+ isVisible: () => true,
13968
+ },
13822
13969
  ];
13823
13970
 
13824
13971
  /**
@@ -13943,6 +14090,18 @@ const partial_columns = [
13943
14090
  format: (data) => data.note || "",
13944
14091
  isVisible: () => GLOBAL_CONFIG.CC_REPORT_SHOW_SIGNAL_NOTE,
13945
14092
  },
14093
+ {
14094
+ key: "pendingAt",
14095
+ label: "Pending At",
14096
+ format: (data) => (data.pendingAt ? new Date(data.pendingAt).toISOString() : "N/A"),
14097
+ isVisible: () => true,
14098
+ },
14099
+ {
14100
+ key: "scheduledAt",
14101
+ label: "Scheduled At",
14102
+ format: (data) => (data.scheduledAt ? new Date(data.scheduledAt).toISOString() : "N/A"),
14103
+ isVisible: () => true,
14104
+ },
13946
14105
  {
13947
14106
  key: "timestamp",
13948
14107
  label: "Timestamp",
@@ -14065,6 +14224,18 @@ const breakeven_columns = [
14065
14224
  format: (data) => data.note || "",
14066
14225
  isVisible: () => GLOBAL_CONFIG.CC_REPORT_SHOW_SIGNAL_NOTE,
14067
14226
  },
14227
+ {
14228
+ key: "pendingAt",
14229
+ label: "Pending At",
14230
+ format: (data) => (data.pendingAt ? new Date(data.pendingAt).toISOString() : "N/A"),
14231
+ isVisible: () => true,
14232
+ },
14233
+ {
14234
+ key: "scheduledAt",
14235
+ label: "Scheduled At",
14236
+ format: (data) => (data.scheduledAt ? new Date(data.scheduledAt).toISOString() : "N/A"),
14237
+ isVisible: () => true,
14238
+ },
14068
14239
  {
14069
14240
  key: "timestamp",
14070
14241
  label: "Timestamp",
@@ -14343,6 +14514,22 @@ const risk_columns = [
14343
14514
  format: (data) => data.rejectionNote,
14344
14515
  isVisible: () => true,
14345
14516
  },
14517
+ {
14518
+ key: "pendingAt",
14519
+ label: "Pending At",
14520
+ format: (data) => data.currentSignal.pendingAt !== undefined
14521
+ ? new Date(data.currentSignal.pendingAt).toISOString()
14522
+ : "N/A",
14523
+ isVisible: () => true,
14524
+ },
14525
+ {
14526
+ key: "scheduledAt",
14527
+ label: "Scheduled At",
14528
+ format: (data) => data.currentSignal.scheduledAt !== undefined
14529
+ ? new Date(data.currentSignal.scheduledAt).toISOString()
14530
+ : "N/A",
14531
+ isVisible: () => true,
14532
+ },
14346
14533
  {
14347
14534
  key: "timestamp",
14348
14535
  label: "Timestamp",
@@ -14493,6 +14680,18 @@ const schedule_columns = [
14493
14680
  format: (data) => data.cancelId ?? "N/A",
14494
14681
  isVisible: () => true,
14495
14682
  },
14683
+ {
14684
+ key: "pendingAt",
14685
+ label: "Pending At",
14686
+ format: (data) => data.pendingAt !== undefined ? new Date(data.pendingAt).toISOString() : "N/A",
14687
+ isVisible: () => true,
14688
+ },
14689
+ {
14690
+ key: "scheduledAt",
14691
+ label: "Scheduled At",
14692
+ format: (data) => data.scheduledAt !== undefined ? new Date(data.scheduledAt).toISOString() : "N/A",
14693
+ isVisible: () => true,
14694
+ },
14496
14695
  ];
14497
14696
 
14498
14697
  /**
@@ -15884,6 +16083,8 @@ let ReportStorage$6 = class ReportStorage {
15884
16083
  originalPriceTakeProfit: data.signal.originalPriceTakeProfit,
15885
16084
  originalPriceStopLoss: data.signal.originalPriceStopLoss,
15886
16085
  partialExecuted: data.signal.partialExecuted,
16086
+ pendingAt: data.signal.pendingAt,
16087
+ scheduledAt: data.signal.scheduledAt,
15887
16088
  });
15888
16089
  // Trim queue if exceeded MAX_EVENTS
15889
16090
  if (this._eventList.length > MAX_EVENTS$7) {
@@ -15914,6 +16115,8 @@ let ReportStorage$6 = class ReportStorage {
15914
16115
  percentTp: data.percentTp,
15915
16116
  percentSl: data.percentSl,
15916
16117
  pnl: data.pnl.pnlPercentage,
16118
+ pendingAt: data.signal.pendingAt,
16119
+ scheduledAt: data.signal.scheduledAt,
15917
16120
  };
15918
16121
  // Find the last active event with the same signalId
15919
16122
  const lastActiveIndex = this._eventList.findLastIndex((event) => event.action === "active" && event.signalId === data.signal.id);
@@ -15954,6 +16157,8 @@ let ReportStorage$6 = class ReportStorage {
15954
16157
  pnl: data.pnl.pnlPercentage,
15955
16158
  closeReason: data.closeReason,
15956
16159
  duration: durationMin,
16160
+ pendingAt: data.signal.pendingAt,
16161
+ scheduledAt: data.signal.scheduledAt,
15957
16162
  };
15958
16163
  this._eventList.unshift(newEvent);
15959
16164
  // Trim queue if exceeded MAX_EVENTS
@@ -15981,6 +16186,7 @@ let ReportStorage$6 = class ReportStorage {
15981
16186
  originalPriceTakeProfit: data.signal.originalPriceTakeProfit,
15982
16187
  originalPriceStopLoss: data.signal.originalPriceStopLoss,
15983
16188
  partialExecuted: data.signal.partialExecuted,
16189
+ scheduledAt: data.signal.scheduledAt,
15984
16190
  });
15985
16191
  // Trim queue if exceeded MAX_EVENTS
15986
16192
  if (this._eventList.length > MAX_EVENTS$7) {
@@ -16011,6 +16217,7 @@ let ReportStorage$6 = class ReportStorage {
16011
16217
  percentTp: data.percentTp,
16012
16218
  percentSl: data.percentSl,
16013
16219
  pnl: data.pnl.pnlPercentage,
16220
+ scheduledAt: data.signal.scheduledAt,
16014
16221
  };
16015
16222
  // Find the last waiting event with the same signalId
16016
16223
  const lastWaitingIndex = this._eventList.findLastIndex((event) => event.action === "waiting" && event.signalId === data.signal.id);
@@ -16047,6 +16254,7 @@ let ReportStorage$6 = class ReportStorage {
16047
16254
  originalPriceStopLoss: data.signal.originalPriceStopLoss,
16048
16255
  partialExecuted: data.signal.partialExecuted,
16049
16256
  cancelReason: data.reason,
16257
+ scheduledAt: data.signal.scheduledAt,
16050
16258
  });
16051
16259
  // Trim queue if exceeded MAX_EVENTS
16052
16260
  if (this._eventList.length > MAX_EVENTS$7) {
@@ -16538,6 +16746,7 @@ let ReportStorage$5 = class ReportStorage {
16538
16746
  originalPriceTakeProfit: data.signal.originalPriceTakeProfit,
16539
16747
  originalPriceStopLoss: data.signal.originalPriceStopLoss,
16540
16748
  partialExecuted: data.signal.partialExecuted,
16749
+ scheduledAt: data.signal.scheduledAt,
16541
16750
  });
16542
16751
  // Trim queue if exceeded MAX_EVENTS
16543
16752
  if (this._eventList.length > MAX_EVENTS$6) {
@@ -16567,6 +16776,8 @@ let ReportStorage$5 = class ReportStorage {
16567
16776
  originalPriceStopLoss: data.signal.originalPriceStopLoss,
16568
16777
  partialExecuted: data.signal.partialExecuted,
16569
16778
  duration: durationMin,
16779
+ pendingAt: data.signal.pendingAt,
16780
+ scheduledAt: data.signal.scheduledAt,
16570
16781
  };
16571
16782
  this._eventList.unshift(newEvent);
16572
16783
  // Trim queue if exceeded MAX_EVENTS
@@ -16600,6 +16811,7 @@ let ReportStorage$5 = class ReportStorage {
16600
16811
  duration: durationMin,
16601
16812
  cancelReason: data.reason,
16602
16813
  cancelId: data.cancelId,
16814
+ scheduledAt: data.signal.scheduledAt,
16603
16815
  };
16604
16816
  this._eventList.unshift(newEvent);
16605
16817
  // Trim queue if exceeded MAX_EVENTS
@@ -19741,6 +19953,8 @@ let ReportStorage$3 = class ReportStorage {
19741
19953
  originalPriceStopLoss: data.originalPriceStopLoss,
19742
19954
  partialExecuted: data.partialExecuted,
19743
19955
  note: data.note,
19956
+ pendingAt: data.pendingAt,
19957
+ scheduledAt: data.scheduledAt,
19744
19958
  backtest,
19745
19959
  });
19746
19960
  // Trim queue if exceeded MAX_EVENTS
@@ -19773,6 +19987,8 @@ let ReportStorage$3 = class ReportStorage {
19773
19987
  originalPriceStopLoss: data.originalPriceStopLoss,
19774
19988
  partialExecuted: data.partialExecuted,
19775
19989
  note: data.note,
19990
+ pendingAt: data.pendingAt,
19991
+ scheduledAt: data.scheduledAt,
19776
19992
  backtest,
19777
19993
  });
19778
19994
  // Trim queue if exceeded MAX_EVENTS
@@ -20870,6 +21086,8 @@ let ReportStorage$2 = class ReportStorage {
20870
21086
  originalPriceStopLoss: data.originalPriceStopLoss,
20871
21087
  partialExecuted: data.partialExecuted,
20872
21088
  note: data.note,
21089
+ pendingAt: data.pendingAt,
21090
+ scheduledAt: data.scheduledAt,
20873
21091
  backtest,
20874
21092
  });
20875
21093
  // Trim queue if exceeded MAX_EVENTS
@@ -23255,9 +23473,15 @@ class HeatReportService {
23255
23473
  signalId: data.signal?.id,
23256
23474
  position: data.signal?.position,
23257
23475
  note: data.signal?.note,
23476
+ priceOpen: data.signal?.priceOpen,
23477
+ priceTakeProfit: data.signal?.priceTakeProfit,
23478
+ priceStopLoss: data.signal?.priceStopLoss,
23479
+ originalPriceTakeProfit: data.signal?.originalPriceTakeProfit,
23480
+ originalPriceStopLoss: data.signal?.originalPriceStopLoss,
23258
23481
  pnl: data.pnl.pnlPercentage,
23259
23482
  closeReason: data.closeReason,
23260
23483
  openTime: data.signal?.pendingAt,
23484
+ scheduledAt: data.signal?.scheduledAt,
23261
23485
  closeTime: data.closeTimestamp,
23262
23486
  }, {
23263
23487
  symbol: data.symbol,
@@ -23666,6 +23890,8 @@ class RiskReportService {
23666
23890
  originalPriceStopLoss: data.currentSignal?.originalPriceStopLoss,
23667
23891
  partialExecuted: data.currentSignal?.partialExecuted,
23668
23892
  note: data.currentSignal?.note,
23893
+ pendingAt: data.currentSignal?.pendingAt,
23894
+ scheduledAt: data.currentSignal?.scheduledAt,
23669
23895
  minuteEstimatedTime: data.currentSignal?.minuteEstimatedTime,
23670
23896
  }, {
23671
23897
  symbol: data.symbol,
@@ -25608,6 +25834,7 @@ const GET_CONTEXT_METHOD_NAME = "exchange.getContext";
25608
25834
  const HAS_TRADE_CONTEXT_METHOD_NAME = "exchange.hasTradeContext";
25609
25835
  const GET_ORDER_BOOK_METHOD_NAME = "exchange.getOrderBook";
25610
25836
  const GET_RAW_CANDLES_METHOD_NAME = "exchange.getRawCandles";
25837
+ const GET_NEXT_CANDLES_METHOD_NAME = "exchange.getNextCandles";
25611
25838
  /**
25612
25839
  * Checks if trade context is active (execution and method contexts).
25613
25840
  *
@@ -25912,6 +26139,30 @@ async function getRawCandles(symbol, interval, limit, sDate, eDate) {
25912
26139
  }
25913
26140
  return await bt.exchangeConnectionService.getRawCandles(symbol, interval, limit, sDate, eDate);
25914
26141
  }
26142
+ /**
26143
+ * Fetches the set of candles after current time based on execution context.
26144
+ *
26145
+ * Uses the exchange's getNextCandles implementation to retrieve candles
26146
+ * that occur after the current context time.
26147
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
26148
+ * @param interval - Candle interval ("1m" | "3m" | "5m" | "15m" | "30m" | "1h" | "2h" | "4h" | "6h" | "8h")
26149
+ * @param limit - Number of candles to fetch
26150
+ * @returns Promise resolving to array of candle data
26151
+ */
26152
+ async function getNextCandles(symbol, interval, limit) {
26153
+ bt.loggerService.info(GET_NEXT_CANDLES_METHOD_NAME, {
26154
+ symbol,
26155
+ interval,
26156
+ limit,
26157
+ });
26158
+ if (!ExecutionContextService.hasContext()) {
26159
+ throw new Error("getNextCandles requires an execution context");
26160
+ }
26161
+ if (!MethodContextService.hasContext()) {
26162
+ throw new Error("getNextCandles requires a method context");
26163
+ }
26164
+ return await bt.exchangeConnectionService.getNextCandles(symbol, interval, limit);
26165
+ }
25915
26166
 
25916
26167
  const CANCEL_SCHEDULED_METHOD_NAME = "strategy.commitCancelScheduled";
25917
26168
  const CLOSE_PENDING_METHOD_NAME = "strategy.commitClosePending";
@@ -32845,6 +33096,16 @@ const EXCHANGE_METHOD_NAME_FORMAT_PRICE = "ExchangeUtils.formatPrice";
32845
33096
  const EXCHANGE_METHOD_NAME_GET_ORDER_BOOK = "ExchangeUtils.getOrderBook";
32846
33097
  const EXCHANGE_METHOD_NAME_GET_RAW_CANDLES = "ExchangeUtils.getRawCandles";
32847
33098
  const MS_PER_MINUTE = 60000;
33099
+ /**
33100
+ * Gets current timestamp from execution context if available.
33101
+ * Returns current Date() if no execution context exists (non-trading GUI).
33102
+ */
33103
+ const GET_TIMESTAMP_FN = async () => {
33104
+ if (ExecutionContextService.hasContext()) {
33105
+ return new Date(bt.executionContextService.context.when);
33106
+ }
33107
+ return new Date();
33108
+ };
32848
33109
  /**
32849
33110
  * Gets backtest mode flag from execution context if available.
32850
33111
  * Returns false if no execution context exists (live mode).
@@ -32901,6 +33162,27 @@ const INTERVAL_MINUTES$1 = {
32901
33162
  "6h": 360,
32902
33163
  "8h": 480,
32903
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
+ };
32904
33186
  /**
32905
33187
  * Creates exchange instance with methods resolved once during construction.
32906
33188
  * Applies default implementations where schema methods are not provided.
@@ -32922,18 +33204,24 @@ const CREATE_EXCHANGE_INSTANCE_FN = (schema) => {
32922
33204
  };
32923
33205
  /**
32924
33206
  * Attempts to read candles from cache.
32925
- * Validates cache consistency (no gaps in timestamps) before returning.
33207
+ *
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.
32926
33211
  *
32927
33212
  * @param dto - Data transfer object containing symbol, interval, and limit
32928
- * @param sinceTimestamp - Start timestamp in milliseconds
32929
- * @param untilTimestamp - End timestamp in milliseconds
33213
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
33214
+ * @param untilTimestamp - Unused, kept for API compatibility
32930
33215
  * @param exchangeName - Exchange name
32931
- * @returns Cached candles array or null if cache miss or inconsistent
33216
+ * @returns Cached candles array (exactly limit) or null if cache miss
32932
33217
  */
32933
33218
  const READ_CANDLES_CACHE_FN = trycatch(async (dto, sinceTimestamp, untilTimestamp, exchangeName) => {
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
32934
33222
  const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
32935
33223
  // Return cached data only if we have exactly the requested limit
32936
- if (cachedCandles.length === dto.limit) {
33224
+ if (cachedCandles?.length === dto.limit) {
32937
33225
  bt.loggerService.debug(`ExchangeInstance READ_CANDLES_CACHE_FN: cache hit for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
32938
33226
  return cachedCandles;
32939
33227
  }
@@ -32955,7 +33243,12 @@ const READ_CANDLES_CACHE_FN = trycatch(async (dto, sinceTimestamp, untilTimestam
32955
33243
  /**
32956
33244
  * Writes candles to cache with error handling.
32957
33245
  *
32958
- * @param candles - Array of candle data to cache
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
33250
+ *
33251
+ * @param candles - Array of validated candle data to cache
32959
33252
  * @param dto - Data transfer object containing symbol, interval, and limit
32960
33253
  * @param exchangeName - Exchange name
32961
33254
  */
@@ -33026,14 +33319,18 @@ class ExchangeInstance {
33026
33319
  });
33027
33320
  const getCandles = this._methods.getCandles;
33028
33321
  const step = INTERVAL_MINUTES$1[interval];
33029
- const adjust = step * limit;
33030
- if (!adjust) {
33031
- throw new Error(`ExchangeInstance unknown time adjust for interval=${interval}`);
33032
- }
33033
- const when = new Date(Date.now());
33034
- const since = new Date(when.getTime() - adjust * 60 * 1000);
33035
- const sinceTimestamp = since.getTime();
33036
- const untilTimestamp = sinceTimestamp + limit * step * 60 * 1000;
33322
+ if (!step) {
33323
+ throw new Error(`ExchangeInstance unknown interval=${interval}`);
33324
+ }
33325
+ const stepMs = step * MS_PER_MINUTE;
33326
+ // Align when down to interval boundary
33327
+ const when = await GET_TIMESTAMP_FN();
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;
33037
33334
  // Try to read from cache first
33038
33335
  const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
33039
33336
  if (cachedCandles !== null) {
@@ -33052,7 +33349,7 @@ class ExchangeInstance {
33052
33349
  remaining -= chunkLimit;
33053
33350
  if (remaining > 0) {
33054
33351
  // Move currentSince forward by the number of candles fetched
33055
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * 60 * 1000);
33352
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
33056
33353
  }
33057
33354
  }
33058
33355
  }
@@ -33060,17 +33357,25 @@ class ExchangeInstance {
33060
33357
  const isBacktest = await GET_BACKTEST_FN();
33061
33358
  allData = await getCandles(symbol, interval, since, limit, isBacktest);
33062
33359
  }
33063
- // Filter candles to strictly match the requested range
33064
- const whenTimestamp = when.getTime();
33065
- const stepMs = step * 60 * 1000;
33066
- const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp < whenTimestamp + stepMs);
33067
33360
  // Apply distinct by timestamp to remove duplicates
33068
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
33069
- if (filteredData.length !== uniqueData.length) {
33070
- 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`);
33071
33364
  }
33072
- if (uniqueData.length < limit) {
33073
- bt.loggerService.warn(`ExchangeInstance Expected ${limit} candles, got ${uniqueData.length}`);
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}.`);
33369
+ }
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.`);
33074
33379
  }
33075
33380
  // Write to cache after successful fetch
33076
33381
  await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
@@ -33193,8 +33498,8 @@ class ExchangeInstance {
33193
33498
  symbol,
33194
33499
  depth,
33195
33500
  });
33196
- const to = new Date(Date.now());
33197
- const from = new Date(to.getTime() - GLOBAL_CONFIG.CC_ORDER_BOOK_TIME_OFFSET_MINUTES * 60 * 1000);
33501
+ const to = await GET_TIMESTAMP_FN();
33502
+ const from = new Date(to.getTime() - GLOBAL_CONFIG.CC_ORDER_BOOK_TIME_OFFSET_MINUTES * MS_PER_MINUTE);
33198
33503
  const isBacktest = await GET_BACKTEST_FN();
33199
33504
  return await this._methods.getOrderBook(symbol, depth, from, to, isBacktest);
33200
33505
  };
@@ -33241,9 +33546,11 @@ class ExchangeInstance {
33241
33546
  if (!step) {
33242
33547
  throw new Error(`ExchangeInstance getRawCandles: unknown interval=${interval}`);
33243
33548
  }
33244
- const nowTimestamp = Date.now();
33549
+ const stepMs = step * MS_PER_MINUTE;
33550
+ const when = await GET_TIMESTAMP_FN();
33551
+ const nowTimestamp = when.getTime();
33552
+ const alignedNow = ALIGN_TO_INTERVAL_FN(nowTimestamp, step);
33245
33553
  let sinceTimestamp;
33246
- let untilTimestamp;
33247
33554
  let calculatedLimit;
33248
33555
  // Case 1: all three parameters provided
33249
33556
  if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
@@ -33253,8 +33560,8 @@ class ExchangeInstance {
33253
33560
  if (eDate > nowTimestamp) {
33254
33561
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33255
33562
  }
33256
- sinceTimestamp = sDate;
33257
- untilTimestamp = eDate;
33563
+ // Align sDate down to interval boundary
33564
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
33258
33565
  calculatedLimit = limit;
33259
33566
  }
33260
33567
  // Case 2: sDate + eDate (no limit) - calculate limit from date range
@@ -33265,9 +33572,10 @@ class ExchangeInstance {
33265
33572
  if (eDate > nowTimestamp) {
33266
33573
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33267
33574
  }
33268
- sinceTimestamp = sDate;
33269
- untilTimestamp = eDate;
33270
- 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);
33271
33579
  if (calculatedLimit <= 0) {
33272
33580
  throw new Error(`ExchangeInstance getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
33273
33581
  }
@@ -33277,23 +33585,24 @@ class ExchangeInstance {
33277
33585
  if (eDate > nowTimestamp) {
33278
33586
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33279
33587
  }
33280
- untilTimestamp = eDate;
33281
- 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;
33282
33591
  calculatedLimit = limit;
33283
33592
  }
33284
33593
  // Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
33285
33594
  else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
33286
- sinceTimestamp = sDate;
33287
- untilTimestamp = sDate + limit * step * MS_PER_MINUTE;
33288
- if (untilTimestamp > nowTimestamp) {
33289
- 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.`);
33290
33600
  }
33291
33601
  calculatedLimit = limit;
33292
33602
  }
33293
33603
  // Case 5: Only limit - use Date.now() as reference (backward)
33294
33604
  else if (sDate === undefined && eDate === undefined && limit !== undefined) {
33295
- untilTimestamp = nowTimestamp;
33296
- sinceTimestamp = nowTimestamp - limit * step * MS_PER_MINUTE;
33605
+ sinceTimestamp = alignedNow - limit * stepMs;
33297
33606
  calculatedLimit = limit;
33298
33607
  }
33299
33608
  // Invalid: no parameters or only sDate or only eDate
@@ -33303,6 +33612,7 @@ class ExchangeInstance {
33303
33612
  `Got: sDate=${sDate}, eDate=${eDate}, limit=${limit}`);
33304
33613
  }
33305
33614
  // Try to read from cache first
33615
+ const untilTimestamp = sinceTimestamp + calculatedLimit * stepMs;
33306
33616
  const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit: calculatedLimit }, sinceTimestamp, untilTimestamp, this.exchangeName);
33307
33617
  if (cachedCandles !== null) {
33308
33618
  return cachedCandles;
@@ -33321,23 +33631,32 @@ class ExchangeInstance {
33321
33631
  allData.push(...chunkData);
33322
33632
  remaining -= chunkLimit;
33323
33633
  if (remaining > 0) {
33324
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE);
33634
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
33325
33635
  }
33326
33636
  }
33327
33637
  }
33328
33638
  else {
33329
33639
  allData = await getCandles(symbol, interval, since, calculatedLimit, isBacktest);
33330
33640
  }
33331
- // Filter candles to strictly match the requested range
33332
- const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp &&
33333
- candle.timestamp < untilTimestamp);
33334
33641
  // Apply distinct by timestamp to remove duplicates
33335
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
33336
- if (filteredData.length !== uniqueData.length) {
33337
- bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`);
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.`);
33338
33655
  }
33339
- if (uniqueData.length < calculatedLimit) {
33340
- bt.loggerService.warn(`ExchangeInstance getRawCandles: Expected ${calculatedLimit} candles, got ${uniqueData.length}`);
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.`);
33341
33660
  }
33342
33661
  // Write to cache after successful fetch
33343
33662
  await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);
@@ -34933,4 +35252,4 @@ const set = (object, path, value) => {
34933
35252
  }
34934
35253
  };
34935
35254
 
34936
- export { ActionBase, Backtest, Breakeven, Cache, Constant, Exchange, ExecutionContextService, Heat, Live, Markdown, MarkdownFileBase, MarkdownFolderBase, MethodContextService, Notification, Partial, Performance, PersistBase, PersistBreakevenAdapter, PersistCandleAdapter, PersistPartialAdapter, PersistRiskAdapter, PersistScheduleAdapter, PersistSignalAdapter, PersistStorageAdapter, PositionSize, Report, ReportBase, Risk, Schedule, Storage, StorageBacktest, StorageLive, Strategy, Walker, addActionSchema, addExchangeSchema, addFrameSchema, addRiskSchema, addSizingSchema, addStrategySchema, addWalkerSchema, commitBreakeven, commitCancelScheduled, commitClosePending, commitPartialLoss, commitPartialProfit, commitTrailingStop, commitTrailingTake, emitters, formatPrice, formatQuantity, get, getActionSchema, getAveragePrice, getBacktestTimeframe, getCandles, getColumns, getConfig, getContext, getDate, getDefaultColumns, getDefaultConfig, getExchangeSchema, getFrameSchema, getMode, getOrderBook, getRawCandles, getRiskSchema, getSizingSchema, getStrategySchema, getSymbol, getWalkerSchema, hasTradeContext, backtest as lib, listExchangeSchema, listFrameSchema, listRiskSchema, listSizingSchema, listStrategySchema, listWalkerSchema, listenActivePing, listenActivePingOnce, listenBacktestProgress, listenBreakevenAvailable, listenBreakevenAvailableOnce, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenExit, listenPartialLossAvailable, listenPartialLossAvailableOnce, listenPartialProfitAvailable, listenPartialProfitAvailableOnce, listenPerformance, listenRisk, listenRiskOnce, listenSchedulePing, listenSchedulePingOnce, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenStrategyCommit, listenStrategyCommitOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, listenWalkerProgress, overrideActionSchema, overrideExchangeSchema, overrideFrameSchema, overrideRiskSchema, overrideSizingSchema, overrideStrategySchema, overrideWalkerSchema, parseArgs, roundTicks, set, setColumns, setConfig, setLogger, stopStrategy, validate };
35255
+ export { ActionBase, Backtest, Breakeven, Cache, Constant, Exchange, ExecutionContextService, Heat, Live, Markdown, MarkdownFileBase, MarkdownFolderBase, MethodContextService, Notification, Partial, Performance, PersistBase, PersistBreakevenAdapter, PersistCandleAdapter, PersistPartialAdapter, PersistRiskAdapter, PersistScheduleAdapter, PersistSignalAdapter, PersistStorageAdapter, PositionSize, Report, ReportBase, Risk, Schedule, Storage, StorageBacktest, StorageLive, Strategy, Walker, addActionSchema, addExchangeSchema, addFrameSchema, addRiskSchema, addSizingSchema, addStrategySchema, addWalkerSchema, commitBreakeven, commitCancelScheduled, commitClosePending, commitPartialLoss, commitPartialProfit, commitTrailingStop, commitTrailingTake, emitters, formatPrice, formatQuantity, get, getActionSchema, getAveragePrice, getBacktestTimeframe, getCandles, getColumns, getConfig, getContext, getDate, getDefaultColumns, getDefaultConfig, getExchangeSchema, getFrameSchema, getMode, getNextCandles, getOrderBook, getRawCandles, getRiskSchema, getSizingSchema, getStrategySchema, getSymbol, getWalkerSchema, hasTradeContext, backtest as lib, listExchangeSchema, listFrameSchema, listRiskSchema, listSizingSchema, listStrategySchema, listWalkerSchema, listenActivePing, listenActivePingOnce, listenBacktestProgress, listenBreakevenAvailable, listenBreakevenAvailableOnce, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenExit, listenPartialLossAvailable, listenPartialLossAvailableOnce, listenPartialProfitAvailable, listenPartialProfitAvailableOnce, listenPerformance, listenRisk, listenRiskOnce, listenSchedulePing, listenSchedulePingOnce, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenStrategyCommit, listenStrategyCommitOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, listenWalkerProgress, overrideActionSchema, overrideExchangeSchema, overrideFrameSchema, overrideRiskSchema, overrideSizingSchema, overrideStrategySchema, overrideWalkerSchema, parseArgs, roundTicks, set, setColumns, setConfig, setLogger, stopStrategy, validate };