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.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,
|
|
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
|
|
1561
|
+
* Returns candles only if cache contains ALL requested candles.
|
|
1562
1562
|
*
|
|
1563
|
-
*
|
|
1564
|
-
*
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
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 -
|
|
1573
|
-
* @param
|
|
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,
|
|
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
|
-
//
|
|
1590
|
+
// Calculate expected timestamps and fetch each candle directly
|
|
1591
1591
|
const cachedCandles = [];
|
|
1592
|
-
for
|
|
1593
|
-
const
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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
|
|
1624
|
-
* - candle.timestamp
|
|
1625
|
-
* -
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
*
|
|
1869
|
-
*
|
|
1870
|
-
*
|
|
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 -
|
|
1875
|
-
* @param untilTimestamp -
|
|
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
|
|
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
|
|
1881
|
-
//
|
|
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
|
|
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
|
|
1907
|
-
* - candle.timestamp
|
|
1908
|
-
* -
|
|
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
|
|
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
|
-
|
|
2050
|
-
|
|
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
|
|
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 *
|
|
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(
|
|
2090
|
-
if (
|
|
2091
|
-
const msg = `ClientExchange Removed ${
|
|
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
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
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
|
-
|
|
2127
|
-
|
|
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 *
|
|
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(
|
|
2157
|
-
if (
|
|
2158
|
-
const msg = `ClientExchange getNextCandles: Removed ${
|
|
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
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
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
|
-
|
|
2287
|
-
|
|
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
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
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
|
-
|
|
2311
|
-
|
|
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
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
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
|
-
|
|
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 *
|
|
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(
|
|
2361
|
-
if (
|
|
2362
|
-
const msg = `ClientExchange getRawCandles: Removed ${
|
|
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
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2436
|
+
// Validate adapter returned data
|
|
2437
|
+
if (uniqueData.length === 0) {
|
|
2438
|
+
throw new Error(`ClientExchange getRawCandles: adapter returned empty array. ` +
|
|
2439
|
+
`Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
|
|
2440
|
+
}
|
|
2441
|
+
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
2442
|
+
throw new Error(`ClientExchange getRawCandles: first candle timestamp mismatch. ` +
|
|
2443
|
+
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
2444
|
+
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
2445
|
+
}
|
|
2446
|
+
if (uniqueData.length !== calculatedLimit) {
|
|
2447
|
+
throw new Error(`ClientExchange getRawCandles: candle count mismatch. ` +
|
|
2448
|
+
`Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
|
|
2449
|
+
`Adapter must return exact number of candles requested.`);
|
|
2370
2450
|
}
|
|
2371
2451
|
await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, calculatedLimit, uniqueData);
|
|
2372
2452
|
return uniqueData;
|
|
@@ -12323,13 +12403,6 @@ class WalkerSchemaService {
|
|
|
12323
12403
|
}
|
|
12324
12404
|
}
|
|
12325
12405
|
|
|
12326
|
-
/**
|
|
12327
|
-
* Компенсация для exclusive boundaries при фильтрации свечей.
|
|
12328
|
-
* ClientExchange.getNextCandles использует фильтр:
|
|
12329
|
-
* timestamp > since && timestamp + stepMs < endTime
|
|
12330
|
-
* который исключает первую и последнюю свечи из запрошенного диапазона.
|
|
12331
|
-
*/
|
|
12332
|
-
const CANDLE_EXCLUSIVE_BOUNDARY_OFFSET = 2;
|
|
12333
12406
|
/**
|
|
12334
12407
|
* Private service for backtest orchestration using async generators.
|
|
12335
12408
|
*
|
|
@@ -12595,7 +12668,7 @@ class BacktestLogicPrivateService {
|
|
|
12595
12668
|
// Запрашиваем minuteEstimatedTime + буфер свечей одним запросом
|
|
12596
12669
|
const bufferMinutes = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
|
|
12597
12670
|
const bufferStartTime = new Date(when.getTime() - bufferMinutes * 60 * 1000);
|
|
12598
|
-
const totalCandles = signal.minuteEstimatedTime + GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT
|
|
12671
|
+
const totalCandles = signal.minuteEstimatedTime + GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
|
|
12599
12672
|
let candles;
|
|
12600
12673
|
try {
|
|
12601
12674
|
candles = await this.exchangeCoreService.getNextCandles(symbol, "1m", totalCandles, bufferStartTime, true);
|
|
@@ -33089,6 +33162,27 @@ const INTERVAL_MINUTES$1 = {
|
|
|
33089
33162
|
"6h": 360,
|
|
33090
33163
|
"8h": 480,
|
|
33091
33164
|
};
|
|
33165
|
+
/**
|
|
33166
|
+
* Aligns timestamp down to the nearest interval boundary.
|
|
33167
|
+
* For example, for 15m interval: 00:17 -> 00:15, 00:44 -> 00:30
|
|
33168
|
+
*
|
|
33169
|
+
* Candle timestamp convention:
|
|
33170
|
+
* - Candle timestamp = openTime (when candle opens)
|
|
33171
|
+
* - Candle with timestamp 00:00 covers period [00:00, 00:15) for 15m interval
|
|
33172
|
+
*
|
|
33173
|
+
* Adapter contract:
|
|
33174
|
+
* - Adapter must return candles with timestamp = openTime
|
|
33175
|
+
* - First returned candle.timestamp must equal aligned since
|
|
33176
|
+
* - Adapter must return exactly `limit` candles
|
|
33177
|
+
*
|
|
33178
|
+
* @param timestamp - Timestamp in milliseconds
|
|
33179
|
+
* @param intervalMinutes - Interval in minutes
|
|
33180
|
+
* @returns Aligned timestamp rounded down to interval boundary
|
|
33181
|
+
*/
|
|
33182
|
+
const ALIGN_TO_INTERVAL_FN = (timestamp, intervalMinutes) => {
|
|
33183
|
+
const intervalMs = intervalMinutes * MS_PER_MINUTE;
|
|
33184
|
+
return Math.floor(timestamp / intervalMs) * intervalMs;
|
|
33185
|
+
};
|
|
33092
33186
|
/**
|
|
33093
33187
|
* Creates exchange instance with methods resolved once during construction.
|
|
33094
33188
|
* Applies default implementations where schema methods are not provided.
|
|
@@ -33110,25 +33204,24 @@ const CREATE_EXCHANGE_INSTANCE_FN = (schema) => {
|
|
|
33110
33204
|
};
|
|
33111
33205
|
/**
|
|
33112
33206
|
* Attempts to read candles from cache.
|
|
33113
|
-
* Validates cache consistency (no gaps in timestamps) before returning.
|
|
33114
33207
|
*
|
|
33115
|
-
*
|
|
33116
|
-
*
|
|
33117
|
-
*
|
|
33118
|
-
* - Only fully closed candles within the exclusive range are returned
|
|
33208
|
+
* Cache lookup calculates expected timestamps:
|
|
33209
|
+
* sinceTimestamp + i * stepMs for i = 0..limit-1
|
|
33210
|
+
* Returns all candles if found, null if any missing.
|
|
33119
33211
|
*
|
|
33120
33212
|
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
33121
|
-
* @param sinceTimestamp -
|
|
33122
|
-
* @param untilTimestamp -
|
|
33213
|
+
* @param sinceTimestamp - Aligned start timestamp (openTime of first candle)
|
|
33214
|
+
* @param untilTimestamp - Unused, kept for API compatibility
|
|
33123
33215
|
* @param exchangeName - Exchange name
|
|
33124
|
-
* @returns Cached candles array or null if cache miss
|
|
33216
|
+
* @returns Cached candles array (exactly limit) or null if cache miss
|
|
33125
33217
|
*/
|
|
33126
33218
|
const READ_CANDLES_CACHE_FN = trycatch(async (dto, sinceTimestamp, untilTimestamp, exchangeName) => {
|
|
33127
|
-
// PersistCandleAdapter.readCandlesData
|
|
33128
|
-
//
|
|
33219
|
+
// PersistCandleAdapter.readCandlesData calculates expected timestamps:
|
|
33220
|
+
// sinceTimestamp + i * stepMs for i = 0..limit-1
|
|
33221
|
+
// Returns all candles if found, null if any missing
|
|
33129
33222
|
const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
|
|
33130
33223
|
// Return cached data only if we have exactly the requested limit
|
|
33131
|
-
if (cachedCandles
|
|
33224
|
+
if (cachedCandles?.length === dto.limit) {
|
|
33132
33225
|
bt.loggerService.debug(`ExchangeInstance READ_CANDLES_CACHE_FN: cache hit for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
|
|
33133
33226
|
return cachedCandles;
|
|
33134
33227
|
}
|
|
@@ -33150,11 +33243,12 @@ const READ_CANDLES_CACHE_FN = trycatch(async (dto, sinceTimestamp, untilTimestam
|
|
|
33150
33243
|
/**
|
|
33151
33244
|
* Writes candles to cache with error handling.
|
|
33152
33245
|
*
|
|
33153
|
-
* The candles passed to this function
|
|
33154
|
-
* - candle.timestamp
|
|
33155
|
-
* -
|
|
33246
|
+
* The candles passed to this function should be validated:
|
|
33247
|
+
* - First candle.timestamp equals aligned sinceTimestamp (openTime)
|
|
33248
|
+
* - Exact number of candles as requested (limit)
|
|
33249
|
+
* - Sequential timestamps: sinceTimestamp + i * stepMs
|
|
33156
33250
|
*
|
|
33157
|
-
* @param candles - Array of candle data to cache
|
|
33251
|
+
* @param candles - Array of validated candle data to cache
|
|
33158
33252
|
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
33159
33253
|
* @param exchangeName - Exchange name
|
|
33160
33254
|
*/
|
|
@@ -33225,14 +33319,18 @@ class ExchangeInstance {
|
|
|
33225
33319
|
});
|
|
33226
33320
|
const getCandles = this._methods.getCandles;
|
|
33227
33321
|
const step = INTERVAL_MINUTES$1[interval];
|
|
33228
|
-
|
|
33229
|
-
|
|
33230
|
-
throw new Error(`ExchangeInstance unknown time adjust for interval=${interval}`);
|
|
33322
|
+
if (!step) {
|
|
33323
|
+
throw new Error(`ExchangeInstance unknown interval=${interval}`);
|
|
33231
33324
|
}
|
|
33325
|
+
const stepMs = step * MS_PER_MINUTE;
|
|
33326
|
+
// Align when down to interval boundary
|
|
33232
33327
|
const when = await GET_TIMESTAMP_FN();
|
|
33233
|
-
const
|
|
33234
|
-
const
|
|
33235
|
-
|
|
33328
|
+
const whenTimestamp = when.getTime();
|
|
33329
|
+
const alignedWhen = ALIGN_TO_INTERVAL_FN(whenTimestamp, step);
|
|
33330
|
+
// Calculate since: go back limit candles from aligned when
|
|
33331
|
+
const sinceTimestamp = alignedWhen - limit * stepMs;
|
|
33332
|
+
const since = new Date(sinceTimestamp);
|
|
33333
|
+
const untilTimestamp = alignedWhen;
|
|
33236
33334
|
// Try to read from cache first
|
|
33237
33335
|
const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
|
|
33238
33336
|
if (cachedCandles !== null) {
|
|
@@ -33251,7 +33349,7 @@ class ExchangeInstance {
|
|
|
33251
33349
|
remaining -= chunkLimit;
|
|
33252
33350
|
if (remaining > 0) {
|
|
33253
33351
|
// Move currentSince forward by the number of candles fetched
|
|
33254
|
-
currentSince = new Date(currentSince.getTime() + chunkLimit *
|
|
33352
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
|
|
33255
33353
|
}
|
|
33256
33354
|
}
|
|
33257
33355
|
}
|
|
@@ -33259,27 +33357,25 @@ class ExchangeInstance {
|
|
|
33259
33357
|
const isBacktest = await GET_BACKTEST_FN();
|
|
33260
33358
|
allData = await getCandles(symbol, interval, since, limit, isBacktest);
|
|
33261
33359
|
}
|
|
33262
|
-
// Filter candles to strictly match the requested range
|
|
33263
|
-
const whenTimestamp = when.getTime();
|
|
33264
|
-
const stepMs = step * MS_PER_MINUTE;
|
|
33265
|
-
const filteredData = allData.filter((candle) => {
|
|
33266
|
-
// EXCLUSIVE boundaries:
|
|
33267
|
-
// - candle.timestamp > sinceTimestamp (exclude exact boundary)
|
|
33268
|
-
// - candle.timestamp + stepMs < whenTimestamp (fully closed before "when")
|
|
33269
|
-
if (candle.timestamp <= sinceTimestamp) {
|
|
33270
|
-
return false;
|
|
33271
|
-
}
|
|
33272
|
-
// Check against current time (when)
|
|
33273
|
-
// Only allow candles that have fully CLOSED before "when"
|
|
33274
|
-
return candle.timestamp + stepMs < whenTimestamp;
|
|
33275
|
-
});
|
|
33276
33360
|
// Apply distinct by timestamp to remove duplicates
|
|
33277
|
-
const uniqueData = Array.from(new Map(
|
|
33278
|
-
if (
|
|
33279
|
-
bt.loggerService.warn(`ExchangeInstance Removed ${
|
|
33361
|
+
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
33362
|
+
if (allData.length !== uniqueData.length) {
|
|
33363
|
+
bt.loggerService.warn(`ExchangeInstance getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
|
|
33364
|
+
}
|
|
33365
|
+
// Validate adapter returned data
|
|
33366
|
+
if (uniqueData.length === 0) {
|
|
33367
|
+
throw new Error(`ExchangeInstance getCandles: adapter returned empty array. ` +
|
|
33368
|
+
`Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
|
|
33280
33369
|
}
|
|
33281
|
-
if (uniqueData.
|
|
33282
|
-
|
|
33370
|
+
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
33371
|
+
throw new Error(`ExchangeInstance getCandles: first candle timestamp mismatch. ` +
|
|
33372
|
+
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
33373
|
+
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
33374
|
+
}
|
|
33375
|
+
if (uniqueData.length !== limit) {
|
|
33376
|
+
throw new Error(`ExchangeInstance getCandles: candle count mismatch. ` +
|
|
33377
|
+
`Expected ${limit} candles, got ${uniqueData.length}. ` +
|
|
33378
|
+
`Adapter must return exact number of candles requested.`);
|
|
33283
33379
|
}
|
|
33284
33380
|
// Write to cache after successful fetch
|
|
33285
33381
|
await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
|
|
@@ -33450,10 +33546,11 @@ class ExchangeInstance {
|
|
|
33450
33546
|
if (!step) {
|
|
33451
33547
|
throw new Error(`ExchangeInstance getRawCandles: unknown interval=${interval}`);
|
|
33452
33548
|
}
|
|
33549
|
+
const stepMs = step * MS_PER_MINUTE;
|
|
33453
33550
|
const when = await GET_TIMESTAMP_FN();
|
|
33454
33551
|
const nowTimestamp = when.getTime();
|
|
33552
|
+
const alignedNow = ALIGN_TO_INTERVAL_FN(nowTimestamp, step);
|
|
33455
33553
|
let sinceTimestamp;
|
|
33456
|
-
let untilTimestamp;
|
|
33457
33554
|
let calculatedLimit;
|
|
33458
33555
|
// Case 1: all three parameters provided
|
|
33459
33556
|
if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
|
|
@@ -33463,8 +33560,8 @@ class ExchangeInstance {
|
|
|
33463
33560
|
if (eDate > nowTimestamp) {
|
|
33464
33561
|
throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
|
|
33465
33562
|
}
|
|
33466
|
-
|
|
33467
|
-
|
|
33563
|
+
// Align sDate down to interval boundary
|
|
33564
|
+
sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
|
|
33468
33565
|
calculatedLimit = limit;
|
|
33469
33566
|
}
|
|
33470
33567
|
// Case 2: sDate + eDate (no limit) - calculate limit from date range
|
|
@@ -33475,9 +33572,10 @@ class ExchangeInstance {
|
|
|
33475
33572
|
if (eDate > nowTimestamp) {
|
|
33476
33573
|
throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
|
|
33477
33574
|
}
|
|
33478
|
-
|
|
33479
|
-
|
|
33480
|
-
|
|
33575
|
+
// Align sDate down to interval boundary
|
|
33576
|
+
sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
|
|
33577
|
+
const alignedEDate = ALIGN_TO_INTERVAL_FN(eDate, step);
|
|
33578
|
+
calculatedLimit = Math.ceil((alignedEDate - sinceTimestamp) / stepMs);
|
|
33481
33579
|
if (calculatedLimit <= 0) {
|
|
33482
33580
|
throw new Error(`ExchangeInstance getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
|
|
33483
33581
|
}
|
|
@@ -33487,23 +33585,24 @@ class ExchangeInstance {
|
|
|
33487
33585
|
if (eDate > nowTimestamp) {
|
|
33488
33586
|
throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
|
|
33489
33587
|
}
|
|
33490
|
-
|
|
33491
|
-
|
|
33588
|
+
// Align eDate down and calculate sinceTimestamp
|
|
33589
|
+
const alignedEDate = ALIGN_TO_INTERVAL_FN(eDate, step);
|
|
33590
|
+
sinceTimestamp = alignedEDate - limit * stepMs;
|
|
33492
33591
|
calculatedLimit = limit;
|
|
33493
33592
|
}
|
|
33494
33593
|
// Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
|
|
33495
33594
|
else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
|
|
33496
|
-
|
|
33497
|
-
|
|
33498
|
-
|
|
33499
|
-
|
|
33595
|
+
// Align sDate down to interval boundary
|
|
33596
|
+
sinceTimestamp = ALIGN_TO_INTERVAL_FN(sDate, step);
|
|
33597
|
+
const endTimestamp = sinceTimestamp + limit * stepMs;
|
|
33598
|
+
if (endTimestamp > nowTimestamp) {
|
|
33599
|
+
throw new Error(`ExchangeInstance getRawCandles: calculated endTimestamp (${endTimestamp}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
|
|
33500
33600
|
}
|
|
33501
33601
|
calculatedLimit = limit;
|
|
33502
33602
|
}
|
|
33503
33603
|
// Case 5: Only limit - use Date.now() as reference (backward)
|
|
33504
33604
|
else if (sDate === undefined && eDate === undefined && limit !== undefined) {
|
|
33505
|
-
|
|
33506
|
-
sinceTimestamp = nowTimestamp - limit * step * MS_PER_MINUTE;
|
|
33605
|
+
sinceTimestamp = alignedNow - limit * stepMs;
|
|
33507
33606
|
calculatedLimit = limit;
|
|
33508
33607
|
}
|
|
33509
33608
|
// Invalid: no parameters or only sDate or only eDate
|
|
@@ -33513,6 +33612,7 @@ class ExchangeInstance {
|
|
|
33513
33612
|
`Got: sDate=${sDate}, eDate=${eDate}, limit=${limit}`);
|
|
33514
33613
|
}
|
|
33515
33614
|
// Try to read from cache first
|
|
33615
|
+
const untilTimestamp = sinceTimestamp + calculatedLimit * stepMs;
|
|
33516
33616
|
const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit: calculatedLimit }, sinceTimestamp, untilTimestamp, this.exchangeName);
|
|
33517
33617
|
if (cachedCandles !== null) {
|
|
33518
33618
|
return cachedCandles;
|
|
@@ -33531,25 +33631,32 @@ class ExchangeInstance {
|
|
|
33531
33631
|
allData.push(...chunkData);
|
|
33532
33632
|
remaining -= chunkLimit;
|
|
33533
33633
|
if (remaining > 0) {
|
|
33534
|
-
currentSince = new Date(currentSince.getTime() + chunkLimit *
|
|
33634
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
|
|
33535
33635
|
}
|
|
33536
33636
|
}
|
|
33537
33637
|
}
|
|
33538
33638
|
else {
|
|
33539
33639
|
allData = await getCandles(symbol, interval, since, calculatedLimit, isBacktest);
|
|
33540
33640
|
}
|
|
33541
|
-
// Filter candles to strictly match the requested range
|
|
33542
|
-
// Only include candles that have fully CLOSED before untilTimestamp
|
|
33543
|
-
const stepMs = step * MS_PER_MINUTE;
|
|
33544
|
-
const filteredData = allData.filter((candle) => candle.timestamp > sinceTimestamp &&
|
|
33545
|
-
candle.timestamp + stepMs < untilTimestamp);
|
|
33546
33641
|
// Apply distinct by timestamp to remove duplicates
|
|
33547
|
-
const uniqueData = Array.from(new Map(
|
|
33548
|
-
if (
|
|
33549
|
-
bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${
|
|
33550
|
-
}
|
|
33551
|
-
|
|
33552
|
-
|
|
33642
|
+
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
33643
|
+
if (allData.length !== uniqueData.length) {
|
|
33644
|
+
bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
|
|
33645
|
+
}
|
|
33646
|
+
// Validate adapter returned data
|
|
33647
|
+
if (uniqueData.length === 0) {
|
|
33648
|
+
throw new Error(`ExchangeInstance getRawCandles: adapter returned empty array. ` +
|
|
33649
|
+
`Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
|
|
33650
|
+
}
|
|
33651
|
+
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
33652
|
+
throw new Error(`ExchangeInstance getRawCandles: first candle timestamp mismatch. ` +
|
|
33653
|
+
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
33654
|
+
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
33655
|
+
}
|
|
33656
|
+
if (uniqueData.length !== calculatedLimit) {
|
|
33657
|
+
throw new Error(`ExchangeInstance getRawCandles: candle count mismatch. ` +
|
|
33658
|
+
`Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
|
|
33659
|
+
`Adapter must return exact number of candles requested.`);
|
|
33553
33660
|
}
|
|
33554
33661
|
// Write to cache after successful fetch
|
|
33555
33662
|
await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);
|