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.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createActivator } from 'di-kit';
2
2
  import { scoped } from 'di-scoped';
3
- import { Subject, makeExtendable, singleshot, getErrorMessage, memoize, errorData, not, trycatch, retry, queued, sleep, randomString, str, isObject, ToolRegistry, typo, and, resolveDocuments, timeout, TIMEOUT_SYMBOL as TIMEOUT_SYMBOL$1, compose, singlerun } from 'functools-kit';
3
+ import { Subject, makeExtendable, singleshot, getErrorMessage, memoize, not, errorData, trycatch, retry, queued, sleep, randomString, str, isObject, ToolRegistry, typo, and, resolveDocuments, timeout, TIMEOUT_SYMBOL as TIMEOUT_SYMBOL$1, compose, singlerun } from 'functools-kit';
4
4
  import * as fs from 'fs/promises';
5
5
  import fs__default from 'fs/promises';
6
6
  import path, { join, dirname } from 'path';
@@ -1558,73 +1558,73 @@ class PersistCandleUtils {
1558
1558
  ]));
1559
1559
  /**
1560
1560
  * Reads cached candles for a specific exchange, symbol, and interval.
1561
- * Returns candles only if cache contains exactly the requested limit.
1561
+ * Returns candles only if cache contains ALL requested candles.
1562
1562
  *
1563
- * Boundary semantics (EXCLUSIVE):
1564
- * - sinceTimestamp: candle.timestamp must be > sinceTimestamp
1565
- * - untilTimestamp: candle.timestamp + stepMs must be < untilTimestamp
1566
- * - Only fully closed candles within the exclusive range are returned
1563
+ * Algorithm (matches ClientExchange.ts logic):
1564
+ * 1. Calculate expected timestamps: sinceTimestamp, sinceTimestamp + stepMs, ..., sinceTimestamp + (limit-1) * stepMs
1565
+ * 2. Try to read each expected candle by timestamp key
1566
+ * 3. If ANY candle is missing, return null (cache miss)
1567
+ * 4. If all candles found, return them in order
1567
1568
  *
1568
1569
  * @param symbol - Trading pair symbol
1569
1570
  * @param interval - Candle interval
1570
1571
  * @param exchangeName - Exchange identifier
1571
1572
  * @param limit - Number of candles requested
1572
- * @param sinceTimestamp - Exclusive start timestamp in milliseconds
1573
- * @param untilTimestamp - Exclusive end timestamp in milliseconds
1573
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
1574
+ * @param _untilTimestamp - Unused, kept for API compatibility
1574
1575
  * @returns Promise resolving to array of candles or null if cache is incomplete
1575
1576
  */
1576
- this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, untilTimestamp) => {
1577
+ this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, _untilTimestamp) => {
1577
1578
  bt.loggerService.info("PersistCandleUtils.readCandlesData", {
1578
1579
  symbol,
1579
1580
  interval,
1580
1581
  exchangeName,
1581
1582
  limit,
1582
1583
  sinceTimestamp,
1583
- untilTimestamp,
1584
1584
  });
1585
1585
  const key = `${symbol}:${interval}:${exchangeName}`;
1586
1586
  const isInitial = !this.getCandlesStorage.has(key);
1587
1587
  const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
1588
1588
  await stateStorage.waitForInit(isInitial);
1589
1589
  const stepMs = INTERVAL_MINUTES$5[interval] * MS_PER_MINUTE$2;
1590
- // Collect all cached candles within the time range using EXCLUSIVE boundaries
1590
+ // Calculate expected timestamps and fetch each candle directly
1591
1591
  const cachedCandles = [];
1592
- for await (const timestamp of stateStorage.keys()) {
1593
- const ts = Number(timestamp);
1594
- // EXCLUSIVE boundaries:
1595
- // - candle.timestamp > sinceTimestamp
1596
- // - candle.timestamp + stepMs < untilTimestamp (fully closed before untilTimestamp)
1597
- if (ts > sinceTimestamp && ts + stepMs < untilTimestamp) {
1598
- try {
1599
- const candle = await stateStorage.readValue(timestamp);
1600
- cachedCandles.push(candle);
1601
- }
1602
- catch (error) {
1603
- const message = `PersistCandleUtils.readCandlesData found invalid candle symbol=${symbol} interval=${interval} timestamp=${timestamp}`;
1604
- const payload = {
1605
- error: errorData(error),
1606
- message: getErrorMessage(error),
1607
- };
1608
- bt.loggerService.warn(message, payload);
1609
- console.warn(message, payload);
1610
- errorEmitter.next(error);
1611
- continue;
1612
- }
1592
+ for (let i = 0; i < limit; i++) {
1593
+ const expectedTimestamp = sinceTimestamp + i * stepMs;
1594
+ const timestampKey = String(expectedTimestamp);
1595
+ if (await not(stateStorage.hasValue(timestampKey))) {
1596
+ // Cache miss - candle not found
1597
+ return null;
1598
+ }
1599
+ try {
1600
+ const candle = await stateStorage.readValue(timestampKey);
1601
+ cachedCandles.push(candle);
1602
+ }
1603
+ catch (error) {
1604
+ // Invalid candle in cache - treat as cache miss
1605
+ const message = `PersistCandleUtils.readCandlesData found invalid candle symbol=${symbol} interval=${interval} timestamp=${expectedTimestamp}`;
1606
+ const payload = {
1607
+ error: errorData(error),
1608
+ message: getErrorMessage(error),
1609
+ };
1610
+ bt.loggerService.warn(message, payload);
1611
+ console.warn(message, payload);
1612
+ errorEmitter.next(error);
1613
+ return null;
1613
1614
  }
1614
1615
  }
1615
- // Sort by timestamp ascending
1616
- cachedCandles.sort((a, b) => a.timestamp - b.timestamp);
1617
1616
  return cachedCandles;
1618
1617
  };
1619
1618
  /**
1620
1619
  * Writes candles to cache with atomic file writes.
1621
1620
  * Each candle is stored as a separate JSON file named by its timestamp.
1622
1621
  *
1623
- * The candles passed to this function must already be filtered using EXCLUSIVE boundaries:
1624
- * - candle.timestamp > sinceTimestamp
1625
- * - candle.timestamp + stepMs < untilTimestamp
1622
+ * The candles passed to this function should be validated candles from the adapter:
1623
+ * - First candle.timestamp equals aligned sinceTimestamp (openTime)
1624
+ * - Exact number of candles as requested
1625
+ * - All candles are fully closed (timestamp + stepMs < untilTimestamp)
1626
1626
  *
1627
- * @param candles - Array of candle data to cache (already filtered with exclusive boundaries)
1627
+ * @param candles - Array of candle data to cache (validated by the caller)
1628
1628
  * @param symbol - Trading pair symbol
1629
1629
  * @param interval - Candle interval
1630
1630
  * @param exchangeName - Exchange identifier
@@ -1641,8 +1641,25 @@ class PersistCandleUtils {
1641
1641
  const isInitial = !this.getCandlesStorage.has(key);
1642
1642
  const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
1643
1643
  await stateStorage.waitForInit(isInitial);
1644
- // Write each candle as a separate file
1644
+ // Calculate step in milliseconds to determine candle close time
1645
+ const stepMs = INTERVAL_MINUTES$5[interval] * MS_PER_MINUTE$2;
1646
+ const now = Date.now();
1647
+ // Write each candle as a separate file, skipping incomplete candles
1645
1648
  for (const candle of candles) {
1649
+ // Skip incomplete candles: candle is complete when closeTime <= now
1650
+ // closeTime = timestamp + stepMs
1651
+ const candleCloseTime = candle.timestamp + stepMs;
1652
+ if (candleCloseTime > now) {
1653
+ bt.loggerService.debug("PersistCandleUtils.writeCandlesData: skipping incomplete candle", {
1654
+ symbol,
1655
+ interval,
1656
+ exchangeName,
1657
+ timestamp: candle.timestamp,
1658
+ closeTime: candleCloseTime,
1659
+ now,
1660
+ });
1661
+ continue;
1662
+ }
1646
1663
  if (await not(stateStorage.hasValue(String(candle.timestamp)))) {
1647
1664
  await stateStorage.writeValue(String(candle.timestamp), candle);
1648
1665
  }
@@ -1800,6 +1817,27 @@ const INTERVAL_MINUTES$4 = {
1800
1817
  "6h": 360,
1801
1818
  "8h": 480,
1802
1819
  };
1820
+ /**
1821
+ * Aligns timestamp down to the nearest interval boundary.
1822
+ * For example, for 15m interval: 00:17 -> 00:15, 00:44 -> 00:30
1823
+ *
1824
+ * Candle timestamp convention:
1825
+ * - Candle timestamp = openTime (when candle opens)
1826
+ * - Candle with timestamp 00:00 covers period [00:00, 00:15) for 15m interval
1827
+ *
1828
+ * Adapter contract:
1829
+ * - Adapter must return candles with timestamp = openTime
1830
+ * - First returned candle.timestamp must equal aligned since
1831
+ * - Adapter must return exactly `limit` candles
1832
+ *
1833
+ * @param timestamp - Timestamp in milliseconds
1834
+ * @param intervalMinutes - Interval in minutes
1835
+ * @returns Aligned timestamp rounded down to interval boundary
1836
+ */
1837
+ const ALIGN_TO_INTERVAL_FN$1 = (timestamp, intervalMinutes) => {
1838
+ const intervalMs = intervalMinutes * MS_PER_MINUTE$1;
1839
+ return Math.floor(timestamp / intervalMs) * intervalMs;
1840
+ };
1803
1841
  /**
1804
1842
  * Validates that all candles have valid OHLCV data without anomalies.
1805
1843
  * Detects incomplete candles from Binance API by checking for abnormally low prices or volumes.
@@ -1863,25 +1901,24 @@ const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => {
1863
1901
  };
1864
1902
  /**
1865
1903
  * Attempts to read candles from cache.
1866
- * Validates cache consistency (no gaps in timestamps) before returning.
1867
1904
  *
1868
- * Boundary semantics:
1869
- * - sinceTimestamp: EXCLUSIVE lower bound (candle.timestamp > sinceTimestamp)
1870
- * - untilTimestamp: EXCLUSIVE upper bound (candle.timestamp + stepMs < untilTimestamp)
1871
- * - Only fully closed candles within the exclusive range are returned
1905
+ * Cache lookup calculates expected timestamps:
1906
+ * sinceTimestamp + i * stepMs for i = 0..limit-1
1907
+ * Returns all candles if found, null if any missing.
1872
1908
  *
1873
1909
  * @param dto - Data transfer object containing symbol, interval, and limit
1874
- * @param sinceTimestamp - Exclusive start timestamp in milliseconds
1875
- * @param untilTimestamp - Exclusive end timestamp in milliseconds
1910
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
1911
+ * @param untilTimestamp - Unused, kept for API compatibility
1876
1912
  * @param self - Instance of ClientExchange
1877
- * @returns Cached candles array or null if cache miss or inconsistent
1913
+ * @returns Cached candles array (exactly limit) or null if cache miss
1878
1914
  */
1879
1915
  const READ_CANDLES_CACHE_FN$1 = trycatch(async (dto, sinceTimestamp, untilTimestamp, self) => {
1880
- // PersistCandleAdapter.readCandlesData uses EXCLUSIVE boundaries:
1881
- // Returns candles where: timestamp > sinceTimestamp AND timestamp + stepMs < untilTimestamp
1916
+ // PersistCandleAdapter.readCandlesData calculates expected timestamps:
1917
+ // sinceTimestamp + i * stepMs for i = 0..limit-1
1918
+ // Returns all candles if found, null if any missing
1882
1919
  const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, self.params.exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
1883
1920
  // Return cached data only if we have exactly the requested limit
1884
- if (cachedCandles.length === dto.limit) {
1921
+ if (cachedCandles?.length === dto.limit) {
1885
1922
  self.params.logger.debug(`ClientExchange READ_CANDLES_CACHE_FN: cache hit for symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
1886
1923
  return cachedCandles;
1887
1924
  }
@@ -1903,11 +1940,12 @@ const READ_CANDLES_CACHE_FN$1 = trycatch(async (dto, sinceTimestamp, untilTimest
1903
1940
  /**
1904
1941
  * Writes candles to cache with error handling.
1905
1942
  *
1906
- * The candles passed to this function must already be filtered using EXCLUSIVE boundaries:
1907
- * - candle.timestamp > sinceTimestamp
1908
- * - candle.timestamp + stepMs < untilTimestamp
1943
+ * The candles passed to this function should be validated:
1944
+ * - First candle.timestamp equals aligned sinceTimestamp (openTime)
1945
+ * - Exact number of candles as requested (limit)
1946
+ * - Sequential timestamps: sinceTimestamp + i * stepMs
1909
1947
  *
1910
- * @param candles - Array of candle data to cache (already filtered with exclusive boundaries)
1948
+ * @param candles - Array of validated candle data to cache
1911
1949
  * @param dto - Data transfer object containing symbol, interval, and limit
1912
1950
  * @param self - Instance of ClientExchange
1913
1951
  */
@@ -2034,6 +2072,13 @@ class ClientExchange {
2034
2072
  /**
2035
2073
  * Fetches historical candles backwards from execution context time.
2036
2074
  *
2075
+ * Algorithm:
2076
+ * 1. Align when down to interval boundary (e.g., 00:17 -> 00:15 for 15m)
2077
+ * 2. Calculate since = alignedWhen - limit * step
2078
+ * 3. Fetch candles starting from since
2079
+ * 4. Validate first candle timestamp matches since (adapter must return inclusive data)
2080
+ * 5. Slice to limit
2081
+ *
2037
2082
  * @param symbol - Trading pair symbol
2038
2083
  * @param interval - Candle interval
2039
2084
  * @param limit - Number of candles to fetch
@@ -2046,11 +2091,16 @@ class ClientExchange {
2046
2091
  limit,
2047
2092
  });
2048
2093
  const step = INTERVAL_MINUTES$4[interval];
2049
- const adjust = step * limit;
2050
- if (!adjust) {
2051
- throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
2094
+ if (!step) {
2095
+ throw new Error(`ClientExchange unknown interval=${interval}`);
2052
2096
  }
2053
- const since = new Date(this.params.execution.context.when.getTime() - adjust * MS_PER_MINUTE$1);
2097
+ const stepMs = step * MS_PER_MINUTE$1;
2098
+ // Align when down to interval boundary
2099
+ const whenTimestamp = this.params.execution.context.when.getTime();
2100
+ const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
2101
+ // Calculate since: go back limit candles from aligned when
2102
+ const sinceTimestamp = alignedWhen - limit * stepMs;
2103
+ const since = new Date(sinceTimestamp);
2054
2104
  let allData = [];
2055
2105
  // If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
2056
2106
  if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
@@ -2063,39 +2113,34 @@ class ClientExchange {
2063
2113
  remaining -= chunkLimit;
2064
2114
  if (remaining > 0) {
2065
2115
  // Move currentSince forward by the number of candles fetched
2066
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2116
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2067
2117
  }
2068
2118
  }
2069
2119
  }
2070
2120
  else {
2071
2121
  allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
2072
2122
  }
2073
- // Filter candles to strictly match the requested range
2074
- const whenTimestamp = this.params.execution.context.when.getTime();
2075
- const sinceTimestamp = since.getTime();
2076
- const stepMs = step * MS_PER_MINUTE$1;
2077
- const filteredData = allData.filter((candle) => {
2078
- // EXCLUSIVE boundaries:
2079
- // - candle.timestamp > sinceTimestamp (exclude exact boundary)
2080
- // - candle.timestamp + stepMs < whenTimestamp (fully closed before "when")
2081
- if (candle.timestamp <= sinceTimestamp) {
2082
- return false;
2083
- }
2084
- // Check against current time (when)
2085
- // Only allow candles that have fully CLOSED before "when"
2086
- return candle.timestamp + stepMs < whenTimestamp;
2087
- });
2088
2123
  // Apply distinct by timestamp to remove duplicates
2089
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2090
- if (filteredData.length !== uniqueData.length) {
2091
- const msg = `ClientExchange Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
2124
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
2125
+ if (allData.length !== uniqueData.length) {
2126
+ const msg = `ClientExchange getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
2092
2127
  this.params.logger.warn(msg);
2093
2128
  console.warn(msg);
2094
2129
  }
2095
- if (uniqueData.length < limit) {
2096
- const msg = `ClientExchange Expected ${limit} candles, got ${uniqueData.length}`;
2097
- this.params.logger.warn(msg);
2098
- console.warn(msg);
2130
+ // Validate adapter returned data
2131
+ if (uniqueData.length === 0) {
2132
+ throw new Error(`ClientExchange getCandles: adapter returned empty array. ` +
2133
+ `Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
2134
+ }
2135
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
2136
+ throw new Error(`ClientExchange getCandles: first candle timestamp mismatch. ` +
2137
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
2138
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
2139
+ }
2140
+ if (uniqueData.length !== limit) {
2141
+ throw new Error(`ClientExchange getCandles: candle count mismatch. ` +
2142
+ `Expected ${limit} candles, got ${uniqueData.length}. ` +
2143
+ `Adapter must return exact number of candles requested.`);
2099
2144
  }
2100
2145
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
2101
2146
  return uniqueData;
@@ -2104,6 +2149,13 @@ class ClientExchange {
2104
2149
  * Fetches future candles forwards from execution context time.
2105
2150
  * Used in backtest mode to get candles for signal duration.
2106
2151
  *
2152
+ * Algorithm:
2153
+ * 1. Align when down to interval boundary (e.g., 00:17 -> 00:15 for 15m)
2154
+ * 2. since = alignedWhen (start from aligned when)
2155
+ * 3. Fetch candles starting from since
2156
+ * 4. Validate first candle timestamp matches since (adapter must return inclusive data)
2157
+ * 5. Slice to limit
2158
+ *
2107
2159
  * @param symbol - Trading pair symbol
2108
2160
  * @param interval - Candle interval
2109
2161
  * @param limit - Number of candles to fetch
@@ -2119,12 +2171,21 @@ class ClientExchange {
2119
2171
  if (!this.params.execution.context.backtest) {
2120
2172
  throw new Error(`ClientExchange getNextCandles: cannot fetch future candles in live mode`);
2121
2173
  }
2122
- const since = new Date(this.params.execution.context.when.getTime());
2123
- const now = Date.now();
2124
- // Вычисляем конечное время запроса
2125
2174
  const step = INTERVAL_MINUTES$4[interval];
2126
- const endTime = since.getTime() + limit * step * MS_PER_MINUTE$1;
2127
- // Проверяем что запрошенный период не заходит за Date.now()
2175
+ if (!step) {
2176
+ throw new Error(`ClientExchange getNextCandles: unknown interval=${interval}`);
2177
+ }
2178
+ const stepMs = step * MS_PER_MINUTE$1;
2179
+ const now = Date.now();
2180
+ // Align when down to interval boundary
2181
+ const whenTimestamp = this.params.execution.context.when.getTime();
2182
+ const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
2183
+ // since = alignedWhen (start from aligned when, going forward)
2184
+ const sinceTimestamp = alignedWhen;
2185
+ const since = new Date(sinceTimestamp);
2186
+ // Calculate end time for Date.now() check
2187
+ const endTime = sinceTimestamp + limit * stepMs;
2188
+ // Check that requested period does not exceed Date.now()
2128
2189
  if (endTime > now) {
2129
2190
  return [];
2130
2191
  }
@@ -2140,29 +2201,34 @@ class ClientExchange {
2140
2201
  remaining -= chunkLimit;
2141
2202
  if (remaining > 0) {
2142
2203
  // Move currentSince forward by the number of candles fetched
2143
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2204
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2144
2205
  }
2145
2206
  }
2146
2207
  }
2147
2208
  else {
2148
2209
  allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
2149
2210
  }
2150
- // Filter candles to strictly match the requested range
2151
- const sinceTimestamp = since.getTime();
2152
- const stepMs = step * MS_PER_MINUTE$1;
2153
- const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
2154
- candle.timestamp + stepMs < endTime);
2155
2211
  // Apply distinct by timestamp to remove duplicates
2156
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2157
- if (filteredData.length !== uniqueData.length) {
2158
- const msg = `ClientExchange getNextCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
2212
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
2213
+ if (allData.length !== uniqueData.length) {
2214
+ const msg = `ClientExchange getNextCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
2159
2215
  this.params.logger.warn(msg);
2160
2216
  console.warn(msg);
2161
2217
  }
2162
- if (uniqueData.length < limit) {
2163
- const msg = `ClientExchange getNextCandles: Expected ${limit} candles, got ${uniqueData.length}`;
2164
- this.params.logger.warn(msg);
2165
- console.warn(msg);
2218
+ // Validate adapter returned data
2219
+ if (uniqueData.length === 0) {
2220
+ throw new Error(`ClientExchange getNextCandles: adapter returned empty array. ` +
2221
+ `Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
2222
+ }
2223
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
2224
+ throw new Error(`ClientExchange getNextCandles: first candle timestamp mismatch. ` +
2225
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
2226
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
2227
+ }
2228
+ if (uniqueData.length !== limit) {
2229
+ throw new Error(`ClientExchange getNextCandles: candle count mismatch. ` +
2230
+ `Expected ${limit} candles, got ${uniqueData.length}. ` +
2231
+ `Adapter must return exact number of candles requested.`);
2166
2232
  }
2167
2233
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
2168
2234
  return uniqueData;
@@ -2237,6 +2303,12 @@ class ClientExchange {
2237
2303
  /**
2238
2304
  * Fetches raw candles with flexible date/limit parameters.
2239
2305
  *
2306
+ * Algorithm:
2307
+ * 1. Align all timestamps down to interval boundary
2308
+ * 2. Fetch candles starting from aligned since
2309
+ * 3. Validate first candle timestamp matches aligned since (adapter must return inclusive data)
2310
+ * 4. Slice to limit
2311
+ *
2240
2312
  * All modes respect execution context and prevent look-ahead bias.
2241
2313
  *
2242
2314
  * Parameter combinations:
@@ -2271,9 +2343,10 @@ class ClientExchange {
2271
2343
  if (!step) {
2272
2344
  throw new Error(`ClientExchange getRawCandles: unknown interval=${interval}`);
2273
2345
  }
2346
+ const stepMs = step * MS_PER_MINUTE$1;
2274
2347
  const whenTimestamp = this.params.execution.context.when.getTime();
2348
+ const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
2275
2349
  let sinceTimestamp;
2276
- let untilTimestamp;
2277
2350
  let calculatedLimit;
2278
2351
  // Case 1: all three parameters provided
2279
2352
  if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
@@ -2283,8 +2356,8 @@ class ClientExchange {
2283
2356
  if (eDate > whenTimestamp) {
2284
2357
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2285
2358
  }
2286
- sinceTimestamp = sDate;
2287
- untilTimestamp = eDate;
2359
+ // Align sDate down to interval boundary
2360
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
2288
2361
  calculatedLimit = limit;
2289
2362
  }
2290
2363
  // Case 2: sDate + eDate (no limit) - calculate limit from date range
@@ -2295,9 +2368,10 @@ class ClientExchange {
2295
2368
  if (eDate > whenTimestamp) {
2296
2369
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2297
2370
  }
2298
- sinceTimestamp = sDate;
2299
- untilTimestamp = eDate;
2300
- calculatedLimit = Math.ceil((eDate - sDate) / (step * MS_PER_MINUTE$1));
2371
+ // Align sDate down to interval boundary
2372
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
2373
+ const alignedEDate = ALIGN_TO_INTERVAL_FN$1(eDate, step);
2374
+ calculatedLimit = Math.ceil((alignedEDate - sinceTimestamp) / stepMs);
2301
2375
  if (calculatedLimit <= 0) {
2302
2376
  throw new Error(`ClientExchange getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
2303
2377
  }
@@ -2307,23 +2381,24 @@ class ClientExchange {
2307
2381
  if (eDate > whenTimestamp) {
2308
2382
  throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2309
2383
  }
2310
- untilTimestamp = eDate;
2311
- sinceTimestamp = eDate - limit * step * MS_PER_MINUTE$1;
2384
+ // Align eDate down and calculate sinceTimestamp
2385
+ const alignedEDate = ALIGN_TO_INTERVAL_FN$1(eDate, step);
2386
+ sinceTimestamp = alignedEDate - limit * stepMs;
2312
2387
  calculatedLimit = limit;
2313
2388
  }
2314
2389
  // Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
2315
2390
  else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
2316
- sinceTimestamp = sDate;
2317
- untilTimestamp = sDate + limit * step * MS_PER_MINUTE$1;
2318
- if (untilTimestamp > whenTimestamp) {
2319
- throw new Error(`ClientExchange getRawCandles: calculated endTimestamp (${untilTimestamp}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2391
+ // Align sDate down to interval boundary
2392
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
2393
+ const endTimestamp = sinceTimestamp + limit * stepMs;
2394
+ if (endTimestamp > whenTimestamp) {
2395
+ throw new Error(`ClientExchange getRawCandles: calculated endTimestamp (${endTimestamp}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2320
2396
  }
2321
2397
  calculatedLimit = limit;
2322
2398
  }
2323
2399
  // Case 5: Only limit - use execution.context.when as reference (backward like getCandles)
2324
2400
  else if (sDate === undefined && eDate === undefined && limit !== undefined) {
2325
- untilTimestamp = whenTimestamp;
2326
- sinceTimestamp = whenTimestamp - limit * step * MS_PER_MINUTE$1;
2401
+ sinceTimestamp = alignedWhen - limit * stepMs;
2327
2402
  calculatedLimit = limit;
2328
2403
  }
2329
2404
  // Invalid: no parameters or only sDate or only eDate
@@ -2344,29 +2419,34 @@ class ClientExchange {
2344
2419
  allData.push(...chunkData);
2345
2420
  remaining -= chunkLimit;
2346
2421
  if (remaining > 0) {
2347
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2422
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
2348
2423
  }
2349
2424
  }
2350
2425
  }
2351
2426
  else {
2352
2427
  allData = await GET_CANDLES_FN({ symbol, interval, limit: calculatedLimit }, since, this);
2353
2428
  }
2354
- // Filter candles to strictly match the requested range
2355
- // Only include candles that have fully CLOSED before untilTimestamp
2356
- const stepMs = step * MS_PER_MINUTE$1;
2357
- const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
2358
- candle.timestamp + stepMs < untilTimestamp);
2359
2429
  // Apply distinct by timestamp to remove duplicates
2360
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2361
- if (filteredData.length !== uniqueData.length) {
2362
- const msg = `ClientExchange getRawCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
2430
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
2431
+ if (allData.length !== uniqueData.length) {
2432
+ const msg = `ClientExchange getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
2363
2433
  this.params.logger.warn(msg);
2364
2434
  console.warn(msg);
2365
2435
  }
2366
- if (uniqueData.length < calculatedLimit) {
2367
- const msg = `ClientExchange getRawCandles: Expected ${calculatedLimit} candles, got ${uniqueData.length}`;
2368
- this.params.logger.warn(msg);
2369
- console.warn(msg);
2436
+ // Validate adapter returned data
2437
+ if (uniqueData.length === 0) {
2438
+ throw new Error(`ClientExchange getRawCandles: adapter returned empty array. ` +
2439
+ `Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
2440
+ }
2441
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
2442
+ throw new Error(`ClientExchange getRawCandles: first candle timestamp mismatch. ` +
2443
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
2444
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
2445
+ }
2446
+ if (uniqueData.length !== calculatedLimit) {
2447
+ throw new Error(`ClientExchange getRawCandles: candle count mismatch. ` +
2448
+ `Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
2449
+ `Adapter must return exact number of candles requested.`);
2370
2450
  }
2371
2451
  await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, calculatedLimit, uniqueData);
2372
2452
  return uniqueData;
@@ -4924,6 +5004,68 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
4924
5004
  const result = await CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN(self, scheduled, averagePrice, candle.timestamp, "user");
4925
5005
  return { activated: false, cancelled: true, activationIndex: i, result };
4926
5006
  }
5007
+ // КРИТИЧНО: Проверяем был ли сигнал активирован пользователем через activateScheduled()
5008
+ // Обрабатываем inline (как в tick()) с риск-проверкой по averagePrice
5009
+ if (self._activatedSignal) {
5010
+ const activatedSignal = self._activatedSignal;
5011
+ self._activatedSignal = null;
5012
+ // Check if strategy was stopped
5013
+ if (self._isStopped) {
5014
+ self.params.logger.info("ClientStrategy backtest user-activated signal cancelled (stopped)", {
5015
+ symbol: self.params.execution.context.symbol,
5016
+ signalId: activatedSignal.id,
5017
+ });
5018
+ await self.setScheduledSignal(null);
5019
+ return { activated: false, cancelled: false, activationIndex: i, result: null };
5020
+ }
5021
+ // Риск-проверка по averagePrice (симметрия с LIVE tick())
5022
+ if (await not(CALL_RISK_CHECK_SIGNAL_FN(self, self.params.execution.context.symbol, activatedSignal, averagePrice, candle.timestamp, self.params.execution.context.backtest))) {
5023
+ self.params.logger.info("ClientStrategy backtest user-activated signal rejected by risk", {
5024
+ symbol: self.params.execution.context.symbol,
5025
+ signalId: activatedSignal.id,
5026
+ });
5027
+ await self.setScheduledSignal(null);
5028
+ return { activated: false, cancelled: false, activationIndex: i, result: null };
5029
+ }
5030
+ await self.setScheduledSignal(null);
5031
+ const pendingSignal = {
5032
+ ...activatedSignal,
5033
+ pendingAt: candle.timestamp,
5034
+ _isScheduled: false,
5035
+ };
5036
+ await self.setPendingSignal(pendingSignal);
5037
+ await CALL_RISK_ADD_SIGNAL_FN(self, self.params.execution.context.symbol, pendingSignal, candle.timestamp, self.params.execution.context.backtest);
5038
+ // Emit commit AFTER successful risk check
5039
+ const publicSignalForCommit = TO_PUBLIC_SIGNAL(pendingSignal);
5040
+ await CALL_COMMIT_FN(self, {
5041
+ action: "activate-scheduled",
5042
+ symbol: self.params.execution.context.symbol,
5043
+ strategyName: self.params.strategyName,
5044
+ exchangeName: self.params.exchangeName,
5045
+ frameName: self.params.frameName,
5046
+ signalId: activatedSignal.id,
5047
+ backtest: self.params.execution.context.backtest,
5048
+ activateId: activatedSignal.activateId,
5049
+ timestamp: candle.timestamp,
5050
+ currentPrice: averagePrice,
5051
+ position: publicSignalForCommit.position,
5052
+ priceOpen: publicSignalForCommit.priceOpen,
5053
+ priceTakeProfit: publicSignalForCommit.priceTakeProfit,
5054
+ priceStopLoss: publicSignalForCommit.priceStopLoss,
5055
+ originalPriceTakeProfit: publicSignalForCommit.originalPriceTakeProfit,
5056
+ originalPriceStopLoss: publicSignalForCommit.originalPriceStopLoss,
5057
+ scheduledAt: publicSignalForCommit.scheduledAt,
5058
+ pendingAt: publicSignalForCommit.pendingAt,
5059
+ });
5060
+ await CALL_OPEN_CALLBACKS_FN(self, self.params.execution.context.symbol, pendingSignal, pendingSignal.priceOpen, candle.timestamp, self.params.execution.context.backtest);
5061
+ await CALL_BACKTEST_SCHEDULE_OPEN_FN(self, self.params.execution.context.symbol, pendingSignal, candle.timestamp, self.params.execution.context.backtest);
5062
+ return {
5063
+ activated: true,
5064
+ cancelled: false,
5065
+ activationIndex: i,
5066
+ result: null,
5067
+ };
5068
+ }
4927
5069
  // КРИТИЧНО: Проверяем timeout ПЕРЕД проверкой цены
4928
5070
  const elapsedTime = candle.timestamp - scheduled.scheduledAt;
4929
5071
  if (elapsedTime >= maxTimeToWait) {
@@ -5146,6 +5288,7 @@ class ClientStrategy {
5146
5288
  this._scheduledSignal = null;
5147
5289
  this._cancelledSignal = null;
5148
5290
  this._closedSignal = null;
5291
+ this._activatedSignal = null;
5149
5292
  /** Queue for commit events to be processed in tick()/backtest() with proper timestamp */
5150
5293
  this._commitQueue = [];
5151
5294
  /**
@@ -5183,6 +5326,16 @@ class ClientStrategy {
5183
5326
  this.params.logger.debug("ClientStrategy setPendingSignal", {
5184
5327
  pendingSignal,
5185
5328
  });
5329
+ // КРИТИЧНО: Очищаем флаг закрытия при любом изменении pending signal
5330
+ // - при null: сигнал закрыт по TP/SL/timeout, флаг больше не нужен
5331
+ // - при новом сигнале: флаг от предыдущего сигнала не должен влиять на новый
5332
+ this._closedSignal = null;
5333
+ // ЗАЩИТА ИНВАРИАНТА: При установке нового pending сигнала очищаем scheduled
5334
+ // Не может быть одновременно pending И scheduled (взаимоисключающие состояния)
5335
+ // При null: scheduled может существовать (новый сигнал после закрытия позиции)
5336
+ if (pendingSignal !== null) {
5337
+ this._scheduledSignal = null;
5338
+ }
5186
5339
  this._pendingSignal = pendingSignal;
5187
5340
  // КРИТИЧНО: Всегда вызываем коллбек onWrite для тестирования persist storage
5188
5341
  // даже в backtest режиме, чтобы тесты могли перехватывать вызовы через mock adapter
@@ -5208,6 +5361,11 @@ class ClientStrategy {
5208
5361
  this.params.logger.debug("ClientStrategy setScheduledSignal", {
5209
5362
  scheduledSignal,
5210
5363
  });
5364
+ // КРИТИЧНО: Очищаем флаги отмены и активации при любом изменении scheduled signal
5365
+ // - при null: сигнал отменен/активирован по timeout/SL/user, флаги больше не нужны
5366
+ // - при новом сигнале: флаги от предыдущего сигнала не должны влиять на новый
5367
+ this._cancelledSignal = null;
5368
+ this._activatedSignal = null;
5211
5369
  this._scheduledSignal = scheduledSignal;
5212
5370
  if (this.params.execution.context.backtest) {
5213
5371
  return;
@@ -5397,12 +5555,8 @@ class ClientStrategy {
5397
5555
  const currentTime = this.params.execution.context.when.getTime();
5398
5556
  // Process queued commit events with proper timestamp
5399
5557
  await PROCESS_COMMIT_QUEUE_FN(this, currentTime);
5400
- // Early return if strategy was stopped
5401
- if (this._isStopped) {
5402
- const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
5403
- return await RETURN_IDLE_FN(this, currentPrice);
5404
- }
5405
5558
  // Check if scheduled signal was cancelled - emit cancelled event once
5559
+ // NOTE: No _isStopped check here - cancellation must work for graceful shutdown
5406
5560
  if (this._cancelledSignal) {
5407
5561
  const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
5408
5562
  const cancelledSignal = this._cancelledSignal;
@@ -5411,6 +5565,18 @@ class ClientStrategy {
5411
5565
  symbol: this.params.execution.context.symbol,
5412
5566
  signalId: cancelledSignal.id,
5413
5567
  });
5568
+ // Emit commit with correct timestamp from tick context
5569
+ await CALL_COMMIT_FN(this, {
5570
+ action: "cancel-scheduled",
5571
+ symbol: this.params.execution.context.symbol,
5572
+ strategyName: this.params.strategyName,
5573
+ exchangeName: this.params.exchangeName,
5574
+ frameName: this.params.frameName,
5575
+ signalId: cancelledSignal.id,
5576
+ backtest: this.params.execution.context.backtest,
5577
+ cancelId: cancelledSignal.cancelId,
5578
+ timestamp: currentTime,
5579
+ });
5414
5580
  // Call onCancel callback
5415
5581
  await CALL_CANCEL_CALLBACKS_FN(this, this.params.execution.context.symbol, cancelledSignal, currentPrice, currentTime, this.params.execution.context.backtest);
5416
5582
  const result = {
@@ -5439,6 +5605,18 @@ class ClientStrategy {
5439
5605
  symbol: this.params.execution.context.symbol,
5440
5606
  signalId: closedSignal.id,
5441
5607
  });
5608
+ // Emit commit with correct timestamp from tick context
5609
+ await CALL_COMMIT_FN(this, {
5610
+ action: "close-pending",
5611
+ symbol: this.params.execution.context.symbol,
5612
+ strategyName: this.params.strategyName,
5613
+ exchangeName: this.params.exchangeName,
5614
+ frameName: this.params.frameName,
5615
+ signalId: closedSignal.id,
5616
+ backtest: this.params.execution.context.backtest,
5617
+ closeId: closedSignal.closeId,
5618
+ timestamp: currentTime,
5619
+ });
5442
5620
  // Call onClose callback
5443
5621
  await CALL_CLOSE_CALLBACKS_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, currentTime, this.params.execution.context.backtest);
5444
5622
  // КРИТИЧНО: Очищаем состояние ClientPartial при закрытии позиции
@@ -5465,6 +5643,78 @@ class ClientStrategy {
5465
5643
  await CALL_TICK_CALLBACKS_FN(this, this.params.execution.context.symbol, result, currentTime, this.params.execution.context.backtest);
5466
5644
  return result;
5467
5645
  }
5646
+ // Check if scheduled signal was activated - emit opened event once
5647
+ if (this._activatedSignal) {
5648
+ const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
5649
+ const activatedSignal = this._activatedSignal;
5650
+ this._activatedSignal = null; // Clear after emitting
5651
+ this.params.logger.info("ClientStrategy tick: scheduled signal was activated", {
5652
+ symbol: this.params.execution.context.symbol,
5653
+ signalId: activatedSignal.id,
5654
+ });
5655
+ // Check if strategy was stopped (symmetry with backtest PROCESS_SCHEDULED_SIGNAL_CANDLES_FN)
5656
+ if (this._isStopped) {
5657
+ this.params.logger.info("ClientStrategy tick: user-activated signal cancelled (stopped)", {
5658
+ symbol: this.params.execution.context.symbol,
5659
+ signalId: activatedSignal.id,
5660
+ });
5661
+ await this.setScheduledSignal(null);
5662
+ return await RETURN_IDLE_FN(this, currentPrice);
5663
+ }
5664
+ // Check risk before activation
5665
+ if (await not(CALL_RISK_CHECK_SIGNAL_FN(this, this.params.execution.context.symbol, activatedSignal, currentPrice, currentTime, this.params.execution.context.backtest))) {
5666
+ this.params.logger.info("ClientStrategy tick: activated signal rejected by risk", {
5667
+ symbol: this.params.execution.context.symbol,
5668
+ signalId: activatedSignal.id,
5669
+ });
5670
+ return await RETURN_IDLE_FN(this, currentPrice);
5671
+ }
5672
+ // КРИТИЧЕСКИ ВАЖНО: обновляем pendingAt при активации
5673
+ const pendingSignal = {
5674
+ ...activatedSignal,
5675
+ pendingAt: currentTime,
5676
+ _isScheduled: false,
5677
+ };
5678
+ await this.setPendingSignal(pendingSignal);
5679
+ await CALL_RISK_ADD_SIGNAL_FN(this, this.params.execution.context.symbol, pendingSignal, currentTime, this.params.execution.context.backtest);
5680
+ // Emit commit AFTER successful risk check
5681
+ const publicSignalForCommit = TO_PUBLIC_SIGNAL(pendingSignal);
5682
+ await CALL_COMMIT_FN(this, {
5683
+ action: "activate-scheduled",
5684
+ symbol: this.params.execution.context.symbol,
5685
+ strategyName: this.params.strategyName,
5686
+ exchangeName: this.params.exchangeName,
5687
+ frameName: this.params.frameName,
5688
+ signalId: activatedSignal.id,
5689
+ backtest: this.params.execution.context.backtest,
5690
+ activateId: activatedSignal.activateId,
5691
+ timestamp: currentTime,
5692
+ currentPrice,
5693
+ position: publicSignalForCommit.position,
5694
+ priceOpen: publicSignalForCommit.priceOpen,
5695
+ priceTakeProfit: publicSignalForCommit.priceTakeProfit,
5696
+ priceStopLoss: publicSignalForCommit.priceStopLoss,
5697
+ originalPriceTakeProfit: publicSignalForCommit.originalPriceTakeProfit,
5698
+ originalPriceStopLoss: publicSignalForCommit.originalPriceStopLoss,
5699
+ scheduledAt: publicSignalForCommit.scheduledAt,
5700
+ pendingAt: publicSignalForCommit.pendingAt,
5701
+ });
5702
+ // Call onOpen callback
5703
+ await CALL_OPEN_CALLBACKS_FN(this, this.params.execution.context.symbol, pendingSignal, currentPrice, currentTime, this.params.execution.context.backtest);
5704
+ const result = {
5705
+ action: "opened",
5706
+ signal: TO_PUBLIC_SIGNAL(pendingSignal),
5707
+ strategyName: this.params.method.context.strategyName,
5708
+ exchangeName: this.params.method.context.exchangeName,
5709
+ frameName: this.params.method.context.frameName,
5710
+ symbol: this.params.execution.context.symbol,
5711
+ currentPrice,
5712
+ backtest: this.params.execution.context.backtest,
5713
+ createdAt: currentTime,
5714
+ };
5715
+ await CALL_TICK_CALLBACKS_FN(this, this.params.execution.context.symbol, result, currentTime, this.params.execution.context.backtest);
5716
+ return result;
5717
+ }
5468
5718
  // Monitor scheduled signal
5469
5719
  if (this._scheduledSignal && !this._pendingSignal) {
5470
5720
  const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
@@ -5488,7 +5738,12 @@ class ClientStrategy {
5488
5738
  return await RETURN_SCHEDULED_SIGNAL_ACTIVE_FN(this, this._scheduledSignal, currentPrice);
5489
5739
  }
5490
5740
  // Generate new signal if none exists
5741
+ // NOTE: _isStopped blocks NEW signal generation but allows existing positions to continue
5491
5742
  if (!this._pendingSignal && !this._scheduledSignal) {
5743
+ if (this._isStopped) {
5744
+ const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
5745
+ return await RETURN_IDLE_FN(this, currentPrice);
5746
+ }
5492
5747
  const signal = await GET_SIGNAL_FN(this);
5493
5748
  if (signal) {
5494
5749
  if (signal._isScheduled === true) {
@@ -5562,6 +5817,18 @@ class ClientStrategy {
5562
5817
  const cancelledSignal = this._cancelledSignal;
5563
5818
  this._cancelledSignal = null; // Clear after using
5564
5819
  const closeTimestamp = this.params.execution.context.when.getTime();
5820
+ // Emit commit with correct timestamp from backtest context
5821
+ await CALL_COMMIT_FN(this, {
5822
+ action: "cancel-scheduled",
5823
+ symbol: this.params.execution.context.symbol,
5824
+ strategyName: this.params.strategyName,
5825
+ exchangeName: this.params.exchangeName,
5826
+ frameName: this.params.frameName,
5827
+ signalId: cancelledSignal.id,
5828
+ backtest: true,
5829
+ cancelId: cancelledSignal.cancelId,
5830
+ timestamp: closeTimestamp,
5831
+ });
5565
5832
  await CALL_CANCEL_CALLBACKS_FN(this, this.params.execution.context.symbol, cancelledSignal, currentPrice, closeTimestamp, this.params.execution.context.backtest);
5566
5833
  const cancelledResult = {
5567
5834
  action: "cancelled",
@@ -5587,6 +5854,18 @@ class ClientStrategy {
5587
5854
  const closedSignal = this._closedSignal;
5588
5855
  this._closedSignal = null; // Clear after using
5589
5856
  const closeTimestamp = this.params.execution.context.when.getTime();
5857
+ // Emit commit with correct timestamp from backtest context
5858
+ await CALL_COMMIT_FN(this, {
5859
+ action: "close-pending",
5860
+ symbol: this.params.execution.context.symbol,
5861
+ strategyName: this.params.strategyName,
5862
+ exchangeName: this.params.exchangeName,
5863
+ frameName: this.params.frameName,
5864
+ signalId: closedSignal.id,
5865
+ backtest: true,
5866
+ closeId: closedSignal.closeId,
5867
+ timestamp: closeTimestamp,
5868
+ });
5590
5869
  await CALL_CLOSE_CALLBACKS_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, closeTimestamp, this.params.execution.context.backtest);
5591
5870
  // КРИТИЧНО: Очищаем состояние ClientPartial при закрытии позиции
5592
5871
  await CALL_PARTIAL_CLEAR_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, closeTimestamp, this.params.execution.context.backtest);
@@ -5747,8 +6026,18 @@ class ClientStrategy {
5747
6026
  symbol,
5748
6027
  hasPendingSignal: this._pendingSignal !== null,
5749
6028
  hasScheduledSignal: this._scheduledSignal !== null,
6029
+ hasActivatedSignal: this._activatedSignal !== null,
6030
+ hasCancelledSignal: this._cancelledSignal !== null,
6031
+ hasClosedSignal: this._closedSignal !== null,
5750
6032
  });
5751
6033
  this._isStopped = true;
6034
+ // Clear pending flags to start from clean state
6035
+ // NOTE: _isStopped blocks NEW position opening, but allows:
6036
+ // - cancelScheduled() / closePending() for graceful shutdown
6037
+ // - Monitoring existing _pendingSignal until TP/SL/timeout
6038
+ this._activatedSignal = null;
6039
+ this._cancelledSignal = null;
6040
+ this._closedSignal = null;
5752
6041
  // Clear scheduled signal if exists
5753
6042
  if (!this._scheduledSignal) {
5754
6043
  return;
@@ -5786,8 +6075,9 @@ class ClientStrategy {
5786
6075
  hasScheduledSignal: this._scheduledSignal !== null,
5787
6076
  cancelId,
5788
6077
  });
5789
- // Save cancelled signal for next tick to emit cancelled event
5790
- const hadScheduledSignal = this._scheduledSignal !== null;
6078
+ // NOTE: No _isStopped check - cancellation must work for graceful shutdown
6079
+ // (cancelling scheduled signal is not opening new position)
6080
+ // Save cancelled signal for next tick/backtest to emit cancelled event with correct timestamp
5791
6081
  if (this._scheduledSignal) {
5792
6082
  this._cancelledSignal = Object.assign({}, this._scheduledSignal, {
5793
6083
  cancelId,
@@ -5795,37 +6085,60 @@ class ClientStrategy {
5795
6085
  this._scheduledSignal = null;
5796
6086
  }
5797
6087
  if (backtest) {
5798
- // Emit commit event only if signal was actually cancelled
5799
- if (hadScheduledSignal) {
5800
- await CALL_COMMIT_FN(this, {
5801
- action: "cancel-scheduled",
5802
- symbol,
5803
- strategyName: this.params.strategyName,
5804
- exchangeName: this.params.exchangeName,
5805
- frameName: this.params.frameName,
5806
- signalId: this._cancelledSignal.id,
5807
- backtest,
5808
- cancelId,
5809
- timestamp: this.params.execution.context.when.getTime(),
5810
- });
5811
- }
6088
+ // Commit will be emitted in backtest() with correct candle timestamp
5812
6089
  return;
5813
6090
  }
5814
6091
  await PersistScheduleAdapter.writeScheduleData(this._scheduledSignal, symbol, this.params.method.context.strategyName, this.params.method.context.exchangeName);
5815
- // Emit commit event only if signal was actually cancelled
5816
- if (hadScheduledSignal) {
5817
- await CALL_COMMIT_FN(this, {
5818
- action: "cancel-scheduled",
6092
+ // Commit will be emitted in tick() with correct currentTime
6093
+ }
6094
+ /**
6095
+ * Activates the scheduled signal without waiting for price to reach priceOpen.
6096
+ *
6097
+ * Forces immediate activation of the scheduled signal at the current price.
6098
+ * Does NOT affect active pending signals or strategy operation.
6099
+ * Does NOT set stop flag - strategy can continue generating new signals.
6100
+ *
6101
+ * Use case: User-initiated early activation of a scheduled entry.
6102
+ *
6103
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
6104
+ * @param backtest - Whether running in backtest mode
6105
+ * @param activateId - Optional identifier for this activation operation
6106
+ * @returns Promise that resolves when scheduled signal is activated
6107
+ *
6108
+ * @example
6109
+ * ```typescript
6110
+ * // Activate scheduled signal without waiting for priceOpen
6111
+ * await strategy.activateScheduled("BTCUSDT", false, "user-activate-123");
6112
+ * // Scheduled signal becomes pending signal immediately
6113
+ * ```
6114
+ */
6115
+ async activateScheduled(symbol, backtest, activateId) {
6116
+ this.params.logger.debug("ClientStrategy activateScheduled", {
6117
+ symbol,
6118
+ hasScheduledSignal: this._scheduledSignal !== null,
6119
+ activateId,
6120
+ });
6121
+ // Block activation if strategy stopped - activation = opening NEW position
6122
+ // (unlike cancelScheduled/closePending which handle existing signals for graceful shutdown)
6123
+ if (this._isStopped) {
6124
+ this.params.logger.debug("ClientStrategy activateScheduled: strategy stopped, skipping", {
5819
6125
  symbol,
5820
- strategyName: this.params.strategyName,
5821
- exchangeName: this.params.exchangeName,
5822
- frameName: this.params.frameName,
5823
- signalId: this._cancelledSignal.id,
5824
- backtest,
5825
- cancelId,
5826
- timestamp: this.params.execution.context.when.getTime(),
5827
6126
  });
6127
+ return;
6128
+ }
6129
+ // Save activated signal for next tick to emit opened event
6130
+ if (this._scheduledSignal) {
6131
+ this._activatedSignal = Object.assign({}, this._scheduledSignal, {
6132
+ activateId,
6133
+ });
6134
+ this._scheduledSignal = null;
6135
+ }
6136
+ if (backtest) {
6137
+ // Commit will be emitted AFTER successful risk check in PROCESS_SCHEDULED_SIGNAL_CANDLES_FN
6138
+ return;
5828
6139
  }
6140
+ await PersistScheduleAdapter.writeScheduleData(this._scheduledSignal, symbol, this.params.method.context.strategyName, this.params.method.context.exchangeName);
6141
+ // Commit will be emitted AFTER successful risk check in tick()
5829
6142
  }
5830
6143
  /**
5831
6144
  * Closes the pending signal without stopping the strategy.
@@ -5854,8 +6167,8 @@ class ClientStrategy {
5854
6167
  hasPendingSignal: this._pendingSignal !== null,
5855
6168
  closeId,
5856
6169
  });
5857
- // Save closed signal for next tick to emit closed event
5858
- const hadPendingSignal = this._pendingSignal !== null;
6170
+ // NOTE: No _isStopped check - closing position must work for graceful shutdown
6171
+ // Save closed signal for next tick/backtest to emit closed event with correct timestamp
5859
6172
  if (this._pendingSignal) {
5860
6173
  this._closedSignal = Object.assign({}, this._pendingSignal, {
5861
6174
  closeId,
@@ -5863,37 +6176,11 @@ class ClientStrategy {
5863
6176
  this._pendingSignal = null;
5864
6177
  }
5865
6178
  if (backtest) {
5866
- // Emit commit event only if signal was actually closed
5867
- if (hadPendingSignal) {
5868
- await CALL_COMMIT_FN(this, {
5869
- action: "close-pending",
5870
- symbol,
5871
- strategyName: this.params.strategyName,
5872
- exchangeName: this.params.exchangeName,
5873
- frameName: this.params.frameName,
5874
- signalId: this._closedSignal.id,
5875
- backtest,
5876
- closeId,
5877
- timestamp: this.params.execution.context.when.getTime(),
5878
- });
5879
- }
6179
+ // Commit will be emitted in backtest() with correct candle timestamp
5880
6180
  return;
5881
6181
  }
5882
6182
  await PersistSignalAdapter.writeSignalData(this._pendingSignal, symbol, this.params.strategyName, this.params.exchangeName);
5883
- // Emit commit event only if signal was actually closed
5884
- if (hadPendingSignal) {
5885
- await CALL_COMMIT_FN(this, {
5886
- action: "close-pending",
5887
- symbol,
5888
- strategyName: this.params.strategyName,
5889
- exchangeName: this.params.exchangeName,
5890
- frameName: this.params.frameName,
5891
- signalId: this._closedSignal.id,
5892
- backtest,
5893
- closeId,
5894
- timestamp: this.params.execution.context.when.getTime(),
5895
- });
5896
- }
6183
+ // Commit will be emitted in tick() with correct currentTime
5897
6184
  }
5898
6185
  /**
5899
6186
  * Executes partial close at profit level (moving toward TP).
@@ -7732,6 +8019,39 @@ class StrategyConnectionService {
7732
8019
  const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
7733
8020
  return await strategy.breakeven(symbol, currentPrice, backtest);
7734
8021
  };
8022
+ /**
8023
+ * Activates a scheduled signal early without waiting for price to reach priceOpen.
8024
+ *
8025
+ * Delegates to ClientStrategy.activateScheduled() which sets _activatedSignal flag.
8026
+ * The actual activation happens on next tick() when strategy detects the flag.
8027
+ *
8028
+ * @param backtest - Whether running in backtest mode
8029
+ * @param symbol - Trading pair symbol
8030
+ * @param context - Execution context with strategyName, exchangeName, frameName
8031
+ * @param activateId - Optional identifier for the activation reason
8032
+ * @returns Promise that resolves when activation flag is set
8033
+ *
8034
+ * @example
8035
+ * ```typescript
8036
+ * // Activate scheduled signal early
8037
+ * await strategyConnectionService.activateScheduled(
8038
+ * false,
8039
+ * "BTCUSDT",
8040
+ * { strategyName: "my-strategy", exchangeName: "binance", frameName: "" },
8041
+ * "manual-activation"
8042
+ * );
8043
+ * ```
8044
+ */
8045
+ this.activateScheduled = async (backtest, symbol, context, activateId) => {
8046
+ this.loggerService.log("strategyConnectionService activateScheduled", {
8047
+ symbol,
8048
+ context,
8049
+ backtest,
8050
+ activateId,
8051
+ });
8052
+ const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
8053
+ return await strategy.activateScheduled(symbol, backtest, activateId);
8054
+ };
7735
8055
  }
7736
8056
  }
7737
8057
 
@@ -11044,6 +11364,39 @@ class StrategyCoreService {
11044
11364
  await this.validate(context);
11045
11365
  return await this.strategyConnectionService.breakeven(backtest, symbol, currentPrice, context);
11046
11366
  };
11367
+ /**
11368
+ * Activates a scheduled signal early without waiting for price to reach priceOpen.
11369
+ *
11370
+ * Validates strategy existence and delegates to connection service
11371
+ * to set the activation flag. The actual activation happens on next tick().
11372
+ *
11373
+ * @param backtest - Whether running in backtest mode
11374
+ * @param symbol - Trading pair symbol
11375
+ * @param context - Execution context with strategyName, exchangeName, frameName
11376
+ * @param activateId - Optional identifier for the activation reason
11377
+ * @returns Promise that resolves when activation flag is set
11378
+ *
11379
+ * @example
11380
+ * ```typescript
11381
+ * // Activate scheduled signal early
11382
+ * await strategyCoreService.activateScheduled(
11383
+ * false,
11384
+ * "BTCUSDT",
11385
+ * { strategyName: "my-strategy", exchangeName: "binance", frameName: "" },
11386
+ * "manual-activation"
11387
+ * );
11388
+ * ```
11389
+ */
11390
+ this.activateScheduled = async (backtest, symbol, context, activateId) => {
11391
+ this.loggerService.log("strategyCoreService activateScheduled", {
11392
+ symbol,
11393
+ context,
11394
+ backtest,
11395
+ activateId,
11396
+ });
11397
+ await this.validate(context);
11398
+ return await this.strategyConnectionService.activateScheduled(backtest, symbol, context, activateId);
11399
+ };
11047
11400
  }
11048
11401
  }
11049
11402
 
@@ -12323,13 +12676,8 @@ class WalkerSchemaService {
12323
12676
  }
12324
12677
  }
12325
12678
 
12326
- /**
12327
- * Компенсация для exclusive boundaries при фильтрации свечей.
12328
- * ClientExchange.getNextCandles использует фильтр:
12329
- * timestamp > since && timestamp + stepMs < endTime
12330
- * который исключает первую и последнюю свечи из запрошенного диапазона.
12331
- */
12332
- const CANDLE_EXCLUSIVE_BOUNDARY_OFFSET = 2;
12679
+ const ACTIVE_CANDLE_INCLUDED = 1;
12680
+ const SCHEDULE_ACTIVATION_CANDLE_SKIP = 1;
12333
12681
  /**
12334
12682
  * Private service for backtest orchestration using async generators.
12335
12683
  *
@@ -12453,9 +12801,9 @@ class BacktestLogicPrivateService {
12453
12801
  // - CC_SCHEDULE_AWAIT_MINUTES для ожидания активации
12454
12802
  // - minuteEstimatedTime для работы сигнала ПОСЛЕ активации
12455
12803
  // - +1 потому что when включается как первая свеча
12456
- const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
12804
+ const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - ACTIVE_CANDLE_INCLUDED;
12457
12805
  const bufferStartTime = new Date(when.getTime() - bufferMinutes * 60 * 1000);
12458
- const candlesNeeded = bufferMinutes + GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES + signal.minuteEstimatedTime + 1;
12806
+ const candlesNeeded = bufferMinutes + GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES + signal.minuteEstimatedTime + SCHEDULE_ACTIVATION_CANDLE_SKIP;
12459
12807
  let candles;
12460
12808
  try {
12461
12809
  candles = await this.exchangeCoreService.getNextCandles(symbol, "1m", candlesNeeded, bufferStartTime, true);
@@ -12593,9 +12941,9 @@ class BacktestLogicPrivateService {
12593
12941
  // КРИТИЧНО: Получаем свечи включая буфер для VWAP
12594
12942
  // Сдвигаем начало назад на CC_AVG_PRICE_CANDLES_COUNT-1 минут для буфера VWAP
12595
12943
  // Запрашиваем minuteEstimatedTime + буфер свечей одним запросом
12596
- const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
12944
+ const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - ACTIVE_CANDLE_INCLUDED;
12597
12945
  const bufferStartTime = new Date(when.getTime() - bufferMinutes * 60 * 1000);
12598
- const totalCandles = signal.minuteEstimatedTime + GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT + CANDLE_EXCLUSIVE_BOUNDARY_OFFSET;
12946
+ const totalCandles = signal.minuteEstimatedTime + GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
12599
12947
  let candles;
12600
12948
  try {
12601
12949
  candles = await this.exchangeCoreService.getNextCandles(symbol, "1m", totalCandles, bufferStartTime, true);
@@ -24283,6 +24631,67 @@ class StrategyReportService {
24283
24631
  walkerName: "",
24284
24632
  });
24285
24633
  };
24634
+ /**
24635
+ * Logs an activate-scheduled event when a scheduled signal is activated early.
24636
+ *
24637
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
24638
+ * @param currentPrice - Current market price at time of activation
24639
+ * @param isBacktest - Whether this is a backtest or live trading event
24640
+ * @param context - Strategy context with strategyName, exchangeName, frameName
24641
+ * @param timestamp - Timestamp from StrategyCommitContract (execution context time)
24642
+ * @param position - Trade direction: "long" or "short"
24643
+ * @param priceOpen - Entry price for the position
24644
+ * @param priceTakeProfit - Effective take profit price
24645
+ * @param priceStopLoss - Effective stop loss price
24646
+ * @param originalPriceTakeProfit - Original take profit before trailing
24647
+ * @param originalPriceStopLoss - Original stop loss before trailing
24648
+ * @param scheduledAt - Signal creation timestamp in milliseconds
24649
+ * @param pendingAt - Pending timestamp in milliseconds
24650
+ * @param activateId - Optional identifier for the activation reason
24651
+ */
24652
+ this.activateScheduled = async (symbol, currentPrice, isBacktest, context, timestamp, position, priceOpen, priceTakeProfit, priceStopLoss, originalPriceTakeProfit, originalPriceStopLoss, scheduledAt, pendingAt, activateId) => {
24653
+ this.loggerService.log("strategyReportService activateScheduled", {
24654
+ symbol,
24655
+ currentPrice,
24656
+ isBacktest,
24657
+ activateId,
24658
+ });
24659
+ if (!this.subscribe.hasValue()) {
24660
+ return;
24661
+ }
24662
+ const scheduledRow = await this.strategyCoreService.getScheduledSignal(isBacktest, symbol, {
24663
+ exchangeName: context.exchangeName,
24664
+ strategyName: context.strategyName,
24665
+ frameName: context.frameName,
24666
+ });
24667
+ if (!scheduledRow) {
24668
+ return;
24669
+ }
24670
+ const createdAt = new Date(timestamp).toISOString();
24671
+ await Report.writeData("strategy", {
24672
+ action: "activate-scheduled",
24673
+ activateId,
24674
+ currentPrice,
24675
+ symbol,
24676
+ timestamp,
24677
+ createdAt,
24678
+ position,
24679
+ priceOpen,
24680
+ priceTakeProfit,
24681
+ priceStopLoss,
24682
+ originalPriceTakeProfit,
24683
+ originalPriceStopLoss,
24684
+ scheduledAt,
24685
+ pendingAt,
24686
+ }, {
24687
+ signalId: scheduledRow.id,
24688
+ exchangeName: context.exchangeName,
24689
+ frameName: context.frameName,
24690
+ strategyName: context.strategyName,
24691
+ symbol,
24692
+ walkerName: "",
24693
+ });
24694
+ };
24286
24695
  /**
24287
24696
  * Initializes the service for event logging.
24288
24697
  *
@@ -24342,7 +24751,14 @@ class StrategyReportService {
24342
24751
  frameName: event.frameName,
24343
24752
  strategyName: event.strategyName,
24344
24753
  }, event.timestamp, event.position, event.priceOpen, event.priceTakeProfit, event.priceStopLoss, event.originalPriceTakeProfit, event.originalPriceStopLoss, event.scheduledAt, event.pendingAt));
24345
- const disposeFn = compose(() => unCancelSchedule(), () => unClosePending(), () => unPartialProfit(), () => unPartialLoss(), () => unTrailingStop(), () => unTrailingTake(), () => unBreakeven());
24754
+ const unActivateScheduled = strategyCommitSubject
24755
+ .filter(({ action }) => action === "activate-scheduled")
24756
+ .connect(async (event) => await this.activateScheduled(event.symbol, event.currentPrice, event.backtest, {
24757
+ exchangeName: event.exchangeName,
24758
+ frameName: event.frameName,
24759
+ strategyName: event.strategyName,
24760
+ }, event.timestamp, event.position, event.priceOpen, event.priceTakeProfit, event.priceStopLoss, event.originalPriceTakeProfit, event.originalPriceStopLoss, event.scheduledAt, event.pendingAt, event.activateId));
24761
+ const disposeFn = compose(() => unCancelSchedule(), () => unClosePending(), () => unPartialProfit(), () => unPartialLoss(), () => unTrailingStop(), () => unTrailingTake(), () => unBreakeven(), () => unActivateScheduled());
24346
24762
  return () => {
24347
24763
  disposeFn();
24348
24764
  this.subscribe.clear();
@@ -24473,6 +24889,7 @@ class ReportStorage {
24473
24889
  trailingStopCount: 0,
24474
24890
  trailingTakeCount: 0,
24475
24891
  breakevenCount: 0,
24892
+ activateScheduledCount: 0,
24476
24893
  };
24477
24894
  }
24478
24895
  return {
@@ -24485,6 +24902,7 @@ class ReportStorage {
24485
24902
  trailingStopCount: this._eventList.filter(e => e.action === "trailing-stop").length,
24486
24903
  trailingTakeCount: this._eventList.filter(e => e.action === "trailing-take").length,
24487
24904
  breakevenCount: this._eventList.filter(e => e.action === "breakeven").length,
24905
+ activateScheduledCount: this._eventList.filter(e => e.action === "activate-scheduled").length,
24488
24906
  };
24489
24907
  }
24490
24908
  /**
@@ -24533,6 +24951,7 @@ class ReportStorage {
24533
24951
  `- Trailing stop: ${stats.trailingStopCount}`,
24534
24952
  `- Trailing take: ${stats.trailingTakeCount}`,
24535
24953
  `- Breakeven: ${stats.breakevenCount}`,
24954
+ `- Activate scheduled: ${stats.activateScheduledCount}`,
24536
24955
  ].join("\n");
24537
24956
  }
24538
24957
  /**
@@ -24999,6 +25418,66 @@ class StrategyMarkdownService {
24999
25418
  pendingAt,
25000
25419
  });
25001
25420
  };
25421
+ /**
25422
+ * Records an activate-scheduled event when a scheduled signal is activated early.
25423
+ *
25424
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
25425
+ * @param currentPrice - Current market price at time of activation
25426
+ * @param isBacktest - Whether this is a backtest or live trading event
25427
+ * @param context - Strategy context with strategyName, exchangeName, frameName
25428
+ * @param timestamp - Timestamp from StrategyCommitContract (execution context time)
25429
+ * @param position - Trade direction: "long" or "short"
25430
+ * @param priceOpen - Entry price for the position
25431
+ * @param priceTakeProfit - Effective take profit price
25432
+ * @param priceStopLoss - Effective stop loss price
25433
+ * @param originalPriceTakeProfit - Original take profit before trailing
25434
+ * @param originalPriceStopLoss - Original stop loss before trailing
25435
+ * @param scheduledAt - Signal creation timestamp in milliseconds
25436
+ * @param pendingAt - Pending timestamp in milliseconds
25437
+ * @param activateId - Optional identifier for the activation reason
25438
+ */
25439
+ this.activateScheduled = async (symbol, currentPrice, isBacktest, context, timestamp, position, priceOpen, priceTakeProfit, priceStopLoss, originalPriceTakeProfit, originalPriceStopLoss, scheduledAt, pendingAt, activateId) => {
25440
+ this.loggerService.log("strategyMarkdownService activateScheduled", {
25441
+ symbol,
25442
+ currentPrice,
25443
+ isBacktest,
25444
+ activateId,
25445
+ });
25446
+ if (!this.subscribe.hasValue()) {
25447
+ return;
25448
+ }
25449
+ const scheduledRow = await this.strategyCoreService.getScheduledSignal(isBacktest, symbol, {
25450
+ exchangeName: context.exchangeName,
25451
+ strategyName: context.strategyName,
25452
+ frameName: context.frameName,
25453
+ });
25454
+ if (!scheduledRow) {
25455
+ return;
25456
+ }
25457
+ const createdAt = new Date(timestamp).toISOString();
25458
+ const storage = this.getStorage(symbol, context.strategyName, context.exchangeName, context.frameName, isBacktest);
25459
+ storage.addEvent({
25460
+ timestamp,
25461
+ symbol,
25462
+ strategyName: context.strategyName,
25463
+ exchangeName: context.exchangeName,
25464
+ frameName: context.frameName,
25465
+ signalId: scheduledRow.id,
25466
+ action: "activate-scheduled",
25467
+ activateId,
25468
+ currentPrice,
25469
+ createdAt,
25470
+ backtest: isBacktest,
25471
+ position,
25472
+ priceOpen,
25473
+ priceTakeProfit,
25474
+ priceStopLoss,
25475
+ originalPriceTakeProfit,
25476
+ originalPriceStopLoss,
25477
+ scheduledAt,
25478
+ pendingAt,
25479
+ });
25480
+ };
25002
25481
  /**
25003
25482
  * Retrieves aggregated statistics from accumulated strategy events.
25004
25483
  *
@@ -25172,7 +25651,14 @@ class StrategyMarkdownService {
25172
25651
  frameName: event.frameName,
25173
25652
  strategyName: event.strategyName,
25174
25653
  }, event.timestamp, event.position, event.priceOpen, event.priceTakeProfit, event.priceStopLoss, event.originalPriceTakeProfit, event.originalPriceStopLoss, event.scheduledAt, event.pendingAt));
25175
- const disposeFn = compose(() => unCancelSchedule(), () => unClosePending(), () => unPartialProfit(), () => unPartialLoss(), () => unTrailingStop(), () => unTrailingTake(), () => unBreakeven());
25654
+ const unActivateScheduled = strategyCommitSubject
25655
+ .filter(({ action }) => action === "activate-scheduled")
25656
+ .connect(async (event) => await this.activateScheduled(event.symbol, event.currentPrice, event.backtest, {
25657
+ exchangeName: event.exchangeName,
25658
+ frameName: event.frameName,
25659
+ strategyName: event.strategyName,
25660
+ }, event.timestamp, event.position, event.priceOpen, event.priceTakeProfit, event.priceStopLoss, event.originalPriceTakeProfit, event.originalPriceStopLoss, event.scheduledAt, event.pendingAt, event.activateId));
25661
+ const disposeFn = compose(() => unCancelSchedule(), () => unClosePending(), () => unPartialProfit(), () => unPartialLoss(), () => unTrailingStop(), () => unTrailingTake(), () => unBreakeven(), () => unActivateScheduled());
25176
25662
  return () => {
25177
25663
  disposeFn();
25178
25664
  this.subscribe.clear();
@@ -26098,6 +26584,7 @@ const PARTIAL_LOSS_METHOD_NAME = "strategy.commitPartialLoss";
26098
26584
  const TRAILING_STOP_METHOD_NAME = "strategy.commitTrailingStop";
26099
26585
  const TRAILING_PROFIT_METHOD_NAME = "strategy.commitTrailingTake";
26100
26586
  const BREAKEVEN_METHOD_NAME = "strategy.commitBreakeven";
26587
+ const ACTIVATE_SCHEDULED_METHOD_NAME = "strategy.commitActivateScheduled";
26101
26588
  /**
26102
26589
  * Cancels the scheduled signal without stopping the strategy.
26103
26590
  *
@@ -26415,6 +26902,41 @@ async function commitBreakeven(symbol) {
26415
26902
  const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
26416
26903
  return await bt.strategyCoreService.breakeven(isBacktest, symbol, currentPrice, { exchangeName, frameName, strategyName });
26417
26904
  }
26905
+ /**
26906
+ * Activates a scheduled signal early without waiting for price to reach priceOpen.
26907
+ *
26908
+ * Sets the activation flag on the scheduled signal. The actual activation
26909
+ * happens on the next tick() when strategy detects the flag.
26910
+ *
26911
+ * Automatically detects backtest/live mode from execution context.
26912
+ *
26913
+ * @param symbol - Trading pair symbol
26914
+ * @param activateId - Optional activation ID for tracking user-initiated activations
26915
+ * @returns Promise that resolves when activation flag is set
26916
+ *
26917
+ * @example
26918
+ * ```typescript
26919
+ * import { commitActivateScheduled } from "backtest-kit";
26920
+ *
26921
+ * // Activate scheduled signal early with custom ID
26922
+ * await commitActivateScheduled("BTCUSDT", "manual-activate-001");
26923
+ * ```
26924
+ */
26925
+ async function commitActivateScheduled(symbol, activateId) {
26926
+ bt.loggerService.info(ACTIVATE_SCHEDULED_METHOD_NAME, {
26927
+ symbol,
26928
+ activateId,
26929
+ });
26930
+ if (!ExecutionContextService.hasContext()) {
26931
+ throw new Error("commitActivateScheduled requires an execution context");
26932
+ }
26933
+ if (!MethodContextService.hasContext()) {
26934
+ throw new Error("commitActivateScheduled requires a method context");
26935
+ }
26936
+ const { backtest: isBacktest } = bt.executionContextService.context;
26937
+ const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
26938
+ await bt.strategyCoreService.activateScheduled(isBacktest, symbol, { exchangeName, frameName, strategyName }, activateId);
26939
+ }
26418
26940
 
26419
26941
  const STOP_STRATEGY_METHOD_NAME = "control.stopStrategy";
26420
26942
  /**
@@ -28585,6 +29107,7 @@ const BACKTEST_METHOD_NAME_PARTIAL_PROFIT = "BacktestUtils.commitPartialProfit";
28585
29107
  const BACKTEST_METHOD_NAME_PARTIAL_LOSS = "BacktestUtils.commitPartialLoss";
28586
29108
  const BACKTEST_METHOD_NAME_TRAILING_STOP = "BacktestUtils.commitTrailingStop";
28587
29109
  const BACKTEST_METHOD_NAME_TRAILING_PROFIT = "BacktestUtils.commitTrailingTake";
29110
+ const BACKTEST_METHOD_NAME_ACTIVATE_SCHEDULED = "Backtest.commitActivateScheduled";
28588
29111
  const BACKTEST_METHOD_NAME_GET_DATA = "BacktestUtils.getData";
28589
29112
  /**
28590
29113
  * Internal task function that runs backtest and handles completion.
@@ -29441,6 +29964,46 @@ class BacktestUtils {
29441
29964
  }
29442
29965
  return await bt.strategyCoreService.breakeven(true, symbol, currentPrice, context);
29443
29966
  };
29967
+ /**
29968
+ * Activates a scheduled signal early without waiting for price to reach priceOpen.
29969
+ *
29970
+ * Sets the activation flag on the scheduled signal. The actual activation
29971
+ * happens on the next tick() when strategy detects the flag.
29972
+ *
29973
+ * @param symbol - Trading pair symbol
29974
+ * @param context - Execution context with strategyName, exchangeName, and frameName
29975
+ * @param activateId - Optional activation ID for tracking user-initiated activations
29976
+ * @returns Promise that resolves when activation flag is set
29977
+ *
29978
+ * @example
29979
+ * ```typescript
29980
+ * // Activate scheduled signal early with custom ID
29981
+ * await Backtest.commitActivateScheduled("BTCUSDT", {
29982
+ * strategyName: "my-strategy",
29983
+ * exchangeName: "binance",
29984
+ * frameName: "1h"
29985
+ * }, "manual-activate-001");
29986
+ * ```
29987
+ */
29988
+ this.commitActivateScheduled = async (symbol, context, activateId) => {
29989
+ bt.loggerService.info(BACKTEST_METHOD_NAME_ACTIVATE_SCHEDULED, {
29990
+ symbol,
29991
+ context,
29992
+ activateId,
29993
+ });
29994
+ bt.strategyValidationService.validate(context.strategyName, BACKTEST_METHOD_NAME_ACTIVATE_SCHEDULED);
29995
+ bt.exchangeValidationService.validate(context.exchangeName, BACKTEST_METHOD_NAME_ACTIVATE_SCHEDULED);
29996
+ {
29997
+ const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
29998
+ riskName &&
29999
+ bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_ACTIVATE_SCHEDULED);
30000
+ riskList &&
30001
+ riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_ACTIVATE_SCHEDULED));
30002
+ actions &&
30003
+ actions.forEach((actionName) => bt.actionValidationService.validate(actionName, BACKTEST_METHOD_NAME_ACTIVATE_SCHEDULED));
30004
+ }
30005
+ await bt.strategyCoreService.activateScheduled(true, symbol, context, activateId);
30006
+ };
29444
30007
  /**
29445
30008
  * Gets statistical data from all closed signals for a symbol-strategy pair.
29446
30009
  *
@@ -29616,6 +30179,7 @@ const LIVE_METHOD_NAME_PARTIAL_PROFIT = "LiveUtils.commitPartialProfit";
29616
30179
  const LIVE_METHOD_NAME_PARTIAL_LOSS = "LiveUtils.commitPartialLoss";
29617
30180
  const LIVE_METHOD_NAME_TRAILING_STOP = "LiveUtils.commitTrailingStop";
29618
30181
  const LIVE_METHOD_NAME_TRAILING_PROFIT = "LiveUtils.commitTrailingTake";
30182
+ const LIVE_METHOD_NAME_ACTIVATE_SCHEDULED = "Live.commitActivateScheduled";
29619
30183
  /**
29620
30184
  * Internal task function that runs live trading and handles completion.
29621
30185
  * Consumes live trading results and updates instance state flags.
@@ -30440,6 +31004,46 @@ class LiveUtils {
30440
31004
  frameName: "",
30441
31005
  });
30442
31006
  };
31007
+ /**
31008
+ * Activates a scheduled signal early without waiting for price to reach priceOpen.
31009
+ *
31010
+ * Sets the activation flag on the scheduled signal. The actual activation
31011
+ * happens on the next tick() when strategy detects the flag.
31012
+ *
31013
+ * @param symbol - Trading pair symbol
31014
+ * @param context - Execution context with strategyName and exchangeName
31015
+ * @param activateId - Optional activation ID for tracking user-initiated activations
31016
+ * @returns Promise that resolves when activation flag is set
31017
+ *
31018
+ * @example
31019
+ * ```typescript
31020
+ * // Activate scheduled signal early with custom ID
31021
+ * await Live.commitActivateScheduled("BTCUSDT", {
31022
+ * strategyName: "my-strategy",
31023
+ * exchangeName: "binance"
31024
+ * }, "manual-activate-001");
31025
+ * ```
31026
+ */
31027
+ this.commitActivateScheduled = async (symbol, context, activateId) => {
31028
+ bt.loggerService.info(LIVE_METHOD_NAME_ACTIVATE_SCHEDULED, {
31029
+ symbol,
31030
+ context,
31031
+ activateId,
31032
+ });
31033
+ bt.strategyValidationService.validate(context.strategyName, LIVE_METHOD_NAME_ACTIVATE_SCHEDULED);
31034
+ bt.exchangeValidationService.validate(context.exchangeName, LIVE_METHOD_NAME_ACTIVATE_SCHEDULED);
31035
+ {
31036
+ const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
31037
+ riskName && bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_ACTIVATE_SCHEDULED);
31038
+ riskList && riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_ACTIVATE_SCHEDULED));
31039
+ actions && actions.forEach((actionName) => bt.actionValidationService.validate(actionName, LIVE_METHOD_NAME_ACTIVATE_SCHEDULED));
31040
+ }
31041
+ await bt.strategyCoreService.activateScheduled(false, symbol, {
31042
+ strategyName: context.strategyName,
31043
+ exchangeName: context.exchangeName,
31044
+ frameName: "",
31045
+ }, activateId);
31046
+ };
30443
31047
  /**
30444
31048
  * Gets statistical data from all live trading events for a symbol-strategy pair.
30445
31049
  *
@@ -33089,6 +33693,27 @@ const INTERVAL_MINUTES$1 = {
33089
33693
  "6h": 360,
33090
33694
  "8h": 480,
33091
33695
  };
33696
+ /**
33697
+ * Aligns timestamp down to the nearest interval boundary.
33698
+ * For example, for 15m interval: 00:17 -> 00:15, 00:44 -> 00:30
33699
+ *
33700
+ * Candle timestamp convention:
33701
+ * - Candle timestamp = openTime (when candle opens)
33702
+ * - Candle with timestamp 00:00 covers period [00:00, 00:15) for 15m interval
33703
+ *
33704
+ * Adapter contract:
33705
+ * - Adapter must return candles with timestamp = openTime
33706
+ * - First returned candle.timestamp must equal aligned since
33707
+ * - Adapter must return exactly `limit` candles
33708
+ *
33709
+ * @param timestamp - Timestamp in milliseconds
33710
+ * @param intervalMinutes - Interval in minutes
33711
+ * @returns Aligned timestamp rounded down to interval boundary
33712
+ */
33713
+ const ALIGN_TO_INTERVAL_FN = (timestamp, intervalMinutes) => {
33714
+ const intervalMs = intervalMinutes * MS_PER_MINUTE;
33715
+ return Math.floor(timestamp / intervalMs) * intervalMs;
33716
+ };
33092
33717
  /**
33093
33718
  * Creates exchange instance with methods resolved once during construction.
33094
33719
  * Applies default implementations where schema methods are not provided.
@@ -33110,25 +33735,24 @@ const CREATE_EXCHANGE_INSTANCE_FN = (schema) => {
33110
33735
  };
33111
33736
  /**
33112
33737
  * Attempts to read candles from cache.
33113
- * Validates cache consistency (no gaps in timestamps) before returning.
33114
33738
  *
33115
- * Boundary semantics:
33116
- * - sinceTimestamp: EXCLUSIVE lower bound (candle.timestamp > sinceTimestamp)
33117
- * - untilTimestamp: EXCLUSIVE upper bound (candle.timestamp + stepMs < untilTimestamp)
33118
- * - Only fully closed candles within the exclusive range are returned
33739
+ * Cache lookup calculates expected timestamps:
33740
+ * sinceTimestamp + i * stepMs for i = 0..limit-1
33741
+ * Returns all candles if found, null if any missing.
33119
33742
  *
33120
33743
  * @param dto - Data transfer object containing symbol, interval, and limit
33121
- * @param sinceTimestamp - Exclusive start timestamp in milliseconds
33122
- * @param untilTimestamp - Exclusive end timestamp in milliseconds
33744
+ * @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
33745
+ * @param untilTimestamp - Unused, kept for API compatibility
33123
33746
  * @param exchangeName - Exchange name
33124
- * @returns Cached candles array or null if cache miss or inconsistent
33747
+ * @returns Cached candles array (exactly limit) or null if cache miss
33125
33748
  */
33126
33749
  const READ_CANDLES_CACHE_FN = trycatch(async (dto, sinceTimestamp, untilTimestamp, exchangeName) => {
33127
- // PersistCandleAdapter.readCandlesData uses EXCLUSIVE boundaries:
33128
- // Returns candles where: timestamp > sinceTimestamp AND timestamp + stepMs < untilTimestamp
33750
+ // PersistCandleAdapter.readCandlesData calculates expected timestamps:
33751
+ // sinceTimestamp + i * stepMs for i = 0..limit-1
33752
+ // Returns all candles if found, null if any missing
33129
33753
  const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
33130
33754
  // Return cached data only if we have exactly the requested limit
33131
- if (cachedCandles.length === dto.limit) {
33755
+ if (cachedCandles?.length === dto.limit) {
33132
33756
  bt.loggerService.debug(`ExchangeInstance READ_CANDLES_CACHE_FN: cache hit for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
33133
33757
  return cachedCandles;
33134
33758
  }
@@ -33150,11 +33774,12 @@ const READ_CANDLES_CACHE_FN = trycatch(async (dto, sinceTimestamp, untilTimestam
33150
33774
  /**
33151
33775
  * Writes candles to cache with error handling.
33152
33776
  *
33153
- * The candles passed to this function must already be filtered using EXCLUSIVE boundaries:
33154
- * - candle.timestamp > sinceTimestamp
33155
- * - candle.timestamp + stepMs < untilTimestamp
33777
+ * The candles passed to this function should be validated:
33778
+ * - First candle.timestamp equals aligned sinceTimestamp (openTime)
33779
+ * - Exact number of candles as requested (limit)
33780
+ * - Sequential timestamps: sinceTimestamp + i * stepMs
33156
33781
  *
33157
- * @param candles - Array of candle data to cache (already filtered with exclusive boundaries)
33782
+ * @param candles - Array of validated candle data to cache
33158
33783
  * @param dto - Data transfer object containing symbol, interval, and limit
33159
33784
  * @param exchangeName - Exchange name
33160
33785
  */
@@ -33225,14 +33850,18 @@ class ExchangeInstance {
33225
33850
  });
33226
33851
  const getCandles = this._methods.getCandles;
33227
33852
  const step = INTERVAL_MINUTES$1[interval];
33228
- const adjust = step * limit;
33229
- if (!adjust) {
33230
- throw new Error(`ExchangeInstance unknown time adjust for interval=${interval}`);
33853
+ if (!step) {
33854
+ throw new Error(`ExchangeInstance unknown interval=${interval}`);
33231
33855
  }
33856
+ const stepMs = step * MS_PER_MINUTE;
33857
+ // Align when down to interval boundary
33232
33858
  const when = await GET_TIMESTAMP_FN();
33233
- const since = new Date(when.getTime() - adjust * MS_PER_MINUTE);
33234
- const sinceTimestamp = since.getTime();
33235
- const untilTimestamp = sinceTimestamp + limit * step * MS_PER_MINUTE;
33859
+ const whenTimestamp = when.getTime();
33860
+ const alignedWhen = ALIGN_TO_INTERVAL_FN(whenTimestamp, step);
33861
+ // Calculate since: go back limit candles from aligned when
33862
+ const sinceTimestamp = alignedWhen - limit * stepMs;
33863
+ const since = new Date(sinceTimestamp);
33864
+ const untilTimestamp = alignedWhen;
33236
33865
  // Try to read from cache first
33237
33866
  const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
33238
33867
  if (cachedCandles !== null) {
@@ -33251,7 +33880,7 @@ class ExchangeInstance {
33251
33880
  remaining -= chunkLimit;
33252
33881
  if (remaining > 0) {
33253
33882
  // Move currentSince forward by the number of candles fetched
33254
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE);
33883
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
33255
33884
  }
33256
33885
  }
33257
33886
  }
@@ -33259,27 +33888,25 @@ class ExchangeInstance {
33259
33888
  const isBacktest = await GET_BACKTEST_FN();
33260
33889
  allData = await getCandles(symbol, interval, since, limit, isBacktest);
33261
33890
  }
33262
- // Filter candles to strictly match the requested range
33263
- const whenTimestamp = when.getTime();
33264
- const stepMs = step * MS_PER_MINUTE;
33265
- const filteredData = allData.filter((candle) => {
33266
- // EXCLUSIVE boundaries:
33267
- // - candle.timestamp > sinceTimestamp (exclude exact boundary)
33268
- // - candle.timestamp + stepMs < whenTimestamp (fully closed before "when")
33269
- if (candle.timestamp <= sinceTimestamp) {
33270
- return false;
33271
- }
33272
- // Check against current time (when)
33273
- // Only allow candles that have fully CLOSED before "when"
33274
- return candle.timestamp + stepMs < whenTimestamp;
33275
- });
33276
33891
  // Apply distinct by timestamp to remove duplicates
33277
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
33278
- if (filteredData.length !== uniqueData.length) {
33279
- bt.loggerService.warn(`ExchangeInstance Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`);
33892
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
33893
+ if (allData.length !== uniqueData.length) {
33894
+ bt.loggerService.warn(`ExchangeInstance getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
33895
+ }
33896
+ // Validate adapter returned data
33897
+ if (uniqueData.length === 0) {
33898
+ throw new Error(`ExchangeInstance getCandles: adapter returned empty array. ` +
33899
+ `Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
33280
33900
  }
33281
- if (uniqueData.length < limit) {
33282
- bt.loggerService.warn(`ExchangeInstance Expected ${limit} candles, got ${uniqueData.length}`);
33901
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
33902
+ throw new Error(`ExchangeInstance getCandles: first candle timestamp mismatch. ` +
33903
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
33904
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
33905
+ }
33906
+ if (uniqueData.length !== limit) {
33907
+ throw new Error(`ExchangeInstance getCandles: candle count mismatch. ` +
33908
+ `Expected ${limit} candles, got ${uniqueData.length}. ` +
33909
+ `Adapter must return exact number of candles requested.`);
33283
33910
  }
33284
33911
  // Write to cache after successful fetch
33285
33912
  await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
@@ -33450,10 +34077,11 @@ class ExchangeInstance {
33450
34077
  if (!step) {
33451
34078
  throw new Error(`ExchangeInstance getRawCandles: unknown interval=${interval}`);
33452
34079
  }
34080
+ const stepMs = step * MS_PER_MINUTE;
33453
34081
  const when = await GET_TIMESTAMP_FN();
33454
34082
  const nowTimestamp = when.getTime();
34083
+ const alignedNow = ALIGN_TO_INTERVAL_FN(nowTimestamp, step);
33455
34084
  let sinceTimestamp;
33456
- let untilTimestamp;
33457
34085
  let calculatedLimit;
33458
34086
  // Case 1: all three parameters provided
33459
34087
  if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
@@ -33463,8 +34091,8 @@ class ExchangeInstance {
33463
34091
  if (eDate > nowTimestamp) {
33464
34092
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33465
34093
  }
33466
- sinceTimestamp = sDate;
33467
- untilTimestamp = eDate;
34094
+ // Align sDate down to interval boundary
34095
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
33468
34096
  calculatedLimit = limit;
33469
34097
  }
33470
34098
  // Case 2: sDate + eDate (no limit) - calculate limit from date range
@@ -33475,9 +34103,10 @@ class ExchangeInstance {
33475
34103
  if (eDate > nowTimestamp) {
33476
34104
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33477
34105
  }
33478
- sinceTimestamp = sDate;
33479
- untilTimestamp = eDate;
33480
- calculatedLimit = Math.ceil((eDate - sDate) / (step * MS_PER_MINUTE));
34106
+ // Align sDate down to interval boundary
34107
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
34108
+ const alignedEDate = ALIGN_TO_INTERVAL_FN(eDate, step);
34109
+ calculatedLimit = Math.ceil((alignedEDate - sinceTimestamp) / stepMs);
33481
34110
  if (calculatedLimit <= 0) {
33482
34111
  throw new Error(`ExchangeInstance getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
33483
34112
  }
@@ -33487,23 +34116,24 @@ class ExchangeInstance {
33487
34116
  if (eDate > nowTimestamp) {
33488
34117
  throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33489
34118
  }
33490
- untilTimestamp = eDate;
33491
- sinceTimestamp = eDate - limit * step * MS_PER_MINUTE;
34119
+ // Align eDate down and calculate sinceTimestamp
34120
+ const alignedEDate = ALIGN_TO_INTERVAL_FN(eDate, step);
34121
+ sinceTimestamp = alignedEDate - limit * stepMs;
33492
34122
  calculatedLimit = limit;
33493
34123
  }
33494
34124
  // Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
33495
34125
  else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
33496
- sinceTimestamp = sDate;
33497
- untilTimestamp = sDate + limit * step * MS_PER_MINUTE;
33498
- if (untilTimestamp > nowTimestamp) {
33499
- throw new Error(`ExchangeInstance getRawCandles: calculated endTimestamp (${untilTimestamp}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
34126
+ // Align sDate down to interval boundary
34127
+ sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
34128
+ const endTimestamp = sinceTimestamp + limit * stepMs;
34129
+ if (endTimestamp > nowTimestamp) {
34130
+ throw new Error(`ExchangeInstance getRawCandles: calculated endTimestamp (${endTimestamp}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
33500
34131
  }
33501
34132
  calculatedLimit = limit;
33502
34133
  }
33503
34134
  // Case 5: Only limit - use Date.now() as reference (backward)
33504
34135
  else if (sDate === undefined && eDate === undefined && limit !== undefined) {
33505
- untilTimestamp = nowTimestamp;
33506
- sinceTimestamp = nowTimestamp - limit * step * MS_PER_MINUTE;
34136
+ sinceTimestamp = alignedNow - limit * stepMs;
33507
34137
  calculatedLimit = limit;
33508
34138
  }
33509
34139
  // Invalid: no parameters or only sDate or only eDate
@@ -33513,6 +34143,7 @@ class ExchangeInstance {
33513
34143
  `Got: sDate=${sDate}, eDate=${eDate}, limit=${limit}`);
33514
34144
  }
33515
34145
  // Try to read from cache first
34146
+ const untilTimestamp = sinceTimestamp + calculatedLimit * stepMs;
33516
34147
  const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit: calculatedLimit }, sinceTimestamp, untilTimestamp, this.exchangeName);
33517
34148
  if (cachedCandles !== null) {
33518
34149
  return cachedCandles;
@@ -33531,25 +34162,32 @@ class ExchangeInstance {
33531
34162
  allData.push(...chunkData);
33532
34163
  remaining -= chunkLimit;
33533
34164
  if (remaining > 0) {
33534
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE);
34165
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
33535
34166
  }
33536
34167
  }
33537
34168
  }
33538
34169
  else {
33539
34170
  allData = await getCandles(symbol, interval, since, calculatedLimit, isBacktest);
33540
34171
  }
33541
- // Filter candles to strictly match the requested range
33542
- // Only include candles that have fully CLOSED before untilTimestamp
33543
- const stepMs = step * MS_PER_MINUTE;
33544
- const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
33545
- candle.timestamp + stepMs < untilTimestamp);
33546
34172
  // Apply distinct by timestamp to remove duplicates
33547
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
33548
- if (filteredData.length !== uniqueData.length) {
33549
- bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`);
34173
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
34174
+ if (allData.length !== uniqueData.length) {
34175
+ bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
34176
+ }
34177
+ // Validate adapter returned data
34178
+ if (uniqueData.length === 0) {
34179
+ throw new Error(`ExchangeInstance getRawCandles: adapter returned empty array. ` +
34180
+ `Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
33550
34181
  }
33551
- if (uniqueData.length < calculatedLimit) {
33552
- bt.loggerService.warn(`ExchangeInstance getRawCandles: Expected ${calculatedLimit} candles, got ${uniqueData.length}`);
34182
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
34183
+ throw new Error(`ExchangeInstance getRawCandles: first candle timestamp mismatch. ` +
34184
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
34185
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
34186
+ }
34187
+ if (uniqueData.length !== calculatedLimit) {
34188
+ throw new Error(`ExchangeInstance getRawCandles: candle count mismatch. ` +
34189
+ `Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
34190
+ `Adapter must return exact number of candles requested.`);
33553
34191
  }
33554
34192
  // Write to cache after successful fetch
33555
34193
  await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);
@@ -34341,6 +34979,29 @@ class NotificationInstance {
34341
34979
  createdAt: data.timestamp,
34342
34980
  });
34343
34981
  }
34982
+ else if (data.action === "activate-scheduled") {
34983
+ this._addNotification({
34984
+ type: "activate_scheduled.commit",
34985
+ id: CREATE_KEY_FN(),
34986
+ timestamp: data.timestamp,
34987
+ backtest: data.backtest,
34988
+ symbol: data.symbol,
34989
+ strategyName: data.strategyName,
34990
+ exchangeName: data.exchangeName,
34991
+ signalId: data.signalId,
34992
+ activateId: data.activateId,
34993
+ currentPrice: data.currentPrice,
34994
+ position: data.position,
34995
+ priceOpen: data.priceOpen,
34996
+ priceTakeProfit: data.priceTakeProfit,
34997
+ priceStopLoss: data.priceStopLoss,
34998
+ originalPriceTakeProfit: data.originalPriceTakeProfit,
34999
+ originalPriceStopLoss: data.originalPriceStopLoss,
35000
+ scheduledAt: data.scheduledAt,
35001
+ pendingAt: data.pendingAt,
35002
+ createdAt: data.timestamp,
35003
+ });
35004
+ }
34344
35005
  };
34345
35006
  /**
34346
35007
  * Processes risk rejection events.
@@ -35145,4 +35806,4 @@ const set = (object, path, value) => {
35145
35806
  }
35146
35807
  };
35147
35808
 
35148
- export { ActionBase, Backtest, Breakeven, Cache, Constant, Exchange, ExecutionContextService, Heat, Live, Markdown, MarkdownFileBase, MarkdownFolderBase, MethodContextService, Notification, Partial, Performance, PersistBase, PersistBreakevenAdapter, PersistCandleAdapter, PersistPartialAdapter, PersistRiskAdapter, PersistScheduleAdapter, PersistSignalAdapter, PersistStorageAdapter, PositionSize, Report, ReportBase, Risk, Schedule, Storage, StorageBacktest, StorageLive, Strategy, Walker, addActionSchema, addExchangeSchema, addFrameSchema, addRiskSchema, addSizingSchema, addStrategySchema, addWalkerSchema, commitBreakeven, commitCancelScheduled, commitClosePending, commitPartialLoss, commitPartialProfit, commitTrailingStop, commitTrailingTake, emitters, formatPrice, formatQuantity, get, getActionSchema, getAveragePrice, getBacktestTimeframe, getCandles, getColumns, getConfig, getContext, getDate, getDefaultColumns, getDefaultConfig, getExchangeSchema, getFrameSchema, getMode, getNextCandles, getOrderBook, getRawCandles, getRiskSchema, getSizingSchema, getStrategySchema, getSymbol, getWalkerSchema, hasTradeContext, backtest as lib, listExchangeSchema, listFrameSchema, listRiskSchema, listSizingSchema, listStrategySchema, listWalkerSchema, listenActivePing, listenActivePingOnce, listenBacktestProgress, listenBreakevenAvailable, listenBreakevenAvailableOnce, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenExit, listenPartialLossAvailable, listenPartialLossAvailableOnce, listenPartialProfitAvailable, listenPartialProfitAvailableOnce, listenPerformance, listenRisk, listenRiskOnce, listenSchedulePing, listenSchedulePingOnce, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenStrategyCommit, listenStrategyCommitOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, listenWalkerProgress, overrideActionSchema, overrideExchangeSchema, overrideFrameSchema, overrideRiskSchema, overrideSizingSchema, overrideStrategySchema, overrideWalkerSchema, parseArgs, roundTicks, set, setColumns, setConfig, setLogger, stopStrategy, validate };
35809
+ export { ActionBase, Backtest, Breakeven, Cache, Constant, Exchange, ExecutionContextService, Heat, Live, Markdown, MarkdownFileBase, MarkdownFolderBase, MethodContextService, Notification, Partial, Performance, PersistBase, PersistBreakevenAdapter, PersistCandleAdapter, PersistPartialAdapter, PersistRiskAdapter, PersistScheduleAdapter, PersistSignalAdapter, PersistStorageAdapter, PositionSize, Report, ReportBase, Risk, Schedule, Storage, StorageBacktest, StorageLive, Strategy, Walker, addActionSchema, addExchangeSchema, addFrameSchema, addRiskSchema, addSizingSchema, addStrategySchema, addWalkerSchema, commitActivateScheduled, commitBreakeven, commitCancelScheduled, commitClosePending, commitPartialLoss, commitPartialProfit, commitTrailingStop, commitTrailingTake, emitters, formatPrice, formatQuantity, get, getActionSchema, getAveragePrice, getBacktestTimeframe, getCandles, getColumns, getConfig, getContext, getDate, getDefaultColumns, getDefaultConfig, getExchangeSchema, getFrameSchema, getMode, getNextCandles, getOrderBook, getRawCandles, getRiskSchema, getSizingSchema, getStrategySchema, getSymbol, getWalkerSchema, hasTradeContext, backtest as lib, listExchangeSchema, listFrameSchema, listRiskSchema, listSizingSchema, listStrategySchema, listWalkerSchema, listenActivePing, listenActivePingOnce, listenBacktestProgress, listenBreakevenAvailable, listenBreakevenAvailableOnce, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenExit, listenPartialLossAvailable, listenPartialLossAvailableOnce, listenPartialProfitAvailable, listenPartialProfitAvailableOnce, listenPerformance, listenRisk, listenRiskOnce, listenSchedulePing, listenSchedulePingOnce, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenStrategyCommit, listenStrategyCommitOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, listenWalkerProgress, overrideActionSchema, overrideExchangeSchema, overrideFrameSchema, overrideRiskSchema, overrideSizingSchema, overrideStrategySchema, overrideWalkerSchema, parseArgs, roundTicks, set, setColumns, setConfig, setLogger, stopStrategy, validate };