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 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$2;
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$7 = {
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$5 = 60000;
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$2] = functoolsKit.singleshot(async () => await BASE_WAIT_FOR_INIT_FN(this));
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$2 = BASE_WAIT_FOR_INIT_SYMBOL;
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$7[interval] * MS_PER_MINUTE$5;
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$7[interval] * MS_PER_MINUTE$5;
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
- const MS_PER_MINUTE$4 = 60000;
1944
- const INTERVAL_MINUTES$6 = {
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$4;
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$6[dto.interval];
2189
+ const step = INTERVAL_MINUTES$7[dto.interval];
2120
2190
  const sinceTimestamp = since.getTime();
2121
- const untilTimestamp = sinceTimestamp + dto.limit * step * MS_PER_MINUTE$4;
2122
- // Try to read from cache first
2123
- const cachedCandles = await READ_CANDLES_CACHE_FN$1(dto, sinceTimestamp, untilTimestamp, self);
2124
- if (cachedCandles !== null) {
2125
- return cachedCandles;
2126
- }
2127
- // Cache miss or error - fetch from API
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
- catch (err) {
2138
- const message = `ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}}`;
2139
- const payload = {
2140
- error: functoolsKit.errorData(err),
2141
- message: functoolsKit.getErrorMessage(err),
2142
- };
2143
- self.params.logger.warn(message, payload);
2144
- console.warn(message, payload);
2145
- lastError = err;
2146
- await functoolsKit.sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
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$6[interval];
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$4;
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$6[interval];
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$4;
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$6[interval];
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$4;
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$4);
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$5 = {
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$5[self.params.interval];
3665
+ const intervalMinutes = INTERVAL_MINUTES$6[self.params.interval];
3590
3666
  const intervalMs = intervalMinutes * 60 * 1000;
3591
- // Проверяем что прошел нужный интервал с последнего getSignal
3667
+ const alignedTime = Math.floor(currentTime / intervalMs) * intervalMs;
3668
+ // Проверяем что наступил новый интервал (по aligned timestamp)
3592
3669
  if (self._lastSignalTimestamp !== null &&
3593
- currentTime - self._lastSignalTimestamp < intervalMs) {
3670
+ alignedTime === self._lastSignalTimestamp) {
3594
3671
  return null;
3595
3672
  }
3596
- self._lastSignalTimestamp = currentTime;
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$4 = {
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$4[interval];
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 TICK_TTL = 1 * 60 * 1000 + 1;
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 functoolsKit.sleep(TICK_TTL);
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 functoolsKit.sleep(TICK_TTL);
13442
+ await waitForCandle("1m");
13324
13443
  continue;
13325
13444
  }
13326
13445
  if (result.action === "active") {
13327
- await functoolsKit.sleep(TICK_TTL);
13446
+ await waitForCandle("1m");
13328
13447
  continue;
13329
13448
  }
13330
13449
  if (result.action === "scheduled") {
13331
- await functoolsKit.sleep(TICK_TTL);
13450
+ await waitForCandle("1m");
13332
13451
  continue;
13333
13452
  }
13334
13453
  if (result.action === "waiting") {
13335
- await functoolsKit.sleep(TICK_TTL);
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 functoolsKit.sleep(TICK_TTL);
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
- // Try to read from cache first
26288
- const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
26289
- if (cachedCandles !== null) {
26290
- return cachedCandles;
26291
- }
26292
- let allData = [];
26293
- // If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
26294
- if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
26295
- let remaining = limit;
26296
- let currentSince = new Date(since.getTime());
26297
- const isBacktest = await GET_BACKTEST_FN();
26298
- while (remaining > 0) {
26299
- const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
26300
- const chunkData = await getCandles(symbol, interval, currentSince, chunkLimit, isBacktest);
26301
- allData.push(...chunkData);
26302
- remaining -= chunkLimit;
26303
- if (remaining > 0) {
26304
- // Move currentSince forward by the number of candles fetched
26305
- currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
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
- else {
26310
- const isBacktest = await GET_BACKTEST_FN();
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
- // Try to read from cache first
26570
- const untilTimestamp = sinceTimestamp + calculatedLimit * stepMs;
26571
- const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit: calculatedLimit }, sinceTimestamp, untilTimestamp, this.exchangeName);
26572
- if (cachedCandles !== null) {
26573
- return cachedCandles;
26574
- }
26575
- // Fetch candles
26576
- const since = new Date(sinceTimestamp);
26577
- let allData = [];
26578
- const isBacktest = await GET_BACKTEST_FN();
26579
- const getCandles = this._methods.getCandles;
26580
- if (calculatedLimit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
26581
- let remaining = calculatedLimit;
26582
- let currentSince = new Date(since.getTime());
26583
- while (remaining > 0) {
26584
- const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
26585
- const chunkData = await getCandles(symbol, interval, currentSince, chunkLimit, isBacktest);
26586
- allData.push(...chunkData);
26587
- remaining -= chunkLimit;
26588
- if (remaining > 0) {
26589
- currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
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
- else {
26594
- allData = await getCandles(symbol, interval, since, calculatedLimit, isBacktest);
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;