backtest-kit 2.3.1 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -52
- package/build/index.cjs +307 -200
- package/build/index.mjs +308 -201
- package/package.json +3 -2
- package/types.d.ts +34 -12
package/build/index.cjs
CHANGED
|
@@ -1578,73 +1578,73 @@ class PersistCandleUtils {
|
|
|
1578
1578
|
]));
|
|
1579
1579
|
/**
|
|
1580
1580
|
* Reads cached candles for a specific exchange, symbol, and interval.
|
|
1581
|
-
* Returns candles only if cache contains
|
|
1581
|
+
* Returns candles only if cache contains ALL requested candles.
|
|
1582
1582
|
*
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1583
|
+
* Algorithm (matches ClientExchange.ts logic):
|
|
1584
|
+
* 1. Calculate expected timestamps: sinceTimestamp, sinceTimestamp + stepMs, ..., sinceTimestamp + (limit-1) * stepMs
|
|
1585
|
+
* 2. Try to read each expected candle by timestamp key
|
|
1586
|
+
* 3. If ANY candle is missing, return null (cache miss)
|
|
1587
|
+
* 4. If all candles found, return them in order
|
|
1587
1588
|
*
|
|
1588
1589
|
* @param symbol - Trading pair symbol
|
|
1589
1590
|
* @param interval - Candle interval
|
|
1590
1591
|
* @param exchangeName - Exchange identifier
|
|
1591
1592
|
* @param limit - Number of candles requested
|
|
1592
|
-
* @param sinceTimestamp -
|
|
1593
|
-
* @param
|
|
1593
|
+
* @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
|
|
1594
|
+
* @param _untilTimestamp - Unused, kept for API compatibility
|
|
1594
1595
|
* @returns Promise resolving to array of candles or null if cache is incomplete
|
|
1595
1596
|
*/
|
|
1596
|
-
this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp,
|
|
1597
|
+
this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, _untilTimestamp) => {
|
|
1597
1598
|
bt.loggerService.info("PersistCandleUtils.readCandlesData", {
|
|
1598
1599
|
symbol,
|
|
1599
1600
|
interval,
|
|
1600
1601
|
exchangeName,
|
|
1601
1602
|
limit,
|
|
1602
1603
|
sinceTimestamp,
|
|
1603
|
-
untilTimestamp,
|
|
1604
1604
|
});
|
|
1605
1605
|
const key = `${symbol}:${interval}:${exchangeName}`;
|
|
1606
1606
|
const isInitial = !this.getCandlesStorage.has(key);
|
|
1607
1607
|
const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
|
|
1608
1608
|
await stateStorage.waitForInit(isInitial);
|
|
1609
1609
|
const stepMs = INTERVAL_MINUTES$5[interval] * MS_PER_MINUTE$2;
|
|
1610
|
-
//
|
|
1610
|
+
// Calculate expected timestamps and fetch each candle directly
|
|
1611
1611
|
const cachedCandles = [];
|
|
1612
|
-
for
|
|
1613
|
-
const
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1612
|
+
for (let i = 0; i < limit; i++) {
|
|
1613
|
+
const expectedTimestamp = sinceTimestamp + i * stepMs;
|
|
1614
|
+
const timestampKey = String(expectedTimestamp);
|
|
1615
|
+
if (await functoolsKit.not(stateStorage.hasValue(timestampKey))) {
|
|
1616
|
+
// Cache miss - candle not found
|
|
1617
|
+
return null;
|
|
1618
|
+
}
|
|
1619
|
+
try {
|
|
1620
|
+
const candle = await stateStorage.readValue(timestampKey);
|
|
1621
|
+
cachedCandles.push(candle);
|
|
1622
|
+
}
|
|
1623
|
+
catch (error) {
|
|
1624
|
+
// Invalid candle in cache - treat as cache miss
|
|
1625
|
+
const message = `PersistCandleUtils.readCandlesData found invalid candle symbol=${symbol} interval=${interval} timestamp=${expectedTimestamp}`;
|
|
1626
|
+
const payload = {
|
|
1627
|
+
error: functoolsKit.errorData(error),
|
|
1628
|
+
message: functoolsKit.getErrorMessage(error),
|
|
1629
|
+
};
|
|
1630
|
+
bt.loggerService.warn(message, payload);
|
|
1631
|
+
console.warn(message, payload);
|
|
1632
|
+
errorEmitter.next(error);
|
|
1633
|
+
return null;
|
|
1633
1634
|
}
|
|
1634
1635
|
}
|
|
1635
|
-
// Sort by timestamp ascending
|
|
1636
|
-
cachedCandles.sort((a, b) => a.timestamp - b.timestamp);
|
|
1637
1636
|
return cachedCandles;
|
|
1638
1637
|
};
|
|
1639
1638
|
/**
|
|
1640
1639
|
* Writes candles to cache with atomic file writes.
|
|
1641
1640
|
* Each candle is stored as a separate JSON file named by its timestamp.
|
|
1642
1641
|
*
|
|
1643
|
-
* The candles passed to this function
|
|
1644
|
-
* - candle.timestamp
|
|
1645
|
-
* -
|
|
1642
|
+
* The candles passed to this function should be validated candles from the adapter:
|
|
1643
|
+
* - First candle.timestamp equals aligned sinceTimestamp (openTime)
|
|
1644
|
+
* - Exact number of candles as requested
|
|
1645
|
+
* - All candles are fully closed (timestamp + stepMs < untilTimestamp)
|
|
1646
1646
|
*
|
|
1647
|
-
* @param candles - Array of candle data to cache (
|
|
1647
|
+
* @param candles - Array of candle data to cache (validated by the caller)
|
|
1648
1648
|
* @param symbol - Trading pair symbol
|
|
1649
1649
|
* @param interval - Candle interval
|
|
1650
1650
|
* @param exchangeName - Exchange identifier
|
|
@@ -1661,8 +1661,25 @@ class PersistCandleUtils {
|
|
|
1661
1661
|
const isInitial = !this.getCandlesStorage.has(key);
|
|
1662
1662
|
const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
|
|
1663
1663
|
await stateStorage.waitForInit(isInitial);
|
|
1664
|
-
//
|
|
1664
|
+
// Calculate step in milliseconds to determine candle close time
|
|
1665
|
+
const stepMs = INTERVAL_MINUTES$5[interval] * MS_PER_MINUTE$2;
|
|
1666
|
+
const now = Date.now();
|
|
1667
|
+
// Write each candle as a separate file, skipping incomplete candles
|
|
1665
1668
|
for (const candle of candles) {
|
|
1669
|
+
// Skip incomplete candles: candle is complete when closeTime <= now
|
|
1670
|
+
// closeTime = timestamp + stepMs
|
|
1671
|
+
const candleCloseTime = candle.timestamp + stepMs;
|
|
1672
|
+
if (candleCloseTime > now) {
|
|
1673
|
+
bt.loggerService.debug("PersistCandleUtils.writeCandlesData: skipping incomplete candle", {
|
|
1674
|
+
symbol,
|
|
1675
|
+
interval,
|
|
1676
|
+
exchangeName,
|
|
1677
|
+
timestamp: candle.timestamp,
|
|
1678
|
+
closeTime: candleCloseTime,
|
|
1679
|
+
now,
|
|
1680
|
+
});
|
|
1681
|
+
continue;
|
|
1682
|
+
}
|
|
1666
1683
|
if (await functoolsKit.not(stateStorage.hasValue(String(candle.timestamp)))) {
|
|
1667
1684
|
await stateStorage.writeValue(String(candle.timestamp), candle);
|
|
1668
1685
|
}
|
|
@@ -1820,6 +1837,27 @@ const INTERVAL_MINUTES$4 = {
|
|
|
1820
1837
|
"6h": 360,
|
|
1821
1838
|
"8h": 480,
|
|
1822
1839
|
};
|
|
1840
|
+
/**
|
|
1841
|
+
* Aligns timestamp down to the nearest interval boundary.
|
|
1842
|
+
* For example, for 15m interval: 00:17 -> 00:15, 00:44 -> 00:30
|
|
1843
|
+
*
|
|
1844
|
+
* Candle timestamp convention:
|
|
1845
|
+
* - Candle timestamp = openTime (when candle opens)
|
|
1846
|
+
* - Candle with timestamp 00:00 covers period [00:00, 00:15) for 15m interval
|
|
1847
|
+
*
|
|
1848
|
+
* Adapter contract:
|
|
1849
|
+
* - Adapter must return candles with timestamp = openTime
|
|
1850
|
+
* - First returned candle.timestamp must equal aligned since
|
|
1851
|
+
* - Adapter must return exactly `limit` candles
|
|
1852
|
+
*
|
|
1853
|
+
* @param timestamp - Timestamp in milliseconds
|
|
1854
|
+
* @param intervalMinutes - Interval in minutes
|
|
1855
|
+
* @returns Aligned timestamp rounded down to interval boundary
|
|
1856
|
+
*/
|
|
1857
|
+
const ALIGN_TO_INTERVAL_FN$1 = (timestamp, intervalMinutes) => {
|
|
1858
|
+
const intervalMs = intervalMinutes * MS_PER_MINUTE$1;
|
|
1859
|
+
return Math.floor(timestamp / intervalMs) * intervalMs;
|
|
1860
|
+
};
|
|
1823
1861
|
/**
|
|
1824
1862
|
* Validates that all candles have valid OHLCV data without anomalies.
|
|
1825
1863
|
* Detects incomplete candles from Binance API by checking for abnormally low prices or volumes.
|
|
@@ -1883,25 +1921,24 @@ const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => {
|
|
|
1883
1921
|
};
|
|
1884
1922
|
/**
|
|
1885
1923
|
* Attempts to read candles from cache.
|
|
1886
|
-
* Validates cache consistency (no gaps in timestamps) before returning.
|
|
1887
1924
|
*
|
|
1888
|
-
*
|
|
1889
|
-
*
|
|
1890
|
-
*
|
|
1891
|
-
* - Only fully closed candles within the exclusive range are returned
|
|
1925
|
+
* Cache lookup calculates expected timestamps:
|
|
1926
|
+
* sinceTimestamp + i * stepMs for i = 0..limit-1
|
|
1927
|
+
* Returns all candles if found, null if any missing.
|
|
1892
1928
|
*
|
|
1893
1929
|
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
1894
|
-
* @param sinceTimestamp -
|
|
1895
|
-
* @param untilTimestamp -
|
|
1930
|
+
* @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
|
|
1931
|
+
* @param untilTimestamp - Unused, kept for API compatibility
|
|
1896
1932
|
* @param self - Instance of ClientExchange
|
|
1897
|
-
* @returns Cached candles array or null if cache miss
|
|
1933
|
+
* @returns Cached candles array (exactly limit) or null if cache miss
|
|
1898
1934
|
*/
|
|
1899
1935
|
const READ_CANDLES_CACHE_FN$1 = functoolsKit.trycatch(async (dto, sinceTimestamp, untilTimestamp, self) => {
|
|
1900
|
-
// PersistCandleAdapter.readCandlesData
|
|
1901
|
-
//
|
|
1936
|
+
// PersistCandleAdapter.readCandlesData calculates expected timestamps:
|
|
1937
|
+
// sinceTimestamp + i * stepMs for i = 0..limit-1
|
|
1938
|
+
// Returns all candles if found, null if any missing
|
|
1902
1939
|
const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, self.params.exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
|
|
1903
1940
|
// Return cached data only if we have exactly the requested limit
|
|
1904
|
-
if (cachedCandles
|
|
1941
|
+
if (cachedCandles?.length === dto.limit) {
|
|
1905
1942
|
self.params.logger.debug(`ClientExchange READ_CANDLES_CACHE_FN: cache hit for symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
|
|
1906
1943
|
return cachedCandles;
|
|
1907
1944
|
}
|
|
@@ -1923,11 +1960,12 @@ const READ_CANDLES_CACHE_FN$1 = functoolsKit.trycatch(async (dto, sinceTimestamp
|
|
|
1923
1960
|
/**
|
|
1924
1961
|
* Writes candles to cache with error handling.
|
|
1925
1962
|
*
|
|
1926
|
-
* The candles passed to this function
|
|
1927
|
-
* - candle.timestamp
|
|
1928
|
-
* -
|
|
1963
|
+
* The candles passed to this function should be validated:
|
|
1964
|
+
* - First candle.timestamp equals aligned sinceTimestamp (openTime)
|
|
1965
|
+
* - Exact number of candles as requested (limit)
|
|
1966
|
+
* - Sequential timestamps: sinceTimestamp + i * stepMs
|
|
1929
1967
|
*
|
|
1930
|
-
* @param candles - Array of candle data to cache
|
|
1968
|
+
* @param candles - Array of validated candle data to cache
|
|
1931
1969
|
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
1932
1970
|
* @param self - Instance of ClientExchange
|
|
1933
1971
|
*/
|
|
@@ -2054,6 +2092,13 @@ class ClientExchange {
|
|
|
2054
2092
|
/**
|
|
2055
2093
|
* Fetches historical candles backwards from execution context time.
|
|
2056
2094
|
*
|
|
2095
|
+
* Algorithm:
|
|
2096
|
+
* 1. Align when down to interval boundary (e.g., 00:17 -> 00:15 for 15m)
|
|
2097
|
+
* 2. Calculate since = alignedWhen - limit * step
|
|
2098
|
+
* 3. Fetch candles starting from since
|
|
2099
|
+
* 4. Validate first candle timestamp matches since (adapter must return inclusive data)
|
|
2100
|
+
* 5. Slice to limit
|
|
2101
|
+
*
|
|
2057
2102
|
* @param symbol - Trading pair symbol
|
|
2058
2103
|
* @param interval - Candle interval
|
|
2059
2104
|
* @param limit - Number of candles to fetch
|
|
@@ -2066,11 +2111,16 @@ class ClientExchange {
|
|
|
2066
2111
|
limit,
|
|
2067
2112
|
});
|
|
2068
2113
|
const step = INTERVAL_MINUTES$4[interval];
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
|
|
2114
|
+
if (!step) {
|
|
2115
|
+
throw new Error(`ClientExchange unknown interval=${interval}`);
|
|
2072
2116
|
}
|
|
2073
|
-
const
|
|
2117
|
+
const stepMs = step * MS_PER_MINUTE$1;
|
|
2118
|
+
// Align when down to interval boundary
|
|
2119
|
+
const whenTimestamp = this.params.execution.context.when.getTime();
|
|
2120
|
+
const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
|
|
2121
|
+
// Calculate since: go back limit candles from aligned when
|
|
2122
|
+
const sinceTimestamp = alignedWhen - limit * stepMs;
|
|
2123
|
+
const since = new Date(sinceTimestamp);
|
|
2074
2124
|
let allData = [];
|
|
2075
2125
|
// If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
|
|
2076
2126
|
if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
|
|
@@ -2083,39 +2133,34 @@ class ClientExchange {
|
|
|
2083
2133
|
remaining -= chunkLimit;
|
|
2084
2134
|
if (remaining > 0) {
|
|
2085
2135
|
// Move currentSince forward by the number of candles fetched
|
|
2086
|
-
currentSince = new Date(currentSince.getTime() + chunkLimit *
|
|
2136
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
|
|
2087
2137
|
}
|
|
2088
2138
|
}
|
|
2089
2139
|
}
|
|
2090
2140
|
else {
|
|
2091
2141
|
allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
|
|
2092
2142
|
}
|
|
2093
|
-
// Filter candles to strictly match the requested range
|
|
2094
|
-
const whenTimestamp = this.params.execution.context.when.getTime();
|
|
2095
|
-
const sinceTimestamp = since.getTime();
|
|
2096
|
-
const stepMs = step * MS_PER_MINUTE$1;
|
|
2097
|
-
const filteredData = allData.filter((candle) => {
|
|
2098
|
-
// EXCLUSIVE boundaries:
|
|
2099
|
-
// - candle.timestamp > sinceTimestamp (exclude exact boundary)
|
|
2100
|
-
// - candle.timestamp + stepMs < whenTimestamp (fully closed before "when")
|
|
2101
|
-
if (candle.timestamp <= sinceTimestamp) {
|
|
2102
|
-
return false;
|
|
2103
|
-
}
|
|
2104
|
-
// Check against current time (when)
|
|
2105
|
-
// Only allow candles that have fully CLOSED before "when"
|
|
2106
|
-
return candle.timestamp + stepMs < whenTimestamp;
|
|
2107
|
-
});
|
|
2108
2143
|
// Apply distinct by timestamp to remove duplicates
|
|
2109
|
-
const uniqueData = Array.from(new Map(
|
|
2110
|
-
if (
|
|
2111
|
-
const msg = `ClientExchange Removed ${
|
|
2144
|
+
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
2145
|
+
if (allData.length !== uniqueData.length) {
|
|
2146
|
+
const msg = `ClientExchange getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
|
|
2112
2147
|
this.params.logger.warn(msg);
|
|
2113
2148
|
console.warn(msg);
|
|
2114
2149
|
}
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2150
|
+
// Validate adapter returned data
|
|
2151
|
+
if (uniqueData.length === 0) {
|
|
2152
|
+
throw new Error(`ClientExchange getCandles: adapter returned empty array. ` +
|
|
2153
|
+
`Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
|
|
2154
|
+
}
|
|
2155
|
+
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
2156
|
+
throw new Error(`ClientExchange getCandles: first candle timestamp mismatch. ` +
|
|
2157
|
+
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
2158
|
+
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
2159
|
+
}
|
|
2160
|
+
if (uniqueData.length !== limit) {
|
|
2161
|
+
throw new Error(`ClientExchange getCandles: candle count mismatch. ` +
|
|
2162
|
+
`Expected ${limit} candles, got ${uniqueData.length}. ` +
|
|
2163
|
+
`Adapter must return exact number of candles requested.`);
|
|
2119
2164
|
}
|
|
2120
2165
|
await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
|
|
2121
2166
|
return uniqueData;
|
|
@@ -2124,6 +2169,13 @@ class ClientExchange {
|
|
|
2124
2169
|
* Fetches future candles forwards from execution context time.
|
|
2125
2170
|
* Used in backtest mode to get candles for signal duration.
|
|
2126
2171
|
*
|
|
2172
|
+
* Algorithm:
|
|
2173
|
+
* 1. Align when down to interval boundary (e.g., 00:17 -> 00:15 for 15m)
|
|
2174
|
+
* 2. since = alignedWhen (start from aligned when)
|
|
2175
|
+
* 3. Fetch candles starting from since
|
|
2176
|
+
* 4. Validate first candle timestamp matches since (adapter must return inclusive data)
|
|
2177
|
+
* 5. Slice to limit
|
|
2178
|
+
*
|
|
2127
2179
|
* @param symbol - Trading pair symbol
|
|
2128
2180
|
* @param interval - Candle interval
|
|
2129
2181
|
* @param limit - Number of candles to fetch
|
|
@@ -2139,12 +2191,21 @@ class ClientExchange {
|
|
|
2139
2191
|
if (!this.params.execution.context.backtest) {
|
|
2140
2192
|
throw new Error(`ClientExchange getNextCandles: cannot fetch future candles in live mode`);
|
|
2141
2193
|
}
|
|
2142
|
-
const since = new Date(this.params.execution.context.when.getTime());
|
|
2143
|
-
const now = Date.now();
|
|
2144
|
-
// Вычисляем конечное время запроса
|
|
2145
2194
|
const step = INTERVAL_MINUTES$4[interval];
|
|
2146
|
-
|
|
2147
|
-
|
|
2195
|
+
if (!step) {
|
|
2196
|
+
throw new Error(`ClientExchange getNextCandles: unknown interval=${interval}`);
|
|
2197
|
+
}
|
|
2198
|
+
const stepMs = step * MS_PER_MINUTE$1;
|
|
2199
|
+
const now = Date.now();
|
|
2200
|
+
// Align when down to interval boundary
|
|
2201
|
+
const whenTimestamp = this.params.execution.context.when.getTime();
|
|
2202
|
+
const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
|
|
2203
|
+
// since = alignedWhen (start from aligned when, going forward)
|
|
2204
|
+
const sinceTimestamp = alignedWhen;
|
|
2205
|
+
const since = new Date(sinceTimestamp);
|
|
2206
|
+
// Calculate end time for Date.now() check
|
|
2207
|
+
const endTime = sinceTimestamp + limit * stepMs;
|
|
2208
|
+
// Check that requested period does not exceed Date.now()
|
|
2148
2209
|
if (endTime > now) {
|
|
2149
2210
|
return [];
|
|
2150
2211
|
}
|
|
@@ -2160,29 +2221,34 @@ class ClientExchange {
|
|
|
2160
2221
|
remaining -= chunkLimit;
|
|
2161
2222
|
if (remaining > 0) {
|
|
2162
2223
|
// Move currentSince forward by the number of candles fetched
|
|
2163
|
-
currentSince = new Date(currentSince.getTime() + chunkLimit *
|
|
2224
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
|
|
2164
2225
|
}
|
|
2165
2226
|
}
|
|
2166
2227
|
}
|
|
2167
2228
|
else {
|
|
2168
2229
|
allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
|
|
2169
2230
|
}
|
|
2170
|
-
// Filter candles to strictly match the requested range
|
|
2171
|
-
const sinceTimestamp = since.getTime();
|
|
2172
|
-
const stepMs = step * MS_PER_MINUTE$1;
|
|
2173
|
-
const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
|
|
2174
|
-
candle.timestamp + stepMs < endTime);
|
|
2175
2231
|
// Apply distinct by timestamp to remove duplicates
|
|
2176
|
-
const uniqueData = Array.from(new Map(
|
|
2177
|
-
if (
|
|
2178
|
-
const msg = `ClientExchange getNextCandles: Removed ${
|
|
2232
|
+
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
2233
|
+
if (allData.length !== uniqueData.length) {
|
|
2234
|
+
const msg = `ClientExchange getNextCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
|
|
2179
2235
|
this.params.logger.warn(msg);
|
|
2180
2236
|
console.warn(msg);
|
|
2181
2237
|
}
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2238
|
+
// Validate adapter returned data
|
|
2239
|
+
if (uniqueData.length === 0) {
|
|
2240
|
+
throw new Error(`ClientExchange getNextCandles: adapter returned empty array. ` +
|
|
2241
|
+
`Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
|
|
2242
|
+
}
|
|
2243
|
+
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
2244
|
+
throw new Error(`ClientExchange getNextCandles: first candle timestamp mismatch. ` +
|
|
2245
|
+
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
2246
|
+
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
2247
|
+
}
|
|
2248
|
+
if (uniqueData.length !== limit) {
|
|
2249
|
+
throw new Error(`ClientExchange getNextCandles: candle count mismatch. ` +
|
|
2250
|
+
`Expected ${limit} candles, got ${uniqueData.length}. ` +
|
|
2251
|
+
`Adapter must return exact number of candles requested.`);
|
|
2186
2252
|
}
|
|
2187
2253
|
await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
|
|
2188
2254
|
return uniqueData;
|
|
@@ -2257,6 +2323,12 @@ class ClientExchange {
|
|
|
2257
2323
|
/**
|
|
2258
2324
|
* Fetches raw candles with flexible date/limit parameters.
|
|
2259
2325
|
*
|
|
2326
|
+
* Algorithm:
|
|
2327
|
+
* 1. Align all timestamps down to interval boundary
|
|
2328
|
+
* 2. Fetch candles starting from aligned since
|
|
2329
|
+
* 3. Validate first candle timestamp matches aligned since (adapter must return inclusive data)
|
|
2330
|
+
* 4. Slice to limit
|
|
2331
|
+
*
|
|
2260
2332
|
* All modes respect execution context and prevent look-ahead bias.
|
|
2261
2333
|
*
|
|
2262
2334
|
* Parameter combinations:
|
|
@@ -2291,9 +2363,10 @@ class ClientExchange {
|
|
|
2291
2363
|
if (!step) {
|
|
2292
2364
|
throw new Error(`ClientExchange getRawCandles: unknown interval=${interval}`);
|
|
2293
2365
|
}
|
|
2366
|
+
const stepMs = step * MS_PER_MINUTE$1;
|
|
2294
2367
|
const whenTimestamp = this.params.execution.context.when.getTime();
|
|
2368
|
+
const alignedWhen = ALIGN_TO_INTERVAL_FN$1(whenTimestamp, step);
|
|
2295
2369
|
let sinceTimestamp;
|
|
2296
|
-
let untilTimestamp;
|
|
2297
2370
|
let calculatedLimit;
|
|
2298
2371
|
// Case 1: all three parameters provided
|
|
2299
2372
|
if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
|
|
@@ -2303,8 +2376,8 @@ class ClientExchange {
|
|
|
2303
2376
|
if (eDate > whenTimestamp) {
|
|
2304
2377
|
throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
|
|
2305
2378
|
}
|
|
2306
|
-
|
|
2307
|
-
|
|
2379
|
+
// Align sDate down to interval boundary
|
|
2380
|
+
sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
|
|
2308
2381
|
calculatedLimit = limit;
|
|
2309
2382
|
}
|
|
2310
2383
|
// Case 2: sDate + eDate (no limit) - calculate limit from date range
|
|
@@ -2315,9 +2388,10 @@ class ClientExchange {
|
|
|
2315
2388
|
if (eDate > whenTimestamp) {
|
|
2316
2389
|
throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
|
|
2317
2390
|
}
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2391
|
+
// Align sDate down to interval boundary
|
|
2392
|
+
sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
|
|
2393
|
+
const alignedEDate = ALIGN_TO_INTERVAL_FN$1(eDate, step);
|
|
2394
|
+
calculatedLimit = Math.ceil((alignedEDate - sinceTimestamp) / stepMs);
|
|
2321
2395
|
if (calculatedLimit <= 0) {
|
|
2322
2396
|
throw new Error(`ClientExchange getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
|
|
2323
2397
|
}
|
|
@@ -2327,23 +2401,24 @@ class ClientExchange {
|
|
|
2327
2401
|
if (eDate > whenTimestamp) {
|
|
2328
2402
|
throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
|
|
2329
2403
|
}
|
|
2330
|
-
|
|
2331
|
-
|
|
2404
|
+
// Align eDate down and calculate sinceTimestamp
|
|
2405
|
+
const alignedEDate = ALIGN_TO_INTERVAL_FN$1(eDate, step);
|
|
2406
|
+
sinceTimestamp = alignedEDate - limit * stepMs;
|
|
2332
2407
|
calculatedLimit = limit;
|
|
2333
2408
|
}
|
|
2334
2409
|
// Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
|
|
2335
2410
|
else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2411
|
+
// Align sDate down to interval boundary
|
|
2412
|
+
sinceTimestamp = ALIGN_TO_INTERVAL_FN$1(sDate, step);
|
|
2413
|
+
const endTimestamp = sinceTimestamp + limit * stepMs;
|
|
2414
|
+
if (endTimestamp > whenTimestamp) {
|
|
2415
|
+
throw new Error(`ClientExchange getRawCandles: calculated endTimestamp (${endTimestamp}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
|
|
2340
2416
|
}
|
|
2341
2417
|
calculatedLimit = limit;
|
|
2342
2418
|
}
|
|
2343
2419
|
// Case 5: Only limit - use execution.context.when as reference (backward like getCandles)
|
|
2344
2420
|
else if (sDate === undefined && eDate === undefined && limit !== undefined) {
|
|
2345
|
-
|
|
2346
|
-
sinceTimestamp = whenTimestamp - limit * step * MS_PER_MINUTE$1;
|
|
2421
|
+
sinceTimestamp = alignedWhen - limit * stepMs;
|
|
2347
2422
|
calculatedLimit = limit;
|
|
2348
2423
|
}
|
|
2349
2424
|
// Invalid: no parameters or only sDate or only eDate
|
|
@@ -2364,29 +2439,34 @@ class ClientExchange {
|
|
|
2364
2439
|
allData.push(...chunkData);
|
|
2365
2440
|
remaining -= chunkLimit;
|
|
2366
2441
|
if (remaining > 0) {
|
|
2367
|
-
currentSince = new Date(currentSince.getTime() + chunkLimit *
|
|
2442
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
|
|
2368
2443
|
}
|
|
2369
2444
|
}
|
|
2370
2445
|
}
|
|
2371
2446
|
else {
|
|
2372
2447
|
allData = await GET_CANDLES_FN({ symbol, interval, limit: calculatedLimit }, since, this);
|
|
2373
2448
|
}
|
|
2374
|
-
// Filter candles to strictly match the requested range
|
|
2375
|
-
// Only include candles that have fully CLOSED before untilTimestamp
|
|
2376
|
-
const stepMs = step * MS_PER_MINUTE$1;
|
|
2377
|
-
const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
|
|
2378
|
-
candle.timestamp + stepMs < untilTimestamp);
|
|
2379
2449
|
// Apply distinct by timestamp to remove duplicates
|
|
2380
|
-
const uniqueData = Array.from(new Map(
|
|
2381
|
-
if (
|
|
2382
|
-
const msg = `ClientExchange getRawCandles: Removed ${
|
|
2450
|
+
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
2451
|
+
if (allData.length !== uniqueData.length) {
|
|
2452
|
+
const msg = `ClientExchange getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`;
|
|
2383
2453
|
this.params.logger.warn(msg);
|
|
2384
2454
|
console.warn(msg);
|
|
2385
2455
|
}
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2456
|
+
// Validate adapter returned data
|
|
2457
|
+
if (uniqueData.length === 0) {
|
|
2458
|
+
throw new Error(`ClientExchange getRawCandles: adapter returned empty array. ` +
|
|
2459
|
+
`Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
|
|
2460
|
+
}
|
|
2461
|
+
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
2462
|
+
throw new Error(`ClientExchange getRawCandles: first candle timestamp mismatch. ` +
|
|
2463
|
+
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
2464
|
+
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
2465
|
+
}
|
|
2466
|
+
if (uniqueData.length !== calculatedLimit) {
|
|
2467
|
+
throw new Error(`ClientExchange getRawCandles: candle count mismatch. ` +
|
|
2468
|
+
`Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
|
|
2469
|
+
`Adapter must return exact number of candles requested.`);
|
|
2390
2470
|
}
|
|
2391
2471
|
await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, calculatedLimit, uniqueData);
|
|
2392
2472
|
return uniqueData;
|
|
@@ -12343,13 +12423,6 @@ class WalkerSchemaService {
|
|
|
12343
12423
|
}
|
|
12344
12424
|
}
|
|
12345
12425
|
|
|
12346
|
-
/**
|
|
12347
|
-
* Компенсация для exclusive boundaries при фильтрации свечей.
|
|
12348
|
-
* ClientExchange.getNextCandles использует фильтр:
|
|
12349
|
-
* timestamp > since && timestamp + stepMs < endTime
|
|
12350
|
-
* который исключает первую и последнюю свечи из запрошенного диапазона.
|
|
12351
|
-
*/
|
|
12352
|
-
const CANDLE_EXCLUSIVE_BOUNDARY_OFFSET = 2;
|
|
12353
12426
|
/**
|
|
12354
12427
|
* Private service for backtest orchestration using async generators.
|
|
12355
12428
|
*
|
|
@@ -12615,7 +12688,7 @@ class BacktestLogicPrivateService {
|
|
|
12615
12688
|
// Запрашиваем minuteEstimatedTime + буфер свечей одним запросом
|
|
12616
12689
|
const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
|
|
12617
12690
|
const bufferStartTime = new Date(when.getTime() - bufferMinutes * 60 * 1000);
|
|
12618
|
-
const totalCandles = signal.minuteEstimatedTime + GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT
|
|
12691
|
+
const totalCandles = signal.minuteEstimatedTime + GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
|
|
12619
12692
|
let candles;
|
|
12620
12693
|
try {
|
|
12621
12694
|
candles = await this.exchangeCoreService.getNextCandles(symbol, "1m", totalCandles, bufferStartTime, true);
|
|
@@ -33109,6 +33182,27 @@ const INTERVAL_MINUTES$1 = {
|
|
|
33109
33182
|
"6h": 360,
|
|
33110
33183
|
"8h": 480,
|
|
33111
33184
|
};
|
|
33185
|
+
/**
|
|
33186
|
+
* Aligns timestamp down to the nearest interval boundary.
|
|
33187
|
+
* For example, for 15m interval: 00:17 -> 00:15, 00:44 -> 00:30
|
|
33188
|
+
*
|
|
33189
|
+
* Candle timestamp convention:
|
|
33190
|
+
* - Candle timestamp = openTime (when candle opens)
|
|
33191
|
+
* - Candle with timestamp 00:00 covers period [00:00, 00:15) for 15m interval
|
|
33192
|
+
*
|
|
33193
|
+
* Adapter contract:
|
|
33194
|
+
* - Adapter must return candles with timestamp = openTime
|
|
33195
|
+
* - First returned candle.timestamp must equal aligned since
|
|
33196
|
+
* - Adapter must return exactly `limit` candles
|
|
33197
|
+
*
|
|
33198
|
+
* @param timestamp - Timestamp in milliseconds
|
|
33199
|
+
* @param intervalMinutes - Interval in minutes
|
|
33200
|
+
* @returns Aligned timestamp rounded down to interval boundary
|
|
33201
|
+
*/
|
|
33202
|
+
const ALIGN_TO_INTERVAL_FN = (timestamp, intervalMinutes) => {
|
|
33203
|
+
const intervalMs = intervalMinutes * MS_PER_MINUTE;
|
|
33204
|
+
return Math.floor(timestamp / intervalMs) * intervalMs;
|
|
33205
|
+
};
|
|
33112
33206
|
/**
|
|
33113
33207
|
* Creates exchange instance with methods resolved once during construction.
|
|
33114
33208
|
* Applies default implementations where schema methods are not provided.
|
|
@@ -33130,25 +33224,24 @@ const CREATE_EXCHANGE_INSTANCE_FN = (schema) => {
|
|
|
33130
33224
|
};
|
|
33131
33225
|
/**
|
|
33132
33226
|
* Attempts to read candles from cache.
|
|
33133
|
-
* Validates cache consistency (no gaps in timestamps) before returning.
|
|
33134
33227
|
*
|
|
33135
|
-
*
|
|
33136
|
-
*
|
|
33137
|
-
*
|
|
33138
|
-
* - Only fully closed candles within the exclusive range are returned
|
|
33228
|
+
* Cache lookup calculates expected timestamps:
|
|
33229
|
+
* sinceTimestamp + i * stepMs for i = 0..limit-1
|
|
33230
|
+
* Returns all candles if found, null if any missing.
|
|
33139
33231
|
*
|
|
33140
33232
|
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
33141
|
-
* @param sinceTimestamp -
|
|
33142
|
-
* @param untilTimestamp -
|
|
33233
|
+
* @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
|
|
33234
|
+
* @param untilTimestamp - Unused, kept for API compatibility
|
|
33143
33235
|
* @param exchangeName - Exchange name
|
|
33144
|
-
* @returns Cached candles array or null if cache miss
|
|
33236
|
+
* @returns Cached candles array (exactly limit) or null if cache miss
|
|
33145
33237
|
*/
|
|
33146
33238
|
const READ_CANDLES_CACHE_FN = functoolsKit.trycatch(async (dto, sinceTimestamp, untilTimestamp, exchangeName) => {
|
|
33147
|
-
// PersistCandleAdapter.readCandlesData
|
|
33148
|
-
//
|
|
33239
|
+
// PersistCandleAdapter.readCandlesData calculates expected timestamps:
|
|
33240
|
+
// sinceTimestamp + i * stepMs for i = 0..limit-1
|
|
33241
|
+
// Returns all candles if found, null if any missing
|
|
33149
33242
|
const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
|
|
33150
33243
|
// Return cached data only if we have exactly the requested limit
|
|
33151
|
-
if (cachedCandles
|
|
33244
|
+
if (cachedCandles?.length === dto.limit) {
|
|
33152
33245
|
bt.loggerService.debug(`ExchangeInstance READ_CANDLES_CACHE_FN: cache hit for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
|
|
33153
33246
|
return cachedCandles;
|
|
33154
33247
|
}
|
|
@@ -33170,11 +33263,12 @@ const READ_CANDLES_CACHE_FN = functoolsKit.trycatch(async (dto, sinceTimestamp,
|
|
|
33170
33263
|
/**
|
|
33171
33264
|
* Writes candles to cache with error handling.
|
|
33172
33265
|
*
|
|
33173
|
-
* The candles passed to this function
|
|
33174
|
-
* - candle.timestamp
|
|
33175
|
-
* -
|
|
33266
|
+
* The candles passed to this function should be validated:
|
|
33267
|
+
* - First candle.timestamp equals aligned sinceTimestamp (openTime)
|
|
33268
|
+
* - Exact number of candles as requested (limit)
|
|
33269
|
+
* - Sequential timestamps: sinceTimestamp + i * stepMs
|
|
33176
33270
|
*
|
|
33177
|
-
* @param candles - Array of candle data to cache
|
|
33271
|
+
* @param candles - Array of validated candle data to cache
|
|
33178
33272
|
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
33179
33273
|
* @param exchangeName - Exchange name
|
|
33180
33274
|
*/
|
|
@@ -33245,14 +33339,18 @@ class ExchangeInstance {
|
|
|
33245
33339
|
});
|
|
33246
33340
|
const getCandles = this._methods.getCandles;
|
|
33247
33341
|
const step = INTERVAL_MINUTES$1[interval];
|
|
33248
|
-
|
|
33249
|
-
|
|
33250
|
-
throw new Error(`ExchangeInstance unknown time adjust for interval=${interval}`);
|
|
33342
|
+
if (!step) {
|
|
33343
|
+
throw new Error(`ExchangeInstance unknown interval=${interval}`);
|
|
33251
33344
|
}
|
|
33345
|
+
const stepMs = step * MS_PER_MINUTE;
|
|
33346
|
+
// Align when down to interval boundary
|
|
33252
33347
|
const when = await GET_TIMESTAMP_FN();
|
|
33253
|
-
const
|
|
33254
|
-
const
|
|
33255
|
-
|
|
33348
|
+
const whenTimestamp = when.getTime();
|
|
33349
|
+
const alignedWhen = ALIGN_TO_INTERVAL_FN(whenTimestamp, step);
|
|
33350
|
+
// Calculate since: go back limit candles from aligned when
|
|
33351
|
+
const sinceTimestamp = alignedWhen - limit * stepMs;
|
|
33352
|
+
const since = new Date(sinceTimestamp);
|
|
33353
|
+
const untilTimestamp = alignedWhen;
|
|
33256
33354
|
// Try to read from cache first
|
|
33257
33355
|
const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
|
|
33258
33356
|
if (cachedCandles !== null) {
|
|
@@ -33271,7 +33369,7 @@ class ExchangeInstance {
|
|
|
33271
33369
|
remaining -= chunkLimit;
|
|
33272
33370
|
if (remaining > 0) {
|
|
33273
33371
|
// Move currentSince forward by the number of candles fetched
|
|
33274
|
-
currentSince = new Date(currentSince.getTime() + chunkLimit *
|
|
33372
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
|
|
33275
33373
|
}
|
|
33276
33374
|
}
|
|
33277
33375
|
}
|
|
@@ -33279,27 +33377,25 @@ class ExchangeInstance {
|
|
|
33279
33377
|
const isBacktest = await GET_BACKTEST_FN();
|
|
33280
33378
|
allData = await getCandles(symbol, interval, since, limit, isBacktest);
|
|
33281
33379
|
}
|
|
33282
|
-
// Filter candles to strictly match the requested range
|
|
33283
|
-
const whenTimestamp = when.getTime();
|
|
33284
|
-
const stepMs = step * MS_PER_MINUTE;
|
|
33285
|
-
const filteredData = allData.filter((candle) => {
|
|
33286
|
-
// EXCLUSIVE boundaries:
|
|
33287
|
-
// - candle.timestamp > sinceTimestamp (exclude exact boundary)
|
|
33288
|
-
// - candle.timestamp + stepMs < whenTimestamp (fully closed before "when")
|
|
33289
|
-
if (candle.timestamp <= sinceTimestamp) {
|
|
33290
|
-
return false;
|
|
33291
|
-
}
|
|
33292
|
-
// Check against current time (when)
|
|
33293
|
-
// Only allow candles that have fully CLOSED before "when"
|
|
33294
|
-
return candle.timestamp + stepMs < whenTimestamp;
|
|
33295
|
-
});
|
|
33296
33380
|
// Apply distinct by timestamp to remove duplicates
|
|
33297
|
-
const uniqueData = Array.from(new Map(
|
|
33298
|
-
if (
|
|
33299
|
-
bt.loggerService.warn(`ExchangeInstance Removed ${
|
|
33381
|
+
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
33382
|
+
if (allData.length !== uniqueData.length) {
|
|
33383
|
+
bt.loggerService.warn(`ExchangeInstance getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
|
|
33384
|
+
}
|
|
33385
|
+
// Validate adapter returned data
|
|
33386
|
+
if (uniqueData.length === 0) {
|
|
33387
|
+
throw new Error(`ExchangeInstance getCandles: adapter returned empty array. ` +
|
|
33388
|
+
`Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
|
|
33300
33389
|
}
|
|
33301
|
-
if (uniqueData.
|
|
33302
|
-
|
|
33390
|
+
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
33391
|
+
throw new Error(`ExchangeInstance getCandles: first candle timestamp mismatch. ` +
|
|
33392
|
+
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
33393
|
+
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
33394
|
+
}
|
|
33395
|
+
if (uniqueData.length !== limit) {
|
|
33396
|
+
throw new Error(`ExchangeInstance getCandles: candle count mismatch. ` +
|
|
33397
|
+
`Expected ${limit} candles, got ${uniqueData.length}. ` +
|
|
33398
|
+
`Adapter must return exact number of candles requested.`);
|
|
33303
33399
|
}
|
|
33304
33400
|
// Write to cache after successful fetch
|
|
33305
33401
|
await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
|
|
@@ -33470,10 +33566,11 @@ class ExchangeInstance {
|
|
|
33470
33566
|
if (!step) {
|
|
33471
33567
|
throw new Error(`ExchangeInstance getRawCandles: unknown interval=${interval}`);
|
|
33472
33568
|
}
|
|
33569
|
+
const stepMs = step * MS_PER_MINUTE;
|
|
33473
33570
|
const when = await GET_TIMESTAMP_FN();
|
|
33474
33571
|
const nowTimestamp = when.getTime();
|
|
33572
|
+
const alignedNow = ALIGN_TO_INTERVAL_FN(nowTimestamp, step);
|
|
33475
33573
|
let sinceTimestamp;
|
|
33476
|
-
let untilTimestamp;
|
|
33477
33574
|
let calculatedLimit;
|
|
33478
33575
|
// Case 1: all three parameters provided
|
|
33479
33576
|
if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
|
|
@@ -33483,8 +33580,8 @@ class ExchangeInstance {
|
|
|
33483
33580
|
if (eDate > nowTimestamp) {
|
|
33484
33581
|
throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
|
|
33485
33582
|
}
|
|
33486
|
-
|
|
33487
|
-
|
|
33583
|
+
// Align sDate down to interval boundary
|
|
33584
|
+
sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
|
|
33488
33585
|
calculatedLimit = limit;
|
|
33489
33586
|
}
|
|
33490
33587
|
// Case 2: sDate + eDate (no limit) - calculate limit from date range
|
|
@@ -33495,9 +33592,10 @@ class ExchangeInstance {
|
|
|
33495
33592
|
if (eDate > nowTimestamp) {
|
|
33496
33593
|
throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
|
|
33497
33594
|
}
|
|
33498
|
-
|
|
33499
|
-
|
|
33500
|
-
|
|
33595
|
+
// Align sDate down to interval boundary
|
|
33596
|
+
sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
|
|
33597
|
+
const alignedEDate = ALIGN_TO_INTERVAL_FN(eDate, step);
|
|
33598
|
+
calculatedLimit = Math.ceil((alignedEDate - sinceTimestamp) / stepMs);
|
|
33501
33599
|
if (calculatedLimit <= 0) {
|
|
33502
33600
|
throw new Error(`ExchangeInstance getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
|
|
33503
33601
|
}
|
|
@@ -33507,23 +33605,24 @@ class ExchangeInstance {
|
|
|
33507
33605
|
if (eDate > nowTimestamp) {
|
|
33508
33606
|
throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
|
|
33509
33607
|
}
|
|
33510
|
-
|
|
33511
|
-
|
|
33608
|
+
// Align eDate down and calculate sinceTimestamp
|
|
33609
|
+
const alignedEDate = ALIGN_TO_INTERVAL_FN(eDate, step);
|
|
33610
|
+
sinceTimestamp = alignedEDate - limit * stepMs;
|
|
33512
33611
|
calculatedLimit = limit;
|
|
33513
33612
|
}
|
|
33514
33613
|
// Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
|
|
33515
33614
|
else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
|
|
33516
|
-
|
|
33517
|
-
|
|
33518
|
-
|
|
33519
|
-
|
|
33615
|
+
// Align sDate down to interval boundary
|
|
33616
|
+
sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
|
|
33617
|
+
const endTimestamp = sinceTimestamp + limit * stepMs;
|
|
33618
|
+
if (endTimestamp > nowTimestamp) {
|
|
33619
|
+
throw new Error(`ExchangeInstance getRawCandles: calculated endTimestamp (${endTimestamp}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
|
|
33520
33620
|
}
|
|
33521
33621
|
calculatedLimit = limit;
|
|
33522
33622
|
}
|
|
33523
33623
|
// Case 5: Only limit - use Date.now() as reference (backward)
|
|
33524
33624
|
else if (sDate === undefined && eDate === undefined && limit !== undefined) {
|
|
33525
|
-
|
|
33526
|
-
sinceTimestamp = nowTimestamp - limit * step * MS_PER_MINUTE;
|
|
33625
|
+
sinceTimestamp = alignedNow - limit * stepMs;
|
|
33527
33626
|
calculatedLimit = limit;
|
|
33528
33627
|
}
|
|
33529
33628
|
// Invalid: no parameters or only sDate or only eDate
|
|
@@ -33533,6 +33632,7 @@ class ExchangeInstance {
|
|
|
33533
33632
|
`Got: sDate=${sDate}, eDate=${eDate}, limit=${limit}`);
|
|
33534
33633
|
}
|
|
33535
33634
|
// Try to read from cache first
|
|
33635
|
+
const untilTimestamp = sinceTimestamp + calculatedLimit * stepMs;
|
|
33536
33636
|
const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit: calculatedLimit }, sinceTimestamp, untilTimestamp, this.exchangeName);
|
|
33537
33637
|
if (cachedCandles !== null) {
|
|
33538
33638
|
return cachedCandles;
|
|
@@ -33551,25 +33651,32 @@ class ExchangeInstance {
|
|
|
33551
33651
|
allData.push(...chunkData);
|
|
33552
33652
|
remaining -= chunkLimit;
|
|
33553
33653
|
if (remaining > 0) {
|
|
33554
|
-
currentSince = new Date(currentSince.getTime() + chunkLimit *
|
|
33654
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
|
|
33555
33655
|
}
|
|
33556
33656
|
}
|
|
33557
33657
|
}
|
|
33558
33658
|
else {
|
|
33559
33659
|
allData = await getCandles(symbol, interval, since, calculatedLimit, isBacktest);
|
|
33560
33660
|
}
|
|
33561
|
-
// Filter candles to strictly match the requested range
|
|
33562
|
-
// Only include candles that have fully CLOSED before untilTimestamp
|
|
33563
|
-
const stepMs = step * MS_PER_MINUTE;
|
|
33564
|
-
const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
|
|
33565
|
-
candle.timestamp + stepMs < untilTimestamp);
|
|
33566
33661
|
// Apply distinct by timestamp to remove duplicates
|
|
33567
|
-
const uniqueData = Array.from(new Map(
|
|
33568
|
-
if (
|
|
33569
|
-
bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${
|
|
33570
|
-
}
|
|
33571
|
-
|
|
33572
|
-
|
|
33662
|
+
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
33663
|
+
if (allData.length !== uniqueData.length) {
|
|
33664
|
+
bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
|
|
33665
|
+
}
|
|
33666
|
+
// Validate adapter returned data
|
|
33667
|
+
if (uniqueData.length === 0) {
|
|
33668
|
+
throw new Error(`ExchangeInstance getRawCandles: adapter returned empty array. ` +
|
|
33669
|
+
`Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
|
|
33670
|
+
}
|
|
33671
|
+
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
33672
|
+
throw new Error(`ExchangeInstance getRawCandles: first candle timestamp mismatch. ` +
|
|
33673
|
+
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
33674
|
+
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
33675
|
+
}
|
|
33676
|
+
if (uniqueData.length !== calculatedLimit) {
|
|
33677
|
+
throw new Error(`ExchangeInstance getRawCandles: candle count mismatch. ` +
|
|
33678
|
+
`Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
|
|
33679
|
+
`Adapter must return exact number of candles requested.`);
|
|
33573
33680
|
}
|
|
33574
33681
|
// Write to cache after successful fetch
|
|
33575
33682
|
await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);
|