backtest-kit 2.1.1 → 2.1.3

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/build/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createActivator } from 'di-kit';
2
2
  import { scoped } from 'di-scoped';
3
- import { Subject, trycatch, errorData, getErrorMessage, sleep, memoize, makeExtendable, singleshot, not, retry, randomString, str, isObject, ToolRegistry, typo, and, resolveDocuments, timeout, TIMEOUT_SYMBOL as TIMEOUT_SYMBOL$1, compose, iterateDocuments, distinctDocuments, queued, singlerun } from 'functools-kit';
3
+ import { Subject, makeExtendable, singleshot, getErrorMessage, memoize, not, trycatch, retry, errorData, queued, sleep, randomString, str, isObject, ToolRegistry, typo, and, resolveDocuments, timeout, TIMEOUT_SYMBOL as TIMEOUT_SYMBOL$1, compose, iterateDocuments, distinctDocuments, singlerun } from 'functools-kit';
4
4
  import * as fs from 'fs/promises';
5
5
  import fs__default, { mkdir, writeFile } from 'fs/promises';
6
6
  import path, { join, dirname } from 'path';
@@ -565,1119 +565,899 @@ var emitters = /*#__PURE__*/Object.freeze({
565
565
  walkerStopSubject: walkerStopSubject
566
566
  });
567
567
 
568
- const INTERVAL_MINUTES$4 = {
569
- "1m": 1,
570
- "3m": 3,
571
- "5m": 5,
572
- "15m": 15,
573
- "30m": 30,
574
- "1h": 60,
575
- "2h": 120,
576
- "4h": 240,
577
- "6h": 360,
578
- "8h": 480,
579
- };
568
+ const IS_WINDOWS = os.platform() === "win32";
580
569
  /**
581
- * Validates that all candles have valid OHLCV data without anomalies.
582
- * Detects incomplete candles from Binance API by checking for abnormally low prices or volumes.
583
- * Incomplete candles often have prices like 0.1 instead of normal 100,000 or zero volume.
570
+ * Atomically writes data to a file, ensuring the operation either fully completes or leaves the original file unchanged.
571
+ * Uses a temporary file with a rename strategy on POSIX systems for atomicity, or direct writing with sync on Windows (or when POSIX rename is skipped).
584
572
  *
585
- * @param candles - Array of candle data to validate
586
- * @throws Error if any candles have anomalous OHLCV values
587
- */
588
- const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => {
589
- if (candles.length === 0) {
590
- return;
573
+ *
574
+ * @param {string} file - The file parameter.
575
+ * @param {string | Buffer} data - The data to be processed or validated.
576
+ * @param {Options | BufferEncoding} options - The options parameter (optional).
577
+ * @throws {Error} Throws an error if the write, sync, or rename operation fails, after attempting cleanup of temporary files.
578
+ *
579
+ * @example
580
+ * // Basic usage with default options
581
+ * await writeFileAtomic("output.txt", "Hello, world!");
582
+ * // Writes "Hello, world!" to "output.txt" atomically
583
+ *
584
+ * @example
585
+ * // Custom options and Buffer data
586
+ * const buffer = Buffer.from("Binary data");
587
+ * await writeFileAtomic("data.bin", buffer, { encoding: "binary", mode: 0o644, tmpPrefix: "temp-" });
588
+ * // Writes binary data to "data.bin" with custom permissions and temp prefix
589
+ *
590
+ * @example
591
+ * // Using encoding shorthand
592
+ * await writeFileAtomic("log.txt", "Log entry", "utf16le");
593
+ * // Writes "Log entry" to "log.txt" in UTF-16LE encoding
594
+ *
595
+ * @remarks
596
+ * This function ensures atomicity to prevent partial writes:
597
+ * - On POSIX systems (non-Windows, unless `GLOBAL_CONFIG.CC_SKIP_POSIX_RENAME` is true):
598
+ * - Writes data to a temporary file (e.g., `.tmp-<random>-filename`) in the same directory.
599
+ * - Uses `crypto.randomBytes` to generate a unique temporary name, reducing collision risk.
600
+ * - Syncs the data to disk and renames the temporary file to the target file atomically with `fs.rename`.
601
+ * - Cleans up the temporary file on failure, swallowing cleanup errors to prioritize throwing the original error.
602
+ * - On Windows (or when POSIX rename is skipped):
603
+ * - Writes directly to the target file, syncing data to disk to minimize corruption risk (though not fully atomic).
604
+ * - Closes the file handle on failure without additional cleanup.
605
+ * - Accepts `options` as an object or a string (interpreted as `encoding`), defaulting to `{ encoding: "utf8", mode: 0o666, tmpPrefix: ".tmp-" }`.
606
+ * Useful in the agent swarm system for safely writing configuration files, logs, or state data where partial writes could cause corruption.
607
+ *
608
+ * @see {@link https://nodejs.org/api/fs.html#fspromiseswritefilefile-data-options|fs.promises.writeFile} for file writing details.
609
+ * @see {@link https://nodejs.org/api/crypto.html#cryptorandombytessize-callback|crypto.randomBytes} for temporary file naming.
610
+ * @see {@link ../config/params|GLOBAL_CONFIG} for configuration impacting POSIX behavior.
611
+ */
612
+ async function writeFileAtomic(file, data, options = {}) {
613
+ if (typeof options === "string") {
614
+ options = { encoding: options };
591
615
  }
592
- // Calculate reference price (median or average depending on candle count)
593
- const allPrices = candles.flatMap((c) => [c.open, c.high, c.low, c.close]);
594
- const validPrices = allPrices.filter(p => p > 0);
595
- let referencePrice;
596
- if (candles.length >= GLOBAL_CONFIG.CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN) {
597
- // Use median for reliable statistics with enough data
598
- const sortedPrices = [...validPrices].sort((a, b) => a - b);
599
- referencePrice = sortedPrices[Math.floor(sortedPrices.length / 2)] || 0;
616
+ else if (!options) {
617
+ options = {};
600
618
  }
601
- else {
602
- // Use average for small datasets (more stable than median)
603
- const sum = validPrices.reduce((acc, p) => acc + p, 0);
604
- referencePrice = validPrices.length > 0 ? sum / validPrices.length : 0;
619
+ const { encoding = "utf8", mode = 0o666, tmpPrefix = ".tmp-" } = options;
620
+ let fileHandle = null;
621
+ if (IS_WINDOWS) {
622
+ try {
623
+ // Create and write to temporary file
624
+ fileHandle = await fs__default.open(file, "w", mode);
625
+ // Write data to the temp file
626
+ await fileHandle.writeFile(data, { encoding });
627
+ // Ensure data is flushed to disk
628
+ await fileHandle.sync();
629
+ // Close the file before rename
630
+ await fileHandle.close();
631
+ }
632
+ catch (error) {
633
+ // Clean up if something went wrong
634
+ if (fileHandle) {
635
+ await fileHandle.close().catch(() => { });
636
+ }
637
+ throw error; // Re-throw the original error
638
+ }
639
+ return;
605
640
  }
606
- if (referencePrice === 0) {
607
- throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: cannot calculate reference price (all prices are zero)`);
641
+ // Create a temporary filename in the same directory
642
+ const dir = path.dirname(file);
643
+ const filename = path.basename(file);
644
+ const tmpFile = path.join(dir, `${tmpPrefix}${crypto.randomBytes(6).toString("hex")}-${filename}`);
645
+ try {
646
+ // Create and write to temporary file
647
+ fileHandle = await fs__default.open(tmpFile, "w", mode);
648
+ // Write data to the temp file
649
+ await fileHandle.writeFile(data, { encoding });
650
+ // Ensure data is flushed to disk
651
+ await fileHandle.sync();
652
+ // Close the file before rename
653
+ await fileHandle.close();
654
+ fileHandle = null;
655
+ // Atomically replace the target file with our temp file
656
+ await fs__default.rename(tmpFile, file);
608
657
  }
609
- const minValidPrice = referencePrice / GLOBAL_CONFIG.CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR;
610
- for (let i = 0; i < candles.length; i++) {
611
- const candle = candles[i];
612
- // Check for invalid numeric values
613
- if (!Number.isFinite(candle.open) ||
614
- !Number.isFinite(candle.high) ||
615
- !Number.isFinite(candle.low) ||
616
- !Number.isFinite(candle.close) ||
617
- !Number.isFinite(candle.volume) ||
618
- !Number.isFinite(candle.timestamp)) {
619
- throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has invalid numeric values (NaN or Infinity)`);
658
+ catch (error) {
659
+ // Clean up if something went wrong
660
+ if (fileHandle) {
661
+ await fileHandle.close().catch(() => { });
620
662
  }
621
- // Check for negative values
622
- if (candle.open <= 0 ||
623
- candle.high <= 0 ||
624
- candle.low <= 0 ||
625
- candle.close <= 0 ||
626
- candle.volume < 0) {
627
- throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has zero or negative values`);
663
+ // Try to remove the temporary file
664
+ try {
665
+ await fs__default.unlink(tmpFile).catch(() => { });
628
666
  }
629
- // Check for anomalously low prices (incomplete candle indicator)
630
- if (candle.open < minValidPrice ||
631
- candle.high < minValidPrice ||
632
- candle.low < minValidPrice ||
633
- candle.close < minValidPrice) {
634
- throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has anomalously low price. ` +
635
- `OHLC: [${candle.open}, ${candle.high}, ${candle.low}, ${candle.close}], ` +
636
- `reference: ${referencePrice}, threshold: ${minValidPrice}`);
667
+ catch (_) {
668
+ // Ignore errors during cleanup
637
669
  }
670
+ throw error;
638
671
  }
639
- };
640
- /**
641
- * Retries the getCandles function with specified retry count and delay.
642
- * @param dto - Data transfer object containing symbol, interval, and limit
643
- * @param since - Date object representing the start time for fetching candles
644
- * @param self - Instance of ClientExchange
645
- * @returns Promise resolving to array of candle data
646
- */
647
- const GET_CANDLES_FN = async (dto, since, self) => {
648
- let lastError;
649
- for (let i = 0; i !== GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT; i++) {
672
+ }
673
+
674
+ var _a$2;
675
+ const BASE_WAIT_FOR_INIT_SYMBOL = Symbol("wait-for-init");
676
+ const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_PERSIST_SIGNAL_ADAPTER = "PersistSignalUtils.usePersistSignalAdapter";
677
+ const PERSIST_SIGNAL_UTILS_METHOD_NAME_READ_DATA = "PersistSignalUtils.readSignalData";
678
+ const PERSIST_SIGNAL_UTILS_METHOD_NAME_WRITE_DATA = "PersistSignalUtils.writeSignalData";
679
+ const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_JSON = "PersistSignalUtils.useJson";
680
+ const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_DUMMY = "PersistSignalUtils.useDummy";
681
+ const PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_PERSIST_SCHEDULE_ADAPTER = "PersistScheduleUtils.usePersistScheduleAdapter";
682
+ const PERSIST_SCHEDULE_UTILS_METHOD_NAME_READ_DATA = "PersistScheduleUtils.readScheduleData";
683
+ const PERSIST_SCHEDULE_UTILS_METHOD_NAME_WRITE_DATA = "PersistScheduleUtils.writeScheduleData";
684
+ const PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_JSON = "PersistScheduleUtils.useJson";
685
+ const PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_DUMMY = "PersistScheduleUtils.useDummy";
686
+ const PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_PERSIST_PARTIAL_ADAPTER = "PersistPartialUtils.usePersistPartialAdapter";
687
+ const PERSIST_PARTIAL_UTILS_METHOD_NAME_READ_DATA = "PersistPartialUtils.readPartialData";
688
+ const PERSIST_PARTIAL_UTILS_METHOD_NAME_WRITE_DATA = "PersistPartialUtils.writePartialData";
689
+ const PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_JSON = "PersistPartialUtils.useJson";
690
+ const PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_DUMMY = "PersistPartialUtils.useDummy";
691
+ const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_PERSIST_BREAKEVEN_ADAPTER = "PersistBreakevenUtils.usePersistBreakevenAdapter";
692
+ const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_READ_DATA = "PersistBreakevenUtils.readBreakevenData";
693
+ const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_WRITE_DATA = "PersistBreakevenUtils.writeBreakevenData";
694
+ const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_JSON = "PersistBreakevenUtils.useJson";
695
+ const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_DUMMY = "PersistBreakevenUtils.useDummy";
696
+ const PERSIST_RISK_UTILS_METHOD_NAME_USE_PERSIST_RISK_ADAPTER = "PersistRiskUtils.usePersistRiskAdapter";
697
+ const PERSIST_RISK_UTILS_METHOD_NAME_READ_DATA = "PersistRiskUtils.readPositionData";
698
+ const PERSIST_RISK_UTILS_METHOD_NAME_WRITE_DATA = "PersistRiskUtils.writePositionData";
699
+ const PERSIST_RISK_UTILS_METHOD_NAME_USE_JSON = "PersistRiskUtils.useJson";
700
+ const PERSIST_RISK_UTILS_METHOD_NAME_USE_DUMMY = "PersistRiskUtils.useDummy";
701
+ const PERSIST_BASE_METHOD_NAME_CTOR = "PersistBase.CTOR";
702
+ const PERSIST_BASE_METHOD_NAME_WAIT_FOR_INIT = "PersistBase.waitForInit";
703
+ const PERSIST_BASE_METHOD_NAME_READ_VALUE = "PersistBase.readValue";
704
+ const PERSIST_BASE_METHOD_NAME_WRITE_VALUE = "PersistBase.writeValue";
705
+ const PERSIST_BASE_METHOD_NAME_HAS_VALUE = "PersistBase.hasValue";
706
+ const PERSIST_BASE_METHOD_NAME_KEYS = "PersistBase.keys";
707
+ const BASE_WAIT_FOR_INIT_FN_METHOD_NAME = "PersistBase.waitForInitFn";
708
+ const BASE_UNLINK_RETRY_COUNT = 5;
709
+ const BASE_UNLINK_RETRY_DELAY = 1000;
710
+ const BASE_WAIT_FOR_INIT_FN = async (self) => {
711
+ bt.loggerService.debug(BASE_WAIT_FOR_INIT_FN_METHOD_NAME, {
712
+ entityName: self.entityName,
713
+ directory: self._directory,
714
+ });
715
+ await fs__default.mkdir(self._directory, { recursive: true });
716
+ for await (const key of self.keys()) {
650
717
  try {
651
- const result = await self.params.getCandles(dto.symbol, dto.interval, since, dto.limit, self.params.execution.context.backtest);
652
- VALIDATE_NO_INCOMPLETE_CANDLES_FN(result);
653
- return result;
718
+ await self.readValue(key);
654
719
  }
655
- catch (err) {
656
- const message = `ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}}`;
657
- const payload = {
658
- error: errorData(err),
659
- message: getErrorMessage(err),
660
- };
661
- self.params.logger.warn(message, payload);
662
- console.warn(message, payload);
663
- lastError = err;
664
- await sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
720
+ catch {
721
+ const filePath = self._getFilePath(key);
722
+ console.error(`backtest-kit PersistBase found invalid document for filePath=${filePath} entityName=${self.entityName}`);
723
+ if (await not(BASE_WAIT_FOR_INIT_UNLINK_FN(filePath))) {
724
+ console.error(`backtest-kit PersistBase failed to remove invalid document for filePath=${filePath} entityName=${self.entityName}`);
725
+ }
665
726
  }
666
727
  }
667
- throw lastError;
668
728
  };
669
- /**
670
- * Wrapper to call onCandleData callback with error handling.
671
- * Catches and logs any errors thrown by the user-provided callback.
672
- *
673
- * @param self - ClientExchange instance reference
674
- * @param symbol - Trading pair symbol
675
- * @param interval - Candle interval
676
- * @param since - Start date for candle data
677
- * @param limit - Number of candles
678
- * @param data - Array of candle data
679
- */
680
- const CALL_CANDLE_DATA_CALLBACKS_FN = trycatch(async (self, symbol, interval, since, limit, data) => {
681
- if (self.params.callbacks?.onCandleData) {
682
- await self.params.callbacks.onCandleData(symbol, interval, since, limit, data);
729
+ const BASE_WAIT_FOR_INIT_UNLINK_FN = async (filePath) => trycatch(retry(async () => {
730
+ try {
731
+ await fs__default.unlink(filePath);
732
+ return true;
683
733
  }
684
- }, {
685
- fallback: (error) => {
686
- const message = "ClientExchange CALL_CANDLE_DATA_CALLBACKS_FN thrown";
687
- const payload = {
688
- error: errorData(error),
689
- message: getErrorMessage(error),
690
- };
691
- bt.loggerService.warn(message, payload);
692
- console.warn(message, payload);
693
- errorEmitter.next(error);
694
- },
734
+ catch (error) {
735
+ console.error(`backtest-kit PersistBase unlink failed for filePath=${filePath} error=${getErrorMessage(error)}`);
736
+ throw error;
737
+ }
738
+ }, BASE_UNLINK_RETRY_COUNT, BASE_UNLINK_RETRY_DELAY), {
739
+ defaultValue: false,
695
740
  });
696
741
  /**
697
- * Client implementation for exchange data access.
742
+ * Base class for file-based persistence with atomic writes.
698
743
  *
699
744
  * Features:
700
- * - Historical candle fetching (backwards from execution context)
701
- * - Future candle fetching (forwards for backtest)
702
- * - VWAP calculation from last 5 1m candles
703
- * - Price/quantity formatting for exchange
704
- *
705
- * All methods use prototype functions for memory efficiency.
745
+ * - Atomic file writes using writeFileAtomic
746
+ * - Auto-validation and cleanup of corrupted files
747
+ * - Async generator support for iteration
748
+ * - Retry logic for file deletion
706
749
  *
707
750
  * @example
708
751
  * ```typescript
709
- * const exchange = new ClientExchange({
710
- * exchangeName: "binance",
711
- * getCandles: async (symbol, interval, since, limit) => [...],
712
- * formatPrice: async (symbol, price) => price.toFixed(2),
713
- * formatQuantity: async (symbol, quantity) => quantity.toFixed(8),
714
- * execution: executionService,
715
- * logger: loggerService,
716
- * });
717
- *
718
- * const candles = await exchange.getCandles("BTCUSDT", "1m", 100);
719
- * const vwap = await exchange.getAveragePrice("BTCUSDT");
752
+ * const persist = new PersistBase("my-entity", "./data");
753
+ * await persist.waitForInit(true);
754
+ * await persist.writeValue("key1", { data: "value" });
755
+ * const value = await persist.readValue("key1");
720
756
  * ```
721
757
  */
722
- class ClientExchange {
723
- constructor(params) {
724
- this.params = params;
725
- }
758
+ class PersistBase {
726
759
  /**
727
- * Fetches historical candles backwards from execution context time.
760
+ * Creates new persistence instance.
728
761
  *
729
- * @param symbol - Trading pair symbol
730
- * @param interval - Candle interval
731
- * @param limit - Number of candles to fetch
732
- * @returns Promise resolving to array of candles
762
+ * @param entityName - Unique entity type identifier
763
+ * @param baseDir - Base directory for all entities (default: ./dump/data)
733
764
  */
734
- async getCandles(symbol, interval, limit) {
735
- this.params.logger.debug(`ClientExchange getCandles`, {
736
- symbol,
737
- interval,
738
- limit,
765
+ constructor(entityName, baseDir = join(process.cwd(), "logs/data")) {
766
+ this.entityName = entityName;
767
+ this.baseDir = baseDir;
768
+ this[_a$2] = singleshot(async () => await BASE_WAIT_FOR_INIT_FN(this));
769
+ bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_CTOR, {
770
+ entityName: this.entityName,
771
+ baseDir,
739
772
  });
740
- const step = INTERVAL_MINUTES$4[interval];
741
- const adjust = step * limit;
742
- if (!adjust) {
743
- throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
744
- }
745
- const since = new Date(this.params.execution.context.when.getTime() - adjust * 60 * 1000);
746
- let allData = [];
747
- // If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
748
- if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
749
- let remaining = limit;
750
- let currentSince = new Date(since.getTime());
751
- while (remaining > 0) {
752
- const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
753
- const chunkData = await GET_CANDLES_FN({ symbol, interval, limit: chunkLimit }, currentSince, this);
754
- allData.push(...chunkData);
755
- remaining -= chunkLimit;
756
- if (remaining > 0) {
757
- // Move currentSince forward by the number of candles fetched
758
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * 60 * 1000);
759
- }
760
- }
761
- }
762
- else {
763
- allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
764
- }
765
- // Filter candles to strictly match the requested range
766
- const whenTimestamp = this.params.execution.context.when.getTime();
767
- const sinceTimestamp = since.getTime();
768
- const stepMs = step * 60 * 1000;
769
- const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp < whenTimestamp + stepMs);
770
- // Apply distinct by timestamp to remove duplicates
771
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
772
- if (filteredData.length !== uniqueData.length) {
773
- const msg = `ClientExchange Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
774
- this.params.logger.warn(msg);
775
- console.warn(msg);
776
- }
777
- if (uniqueData.length < limit) {
778
- const msg = `ClientExchange Expected ${limit} candles, got ${uniqueData.length}`;
779
- this.params.logger.warn(msg);
780
- console.warn(msg);
781
- }
782
- await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
783
- return uniqueData;
773
+ this._directory = join(this.baseDir, this.entityName);
784
774
  }
785
775
  /**
786
- * Fetches future candles forwards from execution context time.
787
- * Used in backtest mode to get candles for signal duration.
776
+ * Computes file path for entity ID.
788
777
  *
789
- * @param symbol - Trading pair symbol
790
- * @param interval - Candle interval
791
- * @param limit - Number of candles to fetch
792
- * @returns Promise resolving to array of candles
793
- * @throws Error if trying to fetch future candles in live mode
778
+ * @param entityId - Entity identifier
779
+ * @returns Full file path to entity JSON file
794
780
  */
795
- async getNextCandles(symbol, interval, limit) {
796
- this.params.logger.debug(`ClientExchange getNextCandles`, {
797
- symbol,
798
- interval,
799
- limit,
781
+ _getFilePath(entityId) {
782
+ return join(this.baseDir, this.entityName, `${entityId}.json`);
783
+ }
784
+ async waitForInit(initial) {
785
+ bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_WAIT_FOR_INIT, {
786
+ entityName: this.entityName,
787
+ initial,
800
788
  });
801
- const since = new Date(this.params.execution.context.when.getTime());
802
- const now = Date.now();
803
- // Вычисляем конечное время запроса
804
- const step = INTERVAL_MINUTES$4[interval];
805
- const endTime = since.getTime() + limit * step * 60 * 1000;
806
- // Проверяем что запрошенный период не заходит за Date.now()
807
- if (endTime > now) {
808
- return [];
789
+ await this[BASE_WAIT_FOR_INIT_SYMBOL]();
790
+ }
791
+ async readValue(entityId) {
792
+ bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_READ_VALUE, {
793
+ entityName: this.entityName,
794
+ entityId,
795
+ });
796
+ try {
797
+ const filePath = this._getFilePath(entityId);
798
+ const fileContent = await fs__default.readFile(filePath, "utf-8");
799
+ return JSON.parse(fileContent);
809
800
  }
810
- let allData = [];
811
- // If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
812
- if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
813
- let remaining = limit;
814
- let currentSince = new Date(since.getTime());
815
- while (remaining > 0) {
816
- const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
817
- const chunkData = await GET_CANDLES_FN({ symbol, interval, limit: chunkLimit }, currentSince, this);
818
- allData.push(...chunkData);
819
- remaining -= chunkLimit;
820
- if (remaining > 0) {
821
- // Move currentSince forward by the number of candles fetched
822
- currentSince = new Date(currentSince.getTime() + chunkLimit * step * 60 * 1000);
823
- }
801
+ catch (error) {
802
+ if (error?.code === "ENOENT") {
803
+ throw new Error(`Entity ${this.entityName}:${entityId} not found`);
824
804
  }
805
+ throw new Error(`Failed to read entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`);
825
806
  }
826
- else {
827
- allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
828
- }
829
- // Filter candles to strictly match the requested range
830
- const sinceTimestamp = since.getTime();
831
- const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp < endTime);
832
- // Apply distinct by timestamp to remove duplicates
833
- const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
834
- if (filteredData.length !== uniqueData.length) {
835
- const msg = `ClientExchange getNextCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
836
- this.params.logger.warn(msg);
837
- console.warn(msg);
807
+ }
808
+ async hasValue(entityId) {
809
+ bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_HAS_VALUE, {
810
+ entityName: this.entityName,
811
+ entityId,
812
+ });
813
+ try {
814
+ const filePath = this._getFilePath(entityId);
815
+ await fs__default.access(filePath);
816
+ return true;
838
817
  }
839
- if (uniqueData.length < limit) {
840
- const msg = `ClientExchange getNextCandles: Expected ${limit} candles, got ${uniqueData.length}`;
841
- this.params.logger.warn(msg);
842
- console.warn(msg);
818
+ catch (error) {
819
+ if (error?.code === "ENOENT") {
820
+ return false;
821
+ }
822
+ throw new Error(`Failed to check existence of entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`);
843
823
  }
844
- await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
845
- return uniqueData;
846
824
  }
847
- /**
848
- * Calculates VWAP (Volume Weighted Average Price) from last N 1m candles.
849
- * The number of candles is configurable via GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT.
850
- *
851
- * Formula:
852
- * - Typical Price = (high + low + close) / 3
853
- * - VWAP = sum(typical_price * volume) / sum(volume)
854
- *
855
- * If volume is zero, returns simple average of close prices.
856
- *
857
- * @param symbol - Trading pair symbol
858
- * @returns Promise resolving to VWAP price
859
- * @throws Error if no candles available
860
- */
861
- async getAveragePrice(symbol) {
862
- this.params.logger.debug(`ClientExchange getAveragePrice`, {
863
- symbol,
825
+ async writeValue(entityId, entity) {
826
+ bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_WRITE_VALUE, {
827
+ entityName: this.entityName,
828
+ entityId,
864
829
  });
865
- const candles = await this.getCandles(symbol, "1m", GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT);
866
- if (candles.length === 0) {
867
- throw new Error(`ClientExchange getAveragePrice: no candles data for symbol=${symbol}`);
830
+ try {
831
+ const filePath = this._getFilePath(entityId);
832
+ const serializedData = JSON.stringify(entity);
833
+ await writeFileAtomic(filePath, serializedData, "utf-8");
868
834
  }
869
- // VWAP (Volume Weighted Average Price)
870
- // Используем типичную цену (typical price) = (high + low + close) / 3
871
- const sumPriceVolume = candles.reduce((acc, candle) => {
872
- const typicalPrice = (candle.high + candle.low + candle.close) / 3;
873
- return acc + typicalPrice * candle.volume;
874
- }, 0);
875
- const totalVolume = candles.reduce((acc, candle) => acc + candle.volume, 0);
876
- if (totalVolume === 0) {
877
- // Если объем нулевой, возвращаем простое среднее close цен
878
- const sum = candles.reduce((acc, candle) => acc + candle.close, 0);
879
- return sum / candles.length;
835
+ catch (error) {
836
+ throw new Error(`Failed to write entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`);
880
837
  }
881
- const vwap = sumPriceVolume / totalVolume;
882
- return vwap;
883
838
  }
884
839
  /**
885
- * Formats quantity according to exchange-specific rules for the given symbol.
886
- * Applies proper decimal precision and rounding based on symbol's lot size filters.
840
+ * Async generator yielding all entity IDs.
841
+ * Sorted alphanumerically.
842
+ * Used internally by waitForInit for validation.
887
843
  *
888
- * @param symbol - Trading pair symbol
889
- * @param quantity - Raw quantity to format
890
- * @returns Promise resolving to formatted quantity as string
844
+ * @returns AsyncGenerator yielding entity IDs
845
+ * @throws Error if reading fails
891
846
  */
892
- async formatQuantity(symbol, quantity) {
893
- this.params.logger.debug("binanceService formatQuantity", {
894
- symbol,
895
- quantity,
847
+ async *keys() {
848
+ bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_KEYS, {
849
+ entityName: this.entityName,
896
850
  });
897
- return await this.params.formatQuantity(symbol, quantity, this.params.execution.context.backtest);
898
- }
899
- /**
900
- * Formats price according to exchange-specific rules for the given symbol.
901
- * Applies proper decimal precision and rounding based on symbol's price filters.
902
- *
903
- * @param symbol - Trading pair symbol
904
- * @param price - Raw price to format
905
- * @returns Promise resolving to formatted price as string
851
+ try {
852
+ const files = await fs__default.readdir(this._directory);
853
+ const entityIds = files
854
+ .filter((file) => file.endsWith(".json"))
855
+ .map((file) => file.slice(0, -5))
856
+ .sort((a, b) => a.localeCompare(b, undefined, {
857
+ numeric: true,
858
+ sensitivity: "base",
859
+ }));
860
+ for (const entityId of entityIds) {
861
+ yield entityId;
862
+ }
863
+ }
864
+ catch (error) {
865
+ throw new Error(`Failed to read keys for ${this.entityName}: ${getErrorMessage(error)}`);
866
+ }
867
+ }
868
+ }
869
+ _a$2 = BASE_WAIT_FOR_INIT_SYMBOL;
870
+ // @ts-ignore
871
+ PersistBase = makeExtendable(PersistBase);
872
+ /**
873
+ * Dummy persist adapter that discards all writes.
874
+ * Used for disabling persistence.
875
+ */
876
+ class PersistDummy {
877
+ /**
878
+ * No-op initialization function.
879
+ * @returns Promise that resolves immediately
906
880
  */
907
- async formatPrice(symbol, price) {
908
- this.params.logger.debug("binanceService formatPrice", {
909
- symbol,
910
- price,
911
- });
912
- return await this.params.formatPrice(symbol, price, this.params.execution.context.backtest);
881
+ async waitForInit() {
913
882
  }
914
883
  /**
915
- * Fetches order book for a trading pair.
916
- *
917
- * Calculates time range based on execution context time (when) and
918
- * CC_ORDER_BOOK_TIME_OFFSET_MINUTES, then delegates to the exchange
919
- * schema implementation which may use or ignore the time range.
920
- *
921
- * @param symbol - Trading pair symbol
922
- * @param depth - Maximum depth levels (default: CC_ORDER_BOOK_MAX_DEPTH_LEVELS)
923
- * @returns Promise resolving to order book data
924
- * @throws Error if getOrderBook is not implemented
884
+ * No-op read function.
885
+ * @returns Promise that resolves with empty object
925
886
  */
926
- async getOrderBook(symbol, depth = GLOBAL_CONFIG.CC_ORDER_BOOK_MAX_DEPTH_LEVELS) {
927
- this.params.logger.debug("ClientExchange getOrderBook", {
928
- symbol,
929
- depth,
930
- });
931
- const to = new Date(this.params.execution.context.when.getTime());
932
- const from = new Date(to.getTime() - GLOBAL_CONFIG.CC_ORDER_BOOK_TIME_OFFSET_MINUTES * 60 * 1000);
933
- return await this.params.getOrderBook(symbol, depth, from, to, this.params.execution.context.backtest);
887
+ async readValue() {
888
+ return {};
889
+ }
890
+ /**
891
+ * No-op has value check.
892
+ * @returns Promise that resolves to false
893
+ */
894
+ async hasValue() {
895
+ return false;
896
+ }
897
+ /**
898
+ * No-op write function.
899
+ * @returns Promise that resolves immediately
900
+ */
901
+ async writeValue() {
902
+ }
903
+ /**
904
+ * No-op keys generator.
905
+ * @returns Empty async generator
906
+ */
907
+ async *keys() {
908
+ // Empty generator - no keys
934
909
  }
935
910
  }
936
-
937
- /**
938
- * Default implementation for getCandles.
939
- * Throws an error indicating the method is not implemented.
940
- */
941
- const DEFAULT_GET_CANDLES_FN$1 = async (_symbol, _interval, _since, _limit, _backtest) => {
942
- throw new Error(`getCandles is not implemented for this exchange`);
943
- };
944
- /**
945
- * Default implementation for formatQuantity.
946
- * Returns Bitcoin precision on Binance (8 decimal places).
947
- */
948
- const DEFAULT_FORMAT_QUANTITY_FN$1 = async (_symbol, quantity, _backtest) => {
949
- return quantity.toFixed(8);
950
- };
951
- /**
952
- * Default implementation for formatPrice.
953
- * Returns Bitcoin precision on Binance (2 decimal places).
954
- */
955
- const DEFAULT_FORMAT_PRICE_FN$1 = async (_symbol, price, _backtest) => {
956
- return price.toFixed(2);
957
- };
958
- /**
959
- * Default implementation for getOrderBook.
960
- * Throws an error indicating the method is not implemented.
961
- *
962
- * @param _symbol - Trading pair symbol (unused)
963
- * @param _depth - Maximum depth levels (unused)
964
- * @param _from - Start of time range (unused - can be ignored in live implementations)
965
- * @param _to - End of time range (unused - can be ignored in live implementations)
966
- * @param _backtest - Whether running in backtest mode (unused)
967
- */
968
- const DEFAULT_GET_ORDER_BOOK_FN$1 = async (_symbol, _depth, _from, _to, _backtest) => {
969
- throw new Error(`getOrderBook is not implemented for this exchange`);
970
- };
971
911
  /**
972
- * Connection service routing exchange operations to correct ClientExchange instance.
973
- *
974
- * Routes all IExchange method calls to the appropriate exchange implementation
975
- * based on methodContextService.context.exchangeName. Uses memoization to cache
976
- * ClientExchange instances for performance.
912
+ * Utility class for managing signal persistence.
977
913
  *
978
- * Key features:
979
- * - Automatic exchange routing via method context
980
- * - Memoized ClientExchange instances by exchangeName
981
- * - Implements full IExchange interface
982
- * - Logging for all operations
914
+ * Features:
915
+ * - Memoized storage instances per strategy
916
+ * - Custom adapter support
917
+ * - Atomic read/write operations
918
+ * - Crash-safe signal state management
983
919
  *
984
- * @example
985
- * ```typescript
986
- * // Used internally by framework
987
- * const candles = await exchangeConnectionService.getCandles(
988
- * "BTCUSDT", "1h", 100
989
- * );
990
- * // Automatically routes to correct exchange based on methodContext
991
- * ```
920
+ * Used by ClientStrategy for live mode persistence.
992
921
  */
993
- class ExchangeConnectionService {
922
+ class PersistSignalUtils {
994
923
  constructor() {
995
- this.loggerService = inject(TYPES.loggerService);
996
- this.executionContextService = inject(TYPES.executionContextService);
997
- this.exchangeSchemaService = inject(TYPES.exchangeSchemaService);
998
- this.methodContextService = inject(TYPES.methodContextService);
999
- /**
1000
- * Retrieves memoized ClientExchange instance for given exchange name.
1001
- *
1002
- * Creates ClientExchange on first call, returns cached instance on subsequent calls.
1003
- * Cache key is exchangeName string.
1004
- *
1005
- * @param exchangeName - Name of registered exchange schema
1006
- * @returns Configured ClientExchange instance
1007
- */
1008
- this.getExchange = memoize(([exchangeName]) => `${exchangeName}`, (exchangeName) => {
1009
- const { getCandles = DEFAULT_GET_CANDLES_FN$1, formatPrice = DEFAULT_FORMAT_PRICE_FN$1, formatQuantity = DEFAULT_FORMAT_QUANTITY_FN$1, getOrderBook = DEFAULT_GET_ORDER_BOOK_FN$1, callbacks } = this.exchangeSchemaService.get(exchangeName);
1010
- return new ClientExchange({
1011
- execution: this.executionContextService,
1012
- logger: this.loggerService,
1013
- exchangeName,
1014
- getCandles,
1015
- formatPrice,
1016
- formatQuantity,
1017
- getOrderBook,
1018
- callbacks,
1019
- });
1020
- });
924
+ this.PersistSignalFactory = PersistBase;
925
+ this.getSignalStorage = memoize(([symbol, strategyName, exchangeName]) => `${symbol}:${strategyName}:${exchangeName}`, (symbol, strategyName, exchangeName) => Reflect.construct(this.PersistSignalFactory, [
926
+ `${symbol}_${strategyName}_${exchangeName}`,
927
+ `./dump/data/signal/`,
928
+ ]));
1021
929
  /**
1022
- * Fetches historical candles for symbol using configured exchange.
930
+ * Reads persisted signal data for a symbol and strategy.
1023
931
  *
1024
- * Routes to exchange determined by methodContextService.context.exchangeName.
932
+ * Called by ClientStrategy.waitForInit() to restore state.
933
+ * Returns null if no signal exists.
1025
934
  *
1026
- * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
1027
- * @param interval - Candle interval (e.g., "1h", "1d")
1028
- * @param limit - Maximum number of candles to fetch
1029
- * @returns Promise resolving to array of candle data
935
+ * @param symbol - Trading pair symbol
936
+ * @param strategyName - Strategy identifier
937
+ * @param exchangeName - Exchange identifier
938
+ * @returns Promise resolving to signal or null
1030
939
  */
1031
- this.getCandles = async (symbol, interval, limit) => {
1032
- this.loggerService.log("exchangeConnectionService getCandles", {
1033
- symbol,
1034
- interval,
1035
- limit,
1036
- });
1037
- return await this.getExchange(this.methodContextService.context.exchangeName).getCandles(symbol, interval, limit);
940
+ this.readSignalData = async (symbol, strategyName, exchangeName) => {
941
+ bt.loggerService.info(PERSIST_SIGNAL_UTILS_METHOD_NAME_READ_DATA);
942
+ const key = `${symbol}:${strategyName}:${exchangeName}`;
943
+ const isInitial = !this.getSignalStorage.has(key);
944
+ const stateStorage = this.getSignalStorage(symbol, strategyName, exchangeName);
945
+ await stateStorage.waitForInit(isInitial);
946
+ if (await stateStorage.hasValue(symbol)) {
947
+ return await stateStorage.readValue(symbol);
948
+ }
949
+ return null;
1038
950
  };
1039
951
  /**
1040
- * Fetches next batch of candles relative to executionContext.when.
952
+ * Writes signal data to disk with atomic file writes.
1041
953
  *
1042
- * Returns candles that come after the current execution timestamp.
1043
- * Used for backtest progression and live trading updates.
954
+ * Called by ClientStrategy.setPendingSignal() to persist state.
955
+ * Uses atomic writes to prevent corruption on crashes.
1044
956
  *
1045
- * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
1046
- * @param interval - Candle interval (e.g., "1h", "1d")
1047
- * @param limit - Maximum number of candles to fetch
1048
- * @returns Promise resolving to array of candle data
957
+ * @param signalRow - Signal data (null to clear)
958
+ * @param symbol - Trading pair symbol
959
+ * @param strategyName - Strategy identifier
960
+ * @param exchangeName - Exchange identifier
961
+ * @returns Promise that resolves when write is complete
1049
962
  */
1050
- this.getNextCandles = async (symbol, interval, limit) => {
1051
- this.loggerService.log("exchangeConnectionService getNextCandles", {
1052
- symbol,
1053
- interval,
1054
- limit,
1055
- });
1056
- return await this.getExchange(this.methodContextService.context.exchangeName).getNextCandles(symbol, interval, limit);
963
+ this.writeSignalData = async (signalRow, symbol, strategyName, exchangeName) => {
964
+ bt.loggerService.info(PERSIST_SIGNAL_UTILS_METHOD_NAME_WRITE_DATA);
965
+ const key = `${symbol}:${strategyName}:${exchangeName}`;
966
+ const isInitial = !this.getSignalStorage.has(key);
967
+ const stateStorage = this.getSignalStorage(symbol, strategyName, exchangeName);
968
+ await stateStorage.waitForInit(isInitial);
969
+ await stateStorage.writeValue(symbol, signalRow);
1057
970
  };
971
+ }
972
+ /**
973
+ * Registers a custom persistence adapter.
974
+ *
975
+ * @param Ctor - Custom PersistBase constructor
976
+ *
977
+ * @example
978
+ * ```typescript
979
+ * class RedisPersist extends PersistBase {
980
+ * async readValue(id) { return JSON.parse(await redis.get(id)); }
981
+ * async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
982
+ * }
983
+ * PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
984
+ * ```
985
+ */
986
+ usePersistSignalAdapter(Ctor) {
987
+ bt.loggerService.info(PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_PERSIST_SIGNAL_ADAPTER);
988
+ this.PersistSignalFactory = Ctor;
989
+ }
990
+ /**
991
+ * Switches to the default JSON persist adapter.
992
+ * All future persistence writes will use JSON storage.
993
+ */
994
+ useJson() {
995
+ bt.loggerService.log(PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_JSON);
996
+ this.usePersistSignalAdapter(PersistBase);
997
+ }
998
+ /**
999
+ * Switches to a dummy persist adapter that discards all writes.
1000
+ * All future persistence writes will be no-ops.
1001
+ */
1002
+ useDummy() {
1003
+ bt.loggerService.log(PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_DUMMY);
1004
+ this.usePersistSignalAdapter(PersistDummy);
1005
+ }
1006
+ }
1007
+ /**
1008
+ * Global singleton instance of PersistSignalUtils.
1009
+ * Used by ClientStrategy for signal persistence.
1010
+ *
1011
+ * @example
1012
+ * ```typescript
1013
+ * // Custom adapter
1014
+ * PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
1015
+ *
1016
+ * // Read signal
1017
+ * const signal = await PersistSignalAdapter.readSignalData("my-strategy", "BTCUSDT");
1018
+ *
1019
+ * // Write signal
1020
+ * await PersistSignalAdapter.writeSignalData(signal, "my-strategy", "BTCUSDT");
1021
+ * ```
1022
+ */
1023
+ const PersistSignalAdapter = new PersistSignalUtils();
1024
+ /**
1025
+ * Utility class for managing risk active positions persistence.
1026
+ *
1027
+ * Features:
1028
+ * - Memoized storage instances per risk profile
1029
+ * - Custom adapter support
1030
+ * - Atomic read/write operations for RiskData
1031
+ * - Crash-safe position state management
1032
+ *
1033
+ * Used by ClientRisk for live mode persistence of active positions.
1034
+ */
1035
+ class PersistRiskUtils {
1036
+ constructor() {
1037
+ this.PersistRiskFactory = PersistBase;
1038
+ this.getRiskStorage = memoize(([riskName, exchangeName]) => `${riskName}:${exchangeName}`, (riskName, exchangeName) => Reflect.construct(this.PersistRiskFactory, [
1039
+ `${riskName}_${exchangeName}`,
1040
+ `./dump/data/risk/`,
1041
+ ]));
1058
1042
  /**
1059
- * Retrieves current average price for symbol.
1043
+ * Reads persisted active positions for a risk profile.
1060
1044
  *
1061
- * In live mode: fetches real-time average price from exchange API.
1062
- * In backtest mode: calculates VWAP from candles in current timeframe.
1045
+ * Called by ClientRisk.waitForInit() to restore state.
1046
+ * Returns empty Map if no positions exist.
1063
1047
  *
1064
- * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
1065
- * @returns Promise resolving to average price
1048
+ * @param riskName - Risk profile identifier
1049
+ * @param exchangeName - Exchange identifier
1050
+ * @returns Promise resolving to Map of active positions
1066
1051
  */
1067
- this.getAveragePrice = async (symbol) => {
1068
- this.loggerService.log("exchangeConnectionService getAveragePrice", {
1069
- symbol,
1070
- });
1071
- return await this.getExchange(this.methodContextService.context.exchangeName).getAveragePrice(symbol);
1052
+ this.readPositionData = async (riskName, exchangeName) => {
1053
+ bt.loggerService.info(PERSIST_RISK_UTILS_METHOD_NAME_READ_DATA);
1054
+ const key = `${riskName}:${exchangeName}`;
1055
+ const isInitial = !this.getRiskStorage.has(key);
1056
+ const stateStorage = this.getRiskStorage(riskName, exchangeName);
1057
+ await stateStorage.waitForInit(isInitial);
1058
+ const RISK_STORAGE_KEY = "positions";
1059
+ if (await stateStorage.hasValue(RISK_STORAGE_KEY)) {
1060
+ return await stateStorage.readValue(RISK_STORAGE_KEY);
1061
+ }
1062
+ return [];
1072
1063
  };
1073
1064
  /**
1074
- * Formats price according to exchange-specific precision rules.
1065
+ * Writes active positions to disk with atomic file writes.
1075
1066
  *
1076
- * Ensures price meets exchange requirements for decimal places and tick size.
1067
+ * Called by ClientRisk after addSignal/removeSignal to persist state.
1068
+ * Uses atomic writes to prevent corruption on crashes.
1077
1069
  *
1078
- * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
1079
- * @param price - Raw price value to format
1080
- * @returns Promise resolving to formatted price string
1070
+ * @param positions - Map of active positions
1071
+ * @param riskName - Risk profile identifier
1072
+ * @param exchangeName - Exchange identifier
1073
+ * @returns Promise that resolves when write is complete
1081
1074
  */
1082
- this.formatPrice = async (symbol, price) => {
1083
- this.loggerService.log("exchangeConnectionService getAveragePrice", {
1084
- symbol,
1085
- price,
1086
- });
1087
- return await this.getExchange(this.methodContextService.context.exchangeName).formatPrice(symbol, price);
1075
+ this.writePositionData = async (riskRow, riskName, exchangeName) => {
1076
+ bt.loggerService.info(PERSIST_RISK_UTILS_METHOD_NAME_WRITE_DATA);
1077
+ const key = `${riskName}:${exchangeName}`;
1078
+ const isInitial = !this.getRiskStorage.has(key);
1079
+ const stateStorage = this.getRiskStorage(riskName, exchangeName);
1080
+ await stateStorage.waitForInit(isInitial);
1081
+ const RISK_STORAGE_KEY = "positions";
1082
+ await stateStorage.writeValue(RISK_STORAGE_KEY, riskRow);
1088
1083
  };
1084
+ }
1085
+ /**
1086
+ * Registers a custom persistence adapter.
1087
+ *
1088
+ * @param Ctor - Custom PersistBase constructor
1089
+ *
1090
+ * @example
1091
+ * ```typescript
1092
+ * class RedisPersist extends PersistBase {
1093
+ * async readValue(id) { return JSON.parse(await redis.get(id)); }
1094
+ * async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
1095
+ * }
1096
+ * PersistRiskAdapter.usePersistRiskAdapter(RedisPersist);
1097
+ * ```
1098
+ */
1099
+ usePersistRiskAdapter(Ctor) {
1100
+ bt.loggerService.info(PERSIST_RISK_UTILS_METHOD_NAME_USE_PERSIST_RISK_ADAPTER);
1101
+ this.PersistRiskFactory = Ctor;
1102
+ }
1103
+ /**
1104
+ * Switches to the default JSON persist adapter.
1105
+ * All future persistence writes will use JSON storage.
1106
+ */
1107
+ useJson() {
1108
+ bt.loggerService.log(PERSIST_RISK_UTILS_METHOD_NAME_USE_JSON);
1109
+ this.usePersistRiskAdapter(PersistBase);
1110
+ }
1111
+ /**
1112
+ * Switches to a dummy persist adapter that discards all writes.
1113
+ * All future persistence writes will be no-ops.
1114
+ */
1115
+ useDummy() {
1116
+ bt.loggerService.log(PERSIST_RISK_UTILS_METHOD_NAME_USE_DUMMY);
1117
+ this.usePersistRiskAdapter(PersistDummy);
1118
+ }
1119
+ }
1120
+ /**
1121
+ * Global singleton instance of PersistRiskUtils.
1122
+ * Used by ClientRisk for active positions persistence.
1123
+ *
1124
+ * @example
1125
+ * ```typescript
1126
+ * // Custom adapter
1127
+ * PersistRiskAdapter.usePersistRiskAdapter(RedisPersist);
1128
+ *
1129
+ * // Read positions
1130
+ * const positions = await PersistRiskAdapter.readPositionData("my-risk");
1131
+ *
1132
+ * // Write positions
1133
+ * await PersistRiskAdapter.writePositionData(positionsMap, "my-risk");
1134
+ * ```
1135
+ */
1136
+ const PersistRiskAdapter = new PersistRiskUtils();
1137
+ /**
1138
+ * Utility class for managing scheduled signal persistence.
1139
+ *
1140
+ * Features:
1141
+ * - Memoized storage instances per strategy
1142
+ * - Custom adapter support
1143
+ * - Atomic read/write operations for scheduled signals
1144
+ * - Crash-safe scheduled signal state management
1145
+ *
1146
+ * Used by ClientStrategy for live mode persistence of scheduled signals (_scheduledSignal).
1147
+ */
1148
+ class PersistScheduleUtils {
1149
+ constructor() {
1150
+ this.PersistScheduleFactory = PersistBase;
1151
+ this.getScheduleStorage = memoize(([symbol, strategyName, exchangeName]) => `${symbol}:${strategyName}:${exchangeName}`, (symbol, strategyName, exchangeName) => Reflect.construct(this.PersistScheduleFactory, [
1152
+ `${symbol}_${strategyName}_${exchangeName}`,
1153
+ `./dump/data/schedule/`,
1154
+ ]));
1089
1155
  /**
1090
- * Formats quantity according to exchange-specific precision rules.
1156
+ * Reads persisted scheduled signal data for a symbol and strategy.
1091
1157
  *
1092
- * Ensures quantity meets exchange requirements for decimal places and lot size.
1158
+ * Called by ClientStrategy.waitForInit() to restore scheduled signal state.
1159
+ * Returns null if no scheduled signal exists.
1093
1160
  *
1094
- * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
1095
- * @param quantity - Raw quantity value to format
1096
- * @returns Promise resolving to formatted quantity string
1161
+ * @param symbol - Trading pair symbol
1162
+ * @param strategyName - Strategy identifier
1163
+ * @param exchangeName - Exchange identifier
1164
+ * @returns Promise resolving to scheduled signal or null
1097
1165
  */
1098
- this.formatQuantity = async (symbol, quantity) => {
1099
- this.loggerService.log("exchangeConnectionService getAveragePrice", {
1100
- symbol,
1101
- quantity,
1102
- });
1103
- return await this.getExchange(this.methodContextService.context.exchangeName).formatQuantity(symbol, quantity);
1166
+ this.readScheduleData = async (symbol, strategyName, exchangeName) => {
1167
+ bt.loggerService.info(PERSIST_SCHEDULE_UTILS_METHOD_NAME_READ_DATA);
1168
+ const key = `${symbol}:${strategyName}:${exchangeName}`;
1169
+ const isInitial = !this.getScheduleStorage.has(key);
1170
+ const stateStorage = this.getScheduleStorage(symbol, strategyName, exchangeName);
1171
+ await stateStorage.waitForInit(isInitial);
1172
+ if (await stateStorage.hasValue(symbol)) {
1173
+ return await stateStorage.readValue(symbol);
1174
+ }
1175
+ return null;
1104
1176
  };
1105
1177
  /**
1106
- * Fetches order book for a trading pair using configured exchange.
1178
+ * Writes scheduled signal data to disk with atomic file writes.
1107
1179
  *
1108
- * Routes to exchange determined by methodContextService.context.exchangeName.
1109
- * The ClientExchange will calculate time range and pass it to the schema
1110
- * implementation, which may use (backtest) or ignore (live) the parameters.
1180
+ * Called by ClientStrategy.setScheduledSignal() to persist state.
1181
+ * Uses atomic writes to prevent corruption on crashes.
1111
1182
  *
1112
- * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
1113
- * @param depth - Maximum depth levels (default: CC_ORDER_BOOK_MAX_DEPTH_LEVELS)
1114
- * @returns Promise resolving to order book data
1183
+ * @param scheduledSignalRow - Scheduled signal data (null to clear)
1184
+ * @param symbol - Trading pair symbol
1185
+ * @param strategyName - Strategy identifier
1186
+ * @param exchangeName - Exchange identifier
1187
+ * @returns Promise that resolves when write is complete
1115
1188
  */
1116
- this.getOrderBook = async (symbol, depth) => {
1117
- this.loggerService.log("exchangeConnectionService getOrderBook", {
1118
- symbol,
1119
- depth,
1120
- });
1121
- return await this.getExchange(this.methodContextService.context.exchangeName).getOrderBook(symbol, depth);
1189
+ this.writeScheduleData = async (scheduledSignalRow, symbol, strategyName, exchangeName) => {
1190
+ bt.loggerService.info(PERSIST_SCHEDULE_UTILS_METHOD_NAME_WRITE_DATA);
1191
+ const key = `${symbol}:${strategyName}:${exchangeName}`;
1192
+ const isInitial = !this.getScheduleStorage.has(key);
1193
+ const stateStorage = this.getScheduleStorage(symbol, strategyName, exchangeName);
1194
+ await stateStorage.waitForInit(isInitial);
1195
+ await stateStorage.writeValue(symbol, scheduledSignalRow);
1122
1196
  };
1123
1197
  }
1124
- }
1125
-
1126
- /**
1127
- * Calculates profit/loss for a closed signal with slippage and fees.
1128
- *
1129
- * For signals with partial closes:
1130
- * - Calculates weighted PNL: Σ(percent_i × pnl_i) for each partial + (remaining% × final_pnl)
1131
- * - Each partial close has its own fees and slippage
1132
- * - Total fees = 2 × (number of partial closes + 1 final close) × CC_PERCENT_FEE
1133
- *
1134
- * Formula breakdown:
1135
- * 1. Apply slippage to open/close prices (worse execution)
1136
- * - LONG: buy higher (+slippage), sell lower (-slippage)
1137
- * - SHORT: sell lower (-slippage), buy higher (+slippage)
1138
- * 2. Calculate raw PNL percentage
1139
- * - LONG: ((closePrice - openPrice) / openPrice) * 100
1140
- * - SHORT: ((openPrice - closePrice) / openPrice) * 100
1141
- * 3. Subtract total fees (0.1% * 2 = 0.2% per transaction)
1142
- *
1143
- * @param signal - Closed signal with position details and optional partial history
1144
- * @param priceClose - Actual close price at final exit
1145
- * @returns PNL data with percentage and prices
1146
- *
1147
- * @example
1148
- * ```typescript
1149
- * // Signal without partial closes
1150
- * const pnl = toProfitLossDto(
1151
- * {
1152
- * position: "long",
1153
- * priceOpen: 100,
1154
- * },
1155
- * 110 // close at +10%
1156
- * );
1157
- * console.log(pnl.pnlPercentage); // ~9.6% (after slippage and fees)
1158
- *
1159
- * // Signal with partial closes
1160
- * const pnlPartial = toProfitLossDto(
1161
- * {
1162
- * position: "long",
1163
- * priceOpen: 100,
1164
- * _partial: [
1165
- * { type: "profit", percent: 30, price: 120 }, // +20% on 30%
1166
- * { type: "profit", percent: 40, price: 115 }, // +15% on 40%
1167
- * ],
1168
- * },
1169
- * 105 // final close at +5% for remaining 30%
1170
- * );
1171
- * // Weighted PNL = 30% × 20% + 40% × 15% + 30% × 5% = 6% + 6% + 1.5% = 13.5% (before fees)
1172
- * ```
1173
- */
1174
- const toProfitLossDto = (signal, priceClose) => {
1175
- const priceOpen = signal.priceOpen;
1176
- // Calculate weighted PNL with partial closes
1177
- if (signal._partial && signal._partial.length > 0) {
1178
- let totalWeightedPnl = 0;
1179
- let totalFees = 0;
1180
- // Calculate PNL for each partial close
1181
- for (const partial of signal._partial) {
1182
- const partialPercent = partial.percent;
1183
- const partialPrice = partial.price;
1184
- // Apply slippage to prices
1185
- let priceOpenWithSlippage;
1186
- let priceCloseWithSlippage;
1187
- if (signal.position === "long") {
1188
- priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
1189
- priceCloseWithSlippage = partialPrice * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
1190
- }
1191
- else {
1192
- priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
1193
- priceCloseWithSlippage = partialPrice * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
1194
- }
1195
- // Calculate PNL for this partial
1196
- let partialPnl;
1197
- if (signal.position === "long") {
1198
- partialPnl = ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100;
1199
- }
1200
- else {
1201
- partialPnl = ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
1202
- }
1203
- // Weight by percentage of position closed
1204
- const weightedPnl = (partialPercent / 100) * partialPnl;
1205
- totalWeightedPnl += weightedPnl;
1206
- // Each partial has fees for open + close (2 transactions)
1207
- totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
1208
- }
1209
- // Calculate PNL for remaining position (if any)
1210
- // Compute totalClosed from _partial array
1211
- const totalClosed = signal._partial.reduce((sum, p) => sum + p.percent, 0);
1212
- const remainingPercent = 100 - totalClosed;
1213
- if (remainingPercent > 0) {
1214
- // Apply slippage
1215
- let priceOpenWithSlippage;
1216
- let priceCloseWithSlippage;
1217
- if (signal.position === "long") {
1218
- priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
1219
- priceCloseWithSlippage = priceClose * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
1220
- }
1221
- else {
1222
- priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
1223
- priceCloseWithSlippage = priceClose * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
1224
- }
1225
- // Calculate PNL for remaining
1226
- let remainingPnl;
1227
- if (signal.position === "long") {
1228
- remainingPnl = ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100;
1229
- }
1230
- else {
1231
- remainingPnl = ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
1232
- }
1233
- // Weight by remaining percentage
1234
- const weightedRemainingPnl = (remainingPercent / 100) * remainingPnl;
1235
- totalWeightedPnl += weightedRemainingPnl;
1236
- // Final close also has fees
1237
- totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
1238
- }
1239
- // Subtract total fees from weighted PNL
1240
- const pnlPercentage = totalWeightedPnl - totalFees;
1241
- return {
1242
- pnlPercentage,
1243
- priceOpen,
1244
- priceClose,
1245
- };
1246
- }
1247
- // Original logic for signals without partial closes
1248
- let priceOpenWithSlippage;
1249
- let priceCloseWithSlippage;
1250
- if (signal.position === "long") {
1251
- // LONG: покупаем дороже, продаем дешевле
1252
- priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
1253
- priceCloseWithSlippage = priceClose * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
1254
- }
1255
- else {
1256
- // SHORT: продаем дешевле, покупаем дороже
1257
- priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
1258
- priceCloseWithSlippage = priceClose * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
1198
+ /**
1199
+ * Registers a custom persistence adapter.
1200
+ *
1201
+ * @param Ctor - Custom PersistBase constructor
1202
+ *
1203
+ * @example
1204
+ * ```typescript
1205
+ * class RedisPersist extends PersistBase {
1206
+ * async readValue(id) { return JSON.parse(await redis.get(id)); }
1207
+ * async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
1208
+ * }
1209
+ * PersistScheduleAdapter.usePersistScheduleAdapter(RedisPersist);
1210
+ * ```
1211
+ */
1212
+ usePersistScheduleAdapter(Ctor) {
1213
+ bt.loggerService.info(PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_PERSIST_SCHEDULE_ADAPTER);
1214
+ this.PersistScheduleFactory = Ctor;
1259
1215
  }
1260
- // Применяем комиссию дважды (при открытии и закрытии)
1261
- const totalFee = GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
1262
- let pnlPercentage;
1263
- if (signal.position === "long") {
1264
- // LONG: прибыль при росте цены
1265
- pnlPercentage =
1266
- ((priceCloseWithSlippage - priceOpenWithSlippage) /
1267
- priceOpenWithSlippage) *
1268
- 100;
1216
+ /**
1217
+ * Switches to the default JSON persist adapter.
1218
+ * All future persistence writes will use JSON storage.
1219
+ */
1220
+ useJson() {
1221
+ bt.loggerService.log(PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_JSON);
1222
+ this.usePersistScheduleAdapter(PersistBase);
1269
1223
  }
1270
- else {
1271
- // SHORT: прибыль при падении цены
1272
- pnlPercentage =
1273
- ((priceOpenWithSlippage - priceCloseWithSlippage) /
1274
- priceOpenWithSlippage) *
1275
- 100;
1224
+ /**
1225
+ * Switches to a dummy persist adapter that discards all writes.
1226
+ * All future persistence writes will be no-ops.
1227
+ */
1228
+ useDummy() {
1229
+ bt.loggerService.log(PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_DUMMY);
1230
+ this.usePersistScheduleAdapter(PersistDummy);
1276
1231
  }
1277
- // Вычитаем комиссии
1278
- pnlPercentage -= totalFee;
1279
- return {
1280
- pnlPercentage,
1281
- priceOpen,
1282
- priceClose,
1283
- };
1284
- };
1285
-
1286
- const IS_WINDOWS = os.platform() === "win32";
1232
+ }
1287
1233
  /**
1288
- * Atomically writes data to a file, ensuring the operation either fully completes or leaves the original file unchanged.
1289
- * Uses a temporary file with a rename strategy on POSIX systems for atomicity, or direct writing with sync on Windows (or when POSIX rename is skipped).
1290
- *
1291
- *
1292
- * @param {string} file - The file parameter.
1293
- * @param {string | Buffer} data - The data to be processed or validated.
1294
- * @param {Options | BufferEncoding} options - The options parameter (optional).
1295
- * @throws {Error} Throws an error if the write, sync, or rename operation fails, after attempting cleanup of temporary files.
1296
- *
1297
- * @example
1298
- * // Basic usage with default options
1299
- * await writeFileAtomic("output.txt", "Hello, world!");
1300
- * // Writes "Hello, world!" to "output.txt" atomically
1301
- *
1302
- * @example
1303
- * // Custom options and Buffer data
1304
- * const buffer = Buffer.from("Binary data");
1305
- * await writeFileAtomic("data.bin", buffer, { encoding: "binary", mode: 0o644, tmpPrefix: "temp-" });
1306
- * // Writes binary data to "data.bin" with custom permissions and temp prefix
1234
+ * Global singleton instance of PersistScheduleUtils.
1235
+ * Used by ClientStrategy for scheduled signal persistence.
1307
1236
  *
1308
1237
  * @example
1309
- * // Using encoding shorthand
1310
- * await writeFileAtomic("log.txt", "Log entry", "utf16le");
1311
- * // Writes "Log entry" to "log.txt" in UTF-16LE encoding
1238
+ * ```typescript
1239
+ * // Custom adapter
1240
+ * PersistScheduleAdapter.usePersistScheduleAdapter(RedisPersist);
1312
1241
  *
1313
- * @remarks
1314
- * This function ensures atomicity to prevent partial writes:
1315
- * - On POSIX systems (non-Windows, unless `GLOBAL_CONFIG.CC_SKIP_POSIX_RENAME` is true):
1316
- * - Writes data to a temporary file (e.g., `.tmp-<random>-filename`) in the same directory.
1317
- * - Uses `crypto.randomBytes` to generate a unique temporary name, reducing collision risk.
1318
- * - Syncs the data to disk and renames the temporary file to the target file atomically with `fs.rename`.
1319
- * - Cleans up the temporary file on failure, swallowing cleanup errors to prioritize throwing the original error.
1320
- * - On Windows (or when POSIX rename is skipped):
1321
- * - Writes directly to the target file, syncing data to disk to minimize corruption risk (though not fully atomic).
1322
- * - Closes the file handle on failure without additional cleanup.
1323
- * - Accepts `options` as an object or a string (interpreted as `encoding`), defaulting to `{ encoding: "utf8", mode: 0o666, tmpPrefix: ".tmp-" }`.
1324
- * Useful in the agent swarm system for safely writing configuration files, logs, or state data where partial writes could cause corruption.
1242
+ * // Read scheduled signal
1243
+ * const scheduled = await PersistScheduleAdapter.readScheduleData("my-strategy", "BTCUSDT");
1325
1244
  *
1326
- * @see {@link https://nodejs.org/api/fs.html#fspromiseswritefilefile-data-options|fs.promises.writeFile} for file writing details.
1327
- * @see {@link https://nodejs.org/api/crypto.html#cryptorandombytessize-callback|crypto.randomBytes} for temporary file naming.
1328
- * @see {@link ../config/params|GLOBAL_CONFIG} for configuration impacting POSIX behavior.
1329
- */
1330
- async function writeFileAtomic(file, data, options = {}) {
1331
- if (typeof options === "string") {
1332
- options = { encoding: options };
1333
- }
1334
- else if (!options) {
1335
- options = {};
1336
- }
1337
- const { encoding = "utf8", mode = 0o666, tmpPrefix = ".tmp-" } = options;
1338
- let fileHandle = null;
1339
- if (IS_WINDOWS) {
1340
- try {
1341
- // Create and write to temporary file
1342
- fileHandle = await fs__default.open(file, "w", mode);
1343
- // Write data to the temp file
1344
- await fileHandle.writeFile(data, { encoding });
1345
- // Ensure data is flushed to disk
1346
- await fileHandle.sync();
1347
- // Close the file before rename
1348
- await fileHandle.close();
1349
- }
1350
- catch (error) {
1351
- // Clean up if something went wrong
1352
- if (fileHandle) {
1353
- await fileHandle.close().catch(() => { });
1354
- }
1355
- throw error; // Re-throw the original error
1356
- }
1357
- return;
1358
- }
1359
- // Create a temporary filename in the same directory
1360
- const dir = path.dirname(file);
1361
- const filename = path.basename(file);
1362
- const tmpFile = path.join(dir, `${tmpPrefix}${crypto.randomBytes(6).toString("hex")}-${filename}`);
1363
- try {
1364
- // Create and write to temporary file
1365
- fileHandle = await fs__default.open(tmpFile, "w", mode);
1366
- // Write data to the temp file
1367
- await fileHandle.writeFile(data, { encoding });
1368
- // Ensure data is flushed to disk
1369
- await fileHandle.sync();
1370
- // Close the file before rename
1371
- await fileHandle.close();
1372
- fileHandle = null;
1373
- // Atomically replace the target file with our temp file
1374
- await fs__default.rename(tmpFile, file);
1375
- }
1376
- catch (error) {
1377
- // Clean up if something went wrong
1378
- if (fileHandle) {
1379
- await fileHandle.close().catch(() => { });
1380
- }
1381
- // Try to remove the temporary file
1382
- try {
1383
- await fs__default.unlink(tmpFile).catch(() => { });
1384
- }
1385
- catch (_) {
1386
- // Ignore errors during cleanup
1387
- }
1388
- throw error;
1389
- }
1390
- }
1391
-
1392
- var _a$2;
1393
- const BASE_WAIT_FOR_INIT_SYMBOL = Symbol("wait-for-init");
1394
- const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_PERSIST_SIGNAL_ADAPTER = "PersistSignalUtils.usePersistSignalAdapter";
1395
- const PERSIST_SIGNAL_UTILS_METHOD_NAME_READ_DATA = "PersistSignalUtils.readSignalData";
1396
- const PERSIST_SIGNAL_UTILS_METHOD_NAME_WRITE_DATA = "PersistSignalUtils.writeSignalData";
1397
- const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_JSON = "PersistSignalUtils.useJson";
1398
- const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_DUMMY = "PersistSignalUtils.useDummy";
1399
- const PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_PERSIST_SCHEDULE_ADAPTER = "PersistScheduleUtils.usePersistScheduleAdapter";
1400
- const PERSIST_SCHEDULE_UTILS_METHOD_NAME_READ_DATA = "PersistScheduleUtils.readScheduleData";
1401
- const PERSIST_SCHEDULE_UTILS_METHOD_NAME_WRITE_DATA = "PersistScheduleUtils.writeScheduleData";
1402
- const PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_JSON = "PersistScheduleUtils.useJson";
1403
- const PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_DUMMY = "PersistScheduleUtils.useDummy";
1404
- const PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_PERSIST_PARTIAL_ADAPTER = "PersistPartialUtils.usePersistPartialAdapter";
1405
- const PERSIST_PARTIAL_UTILS_METHOD_NAME_READ_DATA = "PersistPartialUtils.readPartialData";
1406
- const PERSIST_PARTIAL_UTILS_METHOD_NAME_WRITE_DATA = "PersistPartialUtils.writePartialData";
1407
- const PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_JSON = "PersistPartialUtils.useJson";
1408
- const PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_DUMMY = "PersistPartialUtils.useDummy";
1409
- const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_PERSIST_BREAKEVEN_ADAPTER = "PersistBreakevenUtils.usePersistBreakevenAdapter";
1410
- const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_READ_DATA = "PersistBreakevenUtils.readBreakevenData";
1411
- const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_WRITE_DATA = "PersistBreakevenUtils.writeBreakevenData";
1412
- const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_JSON = "PersistBreakevenUtils.useJson";
1413
- const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_DUMMY = "PersistBreakevenUtils.useDummy";
1414
- const PERSIST_RISK_UTILS_METHOD_NAME_USE_PERSIST_RISK_ADAPTER = "PersistRiskUtils.usePersistRiskAdapter";
1415
- const PERSIST_RISK_UTILS_METHOD_NAME_READ_DATA = "PersistRiskUtils.readPositionData";
1416
- const PERSIST_RISK_UTILS_METHOD_NAME_WRITE_DATA = "PersistRiskUtils.writePositionData";
1417
- const PERSIST_RISK_UTILS_METHOD_NAME_USE_JSON = "PersistRiskUtils.useJson";
1418
- const PERSIST_RISK_UTILS_METHOD_NAME_USE_DUMMY = "PersistRiskUtils.useDummy";
1419
- const PERSIST_BASE_METHOD_NAME_CTOR = "PersistBase.CTOR";
1420
- const PERSIST_BASE_METHOD_NAME_WAIT_FOR_INIT = "PersistBase.waitForInit";
1421
- const PERSIST_BASE_METHOD_NAME_READ_VALUE = "PersistBase.readValue";
1422
- const PERSIST_BASE_METHOD_NAME_WRITE_VALUE = "PersistBase.writeValue";
1423
- const PERSIST_BASE_METHOD_NAME_HAS_VALUE = "PersistBase.hasValue";
1424
- const PERSIST_BASE_METHOD_NAME_KEYS = "PersistBase.keys";
1425
- const BASE_WAIT_FOR_INIT_FN_METHOD_NAME = "PersistBase.waitForInitFn";
1426
- const BASE_UNLINK_RETRY_COUNT = 5;
1427
- const BASE_UNLINK_RETRY_DELAY = 1000;
1428
- const BASE_WAIT_FOR_INIT_FN = async (self) => {
1429
- bt.loggerService.debug(BASE_WAIT_FOR_INIT_FN_METHOD_NAME, {
1430
- entityName: self.entityName,
1431
- directory: self._directory,
1432
- });
1433
- await fs__default.mkdir(self._directory, { recursive: true });
1434
- for await (const key of self.keys()) {
1435
- try {
1436
- await self.readValue(key);
1437
- }
1438
- catch {
1439
- const filePath = self._getFilePath(key);
1440
- console.error(`backtest-kit PersistBase found invalid document for filePath=${filePath} entityName=${self.entityName}`);
1441
- if (await not(BASE_WAIT_FOR_INIT_UNLINK_FN(filePath))) {
1442
- console.error(`backtest-kit PersistBase failed to remove invalid document for filePath=${filePath} entityName=${self.entityName}`);
1443
- }
1444
- }
1445
- }
1446
- };
1447
- const BASE_WAIT_FOR_INIT_UNLINK_FN = async (filePath) => trycatch(retry(async () => {
1448
- try {
1449
- await fs__default.unlink(filePath);
1450
- return true;
1451
- }
1452
- catch (error) {
1453
- console.error(`backtest-kit PersistBase unlink failed for filePath=${filePath} error=${getErrorMessage(error)}`);
1454
- throw error;
1455
- }
1456
- }, BASE_UNLINK_RETRY_COUNT, BASE_UNLINK_RETRY_DELAY), {
1457
- defaultValue: false,
1458
- });
1245
+ * // Write scheduled signal
1246
+ * await PersistScheduleAdapter.writeScheduleData(scheduled, "my-strategy", "BTCUSDT");
1247
+ * ```
1248
+ */
1249
+ const PersistScheduleAdapter = new PersistScheduleUtils();
1459
1250
  /**
1460
- * Base class for file-based persistence with atomic writes.
1251
+ * Utility class for managing partial profit/loss levels persistence.
1461
1252
  *
1462
1253
  * Features:
1463
- * - Atomic file writes using writeFileAtomic
1464
- * - Auto-validation and cleanup of corrupted files
1465
- * - Async generator support for iteration
1466
- * - Retry logic for file deletion
1254
+ * - Memoized storage instances per symbol:strategyName
1255
+ * - Custom adapter support
1256
+ * - Atomic read/write operations for partial data
1257
+ * - Crash-safe partial state management
1467
1258
  *
1468
- * @example
1469
- * ```typescript
1470
- * const persist = new PersistBase("my-entity", "./data");
1471
- * await persist.waitForInit(true);
1472
- * await persist.writeValue("key1", { data: "value" });
1473
- * const value = await persist.readValue("key1");
1474
- * ```
1259
+ * Used by ClientPartial for live mode persistence of profit/loss levels.
1475
1260
  */
1476
- class PersistBase {
1477
- /**
1478
- * Creates new persistence instance.
1479
- *
1480
- * @param entityName - Unique entity type identifier
1481
- * @param baseDir - Base directory for all entities (default: ./dump/data)
1482
- */
1483
- constructor(entityName, baseDir = join(process.cwd(), "logs/data")) {
1484
- this.entityName = entityName;
1485
- this.baseDir = baseDir;
1486
- this[_a$2] = singleshot(async () => await BASE_WAIT_FOR_INIT_FN(this));
1487
- bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_CTOR, {
1488
- entityName: this.entityName,
1489
- baseDir,
1490
- });
1491
- this._directory = join(this.baseDir, this.entityName);
1492
- }
1493
- /**
1494
- * Computes file path for entity ID.
1495
- *
1496
- * @param entityId - Entity identifier
1497
- * @returns Full file path to entity JSON file
1498
- */
1499
- _getFilePath(entityId) {
1500
- return join(this.baseDir, this.entityName, `${entityId}.json`);
1501
- }
1502
- async waitForInit(initial) {
1503
- bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_WAIT_FOR_INIT, {
1504
- entityName: this.entityName,
1505
- initial,
1506
- });
1507
- await this[BASE_WAIT_FOR_INIT_SYMBOL]();
1508
- }
1509
- async readValue(entityId) {
1510
- bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_READ_VALUE, {
1511
- entityName: this.entityName,
1512
- entityId,
1513
- });
1514
- try {
1515
- const filePath = this._getFilePath(entityId);
1516
- const fileContent = await fs__default.readFile(filePath, "utf-8");
1517
- return JSON.parse(fileContent);
1518
- }
1519
- catch (error) {
1520
- if (error?.code === "ENOENT") {
1521
- throw new Error(`Entity ${this.entityName}:${entityId} not found`);
1522
- }
1523
- throw new Error(`Failed to read entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`);
1524
- }
1525
- }
1526
- async hasValue(entityId) {
1527
- bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_HAS_VALUE, {
1528
- entityName: this.entityName,
1529
- entityId,
1530
- });
1531
- try {
1532
- const filePath = this._getFilePath(entityId);
1533
- await fs__default.access(filePath);
1534
- return true;
1535
- }
1536
- catch (error) {
1537
- if (error?.code === "ENOENT") {
1538
- return false;
1539
- }
1540
- throw new Error(`Failed to check existence of entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`);
1541
- }
1542
- }
1543
- async writeValue(entityId, entity) {
1544
- bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_WRITE_VALUE, {
1545
- entityName: this.entityName,
1546
- entityId,
1547
- });
1548
- try {
1549
- const filePath = this._getFilePath(entityId);
1550
- const serializedData = JSON.stringify(entity);
1551
- await writeFileAtomic(filePath, serializedData, "utf-8");
1552
- }
1553
- catch (error) {
1554
- throw new Error(`Failed to write entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`);
1555
- }
1556
- }
1557
- /**
1558
- * Async generator yielding all entity IDs.
1559
- * Sorted alphanumerically.
1560
- * Used internally by waitForInit for validation.
1561
- *
1562
- * @returns AsyncGenerator yielding entity IDs
1563
- * @throws Error if reading fails
1564
- */
1565
- async *keys() {
1566
- bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_KEYS, {
1567
- entityName: this.entityName,
1568
- });
1569
- try {
1570
- const files = await fs__default.readdir(this._directory);
1571
- const entityIds = files
1572
- .filter((file) => file.endsWith(".json"))
1573
- .map((file) => file.slice(0, -5))
1574
- .sort((a, b) => a.localeCompare(b, undefined, {
1575
- numeric: true,
1576
- sensitivity: "base",
1577
- }));
1578
- for (const entityId of entityIds) {
1579
- yield entityId;
1261
+ class PersistPartialUtils {
1262
+ constructor() {
1263
+ this.PersistPartialFactory = PersistBase;
1264
+ this.getPartialStorage = memoize(([symbol, strategyName, exchangeName]) => `${symbol}:${strategyName}:${exchangeName}`, (symbol, strategyName, exchangeName) => Reflect.construct(this.PersistPartialFactory, [
1265
+ `${symbol}_${strategyName}_${exchangeName}`,
1266
+ `./dump/data/partial/`,
1267
+ ]));
1268
+ /**
1269
+ * Reads persisted partial data for a symbol and strategy.
1270
+ *
1271
+ * Called by ClientPartial.waitForInit() to restore state.
1272
+ * Returns empty object if no partial data exists.
1273
+ *
1274
+ * @param symbol - Trading pair symbol
1275
+ * @param strategyName - Strategy identifier
1276
+ * @param signalId - Signal identifier
1277
+ * @param exchangeName - Exchange identifier
1278
+ * @returns Promise resolving to partial data record
1279
+ */
1280
+ this.readPartialData = async (symbol, strategyName, signalId, exchangeName) => {
1281
+ bt.loggerService.info(PERSIST_PARTIAL_UTILS_METHOD_NAME_READ_DATA);
1282
+ const key = `${symbol}:${strategyName}:${exchangeName}`;
1283
+ const isInitial = !this.getPartialStorage.has(key);
1284
+ const stateStorage = this.getPartialStorage(symbol, strategyName, exchangeName);
1285
+ await stateStorage.waitForInit(isInitial);
1286
+ if (await stateStorage.hasValue(signalId)) {
1287
+ return await stateStorage.readValue(signalId);
1580
1288
  }
1581
- }
1582
- catch (error) {
1583
- throw new Error(`Failed to read keys for ${this.entityName}: ${getErrorMessage(error)}`);
1584
- }
1585
- }
1586
- }
1587
- _a$2 = BASE_WAIT_FOR_INIT_SYMBOL;
1588
- // @ts-ignore
1589
- PersistBase = makeExtendable(PersistBase);
1590
- /**
1591
- * Dummy persist adapter that discards all writes.
1592
- * Used for disabling persistence.
1593
- */
1594
- class PersistDummy {
1595
- /**
1596
- * No-op initialization function.
1597
- * @returns Promise that resolves immediately
1598
- */
1599
- async waitForInit() {
1289
+ return {};
1290
+ };
1291
+ /**
1292
+ * Writes partial data to disk with atomic file writes.
1293
+ *
1294
+ * Called by ClientPartial after profit/loss level changes to persist state.
1295
+ * Uses atomic writes to prevent corruption on crashes.
1296
+ *
1297
+ * @param partialData - Record of signal IDs to partial data
1298
+ * @param symbol - Trading pair symbol
1299
+ * @param strategyName - Strategy identifier
1300
+ * @param signalId - Signal identifier
1301
+ * @param exchangeName - Exchange identifier
1302
+ * @returns Promise that resolves when write is complete
1303
+ */
1304
+ this.writePartialData = async (partialData, symbol, strategyName, signalId, exchangeName) => {
1305
+ bt.loggerService.info(PERSIST_PARTIAL_UTILS_METHOD_NAME_WRITE_DATA);
1306
+ const key = `${symbol}:${strategyName}:${exchangeName}`;
1307
+ const isInitial = !this.getPartialStorage.has(key);
1308
+ const stateStorage = this.getPartialStorage(symbol, strategyName, exchangeName);
1309
+ await stateStorage.waitForInit(isInitial);
1310
+ await stateStorage.writeValue(signalId, partialData);
1311
+ };
1600
1312
  }
1601
1313
  /**
1602
- * No-op read function.
1603
- * @returns Promise that resolves with empty object
1314
+ * Registers a custom persistence adapter.
1315
+ *
1316
+ * @param Ctor - Custom PersistBase constructor
1317
+ *
1318
+ * @example
1319
+ * ```typescript
1320
+ * class RedisPersist extends PersistBase {
1321
+ * async readValue(id) { return JSON.parse(await redis.get(id)); }
1322
+ * async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
1323
+ * }
1324
+ * PersistPartialAdapter.usePersistPartialAdapter(RedisPersist);
1325
+ * ```
1604
1326
  */
1605
- async readValue() {
1606
- return {};
1327
+ usePersistPartialAdapter(Ctor) {
1328
+ bt.loggerService.info(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_PERSIST_PARTIAL_ADAPTER);
1329
+ this.PersistPartialFactory = Ctor;
1607
1330
  }
1608
1331
  /**
1609
- * No-op has value check.
1610
- * @returns Promise that resolves to false
1332
+ * Switches to the default JSON persist adapter.
1333
+ * All future persistence writes will use JSON storage.
1611
1334
  */
1612
- async hasValue() {
1613
- return false;
1335
+ useJson() {
1336
+ bt.loggerService.log(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_JSON);
1337
+ this.usePersistPartialAdapter(PersistBase);
1614
1338
  }
1615
1339
  /**
1616
- * No-op write function.
1617
- * @returns Promise that resolves immediately
1340
+ * Switches to a dummy persist adapter that discards all writes.
1341
+ * All future persistence writes will be no-ops.
1618
1342
  */
1619
- async writeValue() {
1343
+ useDummy() {
1344
+ bt.loggerService.log(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_DUMMY);
1345
+ this.usePersistPartialAdapter(PersistDummy);
1620
1346
  }
1621
1347
  }
1622
1348
  /**
1623
- * Utility class for managing signal persistence.
1349
+ * Global singleton instance of PersistPartialUtils.
1350
+ * Used by ClientPartial for partial profit/loss levels persistence.
1351
+ *
1352
+ * @example
1353
+ * ```typescript
1354
+ * // Custom adapter
1355
+ * PersistPartialAdapter.usePersistPartialAdapter(RedisPersist);
1356
+ *
1357
+ * // Read partial data
1358
+ * const partialData = await PersistPartialAdapter.readPartialData("BTCUSDT", "my-strategy");
1359
+ *
1360
+ * // Write partial data
1361
+ * await PersistPartialAdapter.writePartialData(partialData, "BTCUSDT", "my-strategy");
1362
+ * ```
1363
+ */
1364
+ const PersistPartialAdapter = new PersistPartialUtils();
1365
+ /**
1366
+ * Persistence utility class for breakeven state management.
1367
+ *
1368
+ * Handles reading and writing breakeven state to disk.
1369
+ * Uses memoized PersistBase instances per symbol-strategy pair.
1624
1370
  *
1625
1371
  * Features:
1626
- * - Memoized storage instances per strategy
1627
- * - Custom adapter support
1628
- * - Atomic read/write operations
1629
- * - Crash-safe signal state management
1372
+ * - Atomic file writes via PersistBase.writeValue()
1373
+ * - Lazy initialization on first access
1374
+ * - Singleton pattern for global access
1375
+ * - Custom adapter support via usePersistBreakevenAdapter()
1630
1376
  *
1631
- * Used by ClientStrategy for live mode persistence.
1377
+ * File structure:
1378
+ * ```
1379
+ * ./dump/data/breakeven/
1380
+ * ├── BTCUSDT_my-strategy/
1381
+ * │ └── state.json // { "signal-id-1": { reached: true }, ... }
1382
+ * └── ETHUSDT_other-strategy/
1383
+ * └── state.json
1384
+ * ```
1385
+ *
1386
+ * @example
1387
+ * ```typescript
1388
+ * // Read breakeven data
1389
+ * const breakevenData = await PersistBreakevenAdapter.readBreakevenData("BTCUSDT", "my-strategy");
1390
+ * // Returns: { "signal-id": { reached: true }, ... }
1391
+ *
1392
+ * // Write breakeven data
1393
+ * await PersistBreakevenAdapter.writeBreakevenData(breakevenData, "BTCUSDT", "my-strategy");
1394
+ * ```
1632
1395
  */
1633
- class PersistSignalUtils {
1396
+ class PersistBreakevenUtils {
1634
1397
  constructor() {
1635
- this.PersistSignalFactory = PersistBase;
1636
- this.getSignalStorage = memoize(([symbol, strategyName, exchangeName]) => `${symbol}:${strategyName}:${exchangeName}`, (symbol, strategyName, exchangeName) => Reflect.construct(this.PersistSignalFactory, [
1398
+ /**
1399
+ * Factory for creating PersistBase instances.
1400
+ * Can be replaced via usePersistBreakevenAdapter().
1401
+ */
1402
+ this.PersistBreakevenFactory = PersistBase;
1403
+ /**
1404
+ * Memoized storage factory for breakeven data.
1405
+ * Creates one PersistBase instance per symbol-strategy-exchange combination.
1406
+ * Key format: "symbol:strategyName:exchangeName"
1407
+ *
1408
+ * @param symbol - Trading pair symbol
1409
+ * @param strategyName - Strategy identifier
1410
+ * @param exchangeName - Exchange identifier
1411
+ * @returns PersistBase instance for this symbol-strategy-exchange combination
1412
+ */
1413
+ this.getBreakevenStorage = memoize(([symbol, strategyName, exchangeName]) => `${symbol}:${strategyName}:${exchangeName}`, (symbol, strategyName, exchangeName) => Reflect.construct(this.PersistBreakevenFactory, [
1637
1414
  `${symbol}_${strategyName}_${exchangeName}`,
1638
- `./dump/data/signal/`,
1415
+ `./dump/data/breakeven/`,
1639
1416
  ]));
1640
1417
  /**
1641
- * Reads persisted signal data for a symbol and strategy.
1418
+ * Reads persisted breakeven data for a symbol and strategy.
1642
1419
  *
1643
- * Called by ClientStrategy.waitForInit() to restore state.
1644
- * Returns null if no signal exists.
1420
+ * Called by ClientBreakeven.waitForInit() to restore state.
1421
+ * Returns empty object if no breakeven data exists.
1645
1422
  *
1646
1423
  * @param symbol - Trading pair symbol
1647
1424
  * @param strategyName - Strategy identifier
1425
+ * @param signalId - Signal identifier
1648
1426
  * @param exchangeName - Exchange identifier
1649
- * @returns Promise resolving to signal or null
1427
+ * @returns Promise resolving to breakeven data record
1650
1428
  */
1651
- this.readSignalData = async (symbol, strategyName, exchangeName) => {
1652
- bt.loggerService.info(PERSIST_SIGNAL_UTILS_METHOD_NAME_READ_DATA);
1429
+ this.readBreakevenData = async (symbol, strategyName, signalId, exchangeName) => {
1430
+ bt.loggerService.info(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_READ_DATA);
1653
1431
  const key = `${symbol}:${strategyName}:${exchangeName}`;
1654
- const isInitial = !this.getSignalStorage.has(key);
1655
- const stateStorage = this.getSignalStorage(symbol, strategyName, exchangeName);
1432
+ const isInitial = !this.getBreakevenStorage.has(key);
1433
+ const stateStorage = this.getBreakevenStorage(symbol, strategyName, exchangeName);
1656
1434
  await stateStorage.waitForInit(isInitial);
1657
- if (await stateStorage.hasValue(symbol)) {
1658
- return await stateStorage.readValue(symbol);
1435
+ if (await stateStorage.hasValue(signalId)) {
1436
+ return await stateStorage.readValue(signalId);
1659
1437
  }
1660
- return null;
1438
+ return {};
1661
1439
  };
1662
1440
  /**
1663
- * Writes signal data to disk with atomic file writes.
1441
+ * Writes breakeven data to disk.
1664
1442
  *
1665
- * Called by ClientStrategy.setPendingSignal() to persist state.
1666
- * Uses atomic writes to prevent corruption on crashes.
1443
+ * Called by ClientBreakeven._persistState() after state changes.
1444
+ * Creates directory and file if they don't exist.
1445
+ * Uses atomic writes to prevent data corruption.
1667
1446
  *
1668
- * @param signalRow - Signal data (null to clear)
1447
+ * @param breakevenData - Breakeven data record to persist
1669
1448
  * @param symbol - Trading pair symbol
1670
1449
  * @param strategyName - Strategy identifier
1450
+ * @param signalId - Signal identifier
1671
1451
  * @param exchangeName - Exchange identifier
1672
1452
  * @returns Promise that resolves when write is complete
1673
1453
  */
1674
- this.writeSignalData = async (signalRow, symbol, strategyName, exchangeName) => {
1675
- bt.loggerService.info(PERSIST_SIGNAL_UTILS_METHOD_NAME_WRITE_DATA);
1454
+ this.writeBreakevenData = async (breakevenData, symbol, strategyName, signalId, exchangeName) => {
1455
+ bt.loggerService.info(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_WRITE_DATA);
1676
1456
  const key = `${symbol}:${strategyName}:${exchangeName}`;
1677
- const isInitial = !this.getSignalStorage.has(key);
1678
- const stateStorage = this.getSignalStorage(symbol, strategyName, exchangeName);
1457
+ const isInitial = !this.getBreakevenStorage.has(key);
1458
+ const stateStorage = this.getBreakevenStorage(symbol, strategyName, exchangeName);
1679
1459
  await stateStorage.waitForInit(isInitial);
1680
- await stateStorage.writeValue(symbol, signalRow);
1460
+ await stateStorage.writeValue(signalId, breakevenData);
1681
1461
  };
1682
1462
  }
1683
1463
  /**
@@ -1691,538 +1471,1131 @@ class PersistSignalUtils {
1691
1471
  * async readValue(id) { return JSON.parse(await redis.get(id)); }
1692
1472
  * async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
1693
1473
  * }
1694
- * PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
1474
+ * PersistBreakevenAdapter.usePersistBreakevenAdapter(RedisPersist);
1695
1475
  * ```
1696
1476
  */
1697
- usePersistSignalAdapter(Ctor) {
1698
- bt.loggerService.info(PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_PERSIST_SIGNAL_ADAPTER);
1699
- this.PersistSignalFactory = Ctor;
1477
+ usePersistBreakevenAdapter(Ctor) {
1478
+ bt.loggerService.info(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_PERSIST_BREAKEVEN_ADAPTER);
1479
+ this.PersistBreakevenFactory = Ctor;
1700
1480
  }
1701
1481
  /**
1702
1482
  * Switches to the default JSON persist adapter.
1703
1483
  * All future persistence writes will use JSON storage.
1704
1484
  */
1705
1485
  useJson() {
1706
- bt.loggerService.log(PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_JSON);
1707
- this.usePersistSignalAdapter(PersistBase);
1486
+ bt.loggerService.log(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_JSON);
1487
+ this.usePersistBreakevenAdapter(PersistBase);
1708
1488
  }
1709
1489
  /**
1710
1490
  * Switches to a dummy persist adapter that discards all writes.
1711
1491
  * All future persistence writes will be no-ops.
1712
1492
  */
1713
1493
  useDummy() {
1714
- bt.loggerService.log(PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_DUMMY);
1715
- this.usePersistSignalAdapter(PersistDummy);
1494
+ bt.loggerService.log(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_DUMMY);
1495
+ this.usePersistBreakevenAdapter(PersistDummy);
1716
1496
  }
1717
1497
  }
1718
1498
  /**
1719
- * Global singleton instance of PersistSignalUtils.
1720
- * Used by ClientStrategy for signal persistence.
1499
+ * Global singleton instance of PersistBreakevenUtils.
1500
+ * Used by ClientBreakeven for breakeven state persistence.
1721
1501
  *
1722
1502
  * @example
1723
1503
  * ```typescript
1724
1504
  * // Custom adapter
1725
- * PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
1505
+ * PersistBreakevenAdapter.usePersistBreakevenAdapter(RedisPersist);
1726
1506
  *
1727
- * // Read signal
1728
- * const signal = await PersistSignalAdapter.readSignalData("my-strategy", "BTCUSDT");
1507
+ * // Read breakeven data
1508
+ * const breakevenData = await PersistBreakevenAdapter.readBreakevenData("BTCUSDT", "my-strategy");
1729
1509
  *
1730
- * // Write signal
1731
- * await PersistSignalAdapter.writeSignalData(signal, "my-strategy", "BTCUSDT");
1510
+ * // Write breakeven data
1511
+ * await PersistBreakevenAdapter.writeBreakevenData(breakevenData, "BTCUSDT", "my-strategy");
1732
1512
  * ```
1733
1513
  */
1734
- const PersistSignalAdapter = new PersistSignalUtils();
1514
+ const PersistBreakevenAdapter = new PersistBreakevenUtils();
1735
1515
  /**
1736
- * Utility class for managing risk active positions persistence.
1516
+ * Utility class for managing candles cache persistence.
1737
1517
  *
1738
1518
  * Features:
1739
- * - Memoized storage instances per risk profile
1740
- * - Custom adapter support
1741
- * - Atomic read/write operations for RiskData
1742
- * - Crash-safe position state management
1519
+ * - Each candle stored as separate JSON file: ${exchangeName}/${symbol}/${interval}/${timestamp}.json
1520
+ * - Cache validation: returns cached data if file count matches requested limit
1521
+ * - Automatic cache invalidation and refresh when data is incomplete
1522
+ * - Atomic read/write operations
1743
1523
  *
1744
- * Used by ClientRisk for live mode persistence of active positions.
1524
+ * Used by ClientExchange for candle data caching.
1745
1525
  */
1746
- class PersistRiskUtils {
1526
+ class PersistCandleUtils {
1747
1527
  constructor() {
1748
- this.PersistRiskFactory = PersistBase;
1749
- this.getRiskStorage = memoize(([riskName, exchangeName]) => `${riskName}:${exchangeName}`, (riskName, exchangeName) => Reflect.construct(this.PersistRiskFactory, [
1750
- `${riskName}_${exchangeName}`,
1751
- `./dump/data/risk/`,
1528
+ this.PersistCandlesFactory = PersistBase;
1529
+ this.getCandlesStorage = memoize(([symbol, interval, exchangeName]) => `${symbol}:${interval}:${exchangeName}`, (symbol, interval, exchangeName) => Reflect.construct(this.PersistCandlesFactory, [
1530
+ `${exchangeName}/${symbol}/${interval}`,
1531
+ `./dump/data/candles/`,
1752
1532
  ]));
1753
1533
  /**
1754
- * Reads persisted active positions for a risk profile.
1755
- *
1756
- * Called by ClientRisk.waitForInit() to restore state.
1757
- * Returns empty Map if no positions exist.
1534
+ * Reads cached candles for a specific exchange, symbol, and interval.
1535
+ * Returns candles only if cache contains exactly the requested limit.
1758
1536
  *
1759
- * @param riskName - Risk profile identifier
1537
+ * @param symbol - Trading pair symbol
1538
+ * @param interval - Candle interval
1760
1539
  * @param exchangeName - Exchange identifier
1761
- * @returns Promise resolving to Map of active positions
1540
+ * @param limit - Number of candles requested
1541
+ * @param sinceTimestamp - Start timestamp (inclusive)
1542
+ * @param untilTimestamp - End timestamp (exclusive)
1543
+ * @returns Promise resolving to array of candles or null if cache is incomplete
1762
1544
  */
1763
- this.readPositionData = async (riskName, exchangeName) => {
1764
- bt.loggerService.info(PERSIST_RISK_UTILS_METHOD_NAME_READ_DATA);
1765
- const key = `${riskName}:${exchangeName}`;
1766
- const isInitial = !this.getRiskStorage.has(key);
1767
- const stateStorage = this.getRiskStorage(riskName, exchangeName);
1545
+ this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, untilTimestamp) => {
1546
+ bt.loggerService.info("PersistCandleUtils.readCandlesData", {
1547
+ symbol,
1548
+ interval,
1549
+ exchangeName,
1550
+ limit,
1551
+ sinceTimestamp,
1552
+ untilTimestamp,
1553
+ });
1554
+ const key = `${symbol}:${interval}:${exchangeName}`;
1555
+ const isInitial = !this.getCandlesStorage.has(key);
1556
+ const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
1768
1557
  await stateStorage.waitForInit(isInitial);
1769
- const RISK_STORAGE_KEY = "positions";
1770
- if (await stateStorage.hasValue(RISK_STORAGE_KEY)) {
1771
- return await stateStorage.readValue(RISK_STORAGE_KEY);
1558
+ // Collect all cached candles within the time range
1559
+ const cachedCandles = [];
1560
+ for await (const timestamp of stateStorage.keys()) {
1561
+ const ts = Number(timestamp);
1562
+ if (ts >= sinceTimestamp && ts < untilTimestamp) {
1563
+ try {
1564
+ const candle = await stateStorage.readValue(timestamp);
1565
+ cachedCandles.push(candle);
1566
+ }
1567
+ catch {
1568
+ // Skip invalid candles
1569
+ continue;
1570
+ }
1571
+ }
1772
1572
  }
1773
- return [];
1573
+ // Sort by timestamp ascending
1574
+ cachedCandles.sort((a, b) => a.timestamp - b.timestamp);
1575
+ return cachedCandles;
1774
1576
  };
1775
1577
  /**
1776
- * Writes active positions to disk with atomic file writes.
1777
- *
1778
- * Called by ClientRisk after addSignal/removeSignal to persist state.
1779
- * Uses atomic writes to prevent corruption on crashes.
1578
+ * Writes candles to cache with atomic file writes.
1579
+ * Each candle is stored as a separate JSON file named by its timestamp.
1780
1580
  *
1781
- * @param positions - Map of active positions
1782
- * @param riskName - Risk profile identifier
1581
+ * @param candles - Array of candle data to cache
1582
+ * @param symbol - Trading pair symbol
1583
+ * @param interval - Candle interval
1783
1584
  * @param exchangeName - Exchange identifier
1784
- * @returns Promise that resolves when write is complete
1585
+ * @returns Promise that resolves when all writes are complete
1785
1586
  */
1786
- this.writePositionData = async (riskRow, riskName, exchangeName) => {
1787
- bt.loggerService.info(PERSIST_RISK_UTILS_METHOD_NAME_WRITE_DATA);
1788
- const key = `${riskName}:${exchangeName}`;
1789
- const isInitial = !this.getRiskStorage.has(key);
1790
- const stateStorage = this.getRiskStorage(riskName, exchangeName);
1587
+ this.writeCandlesData = async (candles, symbol, interval, exchangeName) => {
1588
+ bt.loggerService.info("PersistCandleUtils.writeCandlesData", {
1589
+ symbol,
1590
+ interval,
1591
+ exchangeName,
1592
+ candleCount: candles.length,
1593
+ });
1594
+ const key = `${symbol}:${interval}:${exchangeName}`;
1595
+ const isInitial = !this.getCandlesStorage.has(key);
1596
+ const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
1791
1597
  await stateStorage.waitForInit(isInitial);
1792
- const RISK_STORAGE_KEY = "positions";
1793
- await stateStorage.writeValue(RISK_STORAGE_KEY, riskRow);
1598
+ // Write each candle as a separate file
1599
+ for (const candle of candles) {
1600
+ if (await not(stateStorage.hasValue(String(candle.timestamp)))) {
1601
+ await stateStorage.writeValue(String(candle.timestamp), candle);
1602
+ }
1603
+ }
1604
+ };
1605
+ }
1606
+ /**
1607
+ * Registers a custom persistence adapter.
1608
+ *
1609
+ * @param Ctor - Custom PersistBase constructor
1610
+ */
1611
+ usePersistCandleAdapter(Ctor) {
1612
+ bt.loggerService.info("PersistCandleUtils.usePersistCandleAdapter");
1613
+ this.PersistCandlesFactory = Ctor;
1614
+ }
1615
+ /**
1616
+ * Switches to the default JSON persist adapter.
1617
+ * All future persistence writes will use JSON storage.
1618
+ */
1619
+ useJson() {
1620
+ bt.loggerService.log("PersistCandleUtils.useJson");
1621
+ this.usePersistCandleAdapter(PersistBase);
1622
+ }
1623
+ /**
1624
+ * Switches to a dummy persist adapter that discards all writes.
1625
+ * All future persistence writes will be no-ops.
1626
+ */
1627
+ useDummy() {
1628
+ bt.loggerService.log("PersistCandleUtils.useDummy");
1629
+ this.usePersistCandleAdapter(PersistDummy);
1630
+ }
1631
+ }
1632
+ /**
1633
+ * Global singleton instance of PersistCandleUtils.
1634
+ * Used by ClientExchange for candle data caching.
1635
+ *
1636
+ * @example
1637
+ * ```typescript
1638
+ * // Read cached candles
1639
+ * const candles = await PersistCandleAdapter.readCandlesData(
1640
+ * "BTCUSDT", "1m", "binance", 100, since.getTime(), until.getTime()
1641
+ * );
1642
+ *
1643
+ * // Write candles to cache
1644
+ * await PersistCandleAdapter.writeCandlesData(candles, "BTCUSDT", "1m", "binance");
1645
+ * ```
1646
+ */
1647
+ const PersistCandleAdapter = new PersistCandleUtils();
1648
+
1649
+ const MS_PER_MINUTE$1 = 60000;
1650
+ const INTERVAL_MINUTES$4 = {
1651
+ "1m": 1,
1652
+ "3m": 3,
1653
+ "5m": 5,
1654
+ "15m": 15,
1655
+ "30m": 30,
1656
+ "1h": 60,
1657
+ "2h": 120,
1658
+ "4h": 240,
1659
+ "6h": 360,
1660
+ "8h": 480,
1661
+ };
1662
+ /**
1663
+ * Validates that all candles have valid OHLCV data without anomalies.
1664
+ * Detects incomplete candles from Binance API by checking for abnormally low prices or volumes.
1665
+ * Incomplete candles often have prices like 0.1 instead of normal 100,000 or zero volume.
1666
+ *
1667
+ * @param candles - Array of candle data to validate
1668
+ * @throws Error if any candles have anomalous OHLCV values
1669
+ */
1670
+ const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => {
1671
+ if (candles.length === 0) {
1672
+ return;
1673
+ }
1674
+ // Calculate reference price (median or average depending on candle count)
1675
+ const allPrices = candles.flatMap((c) => [c.open, c.high, c.low, c.close]);
1676
+ const validPrices = allPrices.filter((p) => p > 0);
1677
+ let referencePrice;
1678
+ if (candles.length >= GLOBAL_CONFIG.CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN) {
1679
+ // Use median for reliable statistics with enough data
1680
+ const sortedPrices = [...validPrices].sort((a, b) => a - b);
1681
+ referencePrice = sortedPrices[Math.floor(sortedPrices.length / 2)] || 0;
1682
+ }
1683
+ else {
1684
+ // Use average for small datasets (more stable than median)
1685
+ const sum = validPrices.reduce((acc, p) => acc + p, 0);
1686
+ referencePrice = validPrices.length > 0 ? sum / validPrices.length : 0;
1687
+ }
1688
+ if (referencePrice === 0) {
1689
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: cannot calculate reference price (all prices are zero)`);
1690
+ }
1691
+ const minValidPrice = referencePrice /
1692
+ GLOBAL_CONFIG.CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR;
1693
+ for (let i = 0; i < candles.length; i++) {
1694
+ const candle = candles[i];
1695
+ // Check for invalid numeric values
1696
+ if (!Number.isFinite(candle.open) ||
1697
+ !Number.isFinite(candle.high) ||
1698
+ !Number.isFinite(candle.low) ||
1699
+ !Number.isFinite(candle.close) ||
1700
+ !Number.isFinite(candle.volume) ||
1701
+ !Number.isFinite(candle.timestamp)) {
1702
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has invalid numeric values (NaN or Infinity)`);
1703
+ }
1704
+ // Check for negative values
1705
+ if (candle.open <= 0 ||
1706
+ candle.high <= 0 ||
1707
+ candle.low <= 0 ||
1708
+ candle.close <= 0 ||
1709
+ candle.volume < 0) {
1710
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has zero or negative values`);
1711
+ }
1712
+ // Check for anomalously low prices (incomplete candle indicator)
1713
+ if (candle.open < minValidPrice ||
1714
+ candle.high < minValidPrice ||
1715
+ candle.low < minValidPrice ||
1716
+ candle.close < minValidPrice) {
1717
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has anomalously low price. ` +
1718
+ `OHLC: [${candle.open}, ${candle.high}, ${candle.low}, ${candle.close}], ` +
1719
+ `reference: ${referencePrice}, threshold: ${minValidPrice}`);
1720
+ }
1721
+ }
1722
+ };
1723
+ /**
1724
+ * Attempts to read candles from cache.
1725
+ * Validates cache consistency (no gaps in timestamps) before returning.
1726
+ *
1727
+ * @param dto - Data transfer object containing symbol, interval, and limit
1728
+ * @param sinceTimestamp - Start timestamp in milliseconds
1729
+ * @param untilTimestamp - End timestamp in milliseconds
1730
+ * @param self - Instance of ClientExchange
1731
+ * @returns Cached candles array or null if cache miss or inconsistent
1732
+ */
1733
+ const READ_CANDLES_CACHE_FN$1 = trycatch(async (dto, sinceTimestamp, untilTimestamp, self) => {
1734
+ const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, self.params.exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
1735
+ // Return cached data only if we have exactly the requested limit
1736
+ if (cachedCandles.length === dto.limit) {
1737
+ self.params.logger.debug(`ClientExchange READ_CANDLES_CACHE_FN: cache hit for symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
1738
+ return cachedCandles;
1739
+ }
1740
+ self.params.logger.warn(`ClientExchange READ_CANDLES_CACHE_FN: cache inconsistent (count or range mismatch) for symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
1741
+ return null;
1742
+ }, {
1743
+ fallback: async (error) => {
1744
+ const message = `ClientExchange READ_CANDLES_CACHE_FN: cache read failed`;
1745
+ const payload = {
1746
+ error: errorData(error),
1747
+ message: getErrorMessage(error),
1748
+ };
1749
+ bt.loggerService.warn(message, payload);
1750
+ console.warn(message, payload);
1751
+ errorEmitter.next(error);
1752
+ },
1753
+ defaultValue: null,
1754
+ });
1755
+ /**
1756
+ * Writes candles to cache with error handling.
1757
+ *
1758
+ * @param candles - Array of candle data to cache
1759
+ * @param dto - Data transfer object containing symbol, interval, and limit
1760
+ * @param self - Instance of ClientExchange
1761
+ */
1762
+ const WRITE_CANDLES_CACHE_FN$1 = trycatch(queued(async (candles, dto, self) => {
1763
+ await PersistCandleAdapter.writeCandlesData(candles, dto.symbol, dto.interval, self.params.exchangeName);
1764
+ self.params.logger.debug(`ClientExchange WRITE_CANDLES_CACHE_FN: cache updated for symbol=${dto.symbol}, interval=${dto.interval}, count=${candles.length}`);
1765
+ }), {
1766
+ fallback: async (error) => {
1767
+ const message = `ClientExchange WRITE_CANDLES_CACHE_FN: cache write failed`;
1768
+ const payload = {
1769
+ error: errorData(error),
1770
+ message: getErrorMessage(error),
1771
+ };
1772
+ bt.loggerService.warn(message, payload);
1773
+ console.warn(message, payload);
1774
+ errorEmitter.next(error);
1775
+ },
1776
+ defaultValue: null,
1777
+ });
1778
+ /**
1779
+ * Retries the getCandles function with specified retry count and delay.
1780
+ * Uses cache to avoid redundant API calls.
1781
+ *
1782
+ * Cache logic:
1783
+ * - Checks if cached candles exist for the time range
1784
+ * - If cache has exactly dto.limit candles, returns cached data
1785
+ * - Otherwise, fetches from API and updates cache
1786
+ *
1787
+ * @param dto - Data transfer object containing symbol, interval, and limit
1788
+ * @param since - Date object representing the start time for fetching candles
1789
+ * @param self - Instance of ClientExchange
1790
+ * @returns Promise resolving to array of candle data
1791
+ */
1792
+ const GET_CANDLES_FN = async (dto, since, self) => {
1793
+ const step = INTERVAL_MINUTES$4[dto.interval];
1794
+ const sinceTimestamp = since.getTime();
1795
+ const untilTimestamp = sinceTimestamp + dto.limit * step * MS_PER_MINUTE$1;
1796
+ // Try to read from cache first
1797
+ const cachedCandles = await READ_CANDLES_CACHE_FN$1(dto, sinceTimestamp, untilTimestamp, self);
1798
+ if (cachedCandles !== null) {
1799
+ return cachedCandles;
1800
+ }
1801
+ // Cache miss or error - fetch from API
1802
+ let lastError;
1803
+ for (let i = 0; i !== GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT; i++) {
1804
+ try {
1805
+ const result = await self.params.getCandles(dto.symbol, dto.interval, since, dto.limit, self.params.execution.context.backtest);
1806
+ VALIDATE_NO_INCOMPLETE_CANDLES_FN(result);
1807
+ // Write to cache after successful fetch
1808
+ await WRITE_CANDLES_CACHE_FN$1(result, dto, self);
1809
+ return result;
1810
+ }
1811
+ catch (err) {
1812
+ const message = `ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}}`;
1813
+ const payload = {
1814
+ error: errorData(err),
1815
+ message: getErrorMessage(err),
1816
+ };
1817
+ self.params.logger.warn(message, payload);
1818
+ console.warn(message, payload);
1819
+ lastError = err;
1820
+ await sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
1821
+ }
1822
+ }
1823
+ throw lastError;
1824
+ };
1825
+ /**
1826
+ * Wrapper to call onCandleData callback with error handling.
1827
+ * Catches and logs any errors thrown by the user-provided callback.
1828
+ *
1829
+ * @param self - ClientExchange instance reference
1830
+ * @param symbol - Trading pair symbol
1831
+ * @param interval - Candle interval
1832
+ * @param since - Start date for candle data
1833
+ * @param limit - Number of candles
1834
+ * @param data - Array of candle data
1835
+ */
1836
+ const CALL_CANDLE_DATA_CALLBACKS_FN = trycatch(async (self, symbol, interval, since, limit, data) => {
1837
+ if (self.params.callbacks?.onCandleData) {
1838
+ await self.params.callbacks.onCandleData(symbol, interval, since, limit, data);
1839
+ }
1840
+ }, {
1841
+ fallback: (error) => {
1842
+ const message = "ClientExchange CALL_CANDLE_DATA_CALLBACKS_FN thrown";
1843
+ const payload = {
1844
+ error: errorData(error),
1845
+ message: getErrorMessage(error),
1794
1846
  };
1847
+ bt.loggerService.warn(message, payload);
1848
+ console.warn(message, payload);
1849
+ errorEmitter.next(error);
1850
+ },
1851
+ });
1852
+ /**
1853
+ * Client implementation for exchange data access.
1854
+ *
1855
+ * Features:
1856
+ * - Historical candle fetching (backwards from execution context)
1857
+ * - Future candle fetching (forwards for backtest)
1858
+ * - VWAP calculation from last 5 1m candles
1859
+ * - Price/quantity formatting for exchange
1860
+ *
1861
+ * All methods use prototype functions for memory efficiency.
1862
+ *
1863
+ * @example
1864
+ * ```typescript
1865
+ * const exchange = new ClientExchange({
1866
+ * exchangeName: "binance",
1867
+ * getCandles: async (symbol, interval, since, limit) => [...],
1868
+ * formatPrice: async (symbol, price) => price.toFixed(2),
1869
+ * formatQuantity: async (symbol, quantity) => quantity.toFixed(8),
1870
+ * execution: executionService,
1871
+ * logger: loggerService,
1872
+ * });
1873
+ *
1874
+ * const candles = await exchange.getCandles("BTCUSDT", "1m", 100);
1875
+ * const vwap = await exchange.getAveragePrice("BTCUSDT");
1876
+ * ```
1877
+ */
1878
+ class ClientExchange {
1879
+ constructor(params) {
1880
+ this.params = params;
1881
+ }
1882
+ /**
1883
+ * Fetches historical candles backwards from execution context time.
1884
+ *
1885
+ * @param symbol - Trading pair symbol
1886
+ * @param interval - Candle interval
1887
+ * @param limit - Number of candles to fetch
1888
+ * @returns Promise resolving to array of candles
1889
+ */
1890
+ async getCandles(symbol, interval, limit) {
1891
+ this.params.logger.debug(`ClientExchange getCandles`, {
1892
+ symbol,
1893
+ interval,
1894
+ limit,
1895
+ });
1896
+ const step = INTERVAL_MINUTES$4[interval];
1897
+ const adjust = step * limit;
1898
+ if (!adjust) {
1899
+ throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
1900
+ }
1901
+ const since = new Date(this.params.execution.context.when.getTime() - adjust * MS_PER_MINUTE$1);
1902
+ let allData = [];
1903
+ // If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
1904
+ if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
1905
+ let remaining = limit;
1906
+ let currentSince = new Date(since.getTime());
1907
+ while (remaining > 0) {
1908
+ const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
1909
+ const chunkData = await GET_CANDLES_FN({ symbol, interval, limit: chunkLimit }, currentSince, this);
1910
+ allData.push(...chunkData);
1911
+ remaining -= chunkLimit;
1912
+ if (remaining > 0) {
1913
+ // Move currentSince forward by the number of candles fetched
1914
+ currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
1915
+ }
1916
+ }
1917
+ }
1918
+ else {
1919
+ allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
1920
+ }
1921
+ // Filter candles to strictly match the requested range
1922
+ const whenTimestamp = this.params.execution.context.when.getTime();
1923
+ const sinceTimestamp = since.getTime();
1924
+ const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp &&
1925
+ candle.timestamp < whenTimestamp);
1926
+ // Apply distinct by timestamp to remove duplicates
1927
+ const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
1928
+ if (filteredData.length !== uniqueData.length) {
1929
+ const msg = `ClientExchange Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
1930
+ this.params.logger.warn(msg);
1931
+ console.warn(msg);
1932
+ }
1933
+ if (uniqueData.length < limit) {
1934
+ const msg = `ClientExchange Expected ${limit} candles, got ${uniqueData.length}`;
1935
+ this.params.logger.warn(msg);
1936
+ console.warn(msg);
1937
+ }
1938
+ await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
1939
+ return uniqueData;
1795
1940
  }
1796
1941
  /**
1797
- * Registers a custom persistence adapter.
1798
- *
1799
- * @param Ctor - Custom PersistBase constructor
1942
+ * Fetches future candles forwards from execution context time.
1943
+ * Used in backtest mode to get candles for signal duration.
1800
1944
  *
1801
- * @example
1802
- * ```typescript
1803
- * class RedisPersist extends PersistBase {
1804
- * async readValue(id) { return JSON.parse(await redis.get(id)); }
1805
- * async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
1806
- * }
1807
- * PersistRiskAdapter.usePersistRiskAdapter(RedisPersist);
1808
- * ```
1809
- */
1810
- usePersistRiskAdapter(Ctor) {
1811
- bt.loggerService.info(PERSIST_RISK_UTILS_METHOD_NAME_USE_PERSIST_RISK_ADAPTER);
1812
- this.PersistRiskFactory = Ctor;
1813
- }
1814
- /**
1815
- * Switches to the default JSON persist adapter.
1816
- * All future persistence writes will use JSON storage.
1817
- */
1818
- useJson() {
1819
- bt.loggerService.log(PERSIST_RISK_UTILS_METHOD_NAME_USE_JSON);
1820
- this.usePersistRiskAdapter(PersistBase);
1821
- }
1822
- /**
1823
- * Switches to a dummy persist adapter that discards all writes.
1824
- * All future persistence writes will be no-ops.
1945
+ * @param symbol - Trading pair symbol
1946
+ * @param interval - Candle interval
1947
+ * @param limit - Number of candles to fetch
1948
+ * @returns Promise resolving to array of candles
1949
+ * @throws Error if trying to fetch future candles in live mode
1825
1950
  */
1826
- useDummy() {
1827
- bt.loggerService.log(PERSIST_RISK_UTILS_METHOD_NAME_USE_DUMMY);
1828
- this.usePersistRiskAdapter(PersistDummy);
1829
- }
1830
- }
1831
- /**
1832
- * Global singleton instance of PersistRiskUtils.
1833
- * Used by ClientRisk for active positions persistence.
1834
- *
1835
- * @example
1836
- * ```typescript
1837
- * // Custom adapter
1838
- * PersistRiskAdapter.usePersistRiskAdapter(RedisPersist);
1839
- *
1840
- * // Read positions
1841
- * const positions = await PersistRiskAdapter.readPositionData("my-risk");
1842
- *
1843
- * // Write positions
1844
- * await PersistRiskAdapter.writePositionData(positionsMap, "my-risk");
1845
- * ```
1846
- */
1847
- const PersistRiskAdapter = new PersistRiskUtils();
1848
- /**
1849
- * Utility class for managing scheduled signal persistence.
1850
- *
1851
- * Features:
1852
- * - Memoized storage instances per strategy
1853
- * - Custom adapter support
1854
- * - Atomic read/write operations for scheduled signals
1855
- * - Crash-safe scheduled signal state management
1856
- *
1857
- * Used by ClientStrategy for live mode persistence of scheduled signals (_scheduledSignal).
1858
- */
1859
- class PersistScheduleUtils {
1860
- constructor() {
1861
- this.PersistScheduleFactory = PersistBase;
1862
- this.getScheduleStorage = memoize(([symbol, strategyName, exchangeName]) => `${symbol}:${strategyName}:${exchangeName}`, (symbol, strategyName, exchangeName) => Reflect.construct(this.PersistScheduleFactory, [
1863
- `${symbol}_${strategyName}_${exchangeName}`,
1864
- `./dump/data/schedule/`,
1865
- ]));
1866
- /**
1867
- * Reads persisted scheduled signal data for a symbol and strategy.
1868
- *
1869
- * Called by ClientStrategy.waitForInit() to restore scheduled signal state.
1870
- * Returns null if no scheduled signal exists.
1871
- *
1872
- * @param symbol - Trading pair symbol
1873
- * @param strategyName - Strategy identifier
1874
- * @param exchangeName - Exchange identifier
1875
- * @returns Promise resolving to scheduled signal or null
1876
- */
1877
- this.readScheduleData = async (symbol, strategyName, exchangeName) => {
1878
- bt.loggerService.info(PERSIST_SCHEDULE_UTILS_METHOD_NAME_READ_DATA);
1879
- const key = `${symbol}:${strategyName}:${exchangeName}`;
1880
- const isInitial = !this.getScheduleStorage.has(key);
1881
- const stateStorage = this.getScheduleStorage(symbol, strategyName, exchangeName);
1882
- await stateStorage.waitForInit(isInitial);
1883
- if (await stateStorage.hasValue(symbol)) {
1884
- return await stateStorage.readValue(symbol);
1951
+ async getNextCandles(symbol, interval, limit) {
1952
+ this.params.logger.debug(`ClientExchange getNextCandles`, {
1953
+ symbol,
1954
+ interval,
1955
+ limit,
1956
+ });
1957
+ const since = new Date(this.params.execution.context.when.getTime());
1958
+ const now = Date.now();
1959
+ // Вычисляем конечное время запроса
1960
+ const step = INTERVAL_MINUTES$4[interval];
1961
+ const endTime = since.getTime() + limit * step * MS_PER_MINUTE$1;
1962
+ // Проверяем что запрошенный период не заходит за Date.now()
1963
+ if (endTime > now) {
1964
+ return [];
1965
+ }
1966
+ let allData = [];
1967
+ // If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
1968
+ if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
1969
+ let remaining = limit;
1970
+ let currentSince = new Date(since.getTime());
1971
+ while (remaining > 0) {
1972
+ const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
1973
+ const chunkData = await GET_CANDLES_FN({ symbol, interval, limit: chunkLimit }, currentSince, this);
1974
+ allData.push(...chunkData);
1975
+ remaining -= chunkLimit;
1976
+ if (remaining > 0) {
1977
+ // Move currentSince forward by the number of candles fetched
1978
+ currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
1979
+ }
1885
1980
  }
1886
- return null;
1887
- };
1888
- /**
1889
- * Writes scheduled signal data to disk with atomic file writes.
1890
- *
1891
- * Called by ClientStrategy.setScheduledSignal() to persist state.
1892
- * Uses atomic writes to prevent corruption on crashes.
1893
- *
1894
- * @param scheduledSignalRow - Scheduled signal data (null to clear)
1895
- * @param symbol - Trading pair symbol
1896
- * @param strategyName - Strategy identifier
1897
- * @param exchangeName - Exchange identifier
1898
- * @returns Promise that resolves when write is complete
1899
- */
1900
- this.writeScheduleData = async (scheduledSignalRow, symbol, strategyName, exchangeName) => {
1901
- bt.loggerService.info(PERSIST_SCHEDULE_UTILS_METHOD_NAME_WRITE_DATA);
1902
- const key = `${symbol}:${strategyName}:${exchangeName}`;
1903
- const isInitial = !this.getScheduleStorage.has(key);
1904
- const stateStorage = this.getScheduleStorage(symbol, strategyName, exchangeName);
1905
- await stateStorage.waitForInit(isInitial);
1906
- await stateStorage.writeValue(symbol, scheduledSignalRow);
1907
- };
1981
+ }
1982
+ else {
1983
+ allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
1984
+ }
1985
+ // Filter candles to strictly match the requested range
1986
+ const sinceTimestamp = since.getTime();
1987
+ const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp < endTime);
1988
+ // Apply distinct by timestamp to remove duplicates
1989
+ const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
1990
+ if (filteredData.length !== uniqueData.length) {
1991
+ const msg = `ClientExchange getNextCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
1992
+ this.params.logger.warn(msg);
1993
+ console.warn(msg);
1994
+ }
1995
+ if (uniqueData.length < limit) {
1996
+ const msg = `ClientExchange getNextCandles: Expected ${limit} candles, got ${uniqueData.length}`;
1997
+ this.params.logger.warn(msg);
1998
+ console.warn(msg);
1999
+ }
2000
+ await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
2001
+ return uniqueData;
1908
2002
  }
1909
2003
  /**
1910
- * Registers a custom persistence adapter.
2004
+ * Calculates VWAP (Volume Weighted Average Price) from last N 1m candles.
2005
+ * The number of candles is configurable via GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT.
1911
2006
  *
1912
- * @param Ctor - Custom PersistBase constructor
2007
+ * Formula:
2008
+ * - Typical Price = (high + low + close) / 3
2009
+ * - VWAP = sum(typical_price * volume) / sum(volume)
1913
2010
  *
1914
- * @example
1915
- * ```typescript
1916
- * class RedisPersist extends PersistBase {
1917
- * async readValue(id) { return JSON.parse(await redis.get(id)); }
1918
- * async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
1919
- * }
1920
- * PersistScheduleAdapter.usePersistScheduleAdapter(RedisPersist);
1921
- * ```
2011
+ * If volume is zero, returns simple average of close prices.
2012
+ *
2013
+ * @param symbol - Trading pair symbol
2014
+ * @returns Promise resolving to VWAP price
2015
+ * @throws Error if no candles available
1922
2016
  */
1923
- usePersistScheduleAdapter(Ctor) {
1924
- bt.loggerService.info(PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_PERSIST_SCHEDULE_ADAPTER);
1925
- this.PersistScheduleFactory = Ctor;
2017
+ async getAveragePrice(symbol) {
2018
+ this.params.logger.debug(`ClientExchange getAveragePrice`, {
2019
+ symbol,
2020
+ });
2021
+ const candles = await this.getCandles(symbol, "1m", GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT);
2022
+ if (candles.length === 0) {
2023
+ throw new Error(`ClientExchange getAveragePrice: no candles data for symbol=${symbol}`);
2024
+ }
2025
+ // VWAP (Volume Weighted Average Price)
2026
+ // Используем типичную цену (typical price) = (high + low + close) / 3
2027
+ const sumPriceVolume = candles.reduce((acc, candle) => {
2028
+ const typicalPrice = (candle.high + candle.low + candle.close) / 3;
2029
+ return acc + typicalPrice * candle.volume;
2030
+ }, 0);
2031
+ const totalVolume = candles.reduce((acc, candle) => acc + candle.volume, 0);
2032
+ if (totalVolume === 0) {
2033
+ // Если объем нулевой, возвращаем простое среднее close цен
2034
+ const sum = candles.reduce((acc, candle) => acc + candle.close, 0);
2035
+ return sum / candles.length;
2036
+ }
2037
+ const vwap = sumPriceVolume / totalVolume;
2038
+ return vwap;
1926
2039
  }
1927
2040
  /**
1928
- * Switches to the default JSON persist adapter.
1929
- * All future persistence writes will use JSON storage.
2041
+ * Formats quantity according to exchange-specific rules for the given symbol.
2042
+ * Applies proper decimal precision and rounding based on symbol's lot size filters.
2043
+ *
2044
+ * @param symbol - Trading pair symbol
2045
+ * @param quantity - Raw quantity to format
2046
+ * @returns Promise resolving to formatted quantity as string
1930
2047
  */
1931
- useJson() {
1932
- bt.loggerService.log(PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_JSON);
1933
- this.usePersistScheduleAdapter(PersistBase);
2048
+ async formatQuantity(symbol, quantity) {
2049
+ this.params.logger.debug("binanceService formatQuantity", {
2050
+ symbol,
2051
+ quantity,
2052
+ });
2053
+ return await this.params.formatQuantity(symbol, quantity, this.params.execution.context.backtest);
1934
2054
  }
1935
2055
  /**
1936
- * Switches to a dummy persist adapter that discards all writes.
1937
- * All future persistence writes will be no-ops.
2056
+ * Formats price according to exchange-specific rules for the given symbol.
2057
+ * Applies proper decimal precision and rounding based on symbol's price filters.
2058
+ *
2059
+ * @param symbol - Trading pair symbol
2060
+ * @param price - Raw price to format
2061
+ * @returns Promise resolving to formatted price as string
1938
2062
  */
1939
- useDummy() {
1940
- bt.loggerService.log(PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_DUMMY);
1941
- this.usePersistScheduleAdapter(PersistDummy);
1942
- }
1943
- }
1944
- /**
1945
- * Global singleton instance of PersistScheduleUtils.
1946
- * Used by ClientStrategy for scheduled signal persistence.
1947
- *
1948
- * @example
1949
- * ```typescript
1950
- * // Custom adapter
1951
- * PersistScheduleAdapter.usePersistScheduleAdapter(RedisPersist);
1952
- *
1953
- * // Read scheduled signal
1954
- * const scheduled = await PersistScheduleAdapter.readScheduleData("my-strategy", "BTCUSDT");
1955
- *
1956
- * // Write scheduled signal
1957
- * await PersistScheduleAdapter.writeScheduleData(scheduled, "my-strategy", "BTCUSDT");
1958
- * ```
1959
- */
1960
- const PersistScheduleAdapter = new PersistScheduleUtils();
1961
- /**
1962
- * Utility class for managing partial profit/loss levels persistence.
1963
- *
1964
- * Features:
1965
- * - Memoized storage instances per symbol:strategyName
1966
- * - Custom adapter support
1967
- * - Atomic read/write operations for partial data
1968
- * - Crash-safe partial state management
1969
- *
1970
- * Used by ClientPartial for live mode persistence of profit/loss levels.
1971
- */
1972
- class PersistPartialUtils {
1973
- constructor() {
1974
- this.PersistPartialFactory = PersistBase;
1975
- this.getPartialStorage = memoize(([symbol, strategyName, exchangeName]) => `${symbol}:${strategyName}:${exchangeName}`, (symbol, strategyName, exchangeName) => Reflect.construct(this.PersistPartialFactory, [
1976
- `${symbol}_${strategyName}_${exchangeName}`,
1977
- `./dump/data/partial/`,
1978
- ]));
1979
- /**
1980
- * Reads persisted partial data for a symbol and strategy.
1981
- *
1982
- * Called by ClientPartial.waitForInit() to restore state.
1983
- * Returns empty object if no partial data exists.
1984
- *
1985
- * @param symbol - Trading pair symbol
1986
- * @param strategyName - Strategy identifier
1987
- * @param signalId - Signal identifier
1988
- * @param exchangeName - Exchange identifier
1989
- * @returns Promise resolving to partial data record
1990
- */
1991
- this.readPartialData = async (symbol, strategyName, signalId, exchangeName) => {
1992
- bt.loggerService.info(PERSIST_PARTIAL_UTILS_METHOD_NAME_READ_DATA);
1993
- const key = `${symbol}:${strategyName}:${exchangeName}`;
1994
- const isInitial = !this.getPartialStorage.has(key);
1995
- const stateStorage = this.getPartialStorage(symbol, strategyName, exchangeName);
1996
- await stateStorage.waitForInit(isInitial);
1997
- if (await stateStorage.hasValue(signalId)) {
1998
- return await stateStorage.readValue(signalId);
1999
- }
2000
- return {};
2001
- };
2002
- /**
2003
- * Writes partial data to disk with atomic file writes.
2004
- *
2005
- * Called by ClientPartial after profit/loss level changes to persist state.
2006
- * Uses atomic writes to prevent corruption on crashes.
2007
- *
2008
- * @param partialData - Record of signal IDs to partial data
2009
- * @param symbol - Trading pair symbol
2010
- * @param strategyName - Strategy identifier
2011
- * @param signalId - Signal identifier
2012
- * @param exchangeName - Exchange identifier
2013
- * @returns Promise that resolves when write is complete
2014
- */
2015
- this.writePartialData = async (partialData, symbol, strategyName, signalId, exchangeName) => {
2016
- bt.loggerService.info(PERSIST_PARTIAL_UTILS_METHOD_NAME_WRITE_DATA);
2017
- const key = `${symbol}:${strategyName}:${exchangeName}`;
2018
- const isInitial = !this.getPartialStorage.has(key);
2019
- const stateStorage = this.getPartialStorage(symbol, strategyName, exchangeName);
2020
- await stateStorage.waitForInit(isInitial);
2021
- await stateStorage.writeValue(signalId, partialData);
2022
- };
2063
+ async formatPrice(symbol, price) {
2064
+ this.params.logger.debug("binanceService formatPrice", {
2065
+ symbol,
2066
+ price,
2067
+ });
2068
+ return await this.params.formatPrice(symbol, price, this.params.execution.context.backtest);
2023
2069
  }
2024
2070
  /**
2025
- * Registers a custom persistence adapter.
2071
+ * Fetches raw candles with flexible date/limit parameters.
2026
2072
  *
2027
- * @param Ctor - Custom PersistBase constructor
2073
+ * All modes respect execution context and prevent look-ahead bias.
2028
2074
  *
2029
- * @example
2030
- * ```typescript
2031
- * class RedisPersist extends PersistBase {
2032
- * async readValue(id) { return JSON.parse(await redis.get(id)); }
2033
- * async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
2034
- * }
2035
- * PersistPartialAdapter.usePersistPartialAdapter(RedisPersist);
2036
- * ```
2037
- */
2038
- usePersistPartialAdapter(Ctor) {
2039
- bt.loggerService.info(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_PERSIST_PARTIAL_ADAPTER);
2040
- this.PersistPartialFactory = Ctor;
2041
- }
2042
- /**
2043
- * Switches to the default JSON persist adapter.
2044
- * All future persistence writes will use JSON storage.
2075
+ * Parameter combinations:
2076
+ * 1. sDate + eDate + limit: fetches with explicit parameters, validates eDate <= when
2077
+ * 2. sDate + eDate: calculates limit from date range, validates eDate <= when
2078
+ * 3. eDate + limit: calculates sDate backward, validates eDate <= when
2079
+ * 4. sDate + limit: fetches forward, validates calculated endTimestamp <= when
2080
+ * 5. Only limit: uses execution.context.when as reference (backward)
2081
+ *
2082
+ * Edge cases:
2083
+ * - If calculated limit is 0 or negative: throws error
2084
+ * - If sDate >= eDate: throws error
2085
+ * - If eDate > when: throws error to prevent look-ahead bias
2086
+ *
2087
+ * @param symbol - Trading pair symbol
2088
+ * @param interval - Candle interval
2089
+ * @param limit - Optional number of candles to fetch
2090
+ * @param sDate - Optional start date in milliseconds
2091
+ * @param eDate - Optional end date in milliseconds
2092
+ * @returns Promise resolving to array of candles
2093
+ * @throws Error if parameters are invalid or conflicting
2045
2094
  */
2046
- useJson() {
2047
- bt.loggerService.log(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_JSON);
2048
- this.usePersistPartialAdapter(PersistBase);
2095
+ async getRawCandles(symbol, interval, limit, sDate, eDate) {
2096
+ this.params.logger.debug(`ClientExchange getRawCandles`, {
2097
+ symbol,
2098
+ interval,
2099
+ limit,
2100
+ sDate,
2101
+ eDate,
2102
+ });
2103
+ const step = INTERVAL_MINUTES$4[interval];
2104
+ if (!step) {
2105
+ throw new Error(`ClientExchange getRawCandles: unknown interval=${interval}`);
2106
+ }
2107
+ const whenTimestamp = this.params.execution.context.when.getTime();
2108
+ let sinceTimestamp;
2109
+ let untilTimestamp;
2110
+ let calculatedLimit;
2111
+ // Case 1: all three parameters provided
2112
+ if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
2113
+ if (sDate >= eDate) {
2114
+ throw new Error(`ClientExchange getRawCandles: sDate (${sDate}) must be < eDate (${eDate})`);
2115
+ }
2116
+ if (eDate > whenTimestamp) {
2117
+ throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2118
+ }
2119
+ sinceTimestamp = sDate;
2120
+ untilTimestamp = eDate;
2121
+ calculatedLimit = limit;
2122
+ }
2123
+ // Case 2: sDate + eDate (no limit) - calculate limit from date range
2124
+ else if (sDate !== undefined && eDate !== undefined && limit === undefined) {
2125
+ if (sDate >= eDate) {
2126
+ throw new Error(`ClientExchange getRawCandles: sDate (${sDate}) must be < eDate (${eDate})`);
2127
+ }
2128
+ if (eDate > whenTimestamp) {
2129
+ throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2130
+ }
2131
+ sinceTimestamp = sDate;
2132
+ untilTimestamp = eDate;
2133
+ calculatedLimit = Math.ceil((eDate - sDate) / (step * MS_PER_MINUTE$1));
2134
+ if (calculatedLimit <= 0) {
2135
+ throw new Error(`ClientExchange getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
2136
+ }
2137
+ }
2138
+ // Case 3: eDate + limit (no sDate) - calculate sDate backward from eDate
2139
+ else if (sDate === undefined && eDate !== undefined && limit !== undefined) {
2140
+ if (eDate > whenTimestamp) {
2141
+ throw new Error(`ClientExchange getRawCandles: eDate (${eDate}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2142
+ }
2143
+ untilTimestamp = eDate;
2144
+ sinceTimestamp = eDate - limit * step * MS_PER_MINUTE$1;
2145
+ calculatedLimit = limit;
2146
+ }
2147
+ // Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
2148
+ else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
2149
+ sinceTimestamp = sDate;
2150
+ untilTimestamp = sDate + limit * step * MS_PER_MINUTE$1;
2151
+ if (untilTimestamp > whenTimestamp) {
2152
+ throw new Error(`ClientExchange getRawCandles: calculated endTimestamp (${untilTimestamp}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
2153
+ }
2154
+ calculatedLimit = limit;
2155
+ }
2156
+ // Case 5: Only limit - use execution.context.when as reference (backward like getCandles)
2157
+ else if (sDate === undefined && eDate === undefined && limit !== undefined) {
2158
+ untilTimestamp = whenTimestamp;
2159
+ sinceTimestamp = whenTimestamp - limit * step * MS_PER_MINUTE$1;
2160
+ calculatedLimit = limit;
2161
+ }
2162
+ // Invalid: no parameters or only sDate or only eDate
2163
+ else {
2164
+ throw new Error(`ClientExchange getRawCandles: invalid parameter combination. ` +
2165
+ `Provide one of: (sDate+eDate+limit), (sDate+eDate), (eDate+limit), (sDate+limit), or (limit only). ` +
2166
+ `Got: sDate=${sDate}, eDate=${eDate}, limit=${limit}`);
2167
+ }
2168
+ // Fetch candles using existing logic
2169
+ const since = new Date(sinceTimestamp);
2170
+ let allData = [];
2171
+ if (calculatedLimit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
2172
+ let remaining = calculatedLimit;
2173
+ let currentSince = new Date(since.getTime());
2174
+ while (remaining > 0) {
2175
+ const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
2176
+ const chunkData = await GET_CANDLES_FN({ symbol, interval, limit: chunkLimit }, currentSince, this);
2177
+ allData.push(...chunkData);
2178
+ remaining -= chunkLimit;
2179
+ if (remaining > 0) {
2180
+ currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE$1);
2181
+ }
2182
+ }
2183
+ }
2184
+ else {
2185
+ allData = await GET_CANDLES_FN({ symbol, interval, limit: calculatedLimit }, since, this);
2186
+ }
2187
+ // Filter candles to strictly match the requested range
2188
+ const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp &&
2189
+ candle.timestamp < untilTimestamp);
2190
+ // Apply distinct by timestamp to remove duplicates
2191
+ const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
2192
+ if (filteredData.length !== uniqueData.length) {
2193
+ const msg = `ClientExchange getRawCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
2194
+ this.params.logger.warn(msg);
2195
+ console.warn(msg);
2196
+ }
2197
+ if (uniqueData.length < calculatedLimit) {
2198
+ const msg = `ClientExchange getRawCandles: Expected ${calculatedLimit} candles, got ${uniqueData.length}`;
2199
+ this.params.logger.warn(msg);
2200
+ console.warn(msg);
2201
+ }
2202
+ await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, calculatedLimit, uniqueData);
2203
+ return uniqueData;
2049
2204
  }
2050
2205
  /**
2051
- * Switches to a dummy persist adapter that discards all writes.
2052
- * All future persistence writes will be no-ops.
2206
+ * Fetches order book for a trading pair.
2207
+ *
2208
+ * Calculates time range based on execution context time (when) and
2209
+ * CC_ORDER_BOOK_TIME_OFFSET_MINUTES, then delegates to the exchange
2210
+ * schema implementation which may use or ignore the time range.
2211
+ *
2212
+ * @param symbol - Trading pair symbol
2213
+ * @param depth - Maximum depth levels (default: CC_ORDER_BOOK_MAX_DEPTH_LEVELS)
2214
+ * @returns Promise resolving to order book data
2215
+ * @throws Error if getOrderBook is not implemented
2053
2216
  */
2054
- useDummy() {
2055
- bt.loggerService.log(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_DUMMY);
2056
- this.usePersistPartialAdapter(PersistDummy);
2217
+ async getOrderBook(symbol, depth = GLOBAL_CONFIG.CC_ORDER_BOOK_MAX_DEPTH_LEVELS) {
2218
+ this.params.logger.debug("ClientExchange getOrderBook", {
2219
+ symbol,
2220
+ depth,
2221
+ });
2222
+ const to = new Date(this.params.execution.context.when.getTime());
2223
+ const from = new Date(to.getTime() -
2224
+ GLOBAL_CONFIG.CC_ORDER_BOOK_TIME_OFFSET_MINUTES * MS_PER_MINUTE$1);
2225
+ return await this.params.getOrderBook(symbol, depth, from, to, this.params.execution.context.backtest);
2057
2226
  }
2058
2227
  }
2228
+
2059
2229
  /**
2060
- * Global singleton instance of PersistPartialUtils.
2061
- * Used by ClientPartial for partial profit/loss levels persistence.
2062
- *
2063
- * @example
2064
- * ```typescript
2065
- * // Custom adapter
2066
- * PersistPartialAdapter.usePersistPartialAdapter(RedisPersist);
2067
- *
2068
- * // Read partial data
2069
- * const partialData = await PersistPartialAdapter.readPartialData("BTCUSDT", "my-strategy");
2070
- *
2071
- * // Write partial data
2072
- * await PersistPartialAdapter.writePartialData(partialData, "BTCUSDT", "my-strategy");
2073
- * ```
2230
+ * Default implementation for getCandles.
2231
+ * Throws an error indicating the method is not implemented.
2074
2232
  */
2075
- const PersistPartialAdapter = new PersistPartialUtils();
2233
+ const DEFAULT_GET_CANDLES_FN$1 = async (_symbol, _interval, _since, _limit, _backtest) => {
2234
+ throw new Error(`getCandles is not implemented for this exchange`);
2235
+ };
2076
2236
  /**
2077
- * Persistence utility class for breakeven state management.
2237
+ * Default implementation for formatQuantity.
2238
+ * Returns Bitcoin precision on Binance (8 decimal places).
2239
+ */
2240
+ const DEFAULT_FORMAT_QUANTITY_FN$1 = async (_symbol, quantity, _backtest) => {
2241
+ return quantity.toFixed(8);
2242
+ };
2243
+ /**
2244
+ * Default implementation for formatPrice.
2245
+ * Returns Bitcoin precision on Binance (2 decimal places).
2246
+ */
2247
+ const DEFAULT_FORMAT_PRICE_FN$1 = async (_symbol, price, _backtest) => {
2248
+ return price.toFixed(2);
2249
+ };
2250
+ /**
2251
+ * Default implementation for getOrderBook.
2252
+ * Throws an error indicating the method is not implemented.
2078
2253
  *
2079
- * Handles reading and writing breakeven state to disk.
2080
- * Uses memoized PersistBase instances per symbol-strategy pair.
2254
+ * @param _symbol - Trading pair symbol (unused)
2255
+ * @param _depth - Maximum depth levels (unused)
2256
+ * @param _from - Start of time range (unused - can be ignored in live implementations)
2257
+ * @param _to - End of time range (unused - can be ignored in live implementations)
2258
+ * @param _backtest - Whether running in backtest mode (unused)
2259
+ */
2260
+ const DEFAULT_GET_ORDER_BOOK_FN$1 = async (_symbol, _depth, _from, _to, _backtest) => {
2261
+ throw new Error(`getOrderBook is not implemented for this exchange`);
2262
+ };
2263
+ /**
2264
+ * Connection service routing exchange operations to correct ClientExchange instance.
2081
2265
  *
2082
- * Features:
2083
- * - Atomic file writes via PersistBase.writeValue()
2084
- * - Lazy initialization on first access
2085
- * - Singleton pattern for global access
2086
- * - Custom adapter support via usePersistBreakevenAdapter()
2266
+ * Routes all IExchange method calls to the appropriate exchange implementation
2267
+ * based on methodContextService.context.exchangeName. Uses memoization to cache
2268
+ * ClientExchange instances for performance.
2087
2269
  *
2088
- * File structure:
2089
- * ```
2090
- * ./dump/data/breakeven/
2091
- * ├── BTCUSDT_my-strategy/
2092
- * │ └── state.json // { "signal-id-1": { reached: true }, ... }
2093
- * └── ETHUSDT_other-strategy/
2094
- * └── state.json
2095
- * ```
2270
+ * Key features:
2271
+ * - Automatic exchange routing via method context
2272
+ * - Memoized ClientExchange instances by exchangeName
2273
+ * - Implements full IExchange interface
2274
+ * - Logging for all operations
2096
2275
  *
2097
2276
  * @example
2098
2277
  * ```typescript
2099
- * // Read breakeven data
2100
- * const breakevenData = await PersistBreakevenAdapter.readBreakevenData("BTCUSDT", "my-strategy");
2101
- * // Returns: { "signal-id": { reached: true }, ... }
2102
- *
2103
- * // Write breakeven data
2104
- * await PersistBreakevenAdapter.writeBreakevenData(breakevenData, "BTCUSDT", "my-strategy");
2278
+ * // Used internally by framework
2279
+ * const candles = await exchangeConnectionService.getCandles(
2280
+ * "BTCUSDT", "1h", 100
2281
+ * );
2282
+ * // Automatically routes to correct exchange based on methodContext
2105
2283
  * ```
2106
2284
  */
2107
- class PersistBreakevenUtils {
2285
+ class ExchangeConnectionService {
2108
2286
  constructor() {
2287
+ this.loggerService = inject(TYPES.loggerService);
2288
+ this.executionContextService = inject(TYPES.executionContextService);
2289
+ this.exchangeSchemaService = inject(TYPES.exchangeSchemaService);
2290
+ this.methodContextService = inject(TYPES.methodContextService);
2291
+ /**
2292
+ * Retrieves memoized ClientExchange instance for given exchange name.
2293
+ *
2294
+ * Creates ClientExchange on first call, returns cached instance on subsequent calls.
2295
+ * Cache key is exchangeName string.
2296
+ *
2297
+ * @param exchangeName - Name of registered exchange schema
2298
+ * @returns Configured ClientExchange instance
2299
+ */
2300
+ this.getExchange = memoize(([exchangeName]) => `${exchangeName}`, (exchangeName) => {
2301
+ const { getCandles = DEFAULT_GET_CANDLES_FN$1, formatPrice = DEFAULT_FORMAT_PRICE_FN$1, formatQuantity = DEFAULT_FORMAT_QUANTITY_FN$1, getOrderBook = DEFAULT_GET_ORDER_BOOK_FN$1, callbacks } = this.exchangeSchemaService.get(exchangeName);
2302
+ return new ClientExchange({
2303
+ execution: this.executionContextService,
2304
+ logger: this.loggerService,
2305
+ exchangeName,
2306
+ getCandles,
2307
+ formatPrice,
2308
+ formatQuantity,
2309
+ getOrderBook,
2310
+ callbacks,
2311
+ });
2312
+ });
2109
2313
  /**
2110
- * Factory for creating PersistBase instances.
2111
- * Can be replaced via usePersistBreakevenAdapter().
2314
+ * Fetches historical candles for symbol using configured exchange.
2315
+ *
2316
+ * Routes to exchange determined by methodContextService.context.exchangeName.
2317
+ *
2318
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
2319
+ * @param interval - Candle interval (e.g., "1h", "1d")
2320
+ * @param limit - Maximum number of candles to fetch
2321
+ * @returns Promise resolving to array of candle data
2112
2322
  */
2113
- this.PersistBreakevenFactory = PersistBase;
2323
+ this.getCandles = async (symbol, interval, limit) => {
2324
+ this.loggerService.log("exchangeConnectionService getCandles", {
2325
+ symbol,
2326
+ interval,
2327
+ limit,
2328
+ });
2329
+ return await this.getExchange(this.methodContextService.context.exchangeName).getCandles(symbol, interval, limit);
2330
+ };
2114
2331
  /**
2115
- * Memoized storage factory for breakeven data.
2116
- * Creates one PersistBase instance per symbol-strategy-exchange combination.
2117
- * Key format: "symbol:strategyName:exchangeName"
2332
+ * Fetches next batch of candles relative to executionContext.when.
2118
2333
  *
2119
- * @param symbol - Trading pair symbol
2120
- * @param strategyName - Strategy identifier
2121
- * @param exchangeName - Exchange identifier
2122
- * @returns PersistBase instance for this symbol-strategy-exchange combination
2334
+ * Returns candles that come after the current execution timestamp.
2335
+ * Used for backtest progression and live trading updates.
2336
+ *
2337
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
2338
+ * @param interval - Candle interval (e.g., "1h", "1d")
2339
+ * @param limit - Maximum number of candles to fetch
2340
+ * @returns Promise resolving to array of candle data
2123
2341
  */
2124
- this.getBreakevenStorage = memoize(([symbol, strategyName, exchangeName]) => `${symbol}:${strategyName}:${exchangeName}`, (symbol, strategyName, exchangeName) => Reflect.construct(this.PersistBreakevenFactory, [
2125
- `${symbol}_${strategyName}_${exchangeName}`,
2126
- `./dump/data/breakeven/`,
2127
- ]));
2342
+ this.getNextCandles = async (symbol, interval, limit) => {
2343
+ this.loggerService.log("exchangeConnectionService getNextCandles", {
2344
+ symbol,
2345
+ interval,
2346
+ limit,
2347
+ });
2348
+ return await this.getExchange(this.methodContextService.context.exchangeName).getNextCandles(symbol, interval, limit);
2349
+ };
2128
2350
  /**
2129
- * Reads persisted breakeven data for a symbol and strategy.
2351
+ * Retrieves current average price for symbol.
2130
2352
  *
2131
- * Called by ClientBreakeven.waitForInit() to restore state.
2132
- * Returns empty object if no breakeven data exists.
2353
+ * In live mode: fetches real-time average price from exchange API.
2354
+ * In backtest mode: calculates VWAP from candles in current timeframe.
2133
2355
  *
2134
- * @param symbol - Trading pair symbol
2135
- * @param strategyName - Strategy identifier
2136
- * @param signalId - Signal identifier
2137
- * @param exchangeName - Exchange identifier
2138
- * @returns Promise resolving to breakeven data record
2356
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
2357
+ * @returns Promise resolving to average price
2139
2358
  */
2140
- this.readBreakevenData = async (symbol, strategyName, signalId, exchangeName) => {
2141
- bt.loggerService.info(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_READ_DATA);
2142
- const key = `${symbol}:${strategyName}:${exchangeName}`;
2143
- const isInitial = !this.getBreakevenStorage.has(key);
2144
- const stateStorage = this.getBreakevenStorage(symbol, strategyName, exchangeName);
2145
- await stateStorage.waitForInit(isInitial);
2146
- if (await stateStorage.hasValue(signalId)) {
2147
- return await stateStorage.readValue(signalId);
2148
- }
2149
- return {};
2359
+ this.getAveragePrice = async (symbol) => {
2360
+ this.loggerService.log("exchangeConnectionService getAveragePrice", {
2361
+ symbol,
2362
+ });
2363
+ return await this.getExchange(this.methodContextService.context.exchangeName).getAveragePrice(symbol);
2150
2364
  };
2151
2365
  /**
2152
- * Writes breakeven data to disk.
2366
+ * Formats price according to exchange-specific precision rules.
2153
2367
  *
2154
- * Called by ClientBreakeven._persistState() after state changes.
2155
- * Creates directory and file if they don't exist.
2156
- * Uses atomic writes to prevent data corruption.
2368
+ * Ensures price meets exchange requirements for decimal places and tick size.
2157
2369
  *
2158
- * @param breakevenData - Breakeven data record to persist
2159
- * @param symbol - Trading pair symbol
2160
- * @param strategyName - Strategy identifier
2161
- * @param signalId - Signal identifier
2162
- * @param exchangeName - Exchange identifier
2163
- * @returns Promise that resolves when write is complete
2370
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
2371
+ * @param price - Raw price value to format
2372
+ * @returns Promise resolving to formatted price string
2164
2373
  */
2165
- this.writeBreakevenData = async (breakevenData, symbol, strategyName, signalId, exchangeName) => {
2166
- bt.loggerService.info(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_WRITE_DATA);
2167
- const key = `${symbol}:${strategyName}:${exchangeName}`;
2168
- const isInitial = !this.getBreakevenStorage.has(key);
2169
- const stateStorage = this.getBreakevenStorage(symbol, strategyName, exchangeName);
2170
- await stateStorage.waitForInit(isInitial);
2171
- await stateStorage.writeValue(signalId, breakevenData);
2374
+ this.formatPrice = async (symbol, price) => {
2375
+ this.loggerService.log("exchangeConnectionService getAveragePrice", {
2376
+ symbol,
2377
+ price,
2378
+ });
2379
+ return await this.getExchange(this.methodContextService.context.exchangeName).formatPrice(symbol, price);
2380
+ };
2381
+ /**
2382
+ * Formats quantity according to exchange-specific precision rules.
2383
+ *
2384
+ * Ensures quantity meets exchange requirements for decimal places and lot size.
2385
+ *
2386
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
2387
+ * @param quantity - Raw quantity value to format
2388
+ * @returns Promise resolving to formatted quantity string
2389
+ */
2390
+ this.formatQuantity = async (symbol, quantity) => {
2391
+ this.loggerService.log("exchangeConnectionService getAveragePrice", {
2392
+ symbol,
2393
+ quantity,
2394
+ });
2395
+ return await this.getExchange(this.methodContextService.context.exchangeName).formatQuantity(symbol, quantity);
2396
+ };
2397
+ /**
2398
+ * Fetches order book for a trading pair using configured exchange.
2399
+ *
2400
+ * Routes to exchange determined by methodContextService.context.exchangeName.
2401
+ * The ClientExchange will calculate time range and pass it to the schema
2402
+ * implementation, which may use (backtest) or ignore (live) the parameters.
2403
+ *
2404
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
2405
+ * @param depth - Maximum depth levels (default: CC_ORDER_BOOK_MAX_DEPTH_LEVELS)
2406
+ * @returns Promise resolving to order book data
2407
+ */
2408
+ this.getOrderBook = async (symbol, depth) => {
2409
+ this.loggerService.log("exchangeConnectionService getOrderBook", {
2410
+ symbol,
2411
+ depth,
2412
+ });
2413
+ return await this.getExchange(this.methodContextService.context.exchangeName).getOrderBook(symbol, depth);
2414
+ };
2415
+ /**
2416
+ * Fetches raw candles with flexible date/limit parameters.
2417
+ *
2418
+ * Routes to exchange determined by methodContextService.context.exchangeName.
2419
+ *
2420
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
2421
+ * @param interval - Candle interval (e.g., "1h", "1d")
2422
+ * @param limit - Optional number of candles to fetch
2423
+ * @param sDate - Optional start date in milliseconds
2424
+ * @param eDate - Optional end date in milliseconds
2425
+ * @returns Promise resolving to array of candle data
2426
+ */
2427
+ this.getRawCandles = async (symbol, interval, limit, sDate, eDate) => {
2428
+ this.loggerService.log("exchangeConnectionService getRawCandles", {
2429
+ symbol,
2430
+ interval,
2431
+ limit,
2432
+ sDate,
2433
+ eDate,
2434
+ });
2435
+ return await this.getExchange(this.methodContextService.context.exchangeName).getRawCandles(symbol, interval, limit, sDate, eDate);
2172
2436
  };
2173
- }
2174
- /**
2175
- * Registers a custom persistence adapter.
2176
- *
2177
- * @param Ctor - Custom PersistBase constructor
2178
- *
2179
- * @example
2180
- * ```typescript
2181
- * class RedisPersist extends PersistBase {
2182
- * async readValue(id) { return JSON.parse(await redis.get(id)); }
2183
- * async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
2184
- * }
2185
- * PersistBreakevenAdapter.usePersistBreakevenAdapter(RedisPersist);
2186
- * ```
2187
- */
2188
- usePersistBreakevenAdapter(Ctor) {
2189
- bt.loggerService.info(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_PERSIST_BREAKEVEN_ADAPTER);
2190
- this.PersistBreakevenFactory = Ctor;
2191
- }
2192
- /**
2193
- * Switches to the default JSON persist adapter.
2194
- * All future persistence writes will use JSON storage.
2195
- */
2196
- useJson() {
2197
- bt.loggerService.log(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_JSON);
2198
- this.usePersistBreakevenAdapter(PersistBase);
2199
- }
2200
- /**
2201
- * Switches to a dummy persist adapter that discards all writes.
2202
- * All future persistence writes will be no-ops.
2203
- */
2204
- useDummy() {
2205
- bt.loggerService.log(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_DUMMY);
2206
- this.usePersistBreakevenAdapter(PersistDummy);
2207
2437
  }
2208
2438
  }
2439
+
2209
2440
  /**
2210
- * Global singleton instance of PersistBreakevenUtils.
2211
- * Used by ClientBreakeven for breakeven state persistence.
2441
+ * Calculates profit/loss for a closed signal with slippage and fees.
2442
+ *
2443
+ * For signals with partial closes:
2444
+ * - Calculates weighted PNL: Σ(percent_i × pnl_i) for each partial + (remaining% × final_pnl)
2445
+ * - Each partial close has its own fees and slippage
2446
+ * - Total fees = 2 × (number of partial closes + 1 final close) × CC_PERCENT_FEE
2447
+ *
2448
+ * Formula breakdown:
2449
+ * 1. Apply slippage to open/close prices (worse execution)
2450
+ * - LONG: buy higher (+slippage), sell lower (-slippage)
2451
+ * - SHORT: sell lower (-slippage), buy higher (+slippage)
2452
+ * 2. Calculate raw PNL percentage
2453
+ * - LONG: ((closePrice - openPrice) / openPrice) * 100
2454
+ * - SHORT: ((openPrice - closePrice) / openPrice) * 100
2455
+ * 3. Subtract total fees (0.1% * 2 = 0.2% per transaction)
2456
+ *
2457
+ * @param signal - Closed signal with position details and optional partial history
2458
+ * @param priceClose - Actual close price at final exit
2459
+ * @returns PNL data with percentage and prices
2212
2460
  *
2213
2461
  * @example
2214
2462
  * ```typescript
2215
- * // Custom adapter
2216
- * PersistBreakevenAdapter.usePersistBreakevenAdapter(RedisPersist);
2217
- *
2218
- * // Read breakeven data
2219
- * const breakevenData = await PersistBreakevenAdapter.readBreakevenData("BTCUSDT", "my-strategy");
2463
+ * // Signal without partial closes
2464
+ * const pnl = toProfitLossDto(
2465
+ * {
2466
+ * position: "long",
2467
+ * priceOpen: 100,
2468
+ * },
2469
+ * 110 // close at +10%
2470
+ * );
2471
+ * console.log(pnl.pnlPercentage); // ~9.6% (after slippage and fees)
2220
2472
  *
2221
- * // Write breakeven data
2222
- * await PersistBreakevenAdapter.writeBreakevenData(breakevenData, "BTCUSDT", "my-strategy");
2473
+ * // Signal with partial closes
2474
+ * const pnlPartial = toProfitLossDto(
2475
+ * {
2476
+ * position: "long",
2477
+ * priceOpen: 100,
2478
+ * _partial: [
2479
+ * { type: "profit", percent: 30, price: 120 }, // +20% on 30%
2480
+ * { type: "profit", percent: 40, price: 115 }, // +15% on 40%
2481
+ * ],
2482
+ * },
2483
+ * 105 // final close at +5% for remaining 30%
2484
+ * );
2485
+ * // Weighted PNL = 30% × 20% + 40% × 15% + 30% × 5% = 6% + 6% + 1.5% = 13.5% (before fees)
2223
2486
  * ```
2224
2487
  */
2225
- const PersistBreakevenAdapter = new PersistBreakevenUtils();
2488
+ const toProfitLossDto = (signal, priceClose) => {
2489
+ const priceOpen = signal.priceOpen;
2490
+ // Calculate weighted PNL with partial closes
2491
+ if (signal._partial && signal._partial.length > 0) {
2492
+ let totalWeightedPnl = 0;
2493
+ let totalFees = 0;
2494
+ // Calculate PNL for each partial close
2495
+ for (const partial of signal._partial) {
2496
+ const partialPercent = partial.percent;
2497
+ const partialPrice = partial.price;
2498
+ // Apply slippage to prices
2499
+ let priceOpenWithSlippage;
2500
+ let priceCloseWithSlippage;
2501
+ if (signal.position === "long") {
2502
+ priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
2503
+ priceCloseWithSlippage = partialPrice * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
2504
+ }
2505
+ else {
2506
+ priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
2507
+ priceCloseWithSlippage = partialPrice * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
2508
+ }
2509
+ // Calculate PNL for this partial
2510
+ let partialPnl;
2511
+ if (signal.position === "long") {
2512
+ partialPnl = ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100;
2513
+ }
2514
+ else {
2515
+ partialPnl = ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
2516
+ }
2517
+ // Weight by percentage of position closed
2518
+ const weightedPnl = (partialPercent / 100) * partialPnl;
2519
+ totalWeightedPnl += weightedPnl;
2520
+ // Each partial has fees for open + close (2 transactions)
2521
+ totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
2522
+ }
2523
+ // Calculate PNL for remaining position (if any)
2524
+ // Compute totalClosed from _partial array
2525
+ const totalClosed = signal._partial.reduce((sum, p) => sum + p.percent, 0);
2526
+ const remainingPercent = 100 - totalClosed;
2527
+ if (remainingPercent > 0) {
2528
+ // Apply slippage
2529
+ let priceOpenWithSlippage;
2530
+ let priceCloseWithSlippage;
2531
+ if (signal.position === "long") {
2532
+ priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
2533
+ priceCloseWithSlippage = priceClose * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
2534
+ }
2535
+ else {
2536
+ priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
2537
+ priceCloseWithSlippage = priceClose * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
2538
+ }
2539
+ // Calculate PNL for remaining
2540
+ let remainingPnl;
2541
+ if (signal.position === "long") {
2542
+ remainingPnl = ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100;
2543
+ }
2544
+ else {
2545
+ remainingPnl = ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
2546
+ }
2547
+ // Weight by remaining percentage
2548
+ const weightedRemainingPnl = (remainingPercent / 100) * remainingPnl;
2549
+ totalWeightedPnl += weightedRemainingPnl;
2550
+ // Final close also has fees
2551
+ totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
2552
+ }
2553
+ // Subtract total fees from weighted PNL
2554
+ const pnlPercentage = totalWeightedPnl - totalFees;
2555
+ return {
2556
+ pnlPercentage,
2557
+ priceOpen,
2558
+ priceClose,
2559
+ };
2560
+ }
2561
+ // Original logic for signals without partial closes
2562
+ let priceOpenWithSlippage;
2563
+ let priceCloseWithSlippage;
2564
+ if (signal.position === "long") {
2565
+ // LONG: покупаем дороже, продаем дешевле
2566
+ priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
2567
+ priceCloseWithSlippage = priceClose * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
2568
+ }
2569
+ else {
2570
+ // SHORT: продаем дешевле, покупаем дороже
2571
+ priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
2572
+ priceCloseWithSlippage = priceClose * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
2573
+ }
2574
+ // Применяем комиссию дважды (при открытии и закрытии)
2575
+ const totalFee = GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
2576
+ let pnlPercentage;
2577
+ if (signal.position === "long") {
2578
+ // LONG: прибыль при росте цены
2579
+ pnlPercentage =
2580
+ ((priceCloseWithSlippage - priceOpenWithSlippage) /
2581
+ priceOpenWithSlippage) *
2582
+ 100;
2583
+ }
2584
+ else {
2585
+ // SHORT: прибыль при падении цены
2586
+ pnlPercentage =
2587
+ ((priceOpenWithSlippage - priceCloseWithSlippage) /
2588
+ priceOpenWithSlippage) *
2589
+ 100;
2590
+ }
2591
+ // Вычитаем комиссии
2592
+ pnlPercentage -= totalFee;
2593
+ return {
2594
+ pnlPercentage,
2595
+ priceOpen,
2596
+ priceClose,
2597
+ };
2598
+ };
2226
2599
 
2227
2600
  /**
2228
2601
  * Converts markdown content to plain text with minimal formatting
@@ -9604,6 +9977,40 @@ class ExchangeCoreService {
9604
9977
  backtest,
9605
9978
  });
9606
9979
  };
9980
+ /**
9981
+ * Fetches raw candles with flexible date/limit parameters and execution context.
9982
+ *
9983
+ * @param symbol - Trading pair symbol
9984
+ * @param interval - Candle interval (e.g., "1m", "1h")
9985
+ * @param when - Timestamp for context (used in backtest mode)
9986
+ * @param backtest - Whether running in backtest mode
9987
+ * @param limit - Optional number of candles to fetch
9988
+ * @param sDate - Optional start date in milliseconds
9989
+ * @param eDate - Optional end date in milliseconds
9990
+ * @returns Promise resolving to array of candles
9991
+ */
9992
+ this.getRawCandles = async (symbol, interval, when, backtest, limit, sDate, eDate) => {
9993
+ this.loggerService.log("exchangeCoreService getRawCandles", {
9994
+ symbol,
9995
+ interval,
9996
+ when,
9997
+ backtest,
9998
+ limit,
9999
+ sDate,
10000
+ eDate,
10001
+ });
10002
+ if (!MethodContextService.hasContext()) {
10003
+ throw new Error("exchangeCoreService getRawCandles requires a method context");
10004
+ }
10005
+ await this.validate(this.methodContextService.context.exchangeName);
10006
+ return await ExecutionContextService.runInContext(async () => {
10007
+ return await this.exchangeConnectionService.getRawCandles(symbol, interval, limit, sDate, eDate);
10008
+ }, {
10009
+ symbol,
10010
+ when,
10011
+ backtest,
10012
+ });
10013
+ };
9607
10014
  }
9608
10015
  }
9609
10016
 
@@ -24907,6 +25314,7 @@ const GET_SYMBOL_METHOD_NAME = "exchange.getSymbol";
24907
25314
  const GET_CONTEXT_METHOD_NAME = "exchange.getContext";
24908
25315
  const HAS_TRADE_CONTEXT_METHOD_NAME = "exchange.hasTradeContext";
24909
25316
  const GET_ORDER_BOOK_METHOD_NAME = "exchange.getOrderBook";
25317
+ const GET_RAW_CANDLES_METHOD_NAME = "exchange.getRawCandles";
24910
25318
  /**
24911
25319
  * Checks if trade context is active (execution and method contexts).
24912
25320
  *
@@ -25164,6 +25572,53 @@ async function getOrderBook(symbol, depth) {
25164
25572
  }
25165
25573
  return await bt.exchangeConnectionService.getOrderBook(symbol, depth);
25166
25574
  }
25575
+ /**
25576
+ * Fetches raw candles with flexible date/limit parameters.
25577
+ *
25578
+ * All modes respect execution context and prevent look-ahead bias.
25579
+ *
25580
+ * Parameter combinations:
25581
+ * 1. sDate + eDate + limit: fetches with explicit parameters, validates eDate <= when
25582
+ * 2. sDate + eDate: calculates limit from date range, validates eDate <= when
25583
+ * 3. eDate + limit: calculates sDate backward, validates eDate <= when
25584
+ * 4. sDate + limit: fetches forward, validates calculated endTimestamp <= when
25585
+ * 5. Only limit: uses execution.context.when as reference (backward)
25586
+ *
25587
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
25588
+ * @param interval - Candle interval ("1m" | "3m" | "5m" | "15m" | "30m" | "1h" | "2h" | "4h" | "6h" | "8h")
25589
+ * @param limit - Optional number of candles to fetch
25590
+ * @param sDate - Optional start date in milliseconds
25591
+ * @param eDate - Optional end date in milliseconds
25592
+ * @returns Promise resolving to array of candle data
25593
+ *
25594
+ * @example
25595
+ * ```typescript
25596
+ * // Fetch 100 candles backward from current context time
25597
+ * const candles = await getRawCandles("BTCUSDT", "1m", 100);
25598
+ *
25599
+ * // Fetch candles for specific date range
25600
+ * const rangeCandles = await getRawCandles("BTCUSDT", "1h", undefined, startMs, endMs);
25601
+ *
25602
+ * // Fetch with all parameters specified
25603
+ * const exactCandles = await getRawCandles("BTCUSDT", "1m", 100, startMs, endMs);
25604
+ * ```
25605
+ */
25606
+ async function getRawCandles(symbol, interval, limit, sDate, eDate) {
25607
+ bt.loggerService.info(GET_RAW_CANDLES_METHOD_NAME, {
25608
+ symbol,
25609
+ interval,
25610
+ limit,
25611
+ sDate,
25612
+ eDate,
25613
+ });
25614
+ if (!ExecutionContextService.hasContext()) {
25615
+ throw new Error("getRawCandles requires an execution context");
25616
+ }
25617
+ if (!MethodContextService.hasContext()) {
25618
+ throw new Error("getRawCandles requires a method context");
25619
+ }
25620
+ return await bt.exchangeConnectionService.getRawCandles(symbol, interval, limit, sDate, eDate);
25621
+ }
25167
25622
 
25168
25623
  const CANCEL_SCHEDULED_METHOD_NAME = "strategy.commitCancelScheduled";
25169
25624
  const CLOSE_PENDING_METHOD_NAME = "strategy.commitClosePending";
@@ -31435,6 +31890,8 @@ const EXCHANGE_METHOD_NAME_GET_AVERAGE_PRICE = "ExchangeUtils.getAveragePrice";
31435
31890
  const EXCHANGE_METHOD_NAME_FORMAT_QUANTITY = "ExchangeUtils.formatQuantity";
31436
31891
  const EXCHANGE_METHOD_NAME_FORMAT_PRICE = "ExchangeUtils.formatPrice";
31437
31892
  const EXCHANGE_METHOD_NAME_GET_ORDER_BOOK = "ExchangeUtils.getOrderBook";
31893
+ const EXCHANGE_METHOD_NAME_GET_RAW_CANDLES = "ExchangeUtils.getRawCandles";
31894
+ const MS_PER_MINUTE = 60000;
31438
31895
  /**
31439
31896
  * Gets backtest mode flag from execution context if available.
31440
31897
  * Returns false if no execution context exists (live mode).
@@ -31510,6 +31967,61 @@ const CREATE_EXCHANGE_INSTANCE_FN = (schema) => {
31510
31967
  getOrderBook,
31511
31968
  };
31512
31969
  };
31970
+ /**
31971
+ * Attempts to read candles from cache.
31972
+ * Validates cache consistency (no gaps in timestamps) before returning.
31973
+ *
31974
+ * @param dto - Data transfer object containing symbol, interval, and limit
31975
+ * @param sinceTimestamp - Start timestamp in milliseconds
31976
+ * @param untilTimestamp - End timestamp in milliseconds
31977
+ * @param exchangeName - Exchange name
31978
+ * @returns Cached candles array or null if cache miss or inconsistent
31979
+ */
31980
+ const READ_CANDLES_CACHE_FN = trycatch(async (dto, sinceTimestamp, untilTimestamp, exchangeName) => {
31981
+ const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
31982
+ // Return cached data only if we have exactly the requested limit
31983
+ if (cachedCandles.length === dto.limit) {
31984
+ bt.loggerService.debug(`ExchangeInstance READ_CANDLES_CACHE_FN: cache hit for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
31985
+ return cachedCandles;
31986
+ }
31987
+ bt.loggerService.warn(`ExchangeInstance READ_CANDLES_CACHE_FN: cache inconsistent (count or range mismatch) for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
31988
+ return null;
31989
+ }, {
31990
+ fallback: async (error) => {
31991
+ const message = `ExchangeInstance READ_CANDLES_CACHE_FN: cache read failed`;
31992
+ const payload = {
31993
+ error: errorData(error),
31994
+ message: getErrorMessage(error),
31995
+ };
31996
+ bt.loggerService.warn(message, payload);
31997
+ console.warn(message, payload);
31998
+ errorEmitter.next(error);
31999
+ },
32000
+ defaultValue: null,
32001
+ });
32002
+ /**
32003
+ * Writes candles to cache with error handling.
32004
+ *
32005
+ * @param candles - Array of candle data to cache
32006
+ * @param dto - Data transfer object containing symbol, interval, and limit
32007
+ * @param exchangeName - Exchange name
32008
+ */
32009
+ const WRITE_CANDLES_CACHE_FN = trycatch(queued(async (candles, dto, exchangeName) => {
32010
+ await PersistCandleAdapter.writeCandlesData(candles, dto.symbol, dto.interval, exchangeName);
32011
+ bt.loggerService.debug(`ExchangeInstance WRITE_CANDLES_CACHE_FN: cache updated for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, count=${candles.length}`);
32012
+ }), {
32013
+ fallback: async (error) => {
32014
+ const message = `ExchangeInstance WRITE_CANDLES_CACHE_FN: cache write failed`;
32015
+ const payload = {
32016
+ error: errorData(error),
32017
+ message: getErrorMessage(error),
32018
+ };
32019
+ bt.loggerService.warn(message, payload);
32020
+ console.warn(message, payload);
32021
+ errorEmitter.next(error);
32022
+ },
32023
+ defaultValue: null,
32024
+ });
31513
32025
  /**
31514
32026
  * Instance class for exchange operations on a specific exchange.
31515
32027
  *
@@ -31567,6 +32079,13 @@ class ExchangeInstance {
31567
32079
  }
31568
32080
  const when = new Date(Date.now());
31569
32081
  const since = new Date(when.getTime() - adjust * 60 * 1000);
32082
+ const sinceTimestamp = since.getTime();
32083
+ const untilTimestamp = sinceTimestamp + limit * step * 60 * 1000;
32084
+ // Try to read from cache first
32085
+ const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
32086
+ if (cachedCandles !== null) {
32087
+ return cachedCandles;
32088
+ }
31570
32089
  let allData = [];
31571
32090
  // If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
31572
32091
  if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
@@ -31589,7 +32108,6 @@ class ExchangeInstance {
31589
32108
  allData = await getCandles(symbol, interval, since, limit, isBacktest);
31590
32109
  }
31591
32110
  // Filter candles to strictly match the requested range
31592
- const sinceTimestamp = since.getTime();
31593
32111
  const whenTimestamp = when.getTime();
31594
32112
  const stepMs = step * 60 * 1000;
31595
32113
  const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp < whenTimestamp + stepMs);
@@ -31601,6 +32119,8 @@ class ExchangeInstance {
31601
32119
  if (uniqueData.length < limit) {
31602
32120
  bt.loggerService.warn(`ExchangeInstance Expected ${limit} candles, got ${uniqueData.length}`);
31603
32121
  }
32122
+ // Write to cache after successful fetch
32123
+ await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
31604
32124
  return uniqueData;
31605
32125
  };
31606
32126
  /**
@@ -31725,6 +32245,151 @@ class ExchangeInstance {
31725
32245
  const isBacktest = await GET_BACKTEST_FN();
31726
32246
  return await this._methods.getOrderBook(symbol, depth, from, to, isBacktest);
31727
32247
  };
32248
+ /**
32249
+ * Fetches raw candles with flexible date/limit parameters.
32250
+ *
32251
+ * Uses Date.now() instead of execution context when for look-ahead bias protection.
32252
+ *
32253
+ * Parameter combinations:
32254
+ * 1. sDate + eDate + limit: fetches with explicit parameters, validates eDate <= now
32255
+ * 2. sDate + eDate: calculates limit from date range, validates eDate <= now
32256
+ * 3. eDate + limit: calculates sDate backward, validates eDate <= now
32257
+ * 4. sDate + limit: fetches forward, validates calculated endTimestamp <= now
32258
+ * 5. Only limit: uses Date.now() as reference (backward)
32259
+ *
32260
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
32261
+ * @param interval - Candle interval (e.g., "1m", "1h")
32262
+ * @param limit - Optional number of candles to fetch
32263
+ * @param sDate - Optional start date in milliseconds
32264
+ * @param eDate - Optional end date in milliseconds
32265
+ * @returns Promise resolving to array of candle data
32266
+ *
32267
+ * @example
32268
+ * ```typescript
32269
+ * const instance = new ExchangeInstance("binance");
32270
+ *
32271
+ * // Fetch 100 candles backward from now
32272
+ * const candles = await instance.getRawCandles("BTCUSDT", "1m", 100);
32273
+ *
32274
+ * // Fetch candles for specific date range
32275
+ * const rangeCandles = await instance.getRawCandles("BTCUSDT", "1h", undefined, startMs, endMs);
32276
+ * ```
32277
+ */
32278
+ this.getRawCandles = async (symbol, interval, limit, sDate, eDate) => {
32279
+ bt.loggerService.info(EXCHANGE_METHOD_NAME_GET_RAW_CANDLES, {
32280
+ exchangeName: this.exchangeName,
32281
+ symbol,
32282
+ interval,
32283
+ limit,
32284
+ sDate,
32285
+ eDate,
32286
+ });
32287
+ const step = INTERVAL_MINUTES$1[interval];
32288
+ if (!step) {
32289
+ throw new Error(`ExchangeInstance getRawCandles: unknown interval=${interval}`);
32290
+ }
32291
+ const nowTimestamp = Date.now();
32292
+ let sinceTimestamp;
32293
+ let untilTimestamp;
32294
+ let calculatedLimit;
32295
+ // Case 1: all three parameters provided
32296
+ if (sDate !== undefined && eDate !== undefined && limit !== undefined) {
32297
+ if (sDate >= eDate) {
32298
+ throw new Error(`ExchangeInstance getRawCandles: sDate (${sDate}) must be < eDate (${eDate})`);
32299
+ }
32300
+ if (eDate > nowTimestamp) {
32301
+ throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
32302
+ }
32303
+ sinceTimestamp = sDate;
32304
+ untilTimestamp = eDate;
32305
+ calculatedLimit = limit;
32306
+ }
32307
+ // Case 2: sDate + eDate (no limit) - calculate limit from date range
32308
+ else if (sDate !== undefined && eDate !== undefined && limit === undefined) {
32309
+ if (sDate >= eDate) {
32310
+ throw new Error(`ExchangeInstance getRawCandles: sDate (${sDate}) must be < eDate (${eDate})`);
32311
+ }
32312
+ if (eDate > nowTimestamp) {
32313
+ throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
32314
+ }
32315
+ sinceTimestamp = sDate;
32316
+ untilTimestamp = eDate;
32317
+ calculatedLimit = Math.ceil((eDate - sDate) / (step * MS_PER_MINUTE));
32318
+ if (calculatedLimit <= 0) {
32319
+ throw new Error(`ExchangeInstance getRawCandles: calculated limit is ${calculatedLimit}, must be > 0`);
32320
+ }
32321
+ }
32322
+ // Case 3: eDate + limit (no sDate) - calculate sDate backward from eDate
32323
+ else if (sDate === undefined && eDate !== undefined && limit !== undefined) {
32324
+ if (eDate > nowTimestamp) {
32325
+ throw new Error(`ExchangeInstance getRawCandles: eDate (${eDate}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
32326
+ }
32327
+ untilTimestamp = eDate;
32328
+ sinceTimestamp = eDate - limit * step * MS_PER_MINUTE;
32329
+ calculatedLimit = limit;
32330
+ }
32331
+ // Case 4: sDate + limit (no eDate) - calculate eDate forward from sDate
32332
+ else if (sDate !== undefined && eDate === undefined && limit !== undefined) {
32333
+ sinceTimestamp = sDate;
32334
+ untilTimestamp = sDate + limit * step * MS_PER_MINUTE;
32335
+ if (untilTimestamp > nowTimestamp) {
32336
+ throw new Error(`ExchangeInstance getRawCandles: calculated endTimestamp (${untilTimestamp}) exceeds current time (${nowTimestamp}). Look-ahead bias protection.`);
32337
+ }
32338
+ calculatedLimit = limit;
32339
+ }
32340
+ // Case 5: Only limit - use Date.now() as reference (backward)
32341
+ else if (sDate === undefined && eDate === undefined && limit !== undefined) {
32342
+ untilTimestamp = nowTimestamp;
32343
+ sinceTimestamp = nowTimestamp - limit * step * MS_PER_MINUTE;
32344
+ calculatedLimit = limit;
32345
+ }
32346
+ // Invalid: no parameters or only sDate or only eDate
32347
+ else {
32348
+ throw new Error(`ExchangeInstance getRawCandles: invalid parameter combination. ` +
32349
+ `Provide one of: (sDate+eDate+limit), (sDate+eDate), (eDate+limit), (sDate+limit), or (limit only). ` +
32350
+ `Got: sDate=${sDate}, eDate=${eDate}, limit=${limit}`);
32351
+ }
32352
+ // Try to read from cache first
32353
+ const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit: calculatedLimit }, sinceTimestamp, untilTimestamp, this.exchangeName);
32354
+ if (cachedCandles !== null) {
32355
+ return cachedCandles;
32356
+ }
32357
+ // Fetch candles
32358
+ const since = new Date(sinceTimestamp);
32359
+ let allData = [];
32360
+ const isBacktest = await GET_BACKTEST_FN();
32361
+ const getCandles = this._methods.getCandles;
32362
+ if (calculatedLimit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
32363
+ let remaining = calculatedLimit;
32364
+ let currentSince = new Date(since.getTime());
32365
+ while (remaining > 0) {
32366
+ const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
32367
+ const chunkData = await getCandles(symbol, interval, currentSince, chunkLimit, isBacktest);
32368
+ allData.push(...chunkData);
32369
+ remaining -= chunkLimit;
32370
+ if (remaining > 0) {
32371
+ currentSince = new Date(currentSince.getTime() + chunkLimit * step * MS_PER_MINUTE);
32372
+ }
32373
+ }
32374
+ }
32375
+ else {
32376
+ allData = await getCandles(symbol, interval, since, calculatedLimit, isBacktest);
32377
+ }
32378
+ // Filter candles to strictly match the requested range
32379
+ const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp &&
32380
+ candle.timestamp < untilTimestamp);
32381
+ // Apply distinct by timestamp to remove duplicates
32382
+ const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
32383
+ if (filteredData.length !== uniqueData.length) {
32384
+ bt.loggerService.warn(`ExchangeInstance getRawCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`);
32385
+ }
32386
+ if (uniqueData.length < calculatedLimit) {
32387
+ bt.loggerService.warn(`ExchangeInstance getRawCandles: Expected ${calculatedLimit} candles, got ${uniqueData.length}`);
32388
+ }
32389
+ // Write to cache after successful fetch
32390
+ await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit: calculatedLimit }, this.exchangeName);
32391
+ return uniqueData;
32392
+ };
31728
32393
  const schema = bt.exchangeSchemaService.get(this.exchangeName);
31729
32394
  this._methods = CREATE_EXCHANGE_INSTANCE_FN(schema);
31730
32395
  }
@@ -31829,6 +32494,24 @@ class ExchangeUtils {
31829
32494
  const instance = this._getInstance(context.exchangeName);
31830
32495
  return await instance.getOrderBook(symbol, depth);
31831
32496
  };
32497
+ /**
32498
+ * Fetches raw candles with flexible date/limit parameters.
32499
+ *
32500
+ * Uses Date.now() instead of execution context when for look-ahead bias protection.
32501
+ *
32502
+ * @param symbol - Trading pair symbol (e.g., "BTCUSDT")
32503
+ * @param interval - Candle interval (e.g., "1m", "1h")
32504
+ * @param context - Execution context with exchange name
32505
+ * @param limit - Optional number of candles to fetch
32506
+ * @param sDate - Optional start date in milliseconds
32507
+ * @param eDate - Optional end date in milliseconds
32508
+ * @returns Promise resolving to array of candle data
32509
+ */
32510
+ this.getRawCandles = async (symbol, interval, context, limit, sDate, eDate) => {
32511
+ bt.exchangeValidationService.validate(context.exchangeName, EXCHANGE_METHOD_NAME_GET_RAW_CANDLES);
32512
+ const instance = this._getInstance(context.exchangeName);
32513
+ return await instance.getRawCandles(symbol, interval, limit, sDate, eDate);
32514
+ };
31832
32515
  }
31833
32516
  }
31834
32517
  /**
@@ -32960,4 +33643,4 @@ const set = (object, path, value) => {
32960
33643
  }
32961
33644
  };
32962
33645
 
32963
- export { ActionBase, Backtest, Breakeven, Cache, Constant, Exchange, ExecutionContextService, Heat, Live, Markdown, MarkdownFileBase, MarkdownFolderBase, MethodContextService, Notification, Optimizer, Partial, Performance, PersistBase, PersistBreakevenAdapter, PersistPartialAdapter, PersistRiskAdapter, PersistScheduleAdapter, PersistSignalAdapter, PositionSize, Report, ReportBase, Risk, Schedule, Walker, addActionSchema, addExchangeSchema, addFrameSchema, addOptimizerSchema, addRiskSchema, addSizingSchema, addStrategySchema, addWalkerSchema, commitBreakeven, commitCancelScheduled, commitClosePending, commitPartialLoss, commitPartialProfit, commitSignalPromptHistory, commitTrailingStop, commitTrailingTake, dumpSignalData, emitters, formatPrice, formatQuantity, get, getActionSchema, getAveragePrice, getBacktestTimeframe, getCandles, getColumns, getConfig, getContext, getDate, getDefaultColumns, getDefaultConfig, getExchangeSchema, getFrameSchema, getMode, getOptimizerSchema, getOrderBook, getRiskSchema, getSizingSchema, getStrategySchema, getSymbol, getWalkerSchema, hasTradeContext, backtest as lib, listExchangeSchema, listFrameSchema, listOptimizerSchema, listRiskSchema, listSizingSchema, listStrategySchema, listWalkerSchema, listenActivePing, listenActivePingOnce, listenBacktestProgress, listenBreakevenAvailable, listenBreakevenAvailableOnce, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenExit, listenOptimizerProgress, listenPartialLossAvailable, listenPartialLossAvailableOnce, listenPartialProfitAvailable, listenPartialProfitAvailableOnce, listenPerformance, listenRisk, listenRiskOnce, listenSchedulePing, listenSchedulePingOnce, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, listenWalkerProgress, overrideActionSchema, overrideExchangeSchema, overrideFrameSchema, overrideOptimizerSchema, overrideRiskSchema, overrideSizingSchema, overrideStrategySchema, overrideWalkerSchema, parseArgs, roundTicks, set, setColumns, setConfig, setLogger, stopStrategy, validate };
33646
+ export { ActionBase, Backtest, Breakeven, Cache, Constant, Exchange, ExecutionContextService, Heat, Live, Markdown, MarkdownFileBase, MarkdownFolderBase, MethodContextService, Notification, Optimizer, Partial, Performance, PersistBase, PersistBreakevenAdapter, PersistCandleAdapter, PersistPartialAdapter, PersistRiskAdapter, PersistScheduleAdapter, PersistSignalAdapter, PositionSize, Report, ReportBase, Risk, Schedule, Walker, addActionSchema, addExchangeSchema, addFrameSchema, addOptimizerSchema, addRiskSchema, addSizingSchema, addStrategySchema, addWalkerSchema, commitBreakeven, commitCancelScheduled, commitClosePending, commitPartialLoss, commitPartialProfit, commitSignalPromptHistory, commitTrailingStop, commitTrailingTake, dumpSignalData, emitters, formatPrice, formatQuantity, get, getActionSchema, getAveragePrice, getBacktestTimeframe, getCandles, getColumns, getConfig, getContext, getDate, getDefaultColumns, getDefaultConfig, getExchangeSchema, getFrameSchema, getMode, getOptimizerSchema, getOrderBook, getRawCandles, getRiskSchema, getSizingSchema, getStrategySchema, getSymbol, getWalkerSchema, hasTradeContext, backtest as lib, listExchangeSchema, listFrameSchema, listOptimizerSchema, listRiskSchema, listSizingSchema, listStrategySchema, listWalkerSchema, listenActivePing, listenActivePingOnce, listenBacktestProgress, listenBreakevenAvailable, listenBreakevenAvailableOnce, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenExit, listenOptimizerProgress, listenPartialLossAvailable, listenPartialLossAvailableOnce, listenPartialProfitAvailable, listenPartialProfitAvailableOnce, listenPerformance, listenRisk, listenRiskOnce, listenSchedulePing, listenSchedulePingOnce, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, listenWalkerProgress, overrideActionSchema, overrideExchangeSchema, overrideFrameSchema, overrideOptimizerSchema, overrideRiskSchema, overrideSizingSchema, overrideStrategySchema, overrideWalkerSchema, parseArgs, roundTicks, set, setColumns, setConfig, setLogger, stopStrategy, validate };