backtest-kit 3.0.18 → 3.1.1

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
@@ -441,6 +441,30 @@ Unlike cloud-based platforms, backtest-kit runs entirely in your environment. Yo
441
441
 
442
442
  The `backtest-kit` ecosystem extends beyond the core library, offering complementary packages and tools to enhance your trading system development experience:
443
443
 
444
+
445
+ ### @backtest-kit/cli
446
+
447
+ > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/cli)** 📟
448
+
449
+ The **@backtest-kit/cli** package is a zero-boilerplate CLI runner for backtest-kit strategies. Point it at your strategy file and run backtests, paper trading, or live bots — no infrastructure code required.
450
+
451
+ #### Key Features
452
+ - 🚀 **Zero Config**: Run a backtest with one command — no setup code needed
453
+ - 🔄 **Three Modes**: `--backtest`, `--paper`, `--live` with graceful SIGINT shutdown
454
+ - 💾 **Auto Cache**: Warms OHLCV candle cache for all intervals before the backtest starts
455
+ - 🌐 **Web Dashboard**: Launch `@backtest-kit/ui` with a single `--ui` flag
456
+ - 📬 **Telegram Alerts**: Formatted trade notifications with price charts via `--telegram`
457
+ - 🗂️ **Monorepo Ready**: Each strategy's `dump/`, `modules/`, and `template/` are automatically isolated by entry point directory
458
+
459
+ #### Use Case
460
+ The fastest way to run any backtest-kit strategy from the command line. Instead of writing boilerplate for storage, notifications, candle caching, and signal logging, add one dependency and wire up your `package.json` scripts. Works equally well for a single-strategy project or a monorepo with dozens of strategies in separate subdirectories.
461
+
462
+ #### Get Started
463
+ ```bash
464
+ npx -y @backtest-kit/cli --init
465
+ ```
466
+
467
+
444
468
  ### @backtest-kit/pinets
445
469
 
446
470
  > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/pinets)** 📜
@@ -531,9 +555,32 @@ npm install @backtest-kit/signals backtest-kit
531
555
  ```
532
556
 
533
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
+
580
+
534
581
  ### @backtest-kit/sidekick
535
582
 
536
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/sidekick)** 🧿
583
+ > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/sidekick)** 🚀
537
584
 
538
585
  The **@backtest-kit/sidekick** package is the easiest way to create a new Backtest Kit trading bot project. Like create-react-app, but for algorithmic trading.
539
586
 
@@ -555,6 +602,7 @@ cd my-trading-bot
555
602
  npm start
556
603
  ```
557
604
 
605
+
558
606
  ## 🤖 Are you a robot?
559
607
 
560
608
  **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,7 +719,7 @@ 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
725
  const INTERVAL_MINUTES$8 = {
@@ -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
  /**
@@ -1940,6 +1947,69 @@ class PersistNotificationUtils {
1940
1947
  */
1941
1948
  const PersistNotificationAdapter = new PersistNotificationUtils();
1942
1949
 
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
+
1943
2013
  const MS_PER_MINUTE$5 = 60000;
1944
2014
  const INTERVAL_MINUTES$7 = {
1945
2015
  "1m": 1,
@@ -2119,34 +2189,40 @@ const GET_CANDLES_FN = async (dto, since, self) => {
2119
2189
  const step = INTERVAL_MINUTES$7[dto.interval];
2120
2190
  const sinceTimestamp = since.getTime();
2121
2191
  const untilTimestamp = sinceTimestamp + dto.limit * step * MS_PER_MINUTE$5;
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;
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.
@@ -2829,8 +2905,9 @@ class ExchangeConnectionService {
2829
2905
  *
2830
2906
  * For signals with partial closes:
2831
2907
  * - Calculates weighted PNL: Σ(percent_i × pnl_i) for each partial + (remaining% × final_pnl)
2832
- * - Each partial close has its own fees and slippage
2833
- * - Total fees = 2 × (number of partial closes + 1 final close) × CC_PERCENT_FEE
2908
+ * - Each partial close has its own slippage
2909
+ * - Open fee is charged once; close fees are proportional to each partial's size
2910
+ * - Total fees = CC_PERCENT_FEE (open) + CC_PERCENT_FEE × 1 (closes sum to 100%) = 2 × CC_PERCENT_FEE
2834
2911
  *
2835
2912
  * Formula breakdown:
2836
2913
  * 1. Apply slippage to open/close prices (worse execution)
@@ -2877,7 +2954,8 @@ const toProfitLossDto = (signal, priceClose) => {
2877
2954
  // Calculate weighted PNL with partial closes
2878
2955
  if (signal._partial && signal._partial.length > 0) {
2879
2956
  let totalWeightedPnl = 0;
2880
- let totalFees = 0;
2957
+ // Open fee is paid once for the whole position
2958
+ let totalFees = GLOBAL_CONFIG.CC_PERCENT_FEE;
2881
2959
  // Calculate PNL for each partial close
2882
2960
  for (const partial of signal._partial) {
2883
2961
  const partialPercent = partial.percent;
@@ -2904,8 +2982,8 @@ const toProfitLossDto = (signal, priceClose) => {
2904
2982
  // Weight by percentage of position closed
2905
2983
  const weightedPnl = (partialPercent / 100) * partialPnl;
2906
2984
  totalWeightedPnl += weightedPnl;
2907
- // Each partial has fees for open + close (2 transactions)
2908
- totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
2985
+ // Close fee is proportional to the size of this partial
2986
+ totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * (partialPercent / 100);
2909
2987
  }
2910
2988
  // Calculate PNL for remaining position (if any)
2911
2989
  // Compute totalClosed from _partial array
@@ -2934,10 +3012,11 @@ const toProfitLossDto = (signal, priceClose) => {
2934
3012
  // Weight by remaining percentage
2935
3013
  const weightedRemainingPnl = (remainingPercent / 100) * remainingPnl;
2936
3014
  totalWeightedPnl += weightedRemainingPnl;
2937
- // Final close also has fees
2938
- totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
3015
+ // Close fee is proportional to the remaining size
3016
+ totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * (remainingPercent / 100);
2939
3017
  }
2940
3018
  // Subtract total fees from weighted PNL
3019
+ // totalFees = CC_PERCENT_FEE (open) + CC_PERCENT_FEE × 1 (all closes sum to 100%) = 2 × CC_PERCENT_FEE
2941
3020
  const pnlPercentage = totalWeightedPnl - totalFees;
2942
3021
  return {
2943
3022
  pnlPercentage,
@@ -26327,55 +26406,61 @@ class ExchangeInstance {
26327
26406
  const sinceTimestamp = alignedWhen - limit * stepMs;
26328
26407
  const since = new Date(sinceTimestamp);
26329
26408
  const untilTimestamp = alignedWhen;
26330
- // Try to read from cache first
26331
- const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
26332
- if (cachedCandles !== null) {
26333
- return cachedCandles;
26334
- }
26335
- let allData = [];
26336
- // If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
26337
- if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
26338
- let remaining = limit;
26339
- let currentSince = new Date(since.getTime());
26340
- const isBacktest = await GET_BACKTEST_FN();
26341
- while (remaining > 0) {
26342
- const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
26343
- const chunkData = await getCandles(symbol, interval, currentSince, chunkLimit, isBacktest);
26344
- allData.push(...chunkData);
26345
- remaining -= chunkLimit;
26346
- if (remaining > 0) {
26347
- // Move currentSince forward by the number of candles fetched
26348
- currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
26409
+ await Candle.acquireLock(`ExchangeInstance.getCandles symbol=${symbol} interval=${interval} limit=${limit}`);
26410
+ try {
26411
+ // Try to read from cache first
26412
+ const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
26413
+ if (cachedCandles !== null) {
26414
+ return cachedCandles;
26415
+ }
26416
+ let allData = [];
26417
+ // If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
26418
+ if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
26419
+ let remaining = limit;
26420
+ let currentSince = new Date(since.getTime());
26421
+ const isBacktest = await GET_BACKTEST_FN();
26422
+ while (remaining > 0) {
26423
+ const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
26424
+ const chunkData = await getCandles(symbol, interval, currentSince, chunkLimit, isBacktest);
26425
+ allData.push(...chunkData);
26426
+ remaining -= chunkLimit;
26427
+ if (remaining > 0) {
26428
+ // Move currentSince forward by the number of candles fetched
26429
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
26430
+ }
26349
26431
  }
26350
26432
  }
26433
+ else {
26434
+ const isBacktest = await GET_BACKTEST_FN();
26435
+ allData = await getCandles(symbol, interval, since, limit, isBacktest);
26436
+ }
26437
+ // Apply distinct by timestamp to remove duplicates
26438
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
26439
+ if (allData.length !== uniqueData.length) {
26440
+ bt.loggerService.warn(`ExchangeInstance getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
26441
+ }
26442
+ // Validate adapter returned data
26443
+ if (uniqueData.length === 0) {
26444
+ throw new Error(`ExchangeInstance getCandles: adapter returned empty array. ` +
26445
+ `Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
26446
+ }
26447
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
26448
+ throw new Error(`ExchangeInstance getCandles: first candle timestamp mismatch. ` +
26449
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
26450
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
26451
+ }
26452
+ if (uniqueData.length !== limit) {
26453
+ throw new Error(`ExchangeInstance getCandles: candle count mismatch. ` +
26454
+ `Expected ${limit} candles, got ${uniqueData.length}. ` +
26455
+ `Adapter must return exact number of candles requested.`);
26456
+ }
26457
+ // Write to cache after successful fetch
26458
+ await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
26459
+ return uniqueData;
26351
26460
  }
26352
- else {
26353
- const isBacktest = await GET_BACKTEST_FN();
26354
- allData = await getCandles(symbol, interval, since, limit, isBacktest);
26355
- }
26356
- // Apply distinct by timestamp to remove duplicates
26357
- const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
26358
- if (allData.length !== uniqueData.length) {
26359
- bt.loggerService.warn(`ExchangeInstance getCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
26360
- }
26361
- // Validate adapter returned data
26362
- if (uniqueData.length === 0) {
26363
- throw new Error(`ExchangeInstance getCandles: adapter returned empty array. ` +
26364
- `Expected ${limit} candles starting from openTime=${sinceTimestamp}.`);
26365
- }
26366
- if (uniqueData[0].timestamp !== sinceTimestamp) {
26367
- throw new Error(`ExchangeInstance getCandles: first candle timestamp mismatch. ` +
26368
- `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
26369
- `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
26370
- }
26371
- if (uniqueData.length !== limit) {
26372
- throw new Error(`ExchangeInstance getCandles: candle count mismatch. ` +
26373
- `Expected ${limit} candles, got ${uniqueData.length}. ` +
26374
- `Adapter must return exact number of candles requested.`);
26461
+ finally {
26462
+ Candle.releaseLock(`ExchangeInstance.getCandles symbol=${symbol} interval=${interval} limit=${limit}`);
26375
26463
  }
26376
- // Write to cache after successful fetch
26377
- await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
26378
- return uniqueData;
26379
26464
  };
26380
26465
  /**
26381
26466
  * Calculates VWAP (Volume Weighted Average Price) from last N 1m candles.
@@ -26609,56 +26694,62 @@ class ExchangeInstance {
26609
26694
  `Provide one of: (sDate+eDate+limit), (sDate+eDate), (eDate+limit), (sDate+limit), or (limit only). ` +
26610
26695
  `Got: sDate=${sDate}, eDate=${eDate}, limit=${limit}`);
26611
26696
  }
26612
- // Try to read from cache first
26613
- const untilTimestamp = sinceTimestamp + calculatedLimit * stepMs;
26614
- const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit: calculatedLimit }, sinceTimestamp, untilTimestamp, this.exchangeName);
26615
- if (cachedCandles !== null) {
26616
- return cachedCandles;
26617
- }
26618
- // Fetch candles
26619
- const since = new Date(sinceTimestamp);
26620
- let allData = [];
26621
- const isBacktest = await GET_BACKTEST_FN();
26622
- const getCandles = this._methods.getCandles;
26623
- if (calculatedLimit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
26624
- let remaining = calculatedLimit;
26625
- let currentSince = new Date(since.getTime());
26626
- while (remaining > 0) {
26627
- const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
26628
- const chunkData = await getCandles(symbol, interval, currentSince, chunkLimit, isBacktest);
26629
- allData.push(...chunkData);
26630
- remaining -= chunkLimit;
26631
- if (remaining > 0) {
26632
- currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
26697
+ await Candle.acquireLock(`ExchangeInstance.getRawCandles symbol=${symbol} interval=${interval} limit=${calculatedLimit}`);
26698
+ try {
26699
+ // Try to read from cache first
26700
+ const untilTimestamp = sinceTimestamp + calculatedLimit * stepMs;
26701
+ const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit: calculatedLimit }, sinceTimestamp, untilTimestamp, this.exchangeName);
26702
+ if (cachedCandles !== null) {
26703
+ return cachedCandles;
26704
+ }
26705
+ // Fetch candles
26706
+ const since = new Date(sinceTimestamp);
26707
+ let allData = [];
26708
+ const isBacktest = await GET_BACKTEST_FN();
26709
+ const getCandles = this._methods.getCandles;
26710
+ if (calculatedLimit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
26711
+ let remaining = calculatedLimit;
26712
+ let currentSince = new Date(since.getTime());
26713
+ while (remaining > 0) {
26714
+ const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
26715
+ const chunkData = await getCandles(symbol, interval, currentSince, chunkLimit, isBacktest);
26716
+ allData.push(...chunkData);
26717
+ remaining -= chunkLimit;
26718
+ if (remaining > 0) {
26719
+ currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
26720
+ }
26633
26721
  }
26634
26722
  }
26723
+ else {
26724
+ allData = await getCandles(symbol, interval, since, calculatedLimit, isBacktest);
26725
+ }
26726
+ // Apply distinct by timestamp to remove duplicates
26727
+ const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
26728
+ if (allData.length !== uniqueData.length) {
26729
+ bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
26730
+ }
26731
+ // Validate adapter returned data
26732
+ if (uniqueData.length === 0) {
26733
+ throw new Error(`ExchangeInstance getRawCandles: adapter returned empty array. ` +
26734
+ `Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
26735
+ }
26736
+ if (uniqueData[0].timestamp !== sinceTimestamp) {
26737
+ throw new Error(`ExchangeInstance getRawCandles: first candle timestamp mismatch. ` +
26738
+ `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
26739
+ `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
26740
+ }
26741
+ if (uniqueData.length !== calculatedLimit) {
26742
+ throw new Error(`ExchangeInstance getRawCandles: candle count mismatch. ` +
26743
+ `Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
26744
+ `Adapter must return exact number of candles requested.`);
26745
+ }
26746
+ // Write to cache after successful fetch
26747
+ await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);
26748
+ return uniqueData;
26635
26749
  }
26636
- else {
26637
- allData = await getCandles(symbol, interval, since, calculatedLimit, isBacktest);
26638
- }
26639
- // Apply distinct by timestamp to remove duplicates
26640
- const uniqueData = Array.from(new Map(allData.map((candle) => [candle.timestamp, candle])).values());
26641
- if (allData.length !== uniqueData.length) {
26642
- bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${allData.length - uniqueData.length} duplicate candles by timestamp`);
26643
- }
26644
- // Validate adapter returned data
26645
- if (uniqueData.length === 0) {
26646
- throw new Error(`ExchangeInstance getRawCandles: adapter returned empty array. ` +
26647
- `Expected ${calculatedLimit} candles starting from openTime=${sinceTimestamp}.`);
26648
- }
26649
- if (uniqueData[0].timestamp !== sinceTimestamp) {
26650
- throw new Error(`ExchangeInstance getRawCandles: first candle timestamp mismatch. ` +
26651
- `Expected openTime=${sinceTimestamp}, got=${uniqueData[0].timestamp}. ` +
26652
- `Adapter must return candles with timestamp=openTime, starting from aligned since.`);
26653
- }
26654
- if (uniqueData.length !== calculatedLimit) {
26655
- throw new Error(`ExchangeInstance getRawCandles: candle count mismatch. ` +
26656
- `Expected ${calculatedLimit} candles, got ${uniqueData.length}. ` +
26657
- `Adapter must return exact number of candles requested.`);
26750
+ finally {
26751
+ Candle.releaseLock(`ExchangeInstance.getRawCandles symbol=${symbol} interval=${interval} limit=${calculatedLimit}`);
26658
26752
  }
26659
- // Write to cache after successful fetch
26660
- await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);
26661
- return uniqueData;
26662
26753
  };
26663
26754
  const schema = bt.exchangeSchemaService.get(this.exchangeName);
26664
26755
  this._methods = CREATE_EXCHANGE_INSTANCE_FN(schema);
@@ -30145,6 +30236,115 @@ function listenStrategyCommitOnce(filterFn, fn) {
30145
30236
  return strategyCommitSubject.filter(filterFn).once(fn);
30146
30237
  }
30147
30238
 
30239
+ const WARN_KB = 30;
30240
+ const DUMP_MESSAGES_METHOD_NAME = "dump.dumpMessages";
30241
+ /**
30242
+ * Dumps chat history and result data to markdown files in a structured directory.
30243
+ *
30244
+ * Creates a subfolder named after `resultId` inside `outputDir`.
30245
+ * If the subfolder already exists, the function returns early without overwriting.
30246
+ * Writes:
30247
+ * - `00_system_prompt.md` — system messages and output data summary
30248
+ * - `NN_user_message.md` — each user message as a separate file
30249
+ * - `NN_llm_output.md` — final LLM output data
30250
+ *
30251
+ * Warns via logger if any user message exceeds 30 KB.
30252
+ *
30253
+ * @param resultId - Unique identifier for the result (used as subfolder name)
30254
+ * @param history - Full chat history containing system, user, and assistant messages
30255
+ * @param result - Structured output data to include in the dump
30256
+ * @param outputDir - Base directory for output files (default: `./dump/strategy`)
30257
+ * @returns Promise that resolves when all files are written
30258
+ *
30259
+ * @example
30260
+ * ```typescript
30261
+ * import { dumpMessages } from "backtest-kit";
30262
+ *
30263
+ * await dumpMessages("result-123", history, { profit: 42 });
30264
+ * ```
30265
+ */
30266
+ async function dumpMessages(resultId, history, result, outputDir = "./dump/strategy") {
30267
+ bt.loggerService.info(DUMP_MESSAGES_METHOD_NAME, {
30268
+ resultId,
30269
+ outputDir,
30270
+ });
30271
+ // Extract system messages and system reminders from existing data
30272
+ const systemMessages = history.filter((m) => m.role === "system");
30273
+ const userMessages = history.filter((m) => m.role === "user");
30274
+ const subfolderPath = path.join(outputDir, String(resultId));
30275
+ try {
30276
+ await fs.access(subfolderPath);
30277
+ return;
30278
+ }
30279
+ catch {
30280
+ await fs.mkdir(subfolderPath, { recursive: true });
30281
+ }
30282
+ {
30283
+ let summary = "# Outline Result Summary\n";
30284
+ {
30285
+ summary += "\n";
30286
+ summary += `**ResultId**: ${resultId}\n`;
30287
+ summary += "\n";
30288
+ }
30289
+ if (result) {
30290
+ summary += "## Output Data\n\n";
30291
+ summary += "```json\n";
30292
+ summary += JSON.stringify(result, null, 2);
30293
+ summary += "\n```\n\n";
30294
+ }
30295
+ // Add system messages to summary
30296
+ if (systemMessages.length > 0) {
30297
+ summary += "## System Messages\n\n";
30298
+ systemMessages.forEach((msg, idx) => {
30299
+ summary += `### System Message ${idx + 1}\n\n`;
30300
+ summary += msg.content;
30301
+ summary += "\n";
30302
+ });
30303
+ }
30304
+ const summaryFile = path.join(subfolderPath, "00_system_prompt.md");
30305
+ await fs.writeFile(summaryFile, summary, "utf8");
30306
+ }
30307
+ {
30308
+ await Promise.all(Array.from(userMessages.entries()).map(async ([idx, message]) => {
30309
+ const messageNum = String(idx + 1).padStart(2, "0");
30310
+ const contentFileName = `${messageNum}_user_message.md`;
30311
+ const contentFilePath = path.join(subfolderPath, contentFileName);
30312
+ {
30313
+ const messageSizeBytes = Buffer.byteLength(message.content, "utf8");
30314
+ const messageSizeKb = Math.floor(messageSizeBytes / 1024);
30315
+ if (messageSizeKb > WARN_KB) {
30316
+ console.warn(`User message ${idx + 1} is ${messageSizeBytes} bytes (${messageSizeKb}kb), which exceeds warning limit`);
30317
+ bt.loggerService.warn(DUMP_MESSAGES_METHOD_NAME, {
30318
+ resultId,
30319
+ messageIndex: idx + 1,
30320
+ messageSizeBytes,
30321
+ messageSizeKb,
30322
+ });
30323
+ }
30324
+ }
30325
+ let content = `# User Input ${idx + 1}\n\n`;
30326
+ content += `**ResultId**: ${resultId}\n\n`;
30327
+ content += message.content;
30328
+ content += "\n";
30329
+ await fs.writeFile(contentFilePath, content, "utf8");
30330
+ }));
30331
+ }
30332
+ {
30333
+ const messageNum = String(userMessages.length + 1).padStart(2, "0");
30334
+ const contentFileName = `${messageNum}_llm_output.md`;
30335
+ const contentFilePath = path.join(subfolderPath, contentFileName);
30336
+ let content = "# Full Outline Result\n\n";
30337
+ content += `**ResultId**: ${resultId}\n\n`;
30338
+ if (result) {
30339
+ content += "## Output Data\n\n";
30340
+ content += "```json\n";
30341
+ content += JSON.stringify(result, null, 2);
30342
+ content += "\n```\n";
30343
+ }
30344
+ await fs.writeFile(contentFilePath, content, "utf8");
30345
+ }
30346
+ }
30347
+
30148
30348
  const BACKTEST_METHOD_NAME_RUN = "BacktestUtils.run";
30149
30349
  const BACKTEST_METHOD_NAME_BACKGROUND = "BacktestUtils.background";
30150
30350
  const BACKTEST_METHOD_NAME_STOP = "BacktestUtils.stop";
@@ -37458,6 +37658,7 @@ exports.commitPartialLoss = commitPartialLoss;
37458
37658
  exports.commitPartialProfit = commitPartialProfit;
37459
37659
  exports.commitTrailingStop = commitTrailingStop;
37460
37660
  exports.commitTrailingTake = commitTrailingTake;
37661
+ exports.dumpMessages = dumpMessages;
37461
37662
  exports.emitters = emitters;
37462
37663
  exports.formatPrice = formatPrice;
37463
37664
  exports.formatQuantity = formatQuantity;