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