backtest-kit 2.3.1 → 2.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.cjs CHANGED
@@ -1578,73 +1578,73 @@ class PersistCandleUtils {
1578
1578
  ]));
1579
1579
  /**
1580
1580
  * Reads cached candles for a specific exchange, symbol, and interval.
1581
- * Returns candles only if cache contains exactly the requested limit.
1581
+ * Returns candles only if cache contains ALL requested candles.
1582
1582
  *
1583
- * Boundary semantics (EXCLUSIVE):
1584
- * - sinceTimestamp: candle.timestamp must be > sinceTimestamp
1585
- * - untilTimestamp: candle.timestamp + stepMs must be < untilTimestamp
1586
- * - Only fully closed candles within the exclusive range are returned
1583
+ * Algorithm (matches ClientExchange.ts logic):
1584
+ * 1. Calculate expected timestamps: sinceTimestamp, sinceTimestamp + stepMs, ..., sinceTimestamp + (limit-1) * stepMs
1585
+ * 2. Try to read each expected candle by timestamp key
1586
+ * 3. If ANY candle is missing, return null (cache miss)
1587
+ * 4. If all candles found, return them in order
1587
1588
  *
1588
1589
  * @param symbol - Trading pair symbol
1589
1590
  * @param interval - Candle interval
1590
1591
  * @param exchangeName - Exchange identifier
1591
1592
  * @param limit - Number of candles requested
1592
- * @param sinceTimestamp - Exclusive start timestamp in milliseconds
1593
- * @param untilTimestamp - Exclusive end timestamp in milliseconds
1593
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
1594
+ * @param _untilTimestamp - Unused, kept for API compatibility
1594
1595
  * @returns Promise resolving to array of candles or null if cache is incomplete
1595
1596
  */
1596
- this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, untilTimestamp) => {
1597
+ this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, _untilTimestamp) => {
1597
1598
  bt.loggerService.info("PersistCandleUtils.readCandlesData", {
1598
1599
  symbol,
1599
1600
  interval,
1600
1601
  exchangeName,
1601
1602
  limit,
1602
1603
  sinceTimestamp,
1603
- untilTimestamp,
1604
1604
  });
1605
1605
  const key = `${symbol}:${interval}:${exchangeName}`;
1606
1606
  const isInitial = !this.getCandlesStorage.has(key);
1607
1607
  const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
1608
1608
  await stateStorage.waitForInit(isInitial);
1609
1609
  const stepMs = INTERVAL_MINUTES$5[interval] * MS_PER_MINUTE$2;
1610
- // Collect all cached candles within the time range using EXCLUSIVE boundaries
1610
+ // Calculate expected timestamps and fetch each candle directly
1611
1611
  const cachedCandles = [];
1612
- for await (const timestamp of stateStorage.keys()) {
1613
- const ts = Number(timestamp);
1614
- // EXCLUSIVE boundaries:
1615
- // - candle.timestamp > sinceTimestamp
1616
- // - candle.timestamp + stepMs < untilTimestamp (fully closed before untilTimestamp)
1617
- if (ts > sinceTimestamp && ts + stepMs < untilTimestamp) {
1618
- try {
1619
- const candle = await stateStorage.readValue(timestamp);
1620
- cachedCandles.push(candle);
1621
- }
1622
- catch (error) {
1623
- const message = `PersistCandleUtils.readCandlesData found invalid candle symbol=${symbol} interval=${interval} timestamp=${timestamp}`;
1624
- const payload = {
1625
- error: functoolsKit.errorData(error),
1626
- message: functoolsKit.getErrorMessage(error),
1627
- };
1628
- bt.loggerService.warn(message, payload);
1629
- console.warn(message, payload);
1630
- errorEmitter.next(error);
1631
- continue;
1632
- }
1612
+ for (let i = 0; i < limit; i++) {
1613
+ const expectedTimestamp = sinceTimestamp + i * stepMs;
1614
+ const timestampKey = String(expectedTimestamp);
1615
+ if (await functoolsKit.not(stateStorage.hasValue(timestampKey))) {
1616
+ // Cache miss - candle not found
1617
+ return null;
1618
+ }
1619
+ try {
1620
+ const candle = await stateStorage.readValue(timestampKey);
1621
+ cachedCandles.push(candle);
1622
+ }
1623
+ catch (error) {
1624
+ // Invalid candle in cache - treat as cache miss
1625
+ const message = `PersistCandleUtils.readCandlesData found invalid candle symbol=${symbol} interval=${interval} timestamp=${expectedTimestamp}`;
1626
+ const payload = {
1627
+ error: functoolsKit.errorData(error),
1628
+ message: functoolsKit.getErrorMessage(error),
1629
+ };
1630
+ bt.loggerService.warn(message, payload);
1631
+ console.warn(message, payload);
1632
+ errorEmitter.next(error);
1633
+ return null;
1633
1634
  }
1634
1635
  }
1635
- // Sort by timestamp ascending
1636
- cachedCandles.sort((a, b) => a.timestamp - b.timestamp);
1637
1636
  return cachedCandles;
1638
1637
  };
1639
1638
  /**
1640
1639
  * Writes candles to cache with atomic file writes.
1641
1640
  * Each candle is stored as a separate JSON file named by its timestamp.
1642
1641
  *
1643
- * The candles passed to this function must already be filtered using EXCLUSIVE boundaries:
1644
- * - candle.timestamp > sinceTimestamp
1645
- * - candle.timestamp + stepMs < untilTimestamp
1642
+ * The candles passed to this function should be validated candles from the adapter:
1643
+ * - First candle.timestamp equals aligned sinceTimestamp (openTime)
1644
+ * - Exact number of candles as requested
1645
+ * - All candles are fully closed (timestamp + stepMs < untilTimestamp)
1646
1646
  *
1647
- * @param candles - Array of candle data to cache (already filtered with exclusive boundaries)
1647
+ * @param candles - Array of candle data to cache (validated by the caller)
1648
1648
  * @param symbol - Trading pair symbol
1649
1649
  * @param interval - Candle interval
1650
1650
  * @param exchangeName - Exchange identifier
@@ -1661,8 +1661,25 @@ class PersistCandleUtils {
1661
1661
  const isInitial = !this.getCandlesStorage.has(key);
1662
1662
  const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
1663
1663
  await stateStorage.waitForInit(isInitial);
1664
- // Write each candle as a separate file
1664
+ // Calculate step in milliseconds to determine candle close time
1665
+ const stepMs = INTERVAL_MINUTES$5[interval] * MS_PER_MINUTE$2;
1666
+ const now = Date.now();
1667
+ // Write each candle as a separate file, skipping incomplete candles
1665
1668
  for (const candle of candles) {
1669
+ // Skip incomplete candles: candle is complete when closeTime <= now
1670
+ // closeTime = timestamp + stepMs
1671
+ const candleCloseTime = candle.timestamp + stepMs;
1672
+ if (candleCloseTime > now) {
1673
+ bt.loggerService.debug("PersistCandleUtils.writeCandlesData: skipping incomplete candle", {
1674
+ symbol,
1675
+ interval,
1676
+ exchangeName,
1677
+ timestamp: candle.timestamp,
1678
+ closeTime: candleCloseTime,
1679
+ now,
1680
+ });
1681
+ continue;
1682
+ }
1666
1683
  if (await functoolsKit.not(stateStorage.hasValue(String(candle.timestamp)))) {
1667
1684
  await stateStorage.writeValue(String(candle.timestamp), candle);
1668
1685
  }
@@ -1820,6 +1837,27 @@ const INTERVAL_MINUTES$4 = {
1820
1837
  "6h": 360,
1821
1838
  "8h": 480,
1822
1839
  };
1840
+ /**
1841
+ * Aligns timestamp down to the nearest interval boundary.
1842
+ * For example, for 15m interval: 00:17 -> 00:15, 00:44 -> 00:30
1843
+ *
1844
+ * Candle timestamp convention:
1845
+ * - Candle timestamp = openTime (when candle opens)
1846
+ * - Candle with timestamp 00:00 covers period [00:00, 00:15) for 15m interval
1847
+ *
1848
+ * Adapter contract:
1849
+ * - Adapter must return candles with timestamp = openTime
1850
+ * - First returned candle.timestamp must equal aligned since
1851
+ * - Adapter must return exactly `limit` candles
1852
+ *
1853
+ * @param timestamp - Timestamp in milliseconds
1854
+ * @param intervalMinutes - Interval in minutes
1855
+ * @returns Aligned timestamp rounded down to interval boundary
1856
+ */
1857
+ const ALIGN_TO_INTERVAL_FN$1 = (timestamp, intervalMinutes) => {
1858
+ const intervalMs = intervalMinutes * MS_PER_MINUTE$1;
1859
+ return Math.floor(timestamp / intervalMs) * intervalMs;
1860
+ };
1823
1861
  /**
1824
1862
  * Validates that all candles have valid OHLCV data without anomalies.
1825
1863
  * Detects incomplete candles from Binance API by checking for abnormally low prices or volumes.
@@ -1883,25 +1921,24 @@ const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => {
1883
1921
  };
1884
1922
  /**
1885
1923
  * Attempts to read candles from cache.
1886
- * Validates cache consistency (no gaps in timestamps) before returning.
1887
1924
  *
1888
- * Boundary semantics:
1889
- * - sinceTimestamp: EXCLUSIVE lower bound (candle.timestamp > sinceTimestamp)
1890
- * - untilTimestamp: EXCLUSIVE upper bound (candle.timestamp + stepMs < untilTimestamp)
1891
- * - Only fully closed candles within the exclusive range are returned
1925
+ * Cache lookup calculates expected timestamps:
1926
+ * sinceTimestamp + i * stepMs for i = 0..limit-1
1927
+ * Returns all candles if found, null if any missing.
1892
1928
  *
1893
1929
  * @param dto - Data transfer object containing symbol, interval, and limit
1894
- * @param sinceTimestamp - Exclusive start timestamp in milliseconds
1895
- * @param untilTimestamp - Exclusive end timestamp in milliseconds
1930
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
1931
+ * @param untilTimestamp - Unused, kept for API compatibility
1896
1932
  * @param self - Instance of ClientExchange
1897
- * @returns Cached candles array or null if cache miss or inconsistent
1933
+ * @returns Cached candles array (exactly limit) or null if cache miss
1898
1934
  */
1899
1935
  const READ_CANDLES_CACHE_FN$1 = functoolsKit.trycatch(async (dto, sinceTimestamp, untilTimestamp, self) => {
1900
- // PersistCandleAdapter.readCandlesData uses EXCLUSIVE boundaries:
1901
- // Returns candles where: timestamp > sinceTimestamp AND timestamp + stepMs < untilTimestamp
1936
+ // PersistCandleAdapter.readCandlesData calculates expected timestamps:
1937
+ // sinceTimestamp + i * stepMs for i = 0..limit-1
1938
+ // Returns all candles if found, null if any missing
1902
1939
  const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, self.params.exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
1903
1940
  // Return cached data only if we have exactly the requested limit
1904
- if (cachedCandles.length === dto.limit) {
1941
+ if (cachedCandles?.length === dto.limit) {
1905
1942
  self.params.logger.debug(`ClientExchange READ_CANDLES_CACHE_FN: cache hit for symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
1906
1943
  return cachedCandles;
1907
1944
  }
@@ -1923,11 +1960,12 @@ const READ_CANDLES_CACHE_FN$1 = functoolsKit.trycatch(async (dto, sinceTimestamp
1923
1960
  /**
1924
1961
  * Writes candles to cache with error handling.
1925
1962
  *
1926
- * The candles passed to this function must already be filtered using EXCLUSIVE boundaries:
1927
- * - candle.timestamp > sinceTimestamp
1928
- * - candle.timestamp + stepMs < untilTimestamp
1963
+ * The candles passed to this function should be validated:
1964
+ * - First candle.timestamp equals aligned sinceTimestamp (openTime)
1965
+ * - Exact number of candles as requested (limit)
1966
+ * - Sequential timestamps: sinceTimestamp + i * stepMs
1929
1967
  *
1930
- * @param candles - Array of candle data to cache (already filtered with exclusive boundaries)
1968
+ * @param candles - Array of validated candle data to cache
1931
1969
  * @param dto - Data transfer object containing symbol, interval, and limit
1932
1970
  * @param self - Instance of ClientExchange
1933
1971
  */
@@ -2054,6 +2092,13 @@ class ClientExchange {
2054
2092
  /**
2055
2093
  * Fetches historical candles backwards from execution context time.
2056
2094
  *
2095
+ * Algorithm:
2096
+ * 1. Align when down to interval boundary (e.g., 00:17 -> 00:15 for 15m)
2097
+ * 2. Calculate since = alignedWhen - limit * step
2098
+ * 3. Fetch candles starting from since
2099
+ * 4. Validate first candle timestamp matches since (adapter must return inclusive data)
2100
+ * 5. Slice to limit
2101
+ *
2057
2102
  * @param symbol - Trading pair symbol
2058
2103
  * @param interval - Candle interval
2059
2104
  * @param limit - Number of candles to fetch
@@ -2066,11 +2111,16 @@ class ClientExchange {
2066
2111
  limit,
2067
2112
  });
2068
2113
  const step = INTERVAL_MINUTES$4[interval];
2069
- const adjust = step * limit;
2070
- if (!adjust) {
2071
- throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
2114
+ if (!step) {
2115
+ throw new Error(`ClientExchange unknown interval=${interval}`);
2072
2116
  }
2073
- const since = new Date(this.params.execution.context.when.getTime() - adjust * MS_PER_MINUTE$1);
2117
+ const stepMs = step * MS_PER_MINUTE$1;
2118
+ // Align when down to interval boundary
2119
+ const whenTimestamp = this.params.execution.context.when.getTime();
2120
+ const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
2121
+ // Calculate since: go back limit candles from aligned when
2122
+ const sinceTimestamp = alignedWhen - limit * stepMs;
2123
+ const since = new Date(sinceTimestamp);
2074
2124
  let allData = [];
2075
2125
  // If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
2076
2126
  if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
@@ -2083,39 +2133,34 @@ class ClientExchange {
2083
2133
  remaining -= chunkLimit;
2084
2134
  if (remaining > 0) {
2085
2135
  // Move currentSince forward by the number of candles fetched
2086
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2136
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2087
2137
  }
2088
2138
  }
2089
2139
  }
2090
2140
  else {
2091
2141
  allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
2092
2142
  }
2093
- // Filter candles to strictly match the requested range
2094
- const whenTimestamp = this.params.execution.context.when.getTime();
2095
- const sinceTimestamp = since.getTime();
2096
- const stepMs = step * MS_PER_MINUTE$1;
2097
- const filteredData = allData.filter((candle) => {
2098
- // EXCLUSIVE boundaries:
2099
- // - candle.timestamp > sinceTimestamp (exclude exact boundary)
2100
- // - candle.timestamp + stepMs < whenTimestamp (fully closed before "when")
2101
- if (candle.timestamp <= sinceTimestamp) {
2102
- return false;
2103
- }
2104
- // Check against current time (when)
2105
- // Only allow candles that have fully CLOSED before "when"
2106
- return candle.timestamp + stepMs < whenTimestamp;
2107
- });
2108
2143
  // Apply distinct by timestamp to remove duplicates
2109
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2110
- if (filteredData.length !== uniqueData.length) {
2111
- const msg = `ClientExchange Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
2144
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
2145
+ if (allData.length !== uniqueData.length) {
2146
+ const msg = `ClientExchange getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
2112
2147
  this.params.logger.warn(msg);
2113
2148
  console.warn(msg);
2114
2149
  }
2115
- if (uniqueData.length < limit) {
2116
- const msg = `ClientExchange Expected ${limit} candles, got ${uniqueData.length}`;
2117
- this.params.logger.warn(msg);
2118
- console.warn(msg);
2150
+ // Validate adapter returned data
2151
+ if (uniqueData.length === 0) {
2152
+ throw new Error(`ClientExchange getCandles: adapter returned empty array. ` +
2153
+ `Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
2154
+ }
2155
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
2156
+ throw new Error(`ClientExchange getCandles: first candle timestamp mismatch. ` +
2157
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
2158
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
2159
+ }
2160
+ if (uniqueData.length !== limit) {
2161
+ throw new Error(`ClientExchange getCandles: candle count mismatch. ` +
2162
+ `Expected ${limit} candles, got ${uniqueData.length}. ` +
2163
+ `Adapter must return exact number of candles requested.`);
2119
2164
  }
2120
2165
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
2121
2166
  return uniqueData;
@@ -2124,6 +2169,13 @@ class ClientExchange {
2124
2169
  * Fetches future candles forwards from execution context time.
2125
2170
  * Used in backtest mode to get candles for signal duration.
2126
2171
  *
2172
+ * Algorithm:
2173
+ * 1. Align when down to interval boundary (e.g., 00:17 -> 00:15 for 15m)
2174
+ * 2. since = alignedWhen (start from aligned when)
2175
+ * 3. Fetch candles starting from since
2176
+ * 4. Validate first candle timestamp matches since (adapter must return inclusive data)
2177
+ * 5. Slice to limit
2178
+ *
2127
2179
  * @param symbol - Trading pair symbol
2128
2180
  * @param interval - Candle interval
2129
2181
  * @param limit - Number of candles to fetch
@@ -2139,12 +2191,21 @@ class ClientExchange {
2139
2191
  if (!this.params.execution.context.backtest) {
2140
2192
  throw new Error(`ClientExchange getNextCandles: cannot fetch future candles in live mode`);
2141
2193
  }
2142
- const since = new Date(this.params.execution.context.when.getTime());
2143
- const now = Date.now();
2144
- // Вычисляем конечное время запроса
2145
2194
  const step = INTERVAL_MINUTES$4[interval];
2146
- const endTime = since.getTime() + limit * step * MS_PER_MINUTE$1;
2147
- // Проверяем что запрошенный период не заходит за Date.now()
2195
+ if (!step) {
2196
+ throw new Error(`ClientExchange getNextCandles: unknown interval=${interval}`);
2197
+ }
2198
+ const stepMs = step * MS_PER_MINUTE$1;
2199
+ const now = Date.now();
2200
+ // Align when down to interval boundary
2201
+ const whenTimestamp = this.params.execution.context.when.getTime();
2202
+ const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
2203
+ // since = alignedWhen (start from aligned when, going forward)
2204
+ const sinceTimestamp = alignedWhen;
2205
+ const since = new Date(sinceTimestamp);
2206
+ // Calculate end time for Date.now() check
2207
+ const endTime = sinceTimestamp + limit * stepMs;
2208
+ // Check that requested period does not exceed Date.now()
2148
2209
  if (endTime > now) {
2149
2210
  return [];
2150
2211
  }
@@ -2160,29 +2221,34 @@ class ClientExchange {
2160
2221
  remaining -= chunkLimit;
2161
2222
  if (remaining > 0) {
2162
2223
  // Move currentSince forward by the number of candles fetched
2163
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2224
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2164
2225
  }
2165
2226
  }
2166
2227
  }
2167
2228
  else {
2168
2229
  allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
2169
2230
  }
2170
- // Filter candles to strictly match the requested range
2171
- const sinceTimestamp = since.getTime();
2172
- const stepMs = step * MS_PER_MINUTE$1;
2173
- const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
2174
- candle.timestamp + stepMs < endTime);
2175
2231
  // Apply distinct by timestamp to remove duplicates
2176
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2177
- if (filteredData.length !== uniqueData.length) {
2178
- const msg = `ClientExchange getNextCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
2232
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
2233
+ if (allData.length !== uniqueData.length) {
2234
+ const msg = `ClientExchange getNextCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
2179
2235
  this.params.logger.warn(msg);
2180
2236
  console.warn(msg);
2181
2237
  }
2182
- if (uniqueData.length < limit) {
2183
- const msg = `ClientExchange getNextCandles: Expected ${limit} candles, got ${uniqueData.length}`;
2184
- this.params.logger.warn(msg);
2185
- console.warn(msg);
2238
+ // Validate adapter returned data
2239
+ if (uniqueData.length === 0) {
2240
+ throw new Error(`ClientExchange getNextCandles: adapter returned empty array. ` +
2241
+ `Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
2242
+ }
2243
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
2244
+ throw new Error(`ClientExchange getNextCandles: first candle timestamp mismatch. ` +
2245
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
2246
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
2247
+ }
2248
+ if (uniqueData.length !== limit) {
2249
+ throw new Error(`ClientExchange getNextCandles: candle count mismatch. ` +
2250
+ `Expected ${limit} candles, got ${uniqueData.length}. ` +
2251
+ `Adapter must return exact number of candles requested.`);
2186
2252
  }
2187
2253
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
2188
2254
  return uniqueData;
@@ -2257,6 +2323,12 @@ class ClientExchange {
2257
2323
  /**
2258
2324
  * Fetches raw candles with flexible date/limit parameters.
2259
2325
  *
2326
+ * Algorithm:
2327
+ * 1. Align all timestamps down to interval boundary
2328
+ * 2. Fetch candles starting from aligned since
2329
+ * 3. Validate first candle timestamp matches aligned since (adapter must return inclusive data)
2330
+ * 4. Slice to limit
2331
+ *
2260
2332
  * All modes respect execution context and prevent look-ahead bias.
2261
2333
  *
2262
2334
  * Parameter combinations:
@@ -2291,9 +2363,10 @@ class ClientExchange {
2291
2363
  if (!step) {
2292
2364
  throw new Error(`ClientExchange getRawCandles: unknown interval=${interval}`);
2293
2365
  }
2366
+ const stepMs = step * MS_PER_MINUTE$1;
2294
2367
  const whenTimestamp = this.params.execution.context.when.getTime();
2368
+ const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
2295
2369
  let sinceTimestamp;
2296
- let untilTimestamp;
2297
2370
  let calculatedLimit;
2298
2371
  // Case 1: all three parameters provided
2299
2372
  if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
@@ -2303,8 +2376,8 @@ class ClientExchange {
2303
2376
  if (eDate > whenTimestamp) {
2304
2377
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2305
2378
  }
2306
- sinceTimestamp = sDate;
2307
- untilTimestamp = eDate;
2379
+ // Align sDate down to interval boundary
2380
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
2308
2381
  calculatedLimit = limit;
2309
2382
  }
2310
2383
  // Case 2: sDate + eDate (no limit) - calculate limit from date range
@@ -2315,9 +2388,10 @@ class ClientExchange {
2315
2388
  if (eDate > whenTimestamp) {
2316
2389
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2317
2390
  }
2318
- sinceTimestamp = sDate;
2319
- untilTimestamp = eDate;
2320
- calculatedLimit = Math.ceil((eDate - sDate) / (step * MS_PER_MINUTE$1));
2391
+ // Align sDate down to interval boundary
2392
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
2393
+ const alignedEDate = ALIGN_TO_INTERVAL_FN$1(eDate, step);
2394
+ calculatedLimit = Math.ceil((alignedEDate - sinceTimestamp) / stepMs);
2321
2395
  if (calculatedLimit <= 0) {
2322
2396
  throw new Error(`ClientExchange getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
2323
2397
  }
@@ -2327,23 +2401,24 @@ class ClientExchange {
2327
2401
  if (eDate > whenTimestamp) {
2328
2402
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2329
2403
  }
2330
- untilTimestamp = eDate;
2331
- sinceTimestamp = eDate - limit * step * MS_PER_MINUTE$1;
2404
+ // Align eDate down and calculate sinceTimestamp
2405
+ const alignedEDate = ALIGN_TO_INTERVAL_FN$1(eDate, step);
2406
+ sinceTimestamp = alignedEDate - limit * stepMs;
2332
2407
  calculatedLimit = limit;
2333
2408
  }
2334
2409
  // Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
2335
2410
  else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
2336
- sinceTimestamp = sDate;
2337
- untilTimestamp = sDate + limit * step * MS_PER_MINUTE$1;
2338
- if (untilTimestamp > whenTimestamp) {
2339
- throw new Error(`ClientExchange getRawCandles: calculated endTimestamp (${untilTimestamp}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2411
+ // Align sDate down to interval boundary
2412
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
2413
+ const endTimestamp = sinceTimestamp + limit * stepMs;
2414
+ if (endTimestamp > whenTimestamp) {
2415
+ throw new Error(`ClientExchange getRawCandles: calculated endTimestamp (${endTimestamp}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2340
2416
  }
2341
2417
  calculatedLimit = limit;
2342
2418
  }
2343
2419
  // Case 5: Only limit - use execution.context.when as reference (backward like getCandles)
2344
2420
  else if (sDate === undefined && eDate === undefined && limit !== undefined) {
2345
- untilTimestamp = whenTimestamp;
2346
- sinceTimestamp = whenTimestamp - limit * step * MS_PER_MINUTE$1;
2421
+ sinceTimestamp = alignedWhen - limit * stepMs;
2347
2422
  calculatedLimit = limit;
2348
2423
  }
2349
2424
  // Invalid: no parameters or only sDate or only eDate
@@ -2364,29 +2439,34 @@ class ClientExchange {
2364
2439
  allData.push(...chunkData);
2365
2440
  remaining -= chunkLimit;
2366
2441
  if (remaining > 0) {
2367
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2442
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2368
2443
  }
2369
2444
  }
2370
2445
  }
2371
2446
  else {
2372
2447
  allData = await GET_CANDLES_FN({ symbol, interval, limit: calculatedLimit }, since, this);
2373
2448
  }
2374
- // Filter candles to strictly match the requested range
2375
- // Only include candles that have fully CLOSED before untilTimestamp
2376
- const stepMs = step * MS_PER_MINUTE$1;
2377
- const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
2378
- candle.timestamp + stepMs < untilTimestamp);
2379
2449
  // Apply distinct by timestamp to remove duplicates
2380
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2381
- if (filteredData.length !== uniqueData.length) {
2382
- const msg = `ClientExchange getRawCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
2450
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
2451
+ if (allData.length !== uniqueData.length) {
2452
+ const msg = `ClientExchange getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
2383
2453
  this.params.logger.warn(msg);
2384
2454
  console.warn(msg);
2385
2455
  }
2386
- if (uniqueData.length < calculatedLimit) {
2387
- const msg = `ClientExchange getRawCandles: Expected ${calculatedLimit} candles, got ${uniqueData.length}`;
2388
- this.params.logger.warn(msg);
2389
- console.warn(msg);
2456
+ // Validate adapter returned data
2457
+ if (uniqueData.length === 0) {
2458
+ throw new Error(`ClientExchange getRawCandles: adapter returned empty array. ` +
2459
+ `Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
2460
+ }
2461
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
2462
+ throw new Error(`ClientExchange getRawCandles: first candle timestamp mismatch. ` +
2463
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
2464
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
2465
+ }
2466
+ if (uniqueData.length !== calculatedLimit) {
2467
+ throw new Error(`ClientExchange getRawCandles: candle count mismatch. ` +
2468
+ `Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
2469
+ `Adapter must return exact number of candles requested.`);
2390
2470
  }
2391
2471
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, calculatedLimit, uniqueData);
2392
2472
  return uniqueData;
@@ -4944,6 +5024,68 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
4944
5024
  const result = await CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN(self, scheduled, averagePrice, candle.timestamp, "user");
4945
5025
  return { activated: false, cancelled: true, activationIndex: i, result };
4946
5026
  }
5027
+ // КРИТИЧНО: Проверяем был ли сигнал активирован пользователем через activateScheduled()
5028
+ // Обрабатываем inline (как в tick()) с риск-проверкой по averagePrice
5029
+ if (self._activatedSignal) {
5030
+ const activatedSignal = self._activatedSignal;
5031
+ self._activatedSignal = null;
5032
+ // Check if strategy was stopped
5033
+ if (self._isStopped) {
5034
+ self.params.logger.info("ClientStrategy backtest user-activated signal cancelled (stopped)", {
5035
+ symbol: self.params.execution.context.symbol,
5036
+ signalId: activatedSignal.id,
5037
+ });
5038
+ await self.setScheduledSignal(null);
5039
+ return { activated: false, cancelled: false, activationIndex: i, result: null };
5040
+ }
5041
+ // Риск-проверка по averagePrice (симметрия с LIVE tick())
5042
+ if (await functoolsKit.not(CALL_RISK_CHECK_SIGNAL_FN(self, self.params.execution.context.symbol, activatedSignal, averagePrice, candle.timestamp, self.params.execution.context.backtest))) {
5043
+ self.params.logger.info("ClientStrategy backtest user-activated signal rejected by risk", {
5044
+ symbol: self.params.execution.context.symbol,
5045
+ signalId: activatedSignal.id,
5046
+ });
5047
+ await self.setScheduledSignal(null);
5048
+ return { activated: false, cancelled: false, activationIndex: i, result: null };
5049
+ }
5050
+ await self.setScheduledSignal(null);
5051
+ const pendingSignal = {
5052
+ ...activatedSignal,
5053
+ pendingAt: candle.timestamp,
5054
+ _isScheduled: false,
5055
+ };
5056
+ await self.setPendingSignal(pendingSignal);
5057
+ await CALL_RISK_ADD_SIGNAL_FN(self, self.params.execution.context.symbol, pendingSignal, candle.timestamp, self.params.execution.context.backtest);
5058
+ // Emit commit AFTER successful risk check
5059
+ const publicSignalForCommit = TO_PUBLIC_SIGNAL(pendingSignal);
5060
+ await CALL_COMMIT_FN(self, {
5061
+ action: "activate-scheduled",
5062
+ symbol: self.params.execution.context.symbol,
5063
+ strategyName: self.params.strategyName,
5064
+ exchangeName: self.params.exchangeName,
5065
+ frameName: self.params.frameName,
5066
+ signalId: activatedSignal.id,
5067
+ backtest: self.params.execution.context.backtest,
5068
+ activateId: activatedSignal.activateId,
5069
+ timestamp: candle.timestamp,
5070
+ currentPrice: averagePrice,
5071
+ position: publicSignalForCommit.position,
5072
+ priceOpen: publicSignalForCommit.priceOpen,
5073
+ priceTakeProfit: publicSignalForCommit.priceTakeProfit,
5074
+ priceStopLoss: publicSignalForCommit.priceStopLoss,
5075
+ originalPriceTakeProfit: publicSignalForCommit.originalPriceTakeProfit,
5076
+ originalPriceStopLoss: publicSignalForCommit.originalPriceStopLoss,
5077
+ scheduledAt: publicSignalForCommit.scheduledAt,
5078
+ pendingAt: publicSignalForCommit.pendingAt,
5079
+ });
5080
+ await CALL_OPEN_CALLBACKS_FN(self, self.params.execution.context.symbol, pendingSignal, pendingSignal.priceOpen, candle.timestamp, self.params.execution.context.backtest);
5081
+ await CALL_BACKTEST_SCHEDULE_OPEN_FN(self, self.params.execution.context.symbol, pendingSignal, candle.timestamp, self.params.execution.context.backtest);
5082
+ return {
5083
+ activated: true,
5084
+ cancelled: false,
5085
+ activationIndex: i,
5086
+ result: null,
5087
+ };
5088
+ }
4947
5089
  // КРИТИЧНО: Проверяем timeout ПЕРЕД проверкой цены
4948
5090
  const elapsedTime = candle.timestamp - scheduled.scheduledAt;
4949
5091
  if (elapsedTime >= maxTimeToWait) {
@@ -5166,6 +5308,7 @@ class ClientStrategy {
5166
5308
  this._scheduledSignal = null;
5167
5309
  this._cancelledSignal = null;
5168
5310
  this._closedSignal = null;
5311
+ this._activatedSignal = null;
5169
5312
  /** Queue for commit events to be processed in tick()/backtest() with proper timestamp */
5170
5313
  this._commitQueue = [];
5171
5314
  /**
@@ -5203,6 +5346,16 @@ class ClientStrategy {
5203
5346
  this.params.logger.debug("ClientStrategy setPendingSignal", {
5204
5347
  pendingSignal,
5205
5348
  });
5349
+ // КРИТИЧНО: Очищаем флаг закрытия при любом изменении pending signal
5350
+ // - при null: сигнал закрыт по TP/SL/timeout, флаг больше не нужен
5351
+ // - при новом сигнале: флаг от предыдущего сигнала не должен влиять на новый
5352
+ this._closedSignal = null;
5353
+ // ЗАЩИТА ИНВАРИАНТА: При установке нового pending сигнала очищаем scheduled
5354
+ // Не может быть одновременно pending И scheduled (взаимоисключающие состояния)
5355
+ // При null: scheduled может существовать (новый сигнал после закрытия позиции)
5356
+ if (pendingSignal !== null) {
5357
+ this._scheduledSignal = null;
5358
+ }
5206
5359
  this._pendingSignal = pendingSignal;
5207
5360
  // КРИТИЧНО: Всегда вызываем коллбек onWrite для тестирования persist storage
5208
5361
  // даже в backtest режиме, чтобы тесты могли перехватывать вызовы через mock adapter
@@ -5228,6 +5381,11 @@ class ClientStrategy {
5228
5381
  this.params.logger.debug("ClientStrategy setScheduledSignal", {
5229
5382
  scheduledSignal,
5230
5383
  });
5384
+ // КРИТИЧНО: Очищаем флаги отмены и активации при любом изменении scheduled signal
5385
+ // - при null: сигнал отменен/активирован по timeout/SL/user, флаги больше не нужны
5386
+ // - при новом сигнале: флаги от предыдущего сигнала не должны влиять на новый
5387
+ this._cancelledSignal = null;
5388
+ this._activatedSignal = null;
5231
5389
  this._scheduledSignal = scheduledSignal;
5232
5390
  if (this.params.execution.context.backtest) {
5233
5391
  return;
@@ -5417,12 +5575,8 @@ class ClientStrategy {
5417
5575
  const currentTime = this.params.execution.context.when.getTime();
5418
5576
  // Process queued commit events with proper timestamp
5419
5577
  await PROCESS_COMMIT_QUEUE_FN(this, currentTime);
5420
- // Early return if strategy was stopped
5421
- if (this._isStopped) {
5422
- const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
5423
- return await RETURN_IDLE_FN(this, currentPrice);
5424
- }
5425
5578
  // Check if scheduled signal was cancelled - emit cancelled event once
5579
+ // NOTE: No _isStopped check here - cancellation must work for graceful shutdown
5426
5580
  if (this._cancelledSignal) {
5427
5581
  const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
5428
5582
  const cancelledSignal = this._cancelledSignal;
@@ -5431,6 +5585,18 @@ class ClientStrategy {
5431
5585
  symbol: this.params.execution.context.symbol,
5432
5586
  signalId: cancelledSignal.id,
5433
5587
  });
5588
+ // Emit commit with correct timestamp from tick context
5589
+ await CALL_COMMIT_FN(this, {
5590
+ action: "cancel-scheduled",
5591
+ symbol: this.params.execution.context.symbol,
5592
+ strategyName: this.params.strategyName,
5593
+ exchangeName: this.params.exchangeName,
5594
+ frameName: this.params.frameName,
5595
+ signalId: cancelledSignal.id,
5596
+ backtest: this.params.execution.context.backtest,
5597
+ cancelId: cancelledSignal.cancelId,
5598
+ timestamp: currentTime,
5599
+ });
5434
5600
  // Call onCancel callback
5435
5601
  await CALL_CANCEL_CALLBACKS_FN(this, this.params.execution.context.symbol, cancelledSignal, currentPrice, currentTime, this.params.execution.context.backtest);
5436
5602
  const result = {
@@ -5459,6 +5625,18 @@ class ClientStrategy {
5459
5625
  symbol: this.params.execution.context.symbol,
5460
5626
  signalId: closedSignal.id,
5461
5627
  });
5628
+ // Emit commit with correct timestamp from tick context
5629
+ await CALL_COMMIT_FN(this, {
5630
+ action: "close-pending",
5631
+ symbol: this.params.execution.context.symbol,
5632
+ strategyName: this.params.strategyName,
5633
+ exchangeName: this.params.exchangeName,
5634
+ frameName: this.params.frameName,
5635
+ signalId: closedSignal.id,
5636
+ backtest: this.params.execution.context.backtest,
5637
+ closeId: closedSignal.closeId,
5638
+ timestamp: currentTime,
5639
+ });
5462
5640
  // Call onClose callback
5463
5641
  await CALL_CLOSE_CALLBACKS_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, currentTime, this.params.execution.context.backtest);
5464
5642
  // КРИТИЧНО: Очищаем состояние ClientPartial при закрытии позиции
@@ -5485,6 +5663,78 @@ class ClientStrategy {
5485
5663
  await CALL_TICK_CALLBACKS_FN(this, this.params.execution.context.symbol, result, currentTime, this.params.execution.context.backtest);
5486
5664
  return result;
5487
5665
  }
5666
+ // Check if scheduled signal was activated - emit opened event once
5667
+ if (this._activatedSignal) {
5668
+ const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
5669
+ const activatedSignal = this._activatedSignal;
5670
+ this._activatedSignal = null; // Clear after emitting
5671
+ this.params.logger.info("ClientStrategy tick: scheduled signal was activated", {
5672
+ symbol: this.params.execution.context.symbol,
5673
+ signalId: activatedSignal.id,
5674
+ });
5675
+ // Check if strategy was stopped (symmetry with backtest PROCESS_SCHEDULED_SIGNAL_CANDLES_FN)
5676
+ if (this._isStopped) {
5677
+ this.params.logger.info("ClientStrategy tick: user-activated signal cancelled (stopped)", {
5678
+ symbol: this.params.execution.context.symbol,
5679
+ signalId: activatedSignal.id,
5680
+ });
5681
+ await this.setScheduledSignal(null);
5682
+ return await RETURN_IDLE_FN(this, currentPrice);
5683
+ }
5684
+ // Check risk before activation
5685
+ if (await functoolsKit.not(CALL_RISK_CHECK_SIGNAL_FN(this, this.params.execution.context.symbol, activatedSignal, currentPrice, currentTime, this.params.execution.context.backtest))) {
5686
+ this.params.logger.info("ClientStrategy tick: activated signal rejected by risk", {
5687
+ symbol: this.params.execution.context.symbol,
5688
+ signalId: activatedSignal.id,
5689
+ });
5690
+ return await RETURN_IDLE_FN(this, currentPrice);
5691
+ }
5692
+ // КРИТИЧЕСКИ ВАЖНО: обновляем pendingAt при активации
5693
+ const pendingSignal = {
5694
+ ...activatedSignal,
5695
+ pendingAt: currentTime,
5696
+ _isScheduled: false,
5697
+ };
5698
+ await this.setPendingSignal(pendingSignal);
5699
+ await CALL_RISK_ADD_SIGNAL_FN(this, this.params.execution.context.symbol, pendingSignal, currentTime, this.params.execution.context.backtest);
5700
+ // Emit commit AFTER successful risk check
5701
+ const publicSignalForCommit = TO_PUBLIC_SIGNAL(pendingSignal);
5702
+ await CALL_COMMIT_FN(this, {
5703
+ action: "activate-scheduled",
5704
+ symbol: this.params.execution.context.symbol,
5705
+ strategyName: this.params.strategyName,
5706
+ exchangeName: this.params.exchangeName,
5707
+ frameName: this.params.frameName,
5708
+ signalId: activatedSignal.id,
5709
+ backtest: this.params.execution.context.backtest,
5710
+ activateId: activatedSignal.activateId,
5711
+ timestamp: currentTime,
5712
+ currentPrice,
5713
+ position: publicSignalForCommit.position,
5714
+ priceOpen: publicSignalForCommit.priceOpen,
5715
+ priceTakeProfit: publicSignalForCommit.priceTakeProfit,
5716
+ priceStopLoss: publicSignalForCommit.priceStopLoss,
5717
+ originalPriceTakeProfit: publicSignalForCommit.originalPriceTakeProfit,
5718
+ originalPriceStopLoss: publicSignalForCommit.originalPriceStopLoss,
5719
+ scheduledAt: publicSignalForCommit.scheduledAt,
5720
+ pendingAt: publicSignalForCommit.pendingAt,
5721
+ });
5722
+ // Call onOpen callback
5723
+ await CALL_OPEN_CALLBACKS_FN(this, this.params.execution.context.symbol, pendingSignal, currentPrice, currentTime, this.params.execution.context.backtest);
5724
+ const result = {
5725
+ action: "opened",
5726
+ signal: TO_PUBLIC_SIGNAL(pendingSignal),
5727
+ strategyName: this.params.method.context.strategyName,
5728
+ exchangeName: this.params.method.context.exchangeName,
5729
+ frameName: this.params.method.context.frameName,
5730
+ symbol: this.params.execution.context.symbol,
5731
+ currentPrice,
5732
+ backtest: this.params.execution.context.backtest,
5733
+ createdAt: currentTime,
5734
+ };
5735
+ await CALL_TICK_CALLBACKS_FN(this, this.params.execution.context.symbol, result, currentTime, this.params.execution.context.backtest);
5736
+ return result;
5737
+ }
5488
5738
  // Monitor scheduled signal
5489
5739
  if (this._scheduledSignal && !this._pendingSignal) {
5490
5740
  const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
@@ -5508,7 +5758,12 @@ class ClientStrategy {
5508
5758
  return await RETURN_SCHEDULED_SIGNAL_ACTIVE_FN(this, this._scheduledSignal, currentPrice);
5509
5759
  }
5510
5760
  // Generate new signal if none exists
5761
+ // NOTE: _isStopped blocks NEW signal generation but allows existing positions to continue
5511
5762
  if (!this._pendingSignal && !this._scheduledSignal) {
5763
+ if (this._isStopped) {
5764
+ const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
5765
+ return await RETURN_IDLE_FN(this, currentPrice);
5766
+ }
5512
5767
  const signal = await GET_SIGNAL_FN(this);
5513
5768
  if (signal) {
5514
5769
  if (signal._isScheduled === true) {
@@ -5582,6 +5837,18 @@ class ClientStrategy {
5582
5837
  const cancelledSignal = this._cancelledSignal;
5583
5838
  this._cancelledSignal = null; // Clear after using
5584
5839
  const closeTimestamp = this.params.execution.context.when.getTime();
5840
+ // Emit commit with correct timestamp from backtest context
5841
+ await CALL_COMMIT_FN(this, {
5842
+ action: "cancel-scheduled",
5843
+ symbol: this.params.execution.context.symbol,
5844
+ strategyName: this.params.strategyName,
5845
+ exchangeName: this.params.exchangeName,
5846
+ frameName: this.params.frameName,
5847
+ signalId: cancelledSignal.id,
5848
+ backtest: true,
5849
+ cancelId: cancelledSignal.cancelId,
5850
+ timestamp: closeTimestamp,
5851
+ });
5585
5852
  await CALL_CANCEL_CALLBACKS_FN(this, this.params.execution.context.symbol, cancelledSignal, currentPrice, closeTimestamp, this.params.execution.context.backtest);
5586
5853
  const cancelledResult = {
5587
5854
  action: "cancelled",
@@ -5607,6 +5874,18 @@ class ClientStrategy {
5607
5874
  const closedSignal = this._closedSignal;
5608
5875
  this._closedSignal = null; // Clear after using
5609
5876
  const closeTimestamp = this.params.execution.context.when.getTime();
5877
+ // Emit commit with correct timestamp from backtest context
5878
+ await CALL_COMMIT_FN(this, {
5879
+ action: "close-pending",
5880
+ symbol: this.params.execution.context.symbol,
5881
+ strategyName: this.params.strategyName,
5882
+ exchangeName: this.params.exchangeName,
5883
+ frameName: this.params.frameName,
5884
+ signalId: closedSignal.id,
5885
+ backtest: true,
5886
+ closeId: closedSignal.closeId,
5887
+ timestamp: closeTimestamp,
5888
+ });
5610
5889
  await CALL_CLOSE_CALLBACKS_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, closeTimestamp, this.params.execution.context.backtest);
5611
5890
  // КРИТИЧНО: Очищаем состояние ClientPartial при закрытии позиции
5612
5891
  await CALL_PARTIAL_CLEAR_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, closeTimestamp, this.params.execution.context.backtest);
@@ -5767,8 +6046,18 @@ class ClientStrategy {
5767
6046
  symbol,
5768
6047
  hasPendingSignal: this._pendingSignal !== null,
5769
6048
  hasScheduledSignal: this._scheduledSignal !== null,
6049
+ hasActivatedSignal: this._activatedSignal !== null,
6050
+ hasCancelledSignal: this._cancelledSignal !== null,
6051
+ hasClosedSignal: this._closedSignal !== null,
5770
6052
  });
5771
6053
  this._isStopped = true;
6054
+ // Clear pending flags to start from clean state
6055
+ // NOTE: _isStopped blocks NEW position opening, but allows:
6056
+ // - cancelScheduled() / closePending() for graceful shutdown
6057
+ // - Monitoring existing _pendingSignal until TP/SL/timeout
6058
+ this._activatedSignal = null;
6059
+ this._cancelledSignal = null;
6060
+ this._closedSignal = null;
5772
6061
  // Clear scheduled signal if exists
5773
6062
  if (!this._scheduledSignal) {
5774
6063
  return;
@@ -5806,8 +6095,9 @@ class ClientStrategy {
5806
6095
  hasScheduledSignal: this._scheduledSignal !== null,
5807
6096
  cancelId,
5808
6097
  });
5809
- // Save cancelled signal for next tick to emit cancelled event
5810
- const hadScheduledSignal = this._scheduledSignal !== null;
6098
+ // NOTE: No _isStopped check - cancellation must work for graceful shutdown
6099
+ // (cancelling scheduled signal is not opening new position)
6100
+ // Save cancelled signal for next tick/backtest to emit cancelled event with correct timestamp
5811
6101
  if (this._scheduledSignal) {
5812
6102
  this._cancelledSignal = Object.assign({}, this._scheduledSignal, {
5813
6103
  cancelId,
@@ -5815,37 +6105,60 @@ class ClientStrategy {
5815
6105
  this._scheduledSignal = null;
5816
6106
  }
5817
6107
  if (backtest) {
5818
- // Emit commit event only if signal was actually cancelled
5819
- if (hadScheduledSignal) {
5820
- await CALL_COMMIT_FN(this, {
5821
- action: "cancel-scheduled",
5822
- symbol,
5823
- strategyName: this.params.strategyName,
5824
- exchangeName: this.params.exchangeName,
5825
- frameName: this.params.frameName,
5826
- signalId: this._cancelledSignal.id,
5827
- backtest,
5828
- cancelId,
5829
- timestamp: this.params.execution.context.when.getTime(),
5830
- });
5831
- }
6108
+ // Commit will be emitted in backtest() with correct candle timestamp
5832
6109
  return;
5833
6110
  }
5834
6111
  await PersistScheduleAdapter.writeScheduleData(this._scheduledSignal, symbol, this.params.method.context.strategyName, this.params.method.context.exchangeName);
5835
- // Emit commit event only if signal was actually cancelled
5836
- if (hadScheduledSignal) {
5837
- await CALL_COMMIT_FN(this, {
5838
- action: "cancel-scheduled",
6112
+ // Commit will be emitted in tick() with correct currentTime
6113
+ }
6114
+ /**
6115
+ * Activates the scheduled signal without waiting for price to reach priceOpen.
6116
+ *
6117
+ * Forces immediate activation of the scheduled signal at the current price.
6118
+ * Does NOT affect active pending signals or strategy operation.
6119
+ * Does NOT set stop flag - strategy can continue generating new signals.
6120
+ *
6121
+ * Use case: User-initiated early activation of a scheduled entry.
6122
+ *
6123
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
6124
+ * @param backtest - Whether running in backtest mode
6125
+ * @param activateId - Optional identifier for this activation operation
6126
+ * @returns Promise that resolves when scheduled signal is activated
6127
+ *
6128
+ * @example
6129
+ * ```typescript
6130
+ * // Activate scheduled signal without waiting for priceOpen
6131
+ * await strategy.activateScheduled("BTCUSDT", false, "user-activate-123");
6132
+ * // Scheduled signal becomes pending signal immediately
6133
+ * ```
6134
+ */
6135
+ async activateScheduled(symbol, backtest, activateId) {
6136
+ this.params.logger.debug("ClientStrategy activateScheduled", {
6137
+ symbol,
6138
+ hasScheduledSignal: this._scheduledSignal !== null,
6139
+ activateId,
6140
+ });
6141
+ // Block activation if strategy stopped - activation = opening NEW position
6142
+ // (unlike cancelScheduled/closePending which handle existing signals for graceful shutdown)
6143
+ if (this._isStopped) {
6144
+ this.params.logger.debug("ClientStrategy activateScheduled: strategy stopped, skipping", {
5839
6145
  symbol,
5840
- strategyName: this.params.strategyName,
5841
- exchangeName: this.params.exchangeName,
5842
- frameName: this.params.frameName,
5843
- signalId: this._cancelledSignal.id,
5844
- backtest,
5845
- cancelId,
5846
- timestamp: this.params.execution.context.when.getTime(),
5847
6146
  });
6147
+ return;
6148
+ }
6149
+ // Save activated signal for next tick to emit opened event
6150
+ if (this._scheduledSignal) {
6151
+ this._activatedSignal = Object.assign({}, this._scheduledSignal, {
6152
+ activateId,
6153
+ });
6154
+ this._scheduledSignal = null;
6155
+ }
6156
+ if (backtest) {
6157
+ // Commit will be emitted AFTER successful risk check in PROCESS_SCHEDULED_SIGNAL_CANDLES_FN
6158
+ return;
5848
6159
  }
6160
+ await PersistScheduleAdapter.writeScheduleData(this._scheduledSignal, symbol, this.params.method.context.strategyName, this.params.method.context.exchangeName);
6161
+ // Commit will be emitted AFTER successful risk check in tick()
5849
6162
  }
5850
6163
  /**
5851
6164
  * Closes the pending signal without stopping the strategy.
@@ -5874,8 +6187,8 @@ class ClientStrategy {
5874
6187
  hasPendingSignal: this._pendingSignal !== null,
5875
6188
  closeId,
5876
6189
  });
5877
- // Save closed signal for next tick to emit closed event
5878
- const hadPendingSignal = this._pendingSignal !== null;
6190
+ // NOTE: No _isStopped check - closing position must work for graceful shutdown
6191
+ // Save closed signal for next tick/backtest to emit closed event with correct timestamp
5879
6192
  if (this._pendingSignal) {
5880
6193
  this._closedSignal = Object.assign({}, this._pendingSignal, {
5881
6194
  closeId,
@@ -5883,37 +6196,11 @@ class ClientStrategy {
5883
6196
  this._pendingSignal = null;
5884
6197
  }
5885
6198
  if (backtest) {
5886
- // Emit commit event only if signal was actually closed
5887
- if (hadPendingSignal) {
5888
- await CALL_COMMIT_FN(this, {
5889
- action: "close-pending",
5890
- symbol,
5891
- strategyName: this.params.strategyName,
5892
- exchangeName: this.params.exchangeName,
5893
- frameName: this.params.frameName,
5894
- signalId: this._closedSignal.id,
5895
- backtest,
5896
- closeId,
5897
- timestamp: this.params.execution.context.when.getTime(),
5898
- });
5899
- }
6199
+ // Commit will be emitted in backtest() with correct candle timestamp
5900
6200
  return;
5901
6201
  }
5902
6202
  await PersistSignalAdapter.writeSignalData(this._pendingSignal, symbol, this.params.strategyName, this.params.exchangeName);
5903
- // Emit commit event only if signal was actually closed
5904
- if (hadPendingSignal) {
5905
- await CALL_COMMIT_FN(this, {
5906
- action: "close-pending",
5907
- symbol,
5908
- strategyName: this.params.strategyName,
5909
- exchangeName: this.params.exchangeName,
5910
- frameName: this.params.frameName,
5911
- signalId: this._closedSignal.id,
5912
- backtest,
5913
- closeId,
5914
- timestamp: this.params.execution.context.when.getTime(),
5915
- });
5916
- }
6203
+ // Commit will be emitted in tick() with correct currentTime
5917
6204
  }
5918
6205
  /**
5919
6206
  * Executes partial close at profit level (moving toward TP).
@@ -7752,6 +8039,39 @@ class StrategyConnectionService {
7752
8039
  const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
7753
8040
  return await strategy.breakeven(symbol, currentPrice, backtest);
7754
8041
  };
8042
+ /**
8043
+ * Activates a scheduled signal early without waiting for price to reach priceOpen.
8044
+ *
8045
+ * Delegates to ClientStrategy.activateScheduled() which sets _activatedSignal flag.
8046
+ * The actual activation happens on next tick() when strategy detects the flag.
8047
+ *
8048
+ * @param backtest - Whether running in backtest mode
8049
+ * @param symbol - Trading pair symbol
8050
+ * @param context - Execution context with strategyName, exchangeName, frameName
8051
+ * @param activateId - Optional identifier for the activation reason
8052
+ * @returns Promise that resolves when activation flag is set
8053
+ *
8054
+ * @example
8055
+ * ```typescript
8056
+ * // Activate scheduled signal early
8057
+ * await strategyConnectionService.activateScheduled(
8058
+ * false,
8059
+ * "BTCUSDT",
8060
+ * { strategyName: "my-strategy", exchangeName: "binance", frameName: "" },
8061
+ * "manual-activation"
8062
+ * );
8063
+ * ```
8064
+ */
8065
+ this.activateScheduled = async (backtest, symbol, context, activateId) => {
8066
+ this.loggerService.log("strategyConnectionService activateScheduled", {
8067
+ symbol,
8068
+ context,
8069
+ backtest,
8070
+ activateId,
8071
+ });
8072
+ const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
8073
+ return await strategy.activateScheduled(symbol, backtest, activateId);
8074
+ };
7755
8075
  }
7756
8076
  }
7757
8077
 
@@ -11064,6 +11384,39 @@ class StrategyCoreService {
11064
11384
  await this.validate(context);
11065
11385
  return await this.strategyConnectionService.breakeven(backtest, symbol, currentPrice, context);
11066
11386
  };
11387
+ /**
11388
+ * Activates a scheduled signal early without waiting for price to reach priceOpen.
11389
+ *
11390
+ * Validates strategy existence and delegates to connection service
11391
+ * to set the activation flag. The actual activation happens on next tick().
11392
+ *
11393
+ * @param backtest - Whether running in backtest mode
11394
+ * @param symbol - Trading pair symbol
11395
+ * @param context - Execution context with strategyName, exchangeName, frameName
11396
+ * @param activateId - Optional identifier for the activation reason
11397
+ * @returns Promise that resolves when activation flag is set
11398
+ *
11399
+ * @example
11400
+ * ```typescript
11401
+ * // Activate scheduled signal early
11402
+ * await strategyCoreService.activateScheduled(
11403
+ * false,
11404
+ * "BTCUSDT",
11405
+ * { strategyName: "my-strategy", exchangeName: "binance", frameName: "" },
11406
+ * "manual-activation"
11407
+ * );
11408
+ * ```
11409
+ */
11410
+ this.activateScheduled = async (backtest, symbol, context, activateId) => {
11411
+ this.loggerService.log("strategyCoreService activateScheduled", {
11412
+ symbol,
11413
+ context,
11414
+ backtest,
11415
+ activateId,
11416
+ });
11417
+ await this.validate(context);
11418
+ return await this.strategyConnectionService.activateScheduled(backtest, symbol, context, activateId);
11419
+ };
11067
11420
  }
11068
11421
  }
11069
11422
 
@@ -12343,13 +12696,8 @@ class WalkerSchemaService {
12343
12696
  }
12344
12697
  }
12345
12698
 
12346
- /**
12347
- * Компенсация для exclusive boundaries при фильтрации свечей.
12348
- * ClientExchange.getNextCandles использует фильтр:
12349
- * timestamp > since && timestamp + stepMs < endTime
12350
- * который исключает первую и последнюю свечи из запрошенного диапазона.
12351
- */
12352
- const CANDLE_EXCLUSIVE_BOUNDARY_OFFSET = 2;
12699
+ const ACTIVE_CANDLE_INCLUDED = 1;
12700
+ const SCHEDULE_ACTIVATION_CANDLE_SKIP = 1;
12353
12701
  /**
12354
12702
  * Private service for backtest orchestration using async generators.
12355
12703
  *
@@ -12473,9 +12821,9 @@ class BacktestLogicPrivateService {
12473
12821
  // - CC_SCHEDULE_AWAIT_MINUTES для ожидания активации
12474
12822
  // - minuteEstimatedTime для работы сигнала ПОСЛЕ активации
12475
12823
  // - +1 потому что when включается как первая свеча
12476
- const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
12824
+ const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - ACTIVE_CANDLE_INCLUDED;
12477
12825
  const bufferStartTime = new Date(when.getTime() - bufferMinutes * 60 * 1000);
12478
- const candlesNeeded = bufferMinutes + GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES + signal.minuteEstimatedTime + 1;
12826
+ const candlesNeeded = bufferMinutes + GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES + signal.minuteEstimatedTime + SCHEDULE_ACTIVATION_CANDLE_SKIP;
12479
12827
  let candles;
12480
12828
  try {
12481
12829
  candles = await this.exchangeCoreService.getNextCandles(symbol, "1m", candlesNeeded, bufferStartTime, true);
@@ -12613,9 +12961,9 @@ class BacktestLogicPrivateService {
12613
12961
  // КРИТИЧНО: Получаем свечи включая буфер для VWAP
12614
12962
  // Сдвигаем начало назад на CC_AVG_PRICE_CANDLES_COUNT-1 минут для буфера VWAP
12615
12963
  // Запрашиваем minuteEstimatedTime + буфер свечей одним запросом
12616
- const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
12964
+ const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - ACTIVE_CANDLE_INCLUDED;
12617
12965
  const bufferStartTime = new Date(when.getTime() - bufferMinutes * 60 * 1000);
12618
- const totalCandles = signal.minuteEstimatedTime + GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT + CANDLE_EXCLUSIVE_BOUNDARY_OFFSET;
12966
+ const totalCandles = signal.minuteEstimatedTime + GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
12619
12967
  let candles;
12620
12968
  try {
12621
12969
  candles = await this.exchangeCoreService.getNextCandles(symbol, "1m", totalCandles, bufferStartTime, true);
@@ -24303,6 +24651,67 @@ class StrategyReportService {
24303
24651
  walkerName: "",
24304
24652
  });
24305
24653
  };
24654
+ /**
24655
+ * Logs an activate-scheduled event when a scheduled signal is activated early.
24656
+ *
24657
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
24658
+ * @param currentPrice - Current market price at time of activation
24659
+ * @param isBacktest - Whether this is a backtest or live trading event
24660
+ * @param context - Strategy context with strategyName, exchangeName, frameName
24661
+ * @param timestamp - Timestamp from StrategyCommitContract (execution context time)
24662
+ * @param position - Trade direction: "long" or "short"
24663
+ * @param priceOpen - Entry price for the position
24664
+ * @param priceTakeProfit - Effective take profit price
24665
+ * @param priceStopLoss - Effective stop loss price
24666
+ * @param originalPriceTakeProfit - Original take profit before trailing
24667
+ * @param originalPriceStopLoss - Original stop loss before trailing
24668
+ * @param scheduledAt - Signal creation timestamp in milliseconds
24669
+ * @param pendingAt - Pending timestamp in milliseconds
24670
+ * @param activateId - Optional identifier for the activation reason
24671
+ */
24672
+ this.activateScheduled = async (symbol, currentPrice, isBacktest, context, timestamp, position, priceOpen, priceTakeProfit, priceStopLoss, originalPriceTakeProfit, originalPriceStopLoss, scheduledAt, pendingAt, activateId) => {
24673
+ this.loggerService.log("strategyReportService activateScheduled", {
24674
+ symbol,
24675
+ currentPrice,
24676
+ isBacktest,
24677
+ activateId,
24678
+ });
24679
+ if (!this.subscribe.hasValue()) {
24680
+ return;
24681
+ }
24682
+ const scheduledRow = await this.strategyCoreService.getScheduledSignal(isBacktest, symbol, {
24683
+ exchangeName: context.exchangeName,
24684
+ strategyName: context.strategyName,
24685
+ frameName: context.frameName,
24686
+ });
24687
+ if (!scheduledRow) {
24688
+ return;
24689
+ }
24690
+ const createdAt = new Date(timestamp).toISOString();
24691
+ await Report.writeData("strategy", {
24692
+ action: "activate-scheduled",
24693
+ activateId,
24694
+ currentPrice,
24695
+ symbol,
24696
+ timestamp,
24697
+ createdAt,
24698
+ position,
24699
+ priceOpen,
24700
+ priceTakeProfit,
24701
+ priceStopLoss,
24702
+ originalPriceTakeProfit,
24703
+ originalPriceStopLoss,
24704
+ scheduledAt,
24705
+ pendingAt,
24706
+ }, {
24707
+ signalId: scheduledRow.id,
24708
+ exchangeName: context.exchangeName,
24709
+ frameName: context.frameName,
24710
+ strategyName: context.strategyName,
24711
+ symbol,
24712
+ walkerName: "",
24713
+ });
24714
+ };
24306
24715
  /**
24307
24716
  * Initializes the service for event logging.
24308
24717
  *
@@ -24362,7 +24771,14 @@ class StrategyReportService {
24362
24771
  frameName: event.frameName,
24363
24772
  strategyName: event.strategyName,
24364
24773
  }, event.timestamp, event.position, event.priceOpen, event.priceTakeProfit, event.priceStopLoss, event.originalPriceTakeProfit, event.originalPriceStopLoss, event.scheduledAt, event.pendingAt));
24365
- const disposeFn = functoolsKit.compose(() => unCancelSchedule(), () => unClosePending(), () => unPartialProfit(), () => unPartialLoss(), () => unTrailingStop(), () => unTrailingTake(), () => unBreakeven());
24774
+ const unActivateScheduled = strategyCommitSubject
24775
+ .filter(({ action }) => action === "activate-scheduled")
24776
+ .connect(async (event) => await this.activateScheduled(event.symbol, event.currentPrice, event.backtest, {
24777
+ exchangeName: event.exchangeName,
24778
+ frameName: event.frameName,
24779
+ strategyName: event.strategyName,
24780
+ }, event.timestamp, event.position, event.priceOpen, event.priceTakeProfit, event.priceStopLoss, event.originalPriceTakeProfit, event.originalPriceStopLoss, event.scheduledAt, event.pendingAt, event.activateId));
24781
+ const disposeFn = functoolsKit.compose(() => unCancelSchedule(), () => unClosePending(), () => unPartialProfit(), () => unPartialLoss(), () => unTrailingStop(), () => unTrailingTake(), () => unBreakeven(), () => unActivateScheduled());
24366
24782
  return () => {
24367
24783
  disposeFn();
24368
24784
  this.subscribe.clear();
@@ -24493,6 +24909,7 @@ class ReportStorage {
24493
24909
  trailingStopCount: 0,
24494
24910
  trailingTakeCount: 0,
24495
24911
  breakevenCount: 0,
24912
+ activateScheduledCount: 0,
24496
24913
  };
24497
24914
  }
24498
24915
  return {
@@ -24505,6 +24922,7 @@ class ReportStorage {
24505
24922
  trailingStopCount: this._eventList.filter(e => e.action === "trailing-stop").length,
24506
24923
  trailingTakeCount: this._eventList.filter(e => e.action === "trailing-take").length,
24507
24924
  breakevenCount: this._eventList.filter(e => e.action === "breakeven").length,
24925
+ activateScheduledCount: this._eventList.filter(e => e.action === "activate-scheduled").length,
24508
24926
  };
24509
24927
  }
24510
24928
  /**
@@ -24553,6 +24971,7 @@ class ReportStorage {
24553
24971
  `- Trailing stop: ${stats.trailingStopCount}`,
24554
24972
  `- Trailing take: ${stats.trailingTakeCount}`,
24555
24973
  `- Breakeven: ${stats.breakevenCount}`,
24974
+ `- Activate scheduled: ${stats.activateScheduledCount}`,
24556
24975
  ].join("\n");
24557
24976
  }
24558
24977
  /**
@@ -25019,6 +25438,66 @@ class StrategyMarkdownService {
25019
25438
  pendingAt,
25020
25439
  });
25021
25440
  };
25441
+ /**
25442
+ * Records an activate-scheduled event when a scheduled signal is activated early.
25443
+ *
25444
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
25445
+ * @param currentPrice - Current market price at time of activation
25446
+ * @param isBacktest - Whether this is a backtest or live trading event
25447
+ * @param context - Strategy context with strategyName, exchangeName, frameName
25448
+ * @param timestamp - Timestamp from StrategyCommitContract (execution context time)
25449
+ * @param position - Trade direction: "long" or "short"
25450
+ * @param priceOpen - Entry price for the position
25451
+ * @param priceTakeProfit - Effective take profit price
25452
+ * @param priceStopLoss - Effective stop loss price
25453
+ * @param originalPriceTakeProfit - Original take profit before trailing
25454
+ * @param originalPriceStopLoss - Original stop loss before trailing
25455
+ * @param scheduledAt - Signal creation timestamp in milliseconds
25456
+ * @param pendingAt - Pending timestamp in milliseconds
25457
+ * @param activateId - Optional identifier for the activation reason
25458
+ */
25459
+ this.activateScheduled = async (symbol, currentPrice, isBacktest, context, timestamp, position, priceOpen, priceTakeProfit, priceStopLoss, originalPriceTakeProfit, originalPriceStopLoss, scheduledAt, pendingAt, activateId) => {
25460
+ this.loggerService.log("strategyMarkdownService activateScheduled", {
25461
+ symbol,
25462
+ currentPrice,
25463
+ isBacktest,
25464
+ activateId,
25465
+ });
25466
+ if (!this.subscribe.hasValue()) {
25467
+ return;
25468
+ }
25469
+ const scheduledRow = await this.strategyCoreService.getScheduledSignal(isBacktest, symbol, {
25470
+ exchangeName: context.exchangeName,
25471
+ strategyName: context.strategyName,
25472
+ frameName: context.frameName,
25473
+ });
25474
+ if (!scheduledRow) {
25475
+ return;
25476
+ }
25477
+ const createdAt = new Date(timestamp).toISOString();
25478
+ const storage = this.getStorage(symbol, context.strategyName, context.exchangeName, context.frameName, isBacktest);
25479
+ storage.addEvent({
25480
+ timestamp,
25481
+ symbol,
25482
+ strategyName: context.strategyName,
25483
+ exchangeName: context.exchangeName,
25484
+ frameName: context.frameName,
25485
+ signalId: scheduledRow.id,
25486
+ action: "activate-scheduled",
25487
+ activateId,
25488
+ currentPrice,
25489
+ createdAt,
25490
+ backtest: isBacktest,
25491
+ position,
25492
+ priceOpen,
25493
+ priceTakeProfit,
25494
+ priceStopLoss,
25495
+ originalPriceTakeProfit,
25496
+ originalPriceStopLoss,
25497
+ scheduledAt,
25498
+ pendingAt,
25499
+ });
25500
+ };
25022
25501
  /**
25023
25502
  * Retrieves aggregated statistics from accumulated strategy events.
25024
25503
  *
@@ -25192,7 +25671,14 @@ class StrategyMarkdownService {
25192
25671
  frameName: event.frameName,
25193
25672
  strategyName: event.strategyName,
25194
25673
  }, event.timestamp, event.position, event.priceOpen, event.priceTakeProfit, event.priceStopLoss, event.originalPriceTakeProfit, event.originalPriceStopLoss, event.scheduledAt, event.pendingAt));
25195
- const disposeFn = functoolsKit.compose(() => unCancelSchedule(), () => unClosePending(), () => unPartialProfit(), () => unPartialLoss(), () => unTrailingStop(), () => unTrailingTake(), () => unBreakeven());
25674
+ const unActivateScheduled = strategyCommitSubject
25675
+ .filter(({ action }) => action === "activate-scheduled")
25676
+ .connect(async (event) => await this.activateScheduled(event.symbol, event.currentPrice, event.backtest, {
25677
+ exchangeName: event.exchangeName,
25678
+ frameName: event.frameName,
25679
+ strategyName: event.strategyName,
25680
+ }, event.timestamp, event.position, event.priceOpen, event.priceTakeProfit, event.priceStopLoss, event.originalPriceTakeProfit, event.originalPriceStopLoss, event.scheduledAt, event.pendingAt, event.activateId));
25681
+ const disposeFn = functoolsKit.compose(() => unCancelSchedule(), () => unClosePending(), () => unPartialProfit(), () => unPartialLoss(), () => unTrailingStop(), () => unTrailingTake(), () => unBreakeven(), () => unActivateScheduled());
25196
25682
  return () => {
25197
25683
  disposeFn();
25198
25684
  this.subscribe.clear();
@@ -26118,6 +26604,7 @@ const PARTIAL_LOSS_METHOD_NAME = "strategy.commitPartialLoss";
26118
26604
  const TRAILING_STOP_METHOD_NAME = "strategy.commitTrailingStop";
26119
26605
  const TRAILING_PROFIT_METHOD_NAME = "strategy.commitTrailingTake";
26120
26606
  const BREAKEVEN_METHOD_NAME = "strategy.commitBreakeven";
26607
+ const ACTIVATE_SCHEDULED_METHOD_NAME = "strategy.commitActivateScheduled";
26121
26608
  /**
26122
26609
  * Cancels the scheduled signal without stopping the strategy.
26123
26610
  *
@@ -26435,6 +26922,41 @@ async function commitBreakeven(symbol) {
26435
26922
  const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
26436
26923
  return await bt.strategyCoreService.breakeven(isBacktest, symbol, currentPrice, { exchangeName, frameName, strategyName });
26437
26924
  }
26925
+ /**
26926
+ * Activates a scheduled signal early without waiting for price to reach priceOpen.
26927
+ *
26928
+ * Sets the activation flag on the scheduled signal. The actual activation
26929
+ * happens on the next tick() when strategy detects the flag.
26930
+ *
26931
+ * Automatically detects backtest/live mode from execution context.
26932
+ *
26933
+ * @param symbol - Trading pair symbol
26934
+ * @param activateId - Optional activation ID for tracking user-initiated activations
26935
+ * @returns Promise that resolves when activation flag is set
26936
+ *
26937
+ * @example
26938
+ * ```typescript
26939
+ * import { commitActivateScheduled } from "backtest-kit";
26940
+ *
26941
+ * // Activate scheduled signal early with custom ID
26942
+ * await commitActivateScheduled("BTCUSDT", "manual-activate-001");
26943
+ * ```
26944
+ */
26945
+ async function commitActivateScheduled(symbol, activateId) {
26946
+ bt.loggerService.info(ACTIVATE_SCHEDULED_METHOD_NAME, {
26947
+ symbol,
26948
+ activateId,
26949
+ });
26950
+ if (!ExecutionContextService.hasContext()) {
26951
+ throw new Error("commitActivateScheduled requires an execution context");
26952
+ }
26953
+ if (!MethodContextService.hasContext()) {
26954
+ throw new Error("commitActivateScheduled requires a method context");
26955
+ }
26956
+ const { backtest: isBacktest } = bt.executionContextService.context;
26957
+ const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
26958
+ await bt.strategyCoreService.activateScheduled(isBacktest, symbol, { exchangeName, frameName, strategyName }, activateId);
26959
+ }
26438
26960
 
26439
26961
  const STOP_STRATEGY_METHOD_NAME = "control.stopStrategy";
26440
26962
  /**
@@ -28605,6 +29127,7 @@ const BACKTEST_METHOD_NAME_PARTIAL_PROFIT = "BacktestUtils.commitPartialProfit";
28605
29127
  const BACKTEST_METHOD_NAME_PARTIAL_LOSS = "BacktestUtils.commitPartialLoss";
28606
29128
  const BACKTEST_METHOD_NAME_TRAILING_STOP = "BacktestUtils.commitTrailingStop";
28607
29129
  const BACKTEST_METHOD_NAME_TRAILING_PROFIT = "BacktestUtils.commitTrailingTake";
29130
+ const BACKTEST_METHOD_NAME_ACTIVATE_SCHEDULED = "Backtest.commitActivateScheduled";
28608
29131
  const BACKTEST_METHOD_NAME_GET_DATA = "BacktestUtils.getData";
28609
29132
  /**
28610
29133
  * Internal task function that runs backtest and handles completion.
@@ -29461,6 +29984,46 @@ class BacktestUtils {
29461
29984
  }
29462
29985
  return await bt.strategyCoreService.breakeven(true, symbol, currentPrice, context);
29463
29986
  };
29987
+ /**
29988
+ * Activates a scheduled signal early without waiting for price to reach priceOpen.
29989
+ *
29990
+ * Sets the activation flag on the scheduled signal. The actual activation
29991
+ * happens on the next tick() when strategy detects the flag.
29992
+ *
29993
+ * @param symbol - Trading pair symbol
29994
+ * @param context - Execution context with strategyName, exchangeName, and frameName
29995
+ * @param activateId - Optional activation ID for tracking user-initiated activations
29996
+ * @returns Promise that resolves when activation flag is set
29997
+ *
29998
+ * @example
29999
+ * ```typescript
30000
+ * // Activate scheduled signal early with custom ID
30001
+ * await Backtest.commitActivateScheduled("BTCUSDT", {
30002
+ * strategyName: "my-strategy",
30003
+ * exchangeName: "binance",
30004
+ * frameName: "1h"
30005
+ * }, "manual-activate-001");
30006
+ * ```
30007
+ */
30008
+ this.commitActivateScheduled = async (symbol, context, activateId) => {
30009
+ bt.loggerService.info(BACKTEST_METHOD_NAME_ACTIVATE_SCHEDULED, {
30010
+ symbol,
30011
+ context,
30012
+ activateId,
30013
+ });
30014
+ bt.strategyValidationService.validate(context.strategyName, BACKTEST_METHOD_NAME_ACTIVATE_SCHEDULED);
30015
+ bt.exchangeValidationService.validate(context.exchangeName, BACKTEST_METHOD_NAME_ACTIVATE_SCHEDULED);
30016
+ {
30017
+ const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
30018
+ riskName &&
30019
+ bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_ACTIVATE_SCHEDULED);
30020
+ riskList &&
30021
+ riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_ACTIVATE_SCHEDULED));
30022
+ actions &&
30023
+ actions.forEach((actionName) => bt.actionValidationService.validate(actionName, BACKTEST_METHOD_NAME_ACTIVATE_SCHEDULED));
30024
+ }
30025
+ await bt.strategyCoreService.activateScheduled(true, symbol, context, activateId);
30026
+ };
29464
30027
  /**
29465
30028
  * Gets statistical data from all closed signals for a symbol-strategy pair.
29466
30029
  *
@@ -29636,6 +30199,7 @@ const LIVE_METHOD_NAME_PARTIAL_PROFIT = "LiveUtils.commitPartialProfit";
29636
30199
  const LIVE_METHOD_NAME_PARTIAL_LOSS = "LiveUtils.commitPartialLoss";
29637
30200
  const LIVE_METHOD_NAME_TRAILING_STOP = "LiveUtils.commitTrailingStop";
29638
30201
  const LIVE_METHOD_NAME_TRAILING_PROFIT = "LiveUtils.commitTrailingTake";
30202
+ const LIVE_METHOD_NAME_ACTIVATE_SCHEDULED = "Live.commitActivateScheduled";
29639
30203
  /**
29640
30204
  * Internal task function that runs live trading and handles completion.
29641
30205
  * Consumes live trading results and updates instance state flags.
@@ -30460,6 +31024,46 @@ class LiveUtils {
30460
31024
  frameName: "",
30461
31025
  });
30462
31026
  };
31027
+ /**
31028
+ * Activates a scheduled signal early without waiting for price to reach priceOpen.
31029
+ *
31030
+ * Sets the activation flag on the scheduled signal. The actual activation
31031
+ * happens on the next tick() when strategy detects the flag.
31032
+ *
31033
+ * @param symbol - Trading pair symbol
31034
+ * @param context - Execution context with strategyName and exchangeName
31035
+ * @param activateId - Optional activation ID for tracking user-initiated activations
31036
+ * @returns Promise that resolves when activation flag is set
31037
+ *
31038
+ * @example
31039
+ * ```typescript
31040
+ * // Activate scheduled signal early with custom ID
31041
+ * await Live.commitActivateScheduled("BTCUSDT", {
31042
+ * strategyName: "my-strategy",
31043
+ * exchangeName: "binance"
31044
+ * }, "manual-activate-001");
31045
+ * ```
31046
+ */
31047
+ this.commitActivateScheduled = async (symbol, context, activateId) => {
31048
+ bt.loggerService.info(LIVE_METHOD_NAME_ACTIVATE_SCHEDULED, {
31049
+ symbol,
31050
+ context,
31051
+ activateId,
31052
+ });
31053
+ bt.strategyValidationService.validate(context.strategyName, LIVE_METHOD_NAME_ACTIVATE_SCHEDULED);
31054
+ bt.exchangeValidationService.validate(context.exchangeName, LIVE_METHOD_NAME_ACTIVATE_SCHEDULED);
31055
+ {
31056
+ const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
31057
+ riskName && bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_ACTIVATE_SCHEDULED);
31058
+ riskList && riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_ACTIVATE_SCHEDULED));
31059
+ actions && actions.forEach((actionName) => bt.actionValidationService.validate(actionName, LIVE_METHOD_NAME_ACTIVATE_SCHEDULED));
31060
+ }
31061
+ await bt.strategyCoreService.activateScheduled(false, symbol, {
31062
+ strategyName: context.strategyName,
31063
+ exchangeName: context.exchangeName,
31064
+ frameName: "",
31065
+ }, activateId);
31066
+ };
30463
31067
  /**
30464
31068
  * Gets statistical data from all live trading events for a symbol-strategy pair.
30465
31069
  *
@@ -33109,6 +33713,27 @@ const INTERVAL_MINUTES$1 = {
33109
33713
  "6h": 360,
33110
33714
  "8h": 480,
33111
33715
  };
33716
+ /**
33717
+ * Aligns timestamp down to the nearest interval boundary.
33718
+ * For example, for 15m interval: 00:17 -> 00:15, 00:44 -> 00:30
33719
+ *
33720
+ * Candle timestamp convention:
33721
+ * - Candle timestamp = openTime (when candle opens)
33722
+ * - Candle with timestamp 00:00 covers period [00:00, 00:15) for 15m interval
33723
+ *
33724
+ * Adapter contract:
33725
+ * - Adapter must return candles with timestamp = openTime
33726
+ * - First returned candle.timestamp must equal aligned since
33727
+ * - Adapter must return exactly `limit` candles
33728
+ *
33729
+ * @param timestamp - Timestamp in milliseconds
33730
+ * @param intervalMinutes - Interval in minutes
33731
+ * @returns Aligned timestamp rounded down to interval boundary
33732
+ */
33733
+ const ALIGN_TO_INTERVAL_FN = (timestamp, intervalMinutes) => {
33734
+ const intervalMs = intervalMinutes * MS_PER_MINUTE;
33735
+ return Math.floor(timestamp / intervalMs) * intervalMs;
33736
+ };
33112
33737
  /**
33113
33738
  * Creates exchange instance with methods resolved once during construction.
33114
33739
  * Applies default implementations where schema methods are not provided.
@@ -33130,25 +33755,24 @@ const CREATE_EXCHANGE_INSTANCE_FN = (schema) => {
33130
33755
  };
33131
33756
  /**
33132
33757
  * Attempts to read candles from cache.
33133
- * Validates cache consistency (no gaps in timestamps) before returning.
33134
33758
  *
33135
- * Boundary semantics:
33136
- * - sinceTimestamp: EXCLUSIVE lower bound (candle.timestamp > sinceTimestamp)
33137
- * - untilTimestamp: EXCLUSIVE upper bound (candle.timestamp + stepMs < untilTimestamp)
33138
- * - Only fully closed candles within the exclusive range are returned
33759
+ * Cache lookup calculates expected timestamps:
33760
+ * sinceTimestamp + i * stepMs for i = 0..limit-1
33761
+ * Returns all candles if found, null if any missing.
33139
33762
  *
33140
33763
  * @param dto - Data transfer object containing symbol, interval, and limit
33141
- * @param sinceTimestamp - Exclusive start timestamp in milliseconds
33142
- * @param untilTimestamp - Exclusive end timestamp in milliseconds
33764
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
33765
+ * @param untilTimestamp - Unused, kept for API compatibility
33143
33766
  * @param exchangeName - Exchange name
33144
- * @returns Cached candles array or null if cache miss or inconsistent
33767
+ * @returns Cached candles array (exactly limit) or null if cache miss
33145
33768
  */
33146
33769
  const READ_CANDLES_CACHE_FN = functoolsKit.trycatch(async (dto, sinceTimestamp, untilTimestamp, exchangeName) => {
33147
- // PersistCandleAdapter.readCandlesData uses EXCLUSIVE boundaries:
33148
- // Returns candles where: timestamp > sinceTimestamp AND timestamp + stepMs < untilTimestamp
33770
+ // PersistCandleAdapter.readCandlesData calculates expected timestamps:
33771
+ // sinceTimestamp + i * stepMs for i = 0..limit-1
33772
+ // Returns all candles if found, null if any missing
33149
33773
  const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
33150
33774
  // Return cached data only if we have exactly the requested limit
33151
- if (cachedCandles.length === dto.limit) {
33775
+ if (cachedCandles?.length === dto.limit) {
33152
33776
  bt.loggerService.debug(`ExchangeInstance READ_CANDLES_CACHE_FN: cache hit for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
33153
33777
  return cachedCandles;
33154
33778
  }
@@ -33170,11 +33794,12 @@ const READ_CANDLES_CACHE_FN = functoolsKit.trycatch(async (dto, sinceTimestamp,
33170
33794
  /**
33171
33795
  * Writes candles to cache with error handling.
33172
33796
  *
33173
- * The candles passed to this function must already be filtered using EXCLUSIVE boundaries:
33174
- * - candle.timestamp > sinceTimestamp
33175
- * - candle.timestamp + stepMs < untilTimestamp
33797
+ * The candles passed to this function should be validated:
33798
+ * - First candle.timestamp equals aligned sinceTimestamp (openTime)
33799
+ * - Exact number of candles as requested (limit)
33800
+ * - Sequential timestamps: sinceTimestamp + i * stepMs
33176
33801
  *
33177
- * @param candles - Array of candle data to cache (already filtered with exclusive boundaries)
33802
+ * @param candles - Array of validated candle data to cache
33178
33803
  * @param dto - Data transfer object containing symbol, interval, and limit
33179
33804
  * @param exchangeName - Exchange name
33180
33805
  */
@@ -33245,14 +33870,18 @@ class ExchangeInstance {
33245
33870
  });
33246
33871
  const getCandles = this._methods.getCandles;
33247
33872
  const step = INTERVAL_MINUTES$1[interval];
33248
- const adjust = step * limit;
33249
- if (!adjust) {
33250
- throw new Error(`ExchangeInstance unknown time adjust for interval=${interval}`);
33873
+ if (!step) {
33874
+ throw new Error(`ExchangeInstance unknown interval=${interval}`);
33251
33875
  }
33876
+ const stepMs = step * MS_PER_MINUTE;
33877
+ // Align when down to interval boundary
33252
33878
  const when = await GET_TIMESTAMP_FN();
33253
- const since = new Date(when.getTime() - adjust * MS_PER_MINUTE);
33254
- const sinceTimestamp = since.getTime();
33255
- const untilTimestamp = sinceTimestamp + limit * step * MS_PER_MINUTE;
33879
+ const whenTimestamp = when.getTime();
33880
+ const alignedWhen = ALIGN_TO_INTERVAL_FN(whenTimestamp, step);
33881
+ // Calculate since: go back limit candles from aligned when
33882
+ const sinceTimestamp = alignedWhen - limit * stepMs;
33883
+ const since = new Date(sinceTimestamp);
33884
+ const untilTimestamp = alignedWhen;
33256
33885
  // Try to read from cache first
33257
33886
  const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
33258
33887
  if (cachedCandles !== null) {
@@ -33271,7 +33900,7 @@ class ExchangeInstance {
33271
33900
  remaining -= chunkLimit;
33272
33901
  if (remaining > 0) {
33273
33902
  // Move currentSince forward by the number of candles fetched
33274
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE);
33903
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
33275
33904
  }
33276
33905
  }
33277
33906
  }
@@ -33279,27 +33908,25 @@ class ExchangeInstance {
33279
33908
  const isBacktest = await GET_BACKTEST_FN();
33280
33909
  allData = await getCandles(symbol, interval, since, limit, isBacktest);
33281
33910
  }
33282
- // Filter candles to strictly match the requested range
33283
- const whenTimestamp = when.getTime();
33284
- const stepMs = step * MS_PER_MINUTE;
33285
- const filteredData = allData.filter((candle) => {
33286
- // EXCLUSIVE boundaries:
33287
- // - candle.timestamp > sinceTimestamp (exclude exact boundary)
33288
- // - candle.timestamp + stepMs < whenTimestamp (fully closed before "when")
33289
- if (candle.timestamp <= sinceTimestamp) {
33290
- return false;
33291
- }
33292
- // Check against current time (when)
33293
- // Only allow candles that have fully CLOSED before "when"
33294
- return candle.timestamp + stepMs < whenTimestamp;
33295
- });
33296
33911
  // Apply distinct by timestamp to remove duplicates
33297
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
33298
- if (filteredData.length !== uniqueData.length) {
33299
- bt.loggerService.warn(`ExchangeInstance Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`);
33912
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
33913
+ if (allData.length !== uniqueData.length) {
33914
+ bt.loggerService.warn(`ExchangeInstance getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
33915
+ }
33916
+ // Validate adapter returned data
33917
+ if (uniqueData.length === 0) {
33918
+ throw new Error(`ExchangeInstance getCandles: adapter returned empty array. ` +
33919
+ `Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
33300
33920
  }
33301
- if (uniqueData.length < limit) {
33302
- bt.loggerService.warn(`ExchangeInstance Expected ${limit} candles, got ${uniqueData.length}`);
33921
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
33922
+ throw new Error(`ExchangeInstance getCandles: first candle timestamp mismatch. ` +
33923
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
33924
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
33925
+ }
33926
+ if (uniqueData.length !== limit) {
33927
+ throw new Error(`ExchangeInstance getCandles: candle count mismatch. ` +
33928
+ `Expected ${limit} candles, got ${uniqueData.length}. ` +
33929
+ `Adapter must return exact number of candles requested.`);
33303
33930
  }
33304
33931
  // Write to cache after successful fetch
33305
33932
  await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
@@ -33470,10 +34097,11 @@ class ExchangeInstance {
33470
34097
  if (!step) {
33471
34098
  throw new Error(`ExchangeInstance getRawCandles: unknown interval=${interval}`);
33472
34099
  }
34100
+ const stepMs = step * MS_PER_MINUTE;
33473
34101
  const when = await GET_TIMESTAMP_FN();
33474
34102
  const nowTimestamp = when.getTime();
34103
+ const alignedNow = ALIGN_TO_INTERVAL_FN(nowTimestamp, step);
33475
34104
  let sinceTimestamp;
33476
- let untilTimestamp;
33477
34105
  let calculatedLimit;
33478
34106
  // Case 1: all three parameters provided
33479
34107
  if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
@@ -33483,8 +34111,8 @@ class ExchangeInstance {
33483
34111
  if (eDate > nowTimestamp) {
33484
34112
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33485
34113
  }
33486
- sinceTimestamp = sDate;
33487
- untilTimestamp = eDate;
34114
+ // Align sDate down to interval boundary
34115
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
33488
34116
  calculatedLimit = limit;
33489
34117
  }
33490
34118
  // Case 2: sDate + eDate (no limit) - calculate limit from date range
@@ -33495,9 +34123,10 @@ class ExchangeInstance {
33495
34123
  if (eDate > nowTimestamp) {
33496
34124
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33497
34125
  }
33498
- sinceTimestamp = sDate;
33499
- untilTimestamp = eDate;
33500
- calculatedLimit = Math.ceil((eDate - sDate) / (step * MS_PER_MINUTE));
34126
+ // Align sDate down to interval boundary
34127
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
34128
+ const alignedEDate = ALIGN_TO_INTERVAL_FN(eDate, step);
34129
+ calculatedLimit = Math.ceil((alignedEDate - sinceTimestamp) / stepMs);
33501
34130
  if (calculatedLimit <= 0) {
33502
34131
  throw new Error(`ExchangeInstance getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
33503
34132
  }
@@ -33507,23 +34136,24 @@ class ExchangeInstance {
33507
34136
  if (eDate > nowTimestamp) {
33508
34137
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33509
34138
  }
33510
- untilTimestamp = eDate;
33511
- sinceTimestamp = eDate - limit * step * MS_PER_MINUTE;
34139
+ // Align eDate down and calculate sinceTimestamp
34140
+ const alignedEDate = ALIGN_TO_INTERVAL_FN(eDate, step);
34141
+ sinceTimestamp = alignedEDate - limit * stepMs;
33512
34142
  calculatedLimit = limit;
33513
34143
  }
33514
34144
  // Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
33515
34145
  else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
33516
- sinceTimestamp = sDate;
33517
- untilTimestamp = sDate + limit * step * MS_PER_MINUTE;
33518
- if (untilTimestamp > nowTimestamp) {
33519
- throw new Error(`ExchangeInstance getRawCandles: calculated endTimestamp (${untilTimestamp}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
34146
+ // Align sDate down to interval boundary
34147
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
34148
+ const endTimestamp = sinceTimestamp + limit * stepMs;
34149
+ if (endTimestamp > nowTimestamp) {
34150
+ throw new Error(`ExchangeInstance getRawCandles: calculated endTimestamp (${endTimestamp}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33520
34151
  }
33521
34152
  calculatedLimit = limit;
33522
34153
  }
33523
34154
  // Case 5: Only limit - use Date.now() as reference (backward)
33524
34155
  else if (sDate === undefined && eDate === undefined && limit !== undefined) {
33525
- untilTimestamp = nowTimestamp;
33526
- sinceTimestamp = nowTimestamp - limit * step * MS_PER_MINUTE;
34156
+ sinceTimestamp = alignedNow - limit * stepMs;
33527
34157
  calculatedLimit = limit;
33528
34158
  }
33529
34159
  // Invalid: no parameters or only sDate or only eDate
@@ -33533,6 +34163,7 @@ class ExchangeInstance {
33533
34163
  `Got: sDate=${sDate}, eDate=${eDate}, limit=${limit}`);
33534
34164
  }
33535
34165
  // Try to read from cache first
34166
+ const untilTimestamp = sinceTimestamp + calculatedLimit * stepMs;
33536
34167
  const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit: calculatedLimit }, sinceTimestamp, untilTimestamp, this.exchangeName);
33537
34168
  if (cachedCandles !== null) {
33538
34169
  return cachedCandles;
@@ -33551,25 +34182,32 @@ class ExchangeInstance {
33551
34182
  allData.push(...chunkData);
33552
34183
  remaining -= chunkLimit;
33553
34184
  if (remaining > 0) {
33554
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE);
34185
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
33555
34186
  }
33556
34187
  }
33557
34188
  }
33558
34189
  else {
33559
34190
  allData = await getCandles(symbol, interval, since, calculatedLimit, isBacktest);
33560
34191
  }
33561
- // Filter candles to strictly match the requested range
33562
- // Only include candles that have fully CLOSED before untilTimestamp
33563
- const stepMs = step * MS_PER_MINUTE;
33564
- const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
33565
- candle.timestamp + stepMs < untilTimestamp);
33566
34192
  // Apply distinct by timestamp to remove duplicates
33567
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
33568
- if (filteredData.length !== uniqueData.length) {
33569
- bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`);
34193
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
34194
+ if (allData.length !== uniqueData.length) {
34195
+ bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
34196
+ }
34197
+ // Validate adapter returned data
34198
+ if (uniqueData.length === 0) {
34199
+ throw new Error(`ExchangeInstance getRawCandles: adapter returned empty array. ` +
34200
+ `Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
33570
34201
  }
33571
- if (uniqueData.length < calculatedLimit) {
33572
- bt.loggerService.warn(`ExchangeInstance getRawCandles: Expected ${calculatedLimit} candles, got ${uniqueData.length}`);
34202
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
34203
+ throw new Error(`ExchangeInstance getRawCandles: first candle timestamp mismatch. ` +
34204
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
34205
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
34206
+ }
34207
+ if (uniqueData.length !== calculatedLimit) {
34208
+ throw new Error(`ExchangeInstance getRawCandles: candle count mismatch. ` +
34209
+ `Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
34210
+ `Adapter must return exact number of candles requested.`);
33573
34211
  }
33574
34212
  // Write to cache after successful fetch
33575
34213
  await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);
@@ -34361,6 +34999,29 @@ class NotificationInstance {
34361
34999
  createdAt: data.timestamp,
34362
35000
  });
34363
35001
  }
35002
+ else if (data.action === "activate-scheduled") {
35003
+ this._addNotification({
35004
+ type: "activate_scheduled.commit",
35005
+ id: CREATE_KEY_FN(),
35006
+ timestamp: data.timestamp,
35007
+ backtest: data.backtest,
35008
+ symbol: data.symbol,
35009
+ strategyName: data.strategyName,
35010
+ exchangeName: data.exchangeName,
35011
+ signalId: data.signalId,
35012
+ activateId: data.activateId,
35013
+ currentPrice: data.currentPrice,
35014
+ position: data.position,
35015
+ priceOpen: data.priceOpen,
35016
+ priceTakeProfit: data.priceTakeProfit,
35017
+ priceStopLoss: data.priceStopLoss,
35018
+ originalPriceTakeProfit: data.originalPriceTakeProfit,
35019
+ originalPriceStopLoss: data.originalPriceStopLoss,
35020
+ scheduledAt: data.scheduledAt,
35021
+ pendingAt: data.pendingAt,
35022
+ createdAt: data.timestamp,
35023
+ });
35024
+ }
34364
35025
  };
34365
35026
  /**
34366
35027
  * Processes risk rejection events.
@@ -35206,6 +35867,7 @@ exports.addRiskSchema = addRiskSchema;
35206
35867
  exports.addSizingSchema = addSizingSchema;
35207
35868
  exports.addStrategySchema = addStrategySchema;
35208
35869
  exports.addWalkerSchema = addWalkerSchema;
35870
+ exports.commitActivateScheduled = commitActivateScheduled;
35209
35871
  exports.commitBreakeven = commitBreakeven;
35210
35872
  exports.commitCancelScheduled = commitCancelScheduled;
35211
35873
  exports.commitClosePending = commitClosePending;