backtest-kit 3.0.17 → 3.1.0
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 +22 -0
- package/build/index.cjs +281 -149
- package/build/index.mjs +282 -151
- package/package.json +1 -1
- package/types.d.ts +17 -1
package/README.md
CHANGED
|
@@ -555,6 +555,28 @@ cd my-trading-bot
|
|
|
555
555
|
npm start
|
|
556
556
|
```
|
|
557
557
|
|
|
558
|
+
|
|
559
|
+
### @backtest-kit/graph
|
|
560
|
+
|
|
561
|
+
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/graph)** 🔗
|
|
562
|
+
|
|
563
|
+
The **@backtest-kit/graph** package lets you compose backtest-kit computations as a typed directed acyclic graph (DAG). Define source nodes that fetch market data and output nodes that compute derived values — then resolve the whole graph in topological order with automatic parallelism.
|
|
564
|
+
|
|
565
|
+
#### Key Features
|
|
566
|
+
- 🔌 **DAG Execution**: Nodes are resolved bottom-up in topological order with `Promise.all` parallelism
|
|
567
|
+
- 🔒 **Type-Safe Values**: TypeScript infers the return type of every node through the graph via generics
|
|
568
|
+
- 🧱 **Two APIs**: Low-level `INode` for runtime/storage, high-level `sourceNode` + `outputNode` builders for authoring
|
|
569
|
+
- 💾 **DB-Ready Serialization**: `serialize` / `deserialize` convert the graph to a flat `IFlatNode[]` list with `id` / `nodeIds`
|
|
570
|
+
- 🌐 **Context-Aware Fetch**: `sourceNode` receives `(symbol, when, exchangeName)` from the execution context automatically
|
|
571
|
+
|
|
572
|
+
#### Use Case
|
|
573
|
+
Perfect for multi-timeframe strategies where multiple Pine Script or indicator computations must be combined. Instead of manually chaining async calls, define each computation as a node and let the graph resolve dependencies in parallel. Adding a new filter or timeframe requires no changes to the existing wiring.
|
|
574
|
+
|
|
575
|
+
#### Get Started
|
|
576
|
+
```bash
|
|
577
|
+
npm install @backtest-kit/graph backtest-kit
|
|
578
|
+
```
|
|
579
|
+
|
|
558
580
|
## 🤖 Are you a robot?
|
|
559
581
|
|
|
560
582
|
**For language models**: Read extended description in [./LLMs.md](./LLMs.md)
|
package/build/index.cjs
CHANGED
|
@@ -442,6 +442,13 @@ const GLOBAL_CONFIG = {
|
|
|
442
442
|
* Default: 50 signals
|
|
443
443
|
*/
|
|
444
444
|
CC_MAX_SIGNALS: 50,
|
|
445
|
+
/**
|
|
446
|
+
* Enables mutex locking for candle fetching to prevent concurrent fetches of the same candles.
|
|
447
|
+
* This can help avoid redundant API calls and ensure data consistency when multiple processes/threads attempt to fetch candles simultaneously.
|
|
448
|
+
*
|
|
449
|
+
* Default: true (mutex locking enabled for candle fetching)
|
|
450
|
+
*/
|
|
451
|
+
CC_ENABLE_CANDLE_FETCH_MUTEX: true,
|
|
445
452
|
};
|
|
446
453
|
const DEFAULT_CONFIG = Object.freeze({ ...GLOBAL_CONFIG });
|
|
447
454
|
|
|
@@ -712,10 +719,10 @@ async function writeFileAtomic(file, data, options = {}) {
|
|
|
712
719
|
}
|
|
713
720
|
}
|
|
714
721
|
|
|
715
|
-
var _a$
|
|
722
|
+
var _a$3;
|
|
716
723
|
const BASE_WAIT_FOR_INIT_SYMBOL = Symbol("wait-for-init");
|
|
717
724
|
// Calculate step in milliseconds for candle close time validation
|
|
718
|
-
const INTERVAL_MINUTES$
|
|
725
|
+
const INTERVAL_MINUTES$8 = {
|
|
719
726
|
"1m": 1,
|
|
720
727
|
"3m": 3,
|
|
721
728
|
"5m": 5,
|
|
@@ -727,7 +734,7 @@ const INTERVAL_MINUTES$7 = {
|
|
|
727
734
|
"6h": 360,
|
|
728
735
|
"8h": 480,
|
|
729
736
|
};
|
|
730
|
-
const MS_PER_MINUTE$
|
|
737
|
+
const MS_PER_MINUTE$6 = 60000;
|
|
731
738
|
const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_PERSIST_SIGNAL_ADAPTER = "PersistSignalUtils.usePersistSignalAdapter";
|
|
732
739
|
const PERSIST_SIGNAL_UTILS_METHOD_NAME_READ_DATA = "PersistSignalUtils.readSignalData";
|
|
733
740
|
const PERSIST_SIGNAL_UTILS_METHOD_NAME_WRITE_DATA = "PersistSignalUtils.writeSignalData";
|
|
@@ -830,7 +837,7 @@ class PersistBase {
|
|
|
830
837
|
constructor(entityName, baseDir = path.join(process.cwd(), "logs/data")) {
|
|
831
838
|
this.entityName = entityName;
|
|
832
839
|
this.baseDir = baseDir;
|
|
833
|
-
this[_a$
|
|
840
|
+
this[_a$3] = functoolsKit.singleshot(async () => await BASE_WAIT_FOR_INIT_FN(this));
|
|
834
841
|
bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_CTOR, {
|
|
835
842
|
entityName: this.entityName,
|
|
836
843
|
baseDir,
|
|
@@ -933,7 +940,7 @@ class PersistBase {
|
|
|
933
940
|
}
|
|
934
941
|
}
|
|
935
942
|
}
|
|
936
|
-
_a$
|
|
943
|
+
_a$3 = BASE_WAIT_FOR_INIT_SYMBOL;
|
|
937
944
|
// @ts-ignore
|
|
938
945
|
PersistBase = functoolsKit.makeExtendable(PersistBase);
|
|
939
946
|
/**
|
|
@@ -1627,7 +1634,7 @@ class PersistCandleUtils {
|
|
|
1627
1634
|
const isInitial = !this.getCandlesStorage.has(key);
|
|
1628
1635
|
const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
|
|
1629
1636
|
await stateStorage.waitForInit(isInitial);
|
|
1630
|
-
const stepMs = INTERVAL_MINUTES$
|
|
1637
|
+
const stepMs = INTERVAL_MINUTES$8[interval] * MS_PER_MINUTE$6;
|
|
1631
1638
|
// Calculate expected timestamps and fetch each candle directly
|
|
1632
1639
|
const cachedCandles = [];
|
|
1633
1640
|
for (let i = 0; i < limit; i++) {
|
|
@@ -1683,7 +1690,7 @@ class PersistCandleUtils {
|
|
|
1683
1690
|
const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
|
|
1684
1691
|
await stateStorage.waitForInit(isInitial);
|
|
1685
1692
|
// Calculate step in milliseconds to determine candle close time
|
|
1686
|
-
const stepMs = INTERVAL_MINUTES$
|
|
1693
|
+
const stepMs = INTERVAL_MINUTES$8[interval] * MS_PER_MINUTE$6;
|
|
1687
1694
|
const now = Date.now();
|
|
1688
1695
|
// Write each candle as a separate file, skipping incomplete candles
|
|
1689
1696
|
for (const candle of candles) {
|
|
@@ -1940,8 +1947,71 @@ class PersistNotificationUtils {
|
|
|
1940
1947
|
*/
|
|
1941
1948
|
const PersistNotificationAdapter = new PersistNotificationUtils();
|
|
1942
1949
|
|
|
1943
|
-
|
|
1944
|
-
const
|
|
1950
|
+
var _a$2, _b$2;
|
|
1951
|
+
const BUSY_DELAY = 100;
|
|
1952
|
+
const SET_BUSY_SYMBOL = Symbol("setBusy");
|
|
1953
|
+
const GET_BUSY_SYMBOL = Symbol("getBusy");
|
|
1954
|
+
const ACQUIRE_LOCK_SYMBOL = Symbol("acquireLock");
|
|
1955
|
+
const RELEASE_LOCK_SYMBOL = Symbol("releaseLock");
|
|
1956
|
+
const ACQUIRE_LOCK_FN = async (self) => {
|
|
1957
|
+
while (self[GET_BUSY_SYMBOL]()) {
|
|
1958
|
+
await functoolsKit.sleep(BUSY_DELAY);
|
|
1959
|
+
}
|
|
1960
|
+
self[SET_BUSY_SYMBOL](true);
|
|
1961
|
+
};
|
|
1962
|
+
class Lock {
|
|
1963
|
+
constructor() {
|
|
1964
|
+
this._isBusy = 0;
|
|
1965
|
+
this[_a$2] = functoolsKit.queued(ACQUIRE_LOCK_FN);
|
|
1966
|
+
this[_b$2] = () => this[SET_BUSY_SYMBOL](false);
|
|
1967
|
+
this.acquireLock = async () => {
|
|
1968
|
+
await this[ACQUIRE_LOCK_SYMBOL](this);
|
|
1969
|
+
};
|
|
1970
|
+
this.releaseLock = async () => {
|
|
1971
|
+
await this[RELEASE_LOCK_SYMBOL]();
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
[SET_BUSY_SYMBOL](isBusy) {
|
|
1975
|
+
this._isBusy += isBusy ? 1 : -1;
|
|
1976
|
+
if (this._isBusy < 0) {
|
|
1977
|
+
throw new Error("Extra release in finally block");
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
[GET_BUSY_SYMBOL]() {
|
|
1981
|
+
return !!this._isBusy;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
_a$2 = ACQUIRE_LOCK_SYMBOL, _b$2 = RELEASE_LOCK_SYMBOL;
|
|
1985
|
+
|
|
1986
|
+
const METHOD_NAME_ACQUIRE_LOCK = "CandleUtils.acquireLock";
|
|
1987
|
+
const METHOD_NAME_RELEASE_LOCK = "CandleUtils.releaseLock";
|
|
1988
|
+
class CandleUtils {
|
|
1989
|
+
constructor() {
|
|
1990
|
+
this._lock = new Lock();
|
|
1991
|
+
this.acquireLock = async (source) => {
|
|
1992
|
+
bt.loggerService.info(METHOD_NAME_ACQUIRE_LOCK, {
|
|
1993
|
+
source,
|
|
1994
|
+
});
|
|
1995
|
+
if (!GLOBAL_CONFIG.CC_ENABLE_CANDLE_FETCH_MUTEX) {
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
return await this._lock.acquireLock();
|
|
1999
|
+
};
|
|
2000
|
+
this.releaseLock = async (source) => {
|
|
2001
|
+
bt.loggerService.info(METHOD_NAME_RELEASE_LOCK, {
|
|
2002
|
+
source,
|
|
2003
|
+
});
|
|
2004
|
+
if (!GLOBAL_CONFIG.CC_ENABLE_CANDLE_FETCH_MUTEX) {
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
return await this._lock.releaseLock();
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
const Candle = new CandleUtils();
|
|
2012
|
+
|
|
2013
|
+
const MS_PER_MINUTE$5 = 60000;
|
|
2014
|
+
const INTERVAL_MINUTES$7 = {
|
|
1945
2015
|
"1m": 1,
|
|
1946
2016
|
"3m": 3,
|
|
1947
2017
|
"5m": 5,
|
|
@@ -1971,7 +2041,7 @@ const INTERVAL_MINUTES$6 = {
|
|
|
1971
2041
|
* @returns Aligned timestamp rounded down to interval boundary
|
|
1972
2042
|
*/
|
|
1973
2043
|
const ALIGN_TO_INTERVAL_FN$2 = (timestamp, intervalMinutes) => {
|
|
1974
|
-
const intervalMs = intervalMinutes * MS_PER_MINUTE$
|
|
2044
|
+
const intervalMs = intervalMinutes * MS_PER_MINUTE$5;
|
|
1975
2045
|
return Math.floor(timestamp / intervalMs) * intervalMs;
|
|
1976
2046
|
};
|
|
1977
2047
|
/**
|
|
@@ -2116,37 +2186,43 @@ const WRITE_CANDLES_CACHE_FN$1 = functoolsKit.trycatch(functoolsKit.queued(async
|
|
|
2116
2186
|
* @returns Promise resolving to array of candle data
|
|
2117
2187
|
*/
|
|
2118
2188
|
const GET_CANDLES_FN = async (dto, since, self) => {
|
|
2119
|
-
const step = INTERVAL_MINUTES$
|
|
2189
|
+
const step = INTERVAL_MINUTES$7[dto.interval];
|
|
2120
2190
|
const sinceTimestamp = since.getTime();
|
|
2121
|
-
const untilTimestamp = sinceTimestamp + dto.limit * step * MS_PER_MINUTE$
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
let lastError;
|
|
2129
|
-
for (let i = 0; i !== GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT; i++) {
|
|
2130
|
-
try {
|
|
2131
|
-
const result = await self.params.getCandles(dto.symbol, dto.interval, since, dto.limit, self.params.execution.context.backtest);
|
|
2132
|
-
VALIDATE_NO_INCOMPLETE_CANDLES_FN(result);
|
|
2133
|
-
// Write to cache after successful fetch
|
|
2134
|
-
await WRITE_CANDLES_CACHE_FN$1(result, dto, self);
|
|
2135
|
-
return result;
|
|
2191
|
+
const untilTimestamp = sinceTimestamp + dto.limit * step * MS_PER_MINUTE$5;
|
|
2192
|
+
await Candle.acquireLock(`ClientExchange GET_CANDLES_FN symbol=${dto.symbol} interval=${dto.interval} limit=${dto.limit}`);
|
|
2193
|
+
try {
|
|
2194
|
+
// Try to read from cache first
|
|
2195
|
+
const cachedCandles = await READ_CANDLES_CACHE_FN$1(dto, sinceTimestamp, untilTimestamp, self);
|
|
2196
|
+
if (cachedCandles !== null) {
|
|
2197
|
+
return cachedCandles;
|
|
2136
2198
|
}
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2199
|
+
// Cache miss or error - fetch from API
|
|
2200
|
+
let lastError;
|
|
2201
|
+
for (let i = 0; i !== GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT; i++) {
|
|
2202
|
+
try {
|
|
2203
|
+
const result = await self.params.getCandles(dto.symbol, dto.interval, since, dto.limit, self.params.execution.context.backtest);
|
|
2204
|
+
VALIDATE_NO_INCOMPLETE_CANDLES_FN(result);
|
|
2205
|
+
// Write to cache after successful fetch
|
|
2206
|
+
await WRITE_CANDLES_CACHE_FN$1(result, dto, self);
|
|
2207
|
+
return result;
|
|
2208
|
+
}
|
|
2209
|
+
catch (err) {
|
|
2210
|
+
const message = `ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}}`;
|
|
2211
|
+
const payload = {
|
|
2212
|
+
error: functoolsKit.errorData(err),
|
|
2213
|
+
message: functoolsKit.getErrorMessage(err),
|
|
2214
|
+
};
|
|
2215
|
+
self.params.logger.warn(message, payload);
|
|
2216
|
+
console.warn(message, payload);
|
|
2217
|
+
lastError = err;
|
|
2218
|
+
await functoolsKit.sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
|
|
2219
|
+
}
|
|
2147
2220
|
}
|
|
2221
|
+
throw lastError;
|
|
2222
|
+
}
|
|
2223
|
+
finally {
|
|
2224
|
+
Candle.releaseLock(`ClientExchange GET_CANDLES_FN symbol=${dto.symbol} interval=${dto.interval} limit=${dto.limit}`);
|
|
2148
2225
|
}
|
|
2149
|
-
throw lastError;
|
|
2150
2226
|
};
|
|
2151
2227
|
/**
|
|
2152
2228
|
* Wrapper to call onCandleData callback with error handling.
|
|
@@ -2226,11 +2302,11 @@ class ClientExchange {
|
|
|
2226
2302
|
interval,
|
|
2227
2303
|
limit,
|
|
2228
2304
|
});
|
|
2229
|
-
const step = INTERVAL_MINUTES$
|
|
2305
|
+
const step = INTERVAL_MINUTES$7[interval];
|
|
2230
2306
|
if (!step) {
|
|
2231
2307
|
throw new Error(`ClientExchange unknown interval=${interval}`);
|
|
2232
2308
|
}
|
|
2233
|
-
const stepMs = step * MS_PER_MINUTE$
|
|
2309
|
+
const stepMs = step * MS_PER_MINUTE$5;
|
|
2234
2310
|
// Align when down to interval boundary
|
|
2235
2311
|
const whenTimestamp = this.params.execution.context.when.getTime();
|
|
2236
2312
|
const alignedWhen = ALIGN_TO_INTERVAL_FN$2(whenTimestamp, step);
|
|
@@ -2307,11 +2383,11 @@ class ClientExchange {
|
|
|
2307
2383
|
if (!this.params.execution.context.backtest) {
|
|
2308
2384
|
throw new Error(`ClientExchange getNextCandles: cannot fetch future candles in live mode`);
|
|
2309
2385
|
}
|
|
2310
|
-
const step = INTERVAL_MINUTES$
|
|
2386
|
+
const step = INTERVAL_MINUTES$7[interval];
|
|
2311
2387
|
if (!step) {
|
|
2312
2388
|
throw new Error(`ClientExchange getNextCandles: unknown interval=${interval}`);
|
|
2313
2389
|
}
|
|
2314
|
-
const stepMs = step * MS_PER_MINUTE$
|
|
2390
|
+
const stepMs = step * MS_PER_MINUTE$5;
|
|
2315
2391
|
const now = Date.now();
|
|
2316
2392
|
// Align when down to interval boundary
|
|
2317
2393
|
const whenTimestamp = this.params.execution.context.when.getTime();
|
|
@@ -2475,11 +2551,11 @@ class ClientExchange {
|
|
|
2475
2551
|
sDate,
|
|
2476
2552
|
eDate,
|
|
2477
2553
|
});
|
|
2478
|
-
const step = INTERVAL_MINUTES$
|
|
2554
|
+
const step = INTERVAL_MINUTES$7[interval];
|
|
2479
2555
|
if (!step) {
|
|
2480
2556
|
throw new Error(`ClientExchange getRawCandles: unknown interval=${interval}`);
|
|
2481
2557
|
}
|
|
2482
|
-
const stepMs = step * MS_PER_MINUTE$
|
|
2558
|
+
const stepMs = step * MS_PER_MINUTE$5;
|
|
2483
2559
|
const whenTimestamp = this.params.execution.context.when.getTime();
|
|
2484
2560
|
const alignedWhen = ALIGN_TO_INTERVAL_FN$2(whenTimestamp, step);
|
|
2485
2561
|
let sinceTimestamp;
|
|
@@ -2608,7 +2684,7 @@ class ClientExchange {
|
|
|
2608
2684
|
const alignedTo = ALIGN_TO_INTERVAL_FN$2(whenTimestamp, GLOBAL_CONFIG.CC_ORDER_BOOK_TIME_OFFSET_MINUTES);
|
|
2609
2685
|
const to = new Date(alignedTo);
|
|
2610
2686
|
const from = new Date(alignedTo -
|
|
2611
|
-
GLOBAL_CONFIG.CC_ORDER_BOOK_TIME_OFFSET_MINUTES * MS_PER_MINUTE$
|
|
2687
|
+
GLOBAL_CONFIG.CC_ORDER_BOOK_TIME_OFFSET_MINUTES * MS_PER_MINUTE$5);
|
|
2612
2688
|
return await this.params.getOrderBook(symbol, depth, from, to, this.params.execution.context.backtest);
|
|
2613
2689
|
}
|
|
2614
2690
|
}
|
|
@@ -3073,7 +3149,7 @@ const beginTime = (run) => (...args) => {
|
|
|
3073
3149
|
return fn();
|
|
3074
3150
|
};
|
|
3075
3151
|
|
|
3076
|
-
const INTERVAL_MINUTES$
|
|
3152
|
+
const INTERVAL_MINUTES$6 = {
|
|
3077
3153
|
"1m": 1,
|
|
3078
3154
|
"3m": 3,
|
|
3079
3155
|
"5m": 5,
|
|
@@ -3586,14 +3662,15 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
|
|
|
3586
3662
|
}
|
|
3587
3663
|
const currentTime = self.params.execution.context.when.getTime();
|
|
3588
3664
|
{
|
|
3589
|
-
const intervalMinutes = INTERVAL_MINUTES$
|
|
3665
|
+
const intervalMinutes = INTERVAL_MINUTES$6[self.params.interval];
|
|
3590
3666
|
const intervalMs = intervalMinutes * 60 * 1000;
|
|
3591
|
-
|
|
3667
|
+
const alignedTime = Math.floor(currentTime / intervalMs) * intervalMs;
|
|
3668
|
+
// Проверяем что наступил новый интервал (по aligned timestamp)
|
|
3592
3669
|
if (self._lastSignalTimestamp !== null &&
|
|
3593
|
-
|
|
3670
|
+
alignedTime === self._lastSignalTimestamp) {
|
|
3594
3671
|
return null;
|
|
3595
3672
|
}
|
|
3596
|
-
self._lastSignalTimestamp =
|
|
3673
|
+
self._lastSignalTimestamp = alignedTime;
|
|
3597
3674
|
}
|
|
3598
3675
|
const currentPrice = await self.params.exchange.getAveragePrice(self.params.execution.context.symbol);
|
|
3599
3676
|
const timeoutMs = GLOBAL_CONFIG.CC_MAX_SIGNAL_GENERATION_SECONDS * 1000;
|
|
@@ -8197,7 +8274,7 @@ class StrategyConnectionService {
|
|
|
8197
8274
|
* Maps FrameInterval to minutes for timestamp calculation.
|
|
8198
8275
|
* Used to generate timeframe arrays with proper spacing.
|
|
8199
8276
|
*/
|
|
8200
|
-
const INTERVAL_MINUTES$
|
|
8277
|
+
const INTERVAL_MINUTES$5 = {
|
|
8201
8278
|
"1m": 1,
|
|
8202
8279
|
"3m": 3,
|
|
8203
8280
|
"5m": 5,
|
|
@@ -8252,7 +8329,7 @@ const GET_TIMEFRAME_FN = async (symbol, self) => {
|
|
|
8252
8329
|
symbol,
|
|
8253
8330
|
});
|
|
8254
8331
|
const { interval, startDate, endDate } = self.params;
|
|
8255
|
-
const intervalMinutes = INTERVAL_MINUTES$
|
|
8332
|
+
const intervalMinutes = INTERVAL_MINUTES$5[interval];
|
|
8256
8333
|
if (!intervalMinutes) {
|
|
8257
8334
|
throw new Error(`ClientFrame unknown interval: ${interval}`);
|
|
8258
8335
|
}
|
|
@@ -13215,7 +13292,49 @@ class BacktestLogicPrivateService {
|
|
|
13215
13292
|
}
|
|
13216
13293
|
}
|
|
13217
13294
|
|
|
13218
|
-
const
|
|
13295
|
+
const EMITTER_CHECK_INTERVAL = 5000;
|
|
13296
|
+
const MS_PER_MINUTE$4 = 60000;
|
|
13297
|
+
const INTERVAL_MINUTES$4 = {
|
|
13298
|
+
"1m": 1,
|
|
13299
|
+
"3m": 3,
|
|
13300
|
+
"5m": 5,
|
|
13301
|
+
"15m": 15,
|
|
13302
|
+
"30m": 30,
|
|
13303
|
+
"1h": 60,
|
|
13304
|
+
"2h": 120,
|
|
13305
|
+
"4h": 240,
|
|
13306
|
+
"6h": 360,
|
|
13307
|
+
"8h": 480,
|
|
13308
|
+
};
|
|
13309
|
+
const createEmitter = functoolsKit.memoize(([interval]) => `${interval}`, (interval) => {
|
|
13310
|
+
const tickSubject = new functoolsKit.Subject();
|
|
13311
|
+
const intervalMs = INTERVAL_MINUTES$4[interval] * MS_PER_MINUTE$4;
|
|
13312
|
+
{
|
|
13313
|
+
let lastAligned = Math.floor(Date.now() / intervalMs) * intervalMs;
|
|
13314
|
+
functoolsKit.Source.fromInterval(EMITTER_CHECK_INTERVAL)
|
|
13315
|
+
.map(() => Math.floor(Date.now() / intervalMs) * intervalMs)
|
|
13316
|
+
.filter((aligned) => {
|
|
13317
|
+
if (aligned !== lastAligned) {
|
|
13318
|
+
lastAligned = aligned;
|
|
13319
|
+
return true;
|
|
13320
|
+
}
|
|
13321
|
+
return false;
|
|
13322
|
+
})
|
|
13323
|
+
.connect(tickSubject.next);
|
|
13324
|
+
}
|
|
13325
|
+
return tickSubject;
|
|
13326
|
+
});
|
|
13327
|
+
/**
|
|
13328
|
+
* Waits for the next candle interval to start and returns the timestamp of the new candle.
|
|
13329
|
+
* @param {CandleInterval} interval - The candle interval (e.g., "1m", "1h") to wait for.
|
|
13330
|
+
* @returns {Promise<number>} A promise that resolves with the timestamp (in milliseconds) of the next candle start.
|
|
13331
|
+
*/
|
|
13332
|
+
const waitForCandle = async (interval) => {
|
|
13333
|
+
const emitter = createEmitter(interval);
|
|
13334
|
+
// Subject is multicast
|
|
13335
|
+
return emitter.toPromise();
|
|
13336
|
+
};
|
|
13337
|
+
|
|
13219
13338
|
/**
|
|
13220
13339
|
* Private service for live trading orchestration using async generators.
|
|
13221
13340
|
*
|
|
@@ -13285,7 +13404,7 @@ class LiveLogicPrivateService {
|
|
|
13285
13404
|
message: functoolsKit.getErrorMessage(error),
|
|
13286
13405
|
});
|
|
13287
13406
|
await errorEmitter.next(error);
|
|
13288
|
-
await
|
|
13407
|
+
await waitForCandle("1m");
|
|
13289
13408
|
continue;
|
|
13290
13409
|
}
|
|
13291
13410
|
this.loggerService.info("liveLogicPrivateService tick result", {
|
|
@@ -13320,19 +13439,19 @@ class LiveLogicPrivateService {
|
|
|
13320
13439
|
});
|
|
13321
13440
|
break;
|
|
13322
13441
|
}
|
|
13323
|
-
await
|
|
13442
|
+
await waitForCandle("1m");
|
|
13324
13443
|
continue;
|
|
13325
13444
|
}
|
|
13326
13445
|
if (result.action === "active") {
|
|
13327
|
-
await
|
|
13446
|
+
await waitForCandle("1m");
|
|
13328
13447
|
continue;
|
|
13329
13448
|
}
|
|
13330
13449
|
if (result.action === "scheduled") {
|
|
13331
|
-
await
|
|
13450
|
+
await waitForCandle("1m");
|
|
13332
13451
|
continue;
|
|
13333
13452
|
}
|
|
13334
13453
|
if (result.action === "waiting") {
|
|
13335
|
-
await
|
|
13454
|
+
await waitForCandle("1m");
|
|
13336
13455
|
continue;
|
|
13337
13456
|
}
|
|
13338
13457
|
// Yield opened, closed, cancelled results
|
|
@@ -13351,7 +13470,7 @@ class LiveLogicPrivateService {
|
|
|
13351
13470
|
break;
|
|
13352
13471
|
}
|
|
13353
13472
|
}
|
|
13354
|
-
await
|
|
13473
|
+
await waitForCandle("1m");
|
|
13355
13474
|
}
|
|
13356
13475
|
}
|
|
13357
13476
|
}
|
|
@@ -26284,55 +26403,61 @@ class ExchangeInstance {
|
|
|
26284
26403
|
const sinceTimestamp = alignedWhen - limit * stepMs;
|
|
26285
26404
|
const since = new Date(sinceTimestamp);
|
|
26286
26405
|
const untilTimestamp = alignedWhen;
|
|
26287
|
-
|
|
26288
|
-
|
|
26289
|
-
|
|
26290
|
-
|
|
26291
|
-
|
|
26292
|
-
|
|
26293
|
-
|
|
26294
|
-
|
|
26295
|
-
|
|
26296
|
-
|
|
26297
|
-
|
|
26298
|
-
|
|
26299
|
-
const
|
|
26300
|
-
|
|
26301
|
-
|
|
26302
|
-
|
|
26303
|
-
|
|
26304
|
-
|
|
26305
|
-
|
|
26406
|
+
await Candle.acquireLock(`ExchangeInstance.getCandles symbol=${symbol} interval=${interval} limit=${limit}`);
|
|
26407
|
+
try {
|
|
26408
|
+
// Try to read from cache first
|
|
26409
|
+
const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
|
|
26410
|
+
if (cachedCandles !== null) {
|
|
26411
|
+
return cachedCandles;
|
|
26412
|
+
}
|
|
26413
|
+
let allData = [];
|
|
26414
|
+
// If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
|
|
26415
|
+
if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
|
|
26416
|
+
let remaining = limit;
|
|
26417
|
+
let currentSince = new Date(since.getTime());
|
|
26418
|
+
const isBacktest = await GET_BACKTEST_FN();
|
|
26419
|
+
while (remaining > 0) {
|
|
26420
|
+
const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
|
|
26421
|
+
const chunkData = await getCandles(symbol, interval, currentSince, chunkLimit, isBacktest);
|
|
26422
|
+
allData.push(...chunkData);
|
|
26423
|
+
remaining -= chunkLimit;
|
|
26424
|
+
if (remaining > 0) {
|
|
26425
|
+
// Move currentSince forward by the number of candles fetched
|
|
26426
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
|
|
26427
|
+
}
|
|
26306
26428
|
}
|
|
26307
26429
|
}
|
|
26430
|
+
else {
|
|
26431
|
+
const isBacktest = await GET_BACKTEST_FN();
|
|
26432
|
+
allData = await getCandles(symbol, interval, since, limit, isBacktest);
|
|
26433
|
+
}
|
|
26434
|
+
// Apply distinct by timestamp to remove duplicates
|
|
26435
|
+
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
26436
|
+
if (allData.length !== uniqueData.length) {
|
|
26437
|
+
bt.loggerService.warn(`ExchangeInstance getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
|
|
26438
|
+
}
|
|
26439
|
+
// Validate adapter returned data
|
|
26440
|
+
if (uniqueData.length === 0) {
|
|
26441
|
+
throw new Error(`ExchangeInstance getCandles: adapter returned empty array. ` +
|
|
26442
|
+
`Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
|
|
26443
|
+
}
|
|
26444
|
+
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
26445
|
+
throw new Error(`ExchangeInstance getCandles: first candle timestamp mismatch. ` +
|
|
26446
|
+
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
26447
|
+
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
26448
|
+
}
|
|
26449
|
+
if (uniqueData.length !== limit) {
|
|
26450
|
+
throw new Error(`ExchangeInstance getCandles: candle count mismatch. ` +
|
|
26451
|
+
`Expected ${limit} candles, got ${uniqueData.length}. ` +
|
|
26452
|
+
`Adapter must return exact number of candles requested.`);
|
|
26453
|
+
}
|
|
26454
|
+
// Write to cache after successful fetch
|
|
26455
|
+
await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
|
|
26456
|
+
return uniqueData;
|
|
26308
26457
|
}
|
|
26309
|
-
|
|
26310
|
-
|
|
26311
|
-
allData = await getCandles(symbol, interval, since, limit, isBacktest);
|
|
26312
|
-
}
|
|
26313
|
-
// Apply distinct by timestamp to remove duplicates
|
|
26314
|
-
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
26315
|
-
if (allData.length !== uniqueData.length) {
|
|
26316
|
-
bt.loggerService.warn(`ExchangeInstance getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
|
|
26317
|
-
}
|
|
26318
|
-
// Validate adapter returned data
|
|
26319
|
-
if (uniqueData.length === 0) {
|
|
26320
|
-
throw new Error(`ExchangeInstance getCandles: adapter returned empty array. ` +
|
|
26321
|
-
`Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
|
|
26322
|
-
}
|
|
26323
|
-
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
26324
|
-
throw new Error(`ExchangeInstance getCandles: first candle timestamp mismatch. ` +
|
|
26325
|
-
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
26326
|
-
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
26327
|
-
}
|
|
26328
|
-
if (uniqueData.length !== limit) {
|
|
26329
|
-
throw new Error(`ExchangeInstance getCandles: candle count mismatch. ` +
|
|
26330
|
-
`Expected ${limit} candles, got ${uniqueData.length}. ` +
|
|
26331
|
-
`Adapter must return exact number of candles requested.`);
|
|
26458
|
+
finally {
|
|
26459
|
+
Candle.releaseLock(`ExchangeInstance.getCandles symbol=${symbol} interval=${interval} limit=${limit}`);
|
|
26332
26460
|
}
|
|
26333
|
-
// Write to cache after successful fetch
|
|
26334
|
-
await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
|
|
26335
|
-
return uniqueData;
|
|
26336
26461
|
};
|
|
26337
26462
|
/**
|
|
26338
26463
|
* Calculates VWAP (Volume Weighted Average Price) from last N 1m candles.
|
|
@@ -26566,56 +26691,62 @@ class ExchangeInstance {
|
|
|
26566
26691
|
`Provide one of: (sDate+eDate+limit), (sDate+eDate), (eDate+limit), (sDate+limit), or (limit only). ` +
|
|
26567
26692
|
`Got: sDate=${sDate}, eDate=${eDate}, limit=${limit}`);
|
|
26568
26693
|
}
|
|
26569
|
-
|
|
26570
|
-
|
|
26571
|
-
|
|
26572
|
-
|
|
26573
|
-
|
|
26574
|
-
|
|
26575
|
-
|
|
26576
|
-
|
|
26577
|
-
|
|
26578
|
-
|
|
26579
|
-
|
|
26580
|
-
|
|
26581
|
-
|
|
26582
|
-
|
|
26583
|
-
|
|
26584
|
-
|
|
26585
|
-
|
|
26586
|
-
|
|
26587
|
-
|
|
26588
|
-
|
|
26589
|
-
|
|
26694
|
+
await Candle.acquireLock(`ExchangeInstance.getRawCandles symbol=${symbol} interval=${interval} limit=${calculatedLimit}`);
|
|
26695
|
+
try {
|
|
26696
|
+
// Try to read from cache first
|
|
26697
|
+
const untilTimestamp = sinceTimestamp + calculatedLimit * stepMs;
|
|
26698
|
+
const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit: calculatedLimit }, sinceTimestamp, untilTimestamp, this.exchangeName);
|
|
26699
|
+
if (cachedCandles !== null) {
|
|
26700
|
+
return cachedCandles;
|
|
26701
|
+
}
|
|
26702
|
+
// Fetch candles
|
|
26703
|
+
const since = new Date(sinceTimestamp);
|
|
26704
|
+
let allData = [];
|
|
26705
|
+
const isBacktest = await GET_BACKTEST_FN();
|
|
26706
|
+
const getCandles = this._methods.getCandles;
|
|
26707
|
+
if (calculatedLimit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
|
|
26708
|
+
let remaining = calculatedLimit;
|
|
26709
|
+
let currentSince = new Date(since.getTime());
|
|
26710
|
+
while (remaining > 0) {
|
|
26711
|
+
const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
|
|
26712
|
+
const chunkData = await getCandles(symbol, interval, currentSince, chunkLimit, isBacktest);
|
|
26713
|
+
allData.push(...chunkData);
|
|
26714
|
+
remaining -= chunkLimit;
|
|
26715
|
+
if (remaining > 0) {
|
|
26716
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
|
|
26717
|
+
}
|
|
26590
26718
|
}
|
|
26591
26719
|
}
|
|
26720
|
+
else {
|
|
26721
|
+
allData = await getCandles(symbol, interval, since, calculatedLimit, isBacktest);
|
|
26722
|
+
}
|
|
26723
|
+
// Apply distinct by timestamp to remove duplicates
|
|
26724
|
+
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
26725
|
+
if (allData.length !== uniqueData.length) {
|
|
26726
|
+
bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
|
|
26727
|
+
}
|
|
26728
|
+
// Validate adapter returned data
|
|
26729
|
+
if (uniqueData.length === 0) {
|
|
26730
|
+
throw new Error(`ExchangeInstance getRawCandles: adapter returned empty array. ` +
|
|
26731
|
+
`Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
|
|
26732
|
+
}
|
|
26733
|
+
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
26734
|
+
throw new Error(`ExchangeInstance getRawCandles: first candle timestamp mismatch. ` +
|
|
26735
|
+
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
26736
|
+
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
26737
|
+
}
|
|
26738
|
+
if (uniqueData.length !== calculatedLimit) {
|
|
26739
|
+
throw new Error(`ExchangeInstance getRawCandles: candle count mismatch. ` +
|
|
26740
|
+
`Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
|
|
26741
|
+
`Adapter must return exact number of candles requested.`);
|
|
26742
|
+
}
|
|
26743
|
+
// Write to cache after successful fetch
|
|
26744
|
+
await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);
|
|
26745
|
+
return uniqueData;
|
|
26592
26746
|
}
|
|
26593
|
-
|
|
26594
|
-
|
|
26595
|
-
}
|
|
26596
|
-
// Apply distinct by timestamp to remove duplicates
|
|
26597
|
-
const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
|
|
26598
|
-
if (allData.length !== uniqueData.length) {
|
|
26599
|
-
bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
|
|
26600
|
-
}
|
|
26601
|
-
// Validate adapter returned data
|
|
26602
|
-
if (uniqueData.length === 0) {
|
|
26603
|
-
throw new Error(`ExchangeInstance getRawCandles: adapter returned empty array. ` +
|
|
26604
|
-
`Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
|
|
26605
|
-
}
|
|
26606
|
-
if (uniqueData[0].timestamp !== sinceTimestamp) {
|
|
26607
|
-
throw new Error(`ExchangeInstance getRawCandles: first candle timestamp mismatch. ` +
|
|
26608
|
-
`Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
|
|
26609
|
-
`Adapter must return candles with timestamp=openTime, starting from aligned since.`);
|
|
26610
|
-
}
|
|
26611
|
-
if (uniqueData.length !== calculatedLimit) {
|
|
26612
|
-
throw new Error(`ExchangeInstance getRawCandles: candle count mismatch. ` +
|
|
26613
|
-
`Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
|
|
26614
|
-
`Adapter must return exact number of candles requested.`);
|
|
26747
|
+
finally {
|
|
26748
|
+
Candle.releaseLock(`ExchangeInstance.getRawCandles symbol=${symbol} interval=${interval} limit=${calculatedLimit}`);
|
|
26615
26749
|
}
|
|
26616
|
-
// Write to cache after successful fetch
|
|
26617
|
-
await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);
|
|
26618
|
-
return uniqueData;
|
|
26619
26750
|
};
|
|
26620
26751
|
const schema = bt.exchangeSchemaService.get(this.exchangeName);
|
|
26621
26752
|
this._methods = CREATE_EXCHANGE_INSTANCE_FN(schema);
|
|
@@ -37498,4 +37629,5 @@ exports.setConfig = setConfig;
|
|
|
37498
37629
|
exports.setLogger = setLogger;
|
|
37499
37630
|
exports.stopStrategy = stopStrategy;
|
|
37500
37631
|
exports.validate = validate;
|
|
37632
|
+
exports.waitForCandle = waitForCandle;
|
|
37501
37633
|
exports.warmCandles = warmCandles;
|