backtest-kit 2.0.12 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/build/index.cjs +2134 -1438
- package/build/index.mjs +2154 -1460
- package/package.json +1 -1
- package/types.d.ts +277 -22
package/build/index.cjs
CHANGED
|
@@ -586,1119 +586,899 @@ var emitters = /*#__PURE__*/Object.freeze({
|
|
|
586
586
|
walkerStopSubject: walkerStopSubject
|
|
587
587
|
});
|
|
588
588
|
|
|
589
|
-
const
|
|
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
|
-
*
|
|
603
|
-
*
|
|
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
|
-
*
|
|
607
|
-
* @
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
614
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
651
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
673
|
-
VALIDATE_NO_INCOMPLETE_CANDLES_FN(result);
|
|
674
|
-
return result;
|
|
739
|
+
await self.readValue(key);
|
|
675
740
|
}
|
|
676
|
-
catch
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
*
|
|
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
|
-
* -
|
|
722
|
-
* -
|
|
723
|
-
* -
|
|
724
|
-
* -
|
|
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
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
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
|
|
744
|
-
constructor(params) {
|
|
745
|
-
this.params = params;
|
|
746
|
-
}
|
|
779
|
+
class PersistBase {
|
|
747
780
|
/**
|
|
748
|
-
*
|
|
781
|
+
* Creates new persistence instance.
|
|
749
782
|
*
|
|
750
|
-
* @param
|
|
751
|
-
* @param
|
|
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
|
-
|
|
756
|
-
this.
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
808
|
-
* Used in backtest mode to get candles for signal duration.
|
|
797
|
+
* Computes file path for entity ID.
|
|
809
798
|
*
|
|
810
|
-
* @param
|
|
811
|
-
* @
|
|
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
|
-
|
|
817
|
-
this.
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
870
|
-
|
|
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
|
-
|
|
887
|
-
|
|
888
|
-
|
|
851
|
+
try {
|
|
852
|
+
const filePath = this._getFilePath(entityId);
|
|
853
|
+
const serializedData = JSON.stringify(entity);
|
|
854
|
+
await writeFileAtomic(filePath, serializedData, "utf-8");
|
|
889
855
|
}
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
*
|
|
907
|
-
*
|
|
861
|
+
* Async generator yielding all entity IDs.
|
|
862
|
+
* Sorted alphanumerically.
|
|
863
|
+
* Used internally by waitForInit for validation.
|
|
908
864
|
*
|
|
909
|
-
* @
|
|
910
|
-
* @
|
|
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
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
quantity,
|
|
868
|
+
async *keys() {
|
|
869
|
+
bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_KEYS, {
|
|
870
|
+
entityName: this.entityName,
|
|
917
871
|
});
|
|
918
|
-
|
|
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
|
-
*
|
|
922
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1000
|
-
* -
|
|
1001
|
-
* -
|
|
1002
|
-
* -
|
|
1003
|
-
* -
|
|
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
|
-
*
|
|
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
|
|
943
|
+
class PersistSignalUtils {
|
|
1015
944
|
constructor() {
|
|
1016
|
-
this.
|
|
1017
|
-
this.
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
*
|
|
951
|
+
* Reads persisted signal data for a symbol and strategy.
|
|
1062
952
|
*
|
|
1063
|
-
*
|
|
1064
|
-
*
|
|
953
|
+
* Called by ClientStrategy.waitForInit() to restore state.
|
|
954
|
+
* Returns null if no signal exists.
|
|
1065
955
|
*
|
|
1066
|
-
* @param symbol - Trading pair symbol
|
|
1067
|
-
* @param
|
|
1068
|
-
* @param
|
|
1069
|
-
* @returns Promise resolving to
|
|
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.
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
-
*
|
|
973
|
+
* Writes signal data to disk with atomic file writes.
|
|
1081
974
|
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
975
|
+
* Called by ClientStrategy.setPendingSignal() to persist state.
|
|
976
|
+
* Uses atomic writes to prevent corruption on crashes.
|
|
1084
977
|
*
|
|
1085
|
-
* @param
|
|
1086
|
-
* @
|
|
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.
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
*
|
|
1064
|
+
* Reads persisted active positions for a risk profile.
|
|
1112
1065
|
*
|
|
1113
|
-
*
|
|
1066
|
+
* Called by ClientRisk.waitForInit() to restore state.
|
|
1067
|
+
* Returns empty Map if no positions exist.
|
|
1114
1068
|
*
|
|
1115
|
-
* @param
|
|
1116
|
-
* @param
|
|
1117
|
-
* @returns Promise resolving to
|
|
1069
|
+
* @param riskName - Risk profile identifier
|
|
1070
|
+
* @param exchangeName - Exchange identifier
|
|
1071
|
+
* @returns Promise resolving to Map of active positions
|
|
1118
1072
|
*/
|
|
1119
|
-
this.
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
*
|
|
1086
|
+
* Writes active positions to disk with atomic file writes.
|
|
1128
1087
|
*
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
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
|
|
1134
|
-
* @param
|
|
1135
|
-
* @
|
|
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.
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
-
*
|
|
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
|
-
* //
|
|
1171
|
-
*
|
|
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
|
-
* //
|
|
1181
|
-
* const
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1184
|
-
*
|
|
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
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
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;
|
|
1157
|
+
const PersistRiskAdapter = new PersistRiskUtils();
|
|
1158
|
+
/**
|
|
1159
|
+
* Utility class for managing scheduled signal persistence.
|
|
1160
|
+
*
|
|
1161
|
+
* Features:
|
|
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
|
|
1166
|
+
*
|
|
1167
|
+
* Used by ClientStrategy for live mode persistence of scheduled signals (_scheduledSignal).
|
|
1168
|
+
*/
|
|
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);
|
|
1253
1195
|
}
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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);
|
|
1266
1217
|
};
|
|
1267
1218
|
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1219
|
+
/**
|
|
1220
|
+
* Registers a custom persistence adapter.
|
|
1221
|
+
*
|
|
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
|
+
* ```
|
|
1232
|
+
*/
|
|
1233
|
+
usePersistScheduleAdapter(Ctor) {
|
|
1234
|
+
bt.loggerService.info(PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_PERSIST_SCHEDULE_ADAPTER);
|
|
1235
|
+
this.PersistScheduleFactory = Ctor;
|
|
1280
1236
|
}
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
priceOpenWithSlippage) *
|
|
1289
|
-
100;
|
|
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);
|
|
1290
1244
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1245
|
+
/**
|
|
1246
|
+
* Switches to a dummy persist adapter that discards all writes.
|
|
1247
|
+
* All future persistence writes will be no-ops.
|
|
1248
|
+
*/
|
|
1249
|
+
useDummy() {
|
|
1250
|
+
bt.loggerService.log(PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_DUMMY);
|
|
1251
|
+
this.usePersistScheduleAdapter(PersistDummy);
|
|
1297
1252
|
}
|
|
1298
|
-
|
|
1299
|
-
pnlPercentage -= totalFee;
|
|
1300
|
-
return {
|
|
1301
|
-
pnlPercentage,
|
|
1302
|
-
priceOpen,
|
|
1303
|
-
priceClose,
|
|
1304
|
-
};
|
|
1305
|
-
};
|
|
1306
|
-
|
|
1307
|
-
const IS_WINDOWS = os.platform() === "win32";
|
|
1253
|
+
}
|
|
1308
1254
|
/**
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
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.
|
|
1255
|
+
* Global singleton instance of PersistScheduleUtils.
|
|
1256
|
+
* Used by ClientStrategy for scheduled signal persistence.
|
|
1317
1257
|
*
|
|
1318
1258
|
* @example
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
1321
|
-
*
|
|
1259
|
+
* ```typescript
|
|
1260
|
+
* // Custom adapter
|
|
1261
|
+
* PersistScheduleAdapter.usePersistScheduleAdapter(RedisPersist);
|
|
1322
1262
|
*
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
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
|
|
1263
|
+
* // Read scheduled signal
|
|
1264
|
+
* const scheduled = await PersistScheduleAdapter.readScheduleData("my-strategy", "BTCUSDT");
|
|
1328
1265
|
*
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1331
|
-
*
|
|
1332
|
-
|
|
1266
|
+
* // Write scheduled signal
|
|
1267
|
+
* await PersistScheduleAdapter.writeScheduleData(scheduled, "my-strategy", "BTCUSDT");
|
|
1268
|
+
* ```
|
|
1269
|
+
*/
|
|
1270
|
+
const PersistScheduleAdapter = new PersistScheduleUtils();
|
|
1271
|
+
/**
|
|
1272
|
+
* Utility class for managing partial profit/loss levels persistence.
|
|
1333
1273
|
*
|
|
1334
|
-
*
|
|
1335
|
-
*
|
|
1336
|
-
* -
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
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.
|
|
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
|
|
1346
1279
|
*
|
|
1347
|
-
*
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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}`);
|
|
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);
|
|
1464
1309
|
}
|
|
1465
|
-
|
|
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
|
+
};
|
|
1466
1333
|
}
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1334
|
+
/**
|
|
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
|
+
* ```
|
|
1347
|
+
*/
|
|
1348
|
+
usePersistPartialAdapter(Ctor) {
|
|
1349
|
+
bt.loggerService.info(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_PERSIST_PARTIAL_ADAPTER);
|
|
1350
|
+
this.PersistPartialFactory = Ctor;
|
|
1472
1351
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1352
|
+
/**
|
|
1353
|
+
* Switches to the default JSON persist adapter.
|
|
1354
|
+
* All future persistence writes will use JSON storage.
|
|
1355
|
+
*/
|
|
1356
|
+
useJson() {
|
|
1357
|
+
bt.loggerService.log(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_JSON);
|
|
1358
|
+
this.usePersistPartialAdapter(PersistBase);
|
|
1476
1359
|
}
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1360
|
+
/**
|
|
1361
|
+
* Switches to a dummy persist adapter that discards all writes.
|
|
1362
|
+
* All future persistence writes will be no-ops.
|
|
1363
|
+
*/
|
|
1364
|
+
useDummy() {
|
|
1365
|
+
bt.loggerService.log(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_DUMMY);
|
|
1366
|
+
this.usePersistPartialAdapter(PersistDummy);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1480
1369
|
/**
|
|
1481
|
-
*
|
|
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.
|
|
1482
1391
|
*
|
|
1483
1392
|
* Features:
|
|
1484
|
-
* - Atomic file writes
|
|
1485
|
-
* -
|
|
1486
|
-
* -
|
|
1487
|
-
* -
|
|
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()
|
|
1397
|
+
*
|
|
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
|
+
* ```
|
|
1488
1406
|
*
|
|
1489
1407
|
* @example
|
|
1490
1408
|
* ```typescript
|
|
1491
|
-
*
|
|
1492
|
-
* await
|
|
1493
|
-
*
|
|
1494
|
-
*
|
|
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");
|
|
1495
1415
|
* ```
|
|
1496
1416
|
*/
|
|
1497
|
-
class
|
|
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);
|
|
1513
|
-
}
|
|
1514
|
-
/**
|
|
1515
|
-
* Computes file path for entity ID.
|
|
1516
|
-
*
|
|
1517
|
-
* @param entityId - Entity identifier
|
|
1518
|
-
* @returns Full file path to entity JSON file
|
|
1519
|
-
*/
|
|
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
|
-
}
|
|
1563
|
-
}
|
|
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
|
-
}
|
|
1577
|
-
}
|
|
1578
|
-
/**
|
|
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
|
|
1585
|
-
*/
|
|
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
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
}
|
|
1608
|
-
_a$2 = BASE_WAIT_FOR_INIT_SYMBOL;
|
|
1609
|
-
// @ts-ignore
|
|
1610
|
-
PersistBase = functoolsKit.makeExtendable(PersistBase);
|
|
1611
|
-
/**
|
|
1612
|
-
* Dummy persist adapter that discards all writes.
|
|
1613
|
-
* Used for disabling persistence.
|
|
1614
|
-
*/
|
|
1615
|
-
class PersistDummy {
|
|
1616
|
-
/**
|
|
1617
|
-
* No-op initialization function.
|
|
1618
|
-
* @returns Promise that resolves immediately
|
|
1619
|
-
*/
|
|
1620
|
-
async waitForInit() {
|
|
1621
|
-
}
|
|
1622
|
-
/**
|
|
1623
|
-
* No-op read function.
|
|
1624
|
-
* @returns Promise that resolves with empty object
|
|
1625
|
-
*/
|
|
1626
|
-
async readValue() {
|
|
1627
|
-
return {};
|
|
1628
|
-
}
|
|
1629
|
-
/**
|
|
1630
|
-
* No-op has value check.
|
|
1631
|
-
* @returns Promise that resolves to false
|
|
1632
|
-
*/
|
|
1633
|
-
async hasValue() {
|
|
1634
|
-
return false;
|
|
1635
|
-
}
|
|
1636
|
-
/**
|
|
1637
|
-
* No-op write function.
|
|
1638
|
-
* @returns Promise that resolves immediately
|
|
1639
|
-
*/
|
|
1640
|
-
async writeValue() {
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
/**
|
|
1644
|
-
* Utility class for managing signal persistence.
|
|
1645
|
-
*
|
|
1646
|
-
* Features:
|
|
1647
|
-
* - Memoized storage instances per strategy
|
|
1648
|
-
* - Custom adapter support
|
|
1649
|
-
* - Atomic read/write operations
|
|
1650
|
-
* - Crash-safe signal state management
|
|
1651
|
-
*
|
|
1652
|
-
* Used by ClientStrategy for live mode persistence.
|
|
1653
|
-
*/
|
|
1654
|
-
class PersistSignalUtils {
|
|
1417
|
+
class PersistBreakevenUtils {
|
|
1655
1418
|
constructor() {
|
|
1656
|
-
|
|
1657
|
-
|
|
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/
|
|
1436
|
+
`./dump/data/breakeven/`,
|
|
1660
1437
|
]));
|
|
1661
1438
|
/**
|
|
1662
|
-
* Reads persisted
|
|
1439
|
+
* Reads persisted breakeven data for a symbol and strategy.
|
|
1663
1440
|
*
|
|
1664
|
-
* Called by
|
|
1665
|
-
* Returns
|
|
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
|
|
1448
|
+
* @returns Promise resolving to breakeven data record
|
|
1671
1449
|
*/
|
|
1672
|
-
this.
|
|
1673
|
-
bt.loggerService.info(
|
|
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.
|
|
1676
|
-
const stateStorage = this.
|
|
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(
|
|
1679
|
-
return await stateStorage.readValue(
|
|
1456
|
+
if (await stateStorage.hasValue(signalId)) {
|
|
1457
|
+
return await stateStorage.readValue(signalId);
|
|
1680
1458
|
}
|
|
1681
|
-
return
|
|
1459
|
+
return {};
|
|
1682
1460
|
};
|
|
1683
1461
|
/**
|
|
1684
|
-
* Writes
|
|
1462
|
+
* Writes breakeven data to disk.
|
|
1685
1463
|
*
|
|
1686
|
-
* Called by
|
|
1687
|
-
*
|
|
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
|
|
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.
|
|
1696
|
-
bt.loggerService.info(
|
|
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.
|
|
1699
|
-
const stateStorage = this.
|
|
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(
|
|
1481
|
+
await stateStorage.writeValue(signalId, breakevenData);
|
|
1702
1482
|
};
|
|
1703
1483
|
}
|
|
1704
1484
|
/**
|
|
@@ -1712,538 +1492,1109 @@ 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
|
-
*
|
|
1495
|
+
* PersistBreakevenAdapter.usePersistBreakevenAdapter(RedisPersist);
|
|
1716
1496
|
* ```
|
|
1717
1497
|
*/
|
|
1718
|
-
|
|
1719
|
-
bt.loggerService.info(
|
|
1720
|
-
this.
|
|
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(
|
|
1728
|
-
this.
|
|
1507
|
+
bt.loggerService.log(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_JSON);
|
|
1508
|
+
this.usePersistBreakevenAdapter(PersistBase);
|
|
1729
1509
|
}
|
|
1730
1510
|
/**
|
|
1731
1511
|
* Switches to a dummy persist adapter that discards all writes.
|
|
1732
1512
|
* All future persistence writes will be no-ops.
|
|
1733
1513
|
*/
|
|
1734
1514
|
useDummy() {
|
|
1735
|
-
bt.loggerService.log(
|
|
1736
|
-
this.
|
|
1515
|
+
bt.loggerService.log(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_DUMMY);
|
|
1516
|
+
this.usePersistBreakevenAdapter(PersistDummy);
|
|
1737
1517
|
}
|
|
1738
1518
|
}
|
|
1739
1519
|
/**
|
|
1740
|
-
* Global singleton instance of
|
|
1741
|
-
* Used by
|
|
1520
|
+
* Global singleton instance of PersistBreakevenUtils.
|
|
1521
|
+
* Used by ClientBreakeven for breakeven state persistence.
|
|
1742
1522
|
*
|
|
1743
1523
|
* @example
|
|
1744
1524
|
* ```typescript
|
|
1745
1525
|
* // Custom adapter
|
|
1746
|
-
*
|
|
1526
|
+
* PersistBreakevenAdapter.usePersistBreakevenAdapter(RedisPersist);
|
|
1747
1527
|
*
|
|
1748
|
-
* // Read
|
|
1749
|
-
* const
|
|
1528
|
+
* // Read breakeven data
|
|
1529
|
+
* const breakevenData = await PersistBreakevenAdapter.readBreakevenData("BTCUSDT", "my-strategy");
|
|
1750
1530
|
*
|
|
1751
|
-
* // Write
|
|
1752
|
-
* await
|
|
1531
|
+
* // Write breakeven data
|
|
1532
|
+
* await PersistBreakevenAdapter.writeBreakevenData(breakevenData, "BTCUSDT", "my-strategy");
|
|
1753
1533
|
* ```
|
|
1754
1534
|
*/
|
|
1755
|
-
const
|
|
1535
|
+
const PersistBreakevenAdapter = new PersistBreakevenUtils();
|
|
1756
1536
|
/**
|
|
1757
|
-
* Utility class for managing
|
|
1537
|
+
* Utility class for managing candles cache persistence.
|
|
1758
1538
|
*
|
|
1759
1539
|
* Features:
|
|
1760
|
-
* -
|
|
1761
|
-
* -
|
|
1762
|
-
* -
|
|
1763
|
-
* -
|
|
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
|
|
1764
1544
|
*
|
|
1765
|
-
* Used by
|
|
1545
|
+
* Used by ClientExchange for candle data caching.
|
|
1766
1546
|
*/
|
|
1767
|
-
class
|
|
1547
|
+
class PersistCandleUtils {
|
|
1768
1548
|
constructor() {
|
|
1769
|
-
this.
|
|
1770
|
-
this.
|
|
1771
|
-
`${
|
|
1772
|
-
`./dump/data/
|
|
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/`,
|
|
1773
1553
|
]));
|
|
1774
1554
|
/**
|
|
1775
|
-
* Reads
|
|
1776
|
-
*
|
|
1777
|
-
* Called by ClientRisk.waitForInit() to restore state.
|
|
1778
|
-
* Returns empty Map if no positions exist.
|
|
1555
|
+
* Reads cached candles for a specific exchange, symbol, and interval.
|
|
1556
|
+
* Returns candles only if cache contains exactly the requested limit.
|
|
1779
1557
|
*
|
|
1780
|
-
* @param
|
|
1558
|
+
* @param symbol - Trading pair symbol
|
|
1559
|
+
* @param interval - Candle interval
|
|
1781
1560
|
* @param exchangeName - Exchange identifier
|
|
1782
|
-
* @
|
|
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
|
|
1783
1565
|
*/
|
|
1784
|
-
this.
|
|
1785
|
-
bt.loggerService.info(
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
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);
|
|
1789
1578
|
await stateStorage.waitForInit(isInitial);
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
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;
|
|
1795
1597
|
};
|
|
1796
1598
|
/**
|
|
1797
|
-
* Writes
|
|
1798
|
-
*
|
|
1799
|
-
* Called by ClientRisk after addSignal/removeSignal to persist state.
|
|
1800
|
-
* Uses atomic writes to prevent corruption on crashes.
|
|
1599
|
+
* Writes candles to cache with atomic file writes.
|
|
1600
|
+
* Each candle is stored as a separate JSON file named by its timestamp.
|
|
1801
1601
|
*
|
|
1802
|
-
* @param
|
|
1803
|
-
* @param
|
|
1602
|
+
* @param candles - Array of candle data to cache
|
|
1603
|
+
* @param symbol - Trading pair symbol
|
|
1604
|
+
* @param interval - Candle interval
|
|
1804
1605
|
* @param exchangeName - Exchange identifier
|
|
1805
|
-
* @returns Promise that resolves when
|
|
1606
|
+
* @returns Promise that resolves when all writes are complete
|
|
1806
1607
|
*/
|
|
1807
|
-
this.
|
|
1808
|
-
bt.loggerService.info(
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
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);
|
|
1812
1618
|
await stateStorage.waitForInit(isInitial);
|
|
1813
|
-
|
|
1814
|
-
|
|
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
|
+
}
|
|
1815
1625
|
};
|
|
1816
1626
|
}
|
|
1817
1627
|
/**
|
|
1818
1628
|
* Registers a custom persistence adapter.
|
|
1819
1629
|
*
|
|
1820
1630
|
* @param Ctor - Custom PersistBase constructor
|
|
1821
|
-
*
|
|
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
|
-
* ```
|
|
1830
1631
|
*/
|
|
1831
|
-
|
|
1832
|
-
bt.loggerService.info(
|
|
1833
|
-
this.
|
|
1632
|
+
usePersistCandleAdapter(Ctor) {
|
|
1633
|
+
bt.loggerService.info("PersistCandleUtils.usePersistCandleAdapter");
|
|
1634
|
+
this.PersistCandlesFactory = Ctor;
|
|
1834
1635
|
}
|
|
1835
1636
|
/**
|
|
1836
1637
|
* Switches to the default JSON persist adapter.
|
|
1837
1638
|
* All future persistence writes will use JSON storage.
|
|
1838
1639
|
*/
|
|
1839
1640
|
useJson() {
|
|
1840
|
-
bt.loggerService.log(
|
|
1841
|
-
this.
|
|
1641
|
+
bt.loggerService.log("PersistCandleUtils.useJson");
|
|
1642
|
+
this.usePersistCandleAdapter(PersistBase);
|
|
1842
1643
|
}
|
|
1843
1644
|
/**
|
|
1844
1645
|
* Switches to a dummy persist adapter that discards all writes.
|
|
1845
1646
|
* All future persistence writes will be no-ops.
|
|
1846
1647
|
*/
|
|
1847
1648
|
useDummy() {
|
|
1848
|
-
bt.loggerService.log(
|
|
1849
|
-
this.
|
|
1649
|
+
bt.loggerService.log("PersistCandleUtils.useDummy");
|
|
1650
|
+
this.usePersistCandleAdapter(PersistDummy);
|
|
1850
1651
|
}
|
|
1851
1652
|
}
|
|
1852
1653
|
/**
|
|
1853
|
-
* Global singleton instance of
|
|
1854
|
-
* Used by
|
|
1654
|
+
* Global singleton instance of PersistCandleUtils.
|
|
1655
|
+
* Used by ClientExchange for candle data caching.
|
|
1855
1656
|
*
|
|
1856
1657
|
* @example
|
|
1857
1658
|
* ```typescript
|
|
1858
|
-
* //
|
|
1859
|
-
*
|
|
1860
|
-
*
|
|
1861
|
-
*
|
|
1862
|
-
* const positions = await PersistRiskAdapter.readPositionData("my-risk");
|
|
1659
|
+
* // Read cached candles
|
|
1660
|
+
* const candles = await PersistCandleAdapter.readCandlesData(
|
|
1661
|
+
* "BTCUSDT", "1m", "binance", 100, since.getTime(), until.getTime()
|
|
1662
|
+
* );
|
|
1863
1663
|
*
|
|
1864
|
-
* // Write
|
|
1865
|
-
* await
|
|
1664
|
+
* // Write candles to cache
|
|
1665
|
+
* await PersistCandleAdapter.writeCandlesData(candles, "BTCUSDT", "1m", "binance");
|
|
1866
1666
|
* ```
|
|
1867
1667
|
*/
|
|
1868
|
-
const
|
|
1668
|
+
const PersistCandleAdapter = new PersistCandleUtils();
|
|
1669
|
+
|
|
1670
|
+
const INTERVAL_MINUTES$4 = {
|
|
1671
|
+
"1m": 1,
|
|
1672
|
+
"3m": 3,
|
|
1673
|
+
"5m": 5,
|
|
1674
|
+
"15m": 15,
|
|
1675
|
+
"30m": 30,
|
|
1676
|
+
"1h": 60,
|
|
1677
|
+
"2h": 120,
|
|
1678
|
+
"4h": 240,
|
|
1679
|
+
"6h": 360,
|
|
1680
|
+
"8h": 480,
|
|
1681
|
+
};
|
|
1869
1682
|
/**
|
|
1870
|
-
*
|
|
1871
|
-
*
|
|
1872
|
-
*
|
|
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
|
|
1683
|
+
* Validates that all candles have valid OHLCV data without anomalies.
|
|
1684
|
+
* Detects incomplete candles from Binance API by checking for abnormally low prices or volumes.
|
|
1685
|
+
* Incomplete candles often have prices like 0.1 instead of normal 100,000 or zero volume.
|
|
1877
1686
|
*
|
|
1878
|
-
*
|
|
1687
|
+
* @param candles - Array of candle data to validate
|
|
1688
|
+
* @throws Error if any candles have anomalous OHLCV values
|
|
1879
1689
|
*/
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
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);
|
|
1906
|
-
}
|
|
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
|
-
};
|
|
1690
|
+
const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => {
|
|
1691
|
+
if (candles.length === 0) {
|
|
1692
|
+
return;
|
|
1929
1693
|
}
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
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;
|
|
1694
|
+
// Calculate reference price (median or average depending on candle count)
|
|
1695
|
+
const allPrices = candles.flatMap((c) => [c.open, c.high, c.low, c.close]);
|
|
1696
|
+
const validPrices = allPrices.filter((p) => p > 0);
|
|
1697
|
+
let referencePrice;
|
|
1698
|
+
if (candles.length >= GLOBAL_CONFIG.CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN) {
|
|
1699
|
+
// Use median for reliable statistics with enough data
|
|
1700
|
+
const sortedPrices = [...validPrices].sort((a, b) => a - b);
|
|
1701
|
+
referencePrice = sortedPrices[Math.floor(sortedPrices.length / 2)] || 0;
|
|
1947
1702
|
}
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
useJson() {
|
|
1953
|
-
bt.loggerService.log(PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_JSON);
|
|
1954
|
-
this.usePersistScheduleAdapter(PersistBase);
|
|
1703
|
+
else {
|
|
1704
|
+
// Use average for small datasets (more stable than median)
|
|
1705
|
+
const sum = validPrices.reduce((acc, p) => acc + p, 0);
|
|
1706
|
+
referencePrice = validPrices.length > 0 ? sum / validPrices.length : 0;
|
|
1955
1707
|
}
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
* All future persistence writes will be no-ops.
|
|
1959
|
-
*/
|
|
1960
|
-
useDummy() {
|
|
1961
|
-
bt.loggerService.log(PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_DUMMY);
|
|
1962
|
-
this.usePersistScheduleAdapter(PersistDummy);
|
|
1708
|
+
if (referencePrice === 0) {
|
|
1709
|
+
throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: cannot calculate reference price (all prices are zero)`);
|
|
1963
1710
|
}
|
|
1964
|
-
|
|
1711
|
+
const minValidPrice = referencePrice /
|
|
1712
|
+
GLOBAL_CONFIG.CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR;
|
|
1713
|
+
for (let i = 0; i < candles.length; i++) {
|
|
1714
|
+
const candle = candles[i];
|
|
1715
|
+
// Check for invalid numeric values
|
|
1716
|
+
if (!Number.isFinite(candle.open) ||
|
|
1717
|
+
!Number.isFinite(candle.high) ||
|
|
1718
|
+
!Number.isFinite(candle.low) ||
|
|
1719
|
+
!Number.isFinite(candle.close) ||
|
|
1720
|
+
!Number.isFinite(candle.volume) ||
|
|
1721
|
+
!Number.isFinite(candle.timestamp)) {
|
|
1722
|
+
throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has invalid numeric values (NaN or Infinity)`);
|
|
1723
|
+
}
|
|
1724
|
+
// Check for negative values
|
|
1725
|
+
if (candle.open <= 0 ||
|
|
1726
|
+
candle.high <= 0 ||
|
|
1727
|
+
candle.low <= 0 ||
|
|
1728
|
+
candle.close <= 0 ||
|
|
1729
|
+
candle.volume < 0) {
|
|
1730
|
+
throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has zero or negative values`);
|
|
1731
|
+
}
|
|
1732
|
+
// Check for anomalously low prices (incomplete candle indicator)
|
|
1733
|
+
if (candle.open < minValidPrice ||
|
|
1734
|
+
candle.high < minValidPrice ||
|
|
1735
|
+
candle.low < minValidPrice ||
|
|
1736
|
+
candle.close < minValidPrice) {
|
|
1737
|
+
throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has anomalously low price. ` +
|
|
1738
|
+
`OHLC: [${candle.open}, ${candle.high}, ${candle.low}, ${candle.close}], ` +
|
|
1739
|
+
`reference: ${referencePrice}, threshold: ${minValidPrice}`);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
};
|
|
1965
1743
|
/**
|
|
1966
|
-
*
|
|
1967
|
-
*
|
|
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");
|
|
1744
|
+
* Attempts to read candles from cache.
|
|
1745
|
+
* Validates cache consistency (no gaps in timestamps) before returning.
|
|
1976
1746
|
*
|
|
1977
|
-
*
|
|
1978
|
-
*
|
|
1979
|
-
*
|
|
1747
|
+
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
1748
|
+
* @param sinceTimestamp - Start timestamp in milliseconds
|
|
1749
|
+
* @param untilTimestamp - End timestamp in milliseconds
|
|
1750
|
+
* @param self - Instance of ClientExchange
|
|
1751
|
+
* @returns Cached candles array or null if cache miss or inconsistent
|
|
1980
1752
|
*/
|
|
1981
|
-
const
|
|
1753
|
+
const READ_CANDLES_CACHE_FN$1 = functoolsKit.trycatch(async (dto, sinceTimestamp, untilTimestamp, self) => {
|
|
1754
|
+
const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, self.params.exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
|
|
1755
|
+
// Return cached data only if we have exactly the requested limit
|
|
1756
|
+
if (cachedCandles.length === dto.limit) {
|
|
1757
|
+
self.params.logger.debug(`ClientExchange READ_CANDLES_CACHE_FN: cache hit for symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
|
|
1758
|
+
return cachedCandles;
|
|
1759
|
+
}
|
|
1760
|
+
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}`);
|
|
1761
|
+
return null;
|
|
1762
|
+
}, {
|
|
1763
|
+
fallback: async (error) => {
|
|
1764
|
+
const message = `ClientExchange READ_CANDLES_CACHE_FN: cache read failed`;
|
|
1765
|
+
const payload = {
|
|
1766
|
+
error: functoolsKit.errorData(error),
|
|
1767
|
+
message: functoolsKit.getErrorMessage(error),
|
|
1768
|
+
};
|
|
1769
|
+
bt.loggerService.warn(message, payload);
|
|
1770
|
+
console.warn(message, payload);
|
|
1771
|
+
errorEmitter.next(error);
|
|
1772
|
+
},
|
|
1773
|
+
defaultValue: null,
|
|
1774
|
+
});
|
|
1982
1775
|
/**
|
|
1983
|
-
*
|
|
1984
|
-
*
|
|
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
|
|
1776
|
+
* Writes candles to cache with error handling.
|
|
1990
1777
|
*
|
|
1991
|
-
*
|
|
1778
|
+
* @param candles - Array of candle data to cache
|
|
1779
|
+
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
1780
|
+
* @param self - Instance of ClientExchange
|
|
1992
1781
|
*/
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
*
|
|
2003
|
-
* Called by ClientPartial.waitForInit() to restore state.
|
|
2004
|
-
* Returns empty object if no partial data exists.
|
|
2005
|
-
*
|
|
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
|
|
2011
|
-
*/
|
|
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 {};
|
|
1782
|
+
const WRITE_CANDLES_CACHE_FN$1 = functoolsKit.trycatch(functoolsKit.queued(async (candles, dto, self) => {
|
|
1783
|
+
await PersistCandleAdapter.writeCandlesData(candles, dto.symbol, dto.interval, self.params.exchangeName);
|
|
1784
|
+
self.params.logger.debug(`ClientExchange WRITE_CANDLES_CACHE_FN: cache updated for symbol=${dto.symbol}, interval=${dto.interval}, count=${candles.length}`);
|
|
1785
|
+
}), {
|
|
1786
|
+
fallback: async (error) => {
|
|
1787
|
+
const message = `ClientExchange WRITE_CANDLES_CACHE_FN: cache write failed`;
|
|
1788
|
+
const payload = {
|
|
1789
|
+
error: functoolsKit.errorData(error),
|
|
1790
|
+
message: functoolsKit.getErrorMessage(error),
|
|
2022
1791
|
};
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
1792
|
+
bt.loggerService.warn(message, payload);
|
|
1793
|
+
console.warn(message, payload);
|
|
1794
|
+
errorEmitter.next(error);
|
|
1795
|
+
},
|
|
1796
|
+
defaultValue: null,
|
|
1797
|
+
});
|
|
1798
|
+
/**
|
|
1799
|
+
* Retries the getCandles function with specified retry count and delay.
|
|
1800
|
+
* Uses cache to avoid redundant API calls.
|
|
1801
|
+
*
|
|
1802
|
+
* Cache logic:
|
|
1803
|
+
* - Checks if cached candles exist for the time range
|
|
1804
|
+
* - If cache has exactly dto.limit candles, returns cached data
|
|
1805
|
+
* - Otherwise, fetches from API and updates cache
|
|
1806
|
+
*
|
|
1807
|
+
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
1808
|
+
* @param since - Date object representing the start time for fetching candles
|
|
1809
|
+
* @param self - Instance of ClientExchange
|
|
1810
|
+
* @returns Promise resolving to array of candle data
|
|
1811
|
+
*/
|
|
1812
|
+
const GET_CANDLES_FN = async (dto, since, self) => {
|
|
1813
|
+
const step = INTERVAL_MINUTES$4[dto.interval];
|
|
1814
|
+
const sinceTimestamp = since.getTime();
|
|
1815
|
+
const untilTimestamp = sinceTimestamp + dto.limit * step * 60 * 1000;
|
|
1816
|
+
// Try to read from cache first
|
|
1817
|
+
const cachedCandles = await READ_CANDLES_CACHE_FN$1(dto, sinceTimestamp, untilTimestamp, self);
|
|
1818
|
+
if (cachedCandles !== null) {
|
|
1819
|
+
return cachedCandles;
|
|
1820
|
+
}
|
|
1821
|
+
// Cache miss or error - fetch from API
|
|
1822
|
+
let lastError;
|
|
1823
|
+
for (let i = 0; i !== GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT; i++) {
|
|
1824
|
+
try {
|
|
1825
|
+
const result = await self.params.getCandles(dto.symbol, dto.interval, since, dto.limit, self.params.execution.context.backtest);
|
|
1826
|
+
VALIDATE_NO_INCOMPLETE_CANDLES_FN(result);
|
|
1827
|
+
// Write to cache after successful fetch
|
|
1828
|
+
await WRITE_CANDLES_CACHE_FN$1(result, dto, self);
|
|
1829
|
+
return result;
|
|
1830
|
+
}
|
|
1831
|
+
catch (err) {
|
|
1832
|
+
const message = `ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}}`;
|
|
1833
|
+
const payload = {
|
|
1834
|
+
error: functoolsKit.errorData(err),
|
|
1835
|
+
message: functoolsKit.getErrorMessage(err),
|
|
1836
|
+
};
|
|
1837
|
+
self.params.logger.warn(message, payload);
|
|
1838
|
+
console.warn(message, payload);
|
|
1839
|
+
lastError = err;
|
|
1840
|
+
await functoolsKit.sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
throw lastError;
|
|
1844
|
+
};
|
|
1845
|
+
/**
|
|
1846
|
+
* Wrapper to call onCandleData callback with error handling.
|
|
1847
|
+
* Catches and logs any errors thrown by the user-provided callback.
|
|
1848
|
+
*
|
|
1849
|
+
* @param self - ClientExchange instance reference
|
|
1850
|
+
* @param symbol - Trading pair symbol
|
|
1851
|
+
* @param interval - Candle interval
|
|
1852
|
+
* @param since - Start date for candle data
|
|
1853
|
+
* @param limit - Number of candles
|
|
1854
|
+
* @param data - Array of candle data
|
|
1855
|
+
*/
|
|
1856
|
+
const CALL_CANDLE_DATA_CALLBACKS_FN = functoolsKit.trycatch(async (self, symbol, interval, since, limit, data) => {
|
|
1857
|
+
if (self.params.callbacks?.onCandleData) {
|
|
1858
|
+
await self.params.callbacks.onCandleData(symbol, interval, since, limit, data);
|
|
1859
|
+
}
|
|
1860
|
+
}, {
|
|
1861
|
+
fallback: (error) => {
|
|
1862
|
+
const message = "ClientExchange CALL_CANDLE_DATA_CALLBACKS_FN thrown";
|
|
1863
|
+
const payload = {
|
|
1864
|
+
error: functoolsKit.errorData(error),
|
|
1865
|
+
message: functoolsKit.getErrorMessage(error),
|
|
2043
1866
|
};
|
|
1867
|
+
bt.loggerService.warn(message, payload);
|
|
1868
|
+
console.warn(message, payload);
|
|
1869
|
+
errorEmitter.next(error);
|
|
1870
|
+
},
|
|
1871
|
+
});
|
|
1872
|
+
/**
|
|
1873
|
+
* Client implementation for exchange data access.
|
|
1874
|
+
*
|
|
1875
|
+
* Features:
|
|
1876
|
+
* - Historical candle fetching (backwards from execution context)
|
|
1877
|
+
* - Future candle fetching (forwards for backtest)
|
|
1878
|
+
* - VWAP calculation from last 5 1m candles
|
|
1879
|
+
* - Price/quantity formatting for exchange
|
|
1880
|
+
*
|
|
1881
|
+
* All methods use prototype functions for memory efficiency.
|
|
1882
|
+
*
|
|
1883
|
+
* @example
|
|
1884
|
+
* ```typescript
|
|
1885
|
+
* const exchange = new ClientExchange({
|
|
1886
|
+
* exchangeName: "binance",
|
|
1887
|
+
* getCandles: async (symbol, interval, since, limit) => [...],
|
|
1888
|
+
* formatPrice: async (symbol, price) => price.toFixed(2),
|
|
1889
|
+
* formatQuantity: async (symbol, quantity) => quantity.toFixed(8),
|
|
1890
|
+
* execution: executionService,
|
|
1891
|
+
* logger: loggerService,
|
|
1892
|
+
* });
|
|
1893
|
+
*
|
|
1894
|
+
* const candles = await exchange.getCandles("BTCUSDT", "1m", 100);
|
|
1895
|
+
* const vwap = await exchange.getAveragePrice("BTCUSDT");
|
|
1896
|
+
* ```
|
|
1897
|
+
*/
|
|
1898
|
+
class ClientExchange {
|
|
1899
|
+
constructor(params) {
|
|
1900
|
+
this.params = params;
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Fetches historical candles backwards from execution context time.
|
|
1904
|
+
*
|
|
1905
|
+
* @param symbol - Trading pair symbol
|
|
1906
|
+
* @param interval - Candle interval
|
|
1907
|
+
* @param limit - Number of candles to fetch
|
|
1908
|
+
* @returns Promise resolving to array of candles
|
|
1909
|
+
*/
|
|
1910
|
+
async getCandles(symbol, interval, limit) {
|
|
1911
|
+
this.params.logger.debug(`ClientExchange getCandles`, {
|
|
1912
|
+
symbol,
|
|
1913
|
+
interval,
|
|
1914
|
+
limit,
|
|
1915
|
+
});
|
|
1916
|
+
const step = INTERVAL_MINUTES$4[interval];
|
|
1917
|
+
const adjust = step * limit;
|
|
1918
|
+
if (!adjust) {
|
|
1919
|
+
throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
|
|
1920
|
+
}
|
|
1921
|
+
const since = new Date(this.params.execution.context.when.getTime() - adjust * 60 * 1000);
|
|
1922
|
+
let allData = [];
|
|
1923
|
+
// If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
|
|
1924
|
+
if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
|
|
1925
|
+
let remaining = limit;
|
|
1926
|
+
let currentSince = new Date(since.getTime());
|
|
1927
|
+
while (remaining > 0) {
|
|
1928
|
+
const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
|
|
1929
|
+
const chunkData = await GET_CANDLES_FN({ symbol, interval, limit: chunkLimit }, currentSince, this);
|
|
1930
|
+
allData.push(...chunkData);
|
|
1931
|
+
remaining -= chunkLimit;
|
|
1932
|
+
if (remaining > 0) {
|
|
1933
|
+
// Move currentSince forward by the number of candles fetched
|
|
1934
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * step * 60 * 1000);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
else {
|
|
1939
|
+
allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
|
|
1940
|
+
}
|
|
1941
|
+
// Filter candles to strictly match the requested range
|
|
1942
|
+
const whenTimestamp = this.params.execution.context.when.getTime();
|
|
1943
|
+
const sinceTimestamp = since.getTime();
|
|
1944
|
+
const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp &&
|
|
1945
|
+
candle.timestamp < whenTimestamp);
|
|
1946
|
+
// Apply distinct by timestamp to remove duplicates
|
|
1947
|
+
const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
|
|
1948
|
+
if (filteredData.length !== uniqueData.length) {
|
|
1949
|
+
const msg = `ClientExchange Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
|
|
1950
|
+
this.params.logger.warn(msg);
|
|
1951
|
+
console.warn(msg);
|
|
1952
|
+
}
|
|
1953
|
+
if (uniqueData.length < limit) {
|
|
1954
|
+
const msg = `ClientExchange Expected ${limit} candles, got ${uniqueData.length}`;
|
|
1955
|
+
this.params.logger.warn(msg);
|
|
1956
|
+
console.warn(msg);
|
|
1957
|
+
}
|
|
1958
|
+
await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
|
|
1959
|
+
return uniqueData;
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Fetches future candles forwards from execution context time.
|
|
1963
|
+
* Used in backtest mode to get candles for signal duration.
|
|
1964
|
+
*
|
|
1965
|
+
* @param symbol - Trading pair symbol
|
|
1966
|
+
* @param interval - Candle interval
|
|
1967
|
+
* @param limit - Number of candles to fetch
|
|
1968
|
+
* @returns Promise resolving to array of candles
|
|
1969
|
+
* @throws Error if trying to fetch future candles in live mode
|
|
1970
|
+
*/
|
|
1971
|
+
async getNextCandles(symbol, interval, limit) {
|
|
1972
|
+
this.params.logger.debug(`ClientExchange getNextCandles`, {
|
|
1973
|
+
symbol,
|
|
1974
|
+
interval,
|
|
1975
|
+
limit,
|
|
1976
|
+
});
|
|
1977
|
+
const since = new Date(this.params.execution.context.when.getTime());
|
|
1978
|
+
const now = Date.now();
|
|
1979
|
+
// Вычисляем конечное время запроса
|
|
1980
|
+
const step = INTERVAL_MINUTES$4[interval];
|
|
1981
|
+
const endTime = since.getTime() + limit * step * 60 * 1000;
|
|
1982
|
+
// Проверяем что запрошенный период не заходит за Date.now()
|
|
1983
|
+
if (endTime > now) {
|
|
1984
|
+
return [];
|
|
1985
|
+
}
|
|
1986
|
+
let allData = [];
|
|
1987
|
+
// If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
|
|
1988
|
+
if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
|
|
1989
|
+
let remaining = limit;
|
|
1990
|
+
let currentSince = new Date(since.getTime());
|
|
1991
|
+
while (remaining > 0) {
|
|
1992
|
+
const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
|
|
1993
|
+
const chunkData = await GET_CANDLES_FN({ symbol, interval, limit: chunkLimit }, currentSince, this);
|
|
1994
|
+
allData.push(...chunkData);
|
|
1995
|
+
remaining -= chunkLimit;
|
|
1996
|
+
if (remaining > 0) {
|
|
1997
|
+
// Move currentSince forward by the number of candles fetched
|
|
1998
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * step * 60 * 1000);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
else {
|
|
2003
|
+
allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
|
|
2004
|
+
}
|
|
2005
|
+
// Filter candles to strictly match the requested range
|
|
2006
|
+
const sinceTimestamp = since.getTime();
|
|
2007
|
+
const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp < endTime);
|
|
2008
|
+
// Apply distinct by timestamp to remove duplicates
|
|
2009
|
+
const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
|
|
2010
|
+
if (filteredData.length !== uniqueData.length) {
|
|
2011
|
+
const msg = `ClientExchange getNextCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
|
|
2012
|
+
this.params.logger.warn(msg);
|
|
2013
|
+
console.warn(msg);
|
|
2014
|
+
}
|
|
2015
|
+
if (uniqueData.length < limit) {
|
|
2016
|
+
const msg = `ClientExchange getNextCandles: Expected ${limit} candles, got ${uniqueData.length}`;
|
|
2017
|
+
this.params.logger.warn(msg);
|
|
2018
|
+
console.warn(msg);
|
|
2019
|
+
}
|
|
2020
|
+
await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
|
|
2021
|
+
return uniqueData;
|
|
2022
|
+
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Calculates VWAP (Volume Weighted Average Price) from last N 1m candles.
|
|
2025
|
+
* The number of candles is configurable via GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT.
|
|
2026
|
+
*
|
|
2027
|
+
* Formula:
|
|
2028
|
+
* - Typical Price = (high + low + close) / 3
|
|
2029
|
+
* - VWAP = sum(typical_price * volume) / sum(volume)
|
|
2030
|
+
*
|
|
2031
|
+
* If volume is zero, returns simple average of close prices.
|
|
2032
|
+
*
|
|
2033
|
+
* @param symbol - Trading pair symbol
|
|
2034
|
+
* @returns Promise resolving to VWAP price
|
|
2035
|
+
* @throws Error if no candles available
|
|
2036
|
+
*/
|
|
2037
|
+
async getAveragePrice(symbol) {
|
|
2038
|
+
this.params.logger.debug(`ClientExchange getAveragePrice`, {
|
|
2039
|
+
symbol,
|
|
2040
|
+
});
|
|
2041
|
+
const candles = await this.getCandles(symbol, "1m", GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT);
|
|
2042
|
+
if (candles.length === 0) {
|
|
2043
|
+
throw new Error(`ClientExchange getAveragePrice: no candles data for symbol=${symbol}`);
|
|
2044
|
+
}
|
|
2045
|
+
// VWAP (Volume Weighted Average Price)
|
|
2046
|
+
// Используем типичную цену (typical price) = (high + low + close) / 3
|
|
2047
|
+
const sumPriceVolume = candles.reduce((acc, candle) => {
|
|
2048
|
+
const typicalPrice = (candle.high + candle.low + candle.close) / 3;
|
|
2049
|
+
return acc + typicalPrice * candle.volume;
|
|
2050
|
+
}, 0);
|
|
2051
|
+
const totalVolume = candles.reduce((acc, candle) => acc + candle.volume, 0);
|
|
2052
|
+
if (totalVolume === 0) {
|
|
2053
|
+
// Если объем нулевой, возвращаем простое среднее close цен
|
|
2054
|
+
const sum = candles.reduce((acc, candle) => acc + candle.close, 0);
|
|
2055
|
+
return sum / candles.length;
|
|
2056
|
+
}
|
|
2057
|
+
const vwap = sumPriceVolume / totalVolume;
|
|
2058
|
+
return vwap;
|
|
2044
2059
|
}
|
|
2045
2060
|
/**
|
|
2046
|
-
*
|
|
2061
|
+
* Formats quantity according to exchange-specific rules for the given symbol.
|
|
2062
|
+
* Applies proper decimal precision and rounding based on symbol's lot size filters.
|
|
2047
2063
|
*
|
|
2048
|
-
* @param
|
|
2064
|
+
* @param symbol - Trading pair symbol
|
|
2065
|
+
* @param quantity - Raw quantity to format
|
|
2066
|
+
* @returns Promise resolving to formatted quantity as string
|
|
2067
|
+
*/
|
|
2068
|
+
async formatQuantity(symbol, quantity) {
|
|
2069
|
+
this.params.logger.debug("binanceService formatQuantity", {
|
|
2070
|
+
symbol,
|
|
2071
|
+
quantity,
|
|
2072
|
+
});
|
|
2073
|
+
return await this.params.formatQuantity(symbol, quantity, this.params.execution.context.backtest);
|
|
2074
|
+
}
|
|
2075
|
+
/**
|
|
2076
|
+
* Formats price according to exchange-specific rules for the given symbol.
|
|
2077
|
+
* Applies proper decimal precision and rounding based on symbol's price filters.
|
|
2049
2078
|
*
|
|
2050
|
-
* @
|
|
2051
|
-
*
|
|
2052
|
-
*
|
|
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
|
-
* ```
|
|
2079
|
+
* @param symbol - Trading pair symbol
|
|
2080
|
+
* @param price - Raw price to format
|
|
2081
|
+
* @returns Promise resolving to formatted price as string
|
|
2058
2082
|
*/
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2083
|
+
async formatPrice(symbol, price) {
|
|
2084
|
+
this.params.logger.debug("binanceService formatPrice", {
|
|
2085
|
+
symbol,
|
|
2086
|
+
price,
|
|
2087
|
+
});
|
|
2088
|
+
return await this.params.formatPrice(symbol, price, this.params.execution.context.backtest);
|
|
2062
2089
|
}
|
|
2063
2090
|
/**
|
|
2064
|
-
*
|
|
2065
|
-
*
|
|
2091
|
+
* Fetches raw candles with flexible date/limit parameters.
|
|
2092
|
+
*
|
|
2093
|
+
* Compatibility layer that:
|
|
2094
|
+
* - RAW MODE (sDate + eDate + limit): fetches exactly as specified, NO look-ahead bias protection
|
|
2095
|
+
* - Other modes: respects execution context and prevents look-ahead bias
|
|
2096
|
+
*
|
|
2097
|
+
* Parameter combinations:
|
|
2098
|
+
* 1. sDate + eDate + limit: RAW MODE - fetches exactly as specified, no validation against when
|
|
2099
|
+
* 2. sDate + eDate: calculates limit from date range, validates endTimestamp <= when
|
|
2100
|
+
* 3. eDate + limit: calculates sDate backward, validates endTimestamp <= when
|
|
2101
|
+
* 4. sDate + limit: fetches forward, validates endTimestamp <= when
|
|
2102
|
+
* 5. Only limit: uses execution.context.when as reference (backward)
|
|
2103
|
+
*
|
|
2104
|
+
* Edge cases:
|
|
2105
|
+
* - If calculated limit is 0 or negative: throws error
|
|
2106
|
+
* - If sDate >= eDate: throws error
|
|
2107
|
+
* - If startTimestamp >= endTimestamp: throws error
|
|
2108
|
+
* - If endTimestamp > when (non-RAW modes only): throws error to prevent look-ahead bias
|
|
2109
|
+
*
|
|
2110
|
+
* @param symbol - Trading pair symbol
|
|
2111
|
+
* @param interval - Candle interval
|
|
2112
|
+
* @param limit - Optional number of candles to fetch
|
|
2113
|
+
* @param sDate - Optional start date in milliseconds
|
|
2114
|
+
* @param eDate - Optional end date in milliseconds
|
|
2115
|
+
* @returns Promise resolving to array of candles
|
|
2116
|
+
* @throws Error if parameters are invalid or conflicting
|
|
2066
2117
|
*/
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2118
|
+
async getRawCandles(symbol, interval, limit, sDate, eDate) {
|
|
2119
|
+
this.params.logger.debug(`ClientExchange getRawCandles`, {
|
|
2120
|
+
symbol,
|
|
2121
|
+
interval,
|
|
2122
|
+
limit,
|
|
2123
|
+
sDate,
|
|
2124
|
+
eDate,
|
|
2125
|
+
});
|
|
2126
|
+
const step = INTERVAL_MINUTES$4[interval];
|
|
2127
|
+
const stepMs = step * 60 * 1000;
|
|
2128
|
+
const when = this.params.execution.context.when.getTime();
|
|
2129
|
+
let startTimestamp;
|
|
2130
|
+
let endTimestamp;
|
|
2131
|
+
let candleLimit;
|
|
2132
|
+
let isRawMode = false;
|
|
2133
|
+
// Case 1: sDate + eDate + limit - RAW MODE (no look-ahead bias protection)
|
|
2134
|
+
if (sDate !== undefined &&
|
|
2135
|
+
eDate !== undefined &&
|
|
2136
|
+
limit !== undefined) {
|
|
2137
|
+
isRawMode = true;
|
|
2138
|
+
if (sDate >= eDate) {
|
|
2139
|
+
throw new Error(`ClientExchange getRawCandles: sDate (${sDate}) must be less than eDate (${eDate})`);
|
|
2140
|
+
}
|
|
2141
|
+
startTimestamp = sDate;
|
|
2142
|
+
endTimestamp = eDate;
|
|
2143
|
+
candleLimit = limit;
|
|
2144
|
+
}
|
|
2145
|
+
// Case 2: sDate + eDate - calculate limit, respect backtest context
|
|
2146
|
+
else if (sDate !== undefined && eDate !== undefined && limit === undefined) {
|
|
2147
|
+
if (sDate >= eDate) {
|
|
2148
|
+
throw new Error(`ClientExchange getRawCandles: sDate (${sDate}) must be less than eDate (${eDate})`);
|
|
2149
|
+
}
|
|
2150
|
+
startTimestamp = sDate;
|
|
2151
|
+
endTimestamp = eDate;
|
|
2152
|
+
const rangeDuration = endTimestamp - startTimestamp;
|
|
2153
|
+
candleLimit = Math.floor(rangeDuration / stepMs);
|
|
2154
|
+
if (candleLimit <= 0) {
|
|
2155
|
+
throw new Error(`ClientExchange getRawCandles: calculated limit is ${candleLimit} for range [${sDate}, ${eDate}]`);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
// Case 3: eDate + limit - calculate sDate backward, respect backtest context
|
|
2159
|
+
else if (eDate !== undefined && limit !== undefined && sDate === undefined) {
|
|
2160
|
+
endTimestamp = eDate;
|
|
2161
|
+
startTimestamp = eDate - limit * stepMs;
|
|
2162
|
+
candleLimit = limit;
|
|
2163
|
+
}
|
|
2164
|
+
// Case 4: sDate + limit - fetch forward, respect backtest context
|
|
2165
|
+
else if (sDate !== undefined && limit !== undefined && eDate === undefined) {
|
|
2166
|
+
startTimestamp = sDate;
|
|
2167
|
+
endTimestamp = sDate + limit * stepMs;
|
|
2168
|
+
candleLimit = limit;
|
|
2169
|
+
}
|
|
2170
|
+
// Case 5: Only limit - use execution context (backward from when)
|
|
2171
|
+
else if (limit !== undefined && sDate === undefined && eDate === undefined) {
|
|
2172
|
+
endTimestamp = when;
|
|
2173
|
+
startTimestamp = when - limit * stepMs;
|
|
2174
|
+
candleLimit = limit;
|
|
2175
|
+
}
|
|
2176
|
+
// Invalid combination
|
|
2177
|
+
else {
|
|
2178
|
+
throw new Error(`ClientExchange getRawCandles: invalid parameter combination. Must provide either (limit), (eDate+limit), (sDate+limit), (sDate+eDate), or (sDate+eDate+limit)`);
|
|
2179
|
+
}
|
|
2180
|
+
// Validate timestamps
|
|
2181
|
+
if (startTimestamp >= endTimestamp) {
|
|
2182
|
+
throw new Error(`ClientExchange getRawCandles: startTimestamp (${startTimestamp}) >= endTimestamp (${endTimestamp})`);
|
|
2183
|
+
}
|
|
2184
|
+
// Check if trying to fetch future data (prevent look-ahead bias)
|
|
2185
|
+
// ONLY for non-RAW modes - RAW MODE bypasses this check
|
|
2186
|
+
if (!isRawMode && endTimestamp > when) {
|
|
2187
|
+
throw new Error(`ClientExchange getRawCandles: endTimestamp (${endTimestamp}) is beyond execution context when (${when}) - look-ahead bias prevented`);
|
|
2188
|
+
}
|
|
2189
|
+
const since = new Date(startTimestamp);
|
|
2190
|
+
let allData = [];
|
|
2191
|
+
// Fetch data in chunks if limit exceeds max per request
|
|
2192
|
+
if (candleLimit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
|
|
2193
|
+
let remaining = candleLimit;
|
|
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 * stepMs);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
else {
|
|
2206
|
+
allData = await GET_CANDLES_FN({ symbol, interval, limit: candleLimit }, since, this);
|
|
2207
|
+
}
|
|
2208
|
+
// Filter candles to strictly match the requested range
|
|
2209
|
+
const filteredData = allData.filter((candle) => candle.timestamp >= startTimestamp &&
|
|
2210
|
+
candle.timestamp < endTimestamp);
|
|
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 < candleLimit) {
|
|
2219
|
+
const msg = `ClientExchange getRawCandles: Expected ${candleLimit} 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, candleLimit, uniqueData);
|
|
2224
|
+
return uniqueData;
|
|
2070
2225
|
}
|
|
2071
2226
|
/**
|
|
2072
|
-
*
|
|
2073
|
-
*
|
|
2227
|
+
* Fetches order book for a trading pair.
|
|
2228
|
+
*
|
|
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.
|
|
2232
|
+
*
|
|
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
|
|
2074
2237
|
*/
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
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 * 60 * 1000);
|
|
2246
|
+
return await this.params.getOrderBook(symbol, depth, from, to, this.params.execution.context.backtest);
|
|
2078
2247
|
}
|
|
2079
2248
|
}
|
|
2249
|
+
|
|
2080
2250
|
/**
|
|
2081
|
-
*
|
|
2082
|
-
*
|
|
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
|
-
* ```
|
|
2251
|
+
* Default implementation for getCandles.
|
|
2252
|
+
* Throws an error indicating the method is not implemented.
|
|
2095
2253
|
*/
|
|
2096
|
-
const
|
|
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
|
+
};
|
|
2097
2257
|
/**
|
|
2098
|
-
*
|
|
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.
|
|
2099
2274
|
*
|
|
2100
|
-
*
|
|
2101
|
-
*
|
|
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)
|
|
2280
|
+
*/
|
|
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
|
+
};
|
|
2284
|
+
/**
|
|
2285
|
+
* Connection service routing exchange operations to correct ClientExchange instance.
|
|
2102
2286
|
*
|
|
2103
|
-
*
|
|
2104
|
-
*
|
|
2105
|
-
*
|
|
2106
|
-
* - Singleton pattern for global access
|
|
2107
|
-
* - Custom adapter support via usePersistBreakevenAdapter()
|
|
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.
|
|
2108
2290
|
*
|
|
2109
|
-
*
|
|
2110
|
-
*
|
|
2111
|
-
*
|
|
2112
|
-
*
|
|
2113
|
-
*
|
|
2114
|
-
* └── ETHUSDT_other-strategy/
|
|
2115
|
-
* └── state.json
|
|
2116
|
-
* ```
|
|
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
|
|
2117
2296
|
*
|
|
2118
2297
|
* @example
|
|
2119
2298
|
* ```typescript
|
|
2120
|
-
* //
|
|
2121
|
-
* const
|
|
2122
|
-
*
|
|
2123
|
-
*
|
|
2124
|
-
* //
|
|
2125
|
-
* await PersistBreakevenAdapter.writeBreakevenData(breakevenData, "BTCUSDT", "my-strategy");
|
|
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
|
|
2126
2304
|
* ```
|
|
2127
2305
|
*/
|
|
2128
|
-
class
|
|
2306
|
+
class ExchangeConnectionService {
|
|
2129
2307
|
constructor() {
|
|
2308
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
2309
|
+
this.executionContextService = inject(TYPES.executionContextService);
|
|
2310
|
+
this.exchangeSchemaService = inject(TYPES.exchangeSchemaService);
|
|
2311
|
+
this.methodContextService = inject(TYPES.methodContextService);
|
|
2312
|
+
/**
|
|
2313
|
+
* Retrieves memoized ClientExchange instance for given exchange name.
|
|
2314
|
+
*
|
|
2315
|
+
* Creates ClientExchange on first call, returns cached instance on subsequent calls.
|
|
2316
|
+
* Cache key is exchangeName string.
|
|
2317
|
+
*
|
|
2318
|
+
* @param exchangeName - Name of registered exchange schema
|
|
2319
|
+
* @returns Configured ClientExchange instance
|
|
2320
|
+
*/
|
|
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
|
+
};
|
|
2130
2352
|
/**
|
|
2131
|
-
*
|
|
2132
|
-
*
|
|
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
|
|
2133
2362
|
*/
|
|
2134
|
-
this.
|
|
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
|
+
};
|
|
2135
2371
|
/**
|
|
2136
|
-
*
|
|
2137
|
-
* Creates one PersistBase instance per symbol-strategy-exchange combination.
|
|
2138
|
-
* Key format: "symbol:strategyName:exchangeName"
|
|
2372
|
+
* Retrieves current average price for symbol.
|
|
2139
2373
|
*
|
|
2140
|
-
*
|
|
2141
|
-
*
|
|
2142
|
-
*
|
|
2143
|
-
* @
|
|
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
|
|
2144
2379
|
*/
|
|
2145
|
-
this.
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
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
|
+
};
|
|
2149
2386
|
/**
|
|
2150
|
-
*
|
|
2387
|
+
* Formats price according to exchange-specific precision rules.
|
|
2151
2388
|
*
|
|
2152
|
-
*
|
|
2153
|
-
* Returns empty object if no breakeven data exists.
|
|
2389
|
+
* Ensures price meets exchange requirements for decimal places and tick size.
|
|
2154
2390
|
*
|
|
2155
|
-
* @param symbol - Trading pair symbol
|
|
2156
|
-
* @param
|
|
2157
|
-
* @
|
|
2158
|
-
* @param exchangeName - Exchange identifier
|
|
2159
|
-
* @returns Promise resolving to breakeven data record
|
|
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
|
|
2160
2394
|
*/
|
|
2161
|
-
this.
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
await
|
|
2167
|
-
if (await stateStorage.hasValue(signalId)) {
|
|
2168
|
-
return await stateStorage.readValue(signalId);
|
|
2169
|
-
}
|
|
2170
|
-
return {};
|
|
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);
|
|
2171
2401
|
};
|
|
2172
2402
|
/**
|
|
2173
|
-
*
|
|
2403
|
+
* Formats quantity according to exchange-specific precision rules.
|
|
2174
2404
|
*
|
|
2175
|
-
*
|
|
2176
|
-
* Creates directory and file if they don't exist.
|
|
2177
|
-
* Uses atomic writes to prevent data corruption.
|
|
2405
|
+
* Ensures quantity meets exchange requirements for decimal places and lot size.
|
|
2178
2406
|
*
|
|
2179
|
-
* @param
|
|
2180
|
-
* @param
|
|
2181
|
-
* @
|
|
2182
|
-
* @param signalId - Signal identifier
|
|
2183
|
-
* @param exchangeName - Exchange identifier
|
|
2184
|
-
* @returns Promise that resolves when write is complete
|
|
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
|
|
2185
2410
|
*/
|
|
2186
|
-
this.
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
await
|
|
2192
|
-
|
|
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);
|
|
2435
|
+
};
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
/**
|
|
2440
|
+
* Calculates profit/loss for a closed signal with slippage and fees.
|
|
2441
|
+
*
|
|
2442
|
+
* For signals with partial closes:
|
|
2443
|
+
* - Calculates weighted PNL: Σ(percent_i × pnl_i) for each partial + (remaining% × final_pnl)
|
|
2444
|
+
* - Each partial close has its own fees and slippage
|
|
2445
|
+
* - Total fees = 2 × (number of partial closes + 1 final close) × CC_PERCENT_FEE
|
|
2446
|
+
*
|
|
2447
|
+
* Formula breakdown:
|
|
2448
|
+
* 1. Apply slippage to open/close prices (worse execution)
|
|
2449
|
+
* - LONG: buy higher (+slippage), sell lower (-slippage)
|
|
2450
|
+
* - SHORT: sell lower (-slippage), buy higher (+slippage)
|
|
2451
|
+
* 2. Calculate raw PNL percentage
|
|
2452
|
+
* - LONG: ((closePrice - openPrice) / openPrice) * 100
|
|
2453
|
+
* - SHORT: ((openPrice - closePrice) / openPrice) * 100
|
|
2454
|
+
* 3. Subtract total fees (0.1% * 2 = 0.2% per transaction)
|
|
2455
|
+
*
|
|
2456
|
+
* @param signal - Closed signal with position details and optional partial history
|
|
2457
|
+
* @param priceClose - Actual close price at final exit
|
|
2458
|
+
* @returns PNL data with percentage and prices
|
|
2459
|
+
*
|
|
2460
|
+
* @example
|
|
2461
|
+
* ```typescript
|
|
2462
|
+
* // Signal without partial closes
|
|
2463
|
+
* const pnl = toProfitLossDto(
|
|
2464
|
+
* {
|
|
2465
|
+
* position: "long",
|
|
2466
|
+
* priceOpen: 100,
|
|
2467
|
+
* },
|
|
2468
|
+
* 110 // close at +10%
|
|
2469
|
+
* );
|
|
2470
|
+
* console.log(pnl.pnlPercentage); // ~9.6% (after slippage and fees)
|
|
2471
|
+
*
|
|
2472
|
+
* // Signal with partial closes
|
|
2473
|
+
* const pnlPartial = toProfitLossDto(
|
|
2474
|
+
* {
|
|
2475
|
+
* position: "long",
|
|
2476
|
+
* priceOpen: 100,
|
|
2477
|
+
* _partial: [
|
|
2478
|
+
* { type: "profit", percent: 30, price: 120 }, // +20% on 30%
|
|
2479
|
+
* { type: "profit", percent: 40, price: 115 }, // +15% on 40%
|
|
2480
|
+
* ],
|
|
2481
|
+
* },
|
|
2482
|
+
* 105 // final close at +5% for remaining 30%
|
|
2483
|
+
* );
|
|
2484
|
+
* // Weighted PNL = 30% × 20% + 40% × 15% + 30% × 5% = 6% + 6% + 1.5% = 13.5% (before fees)
|
|
2485
|
+
* ```
|
|
2486
|
+
*/
|
|
2487
|
+
const toProfitLossDto = (signal, priceClose) => {
|
|
2488
|
+
const priceOpen = signal.priceOpen;
|
|
2489
|
+
// Calculate weighted PNL with partial closes
|
|
2490
|
+
if (signal._partial && signal._partial.length > 0) {
|
|
2491
|
+
let totalWeightedPnl = 0;
|
|
2492
|
+
let totalFees = 0;
|
|
2493
|
+
// Calculate PNL for each partial close
|
|
2494
|
+
for (const partial of signal._partial) {
|
|
2495
|
+
const partialPercent = partial.percent;
|
|
2496
|
+
const partialPrice = partial.price;
|
|
2497
|
+
// Apply slippage to prices
|
|
2498
|
+
let priceOpenWithSlippage;
|
|
2499
|
+
let priceCloseWithSlippage;
|
|
2500
|
+
if (signal.position === "long") {
|
|
2501
|
+
priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2502
|
+
priceCloseWithSlippage = partialPrice * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2503
|
+
}
|
|
2504
|
+
else {
|
|
2505
|
+
priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2506
|
+
priceCloseWithSlippage = partialPrice * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2507
|
+
}
|
|
2508
|
+
// Calculate PNL for this partial
|
|
2509
|
+
let partialPnl;
|
|
2510
|
+
if (signal.position === "long") {
|
|
2511
|
+
partialPnl = ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100;
|
|
2512
|
+
}
|
|
2513
|
+
else {
|
|
2514
|
+
partialPnl = ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
|
|
2515
|
+
}
|
|
2516
|
+
// Weight by percentage of position closed
|
|
2517
|
+
const weightedPnl = (partialPercent / 100) * partialPnl;
|
|
2518
|
+
totalWeightedPnl += weightedPnl;
|
|
2519
|
+
// Each partial has fees for open + close (2 transactions)
|
|
2520
|
+
totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
|
|
2521
|
+
}
|
|
2522
|
+
// Calculate PNL for remaining position (if any)
|
|
2523
|
+
// Compute totalClosed from _partial array
|
|
2524
|
+
const totalClosed = signal._partial.reduce((sum, p) => sum + p.percent, 0);
|
|
2525
|
+
const remainingPercent = 100 - totalClosed;
|
|
2526
|
+
if (remainingPercent > 0) {
|
|
2527
|
+
// Apply slippage
|
|
2528
|
+
let priceOpenWithSlippage;
|
|
2529
|
+
let priceCloseWithSlippage;
|
|
2530
|
+
if (signal.position === "long") {
|
|
2531
|
+
priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2532
|
+
priceCloseWithSlippage = priceClose * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2533
|
+
}
|
|
2534
|
+
else {
|
|
2535
|
+
priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2536
|
+
priceCloseWithSlippage = priceClose * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2537
|
+
}
|
|
2538
|
+
// Calculate PNL for remaining
|
|
2539
|
+
let remainingPnl;
|
|
2540
|
+
if (signal.position === "long") {
|
|
2541
|
+
remainingPnl = ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100;
|
|
2542
|
+
}
|
|
2543
|
+
else {
|
|
2544
|
+
remainingPnl = ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
|
|
2545
|
+
}
|
|
2546
|
+
// Weight by remaining percentage
|
|
2547
|
+
const weightedRemainingPnl = (remainingPercent / 100) * remainingPnl;
|
|
2548
|
+
totalWeightedPnl += weightedRemainingPnl;
|
|
2549
|
+
// Final close also has fees
|
|
2550
|
+
totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
|
|
2551
|
+
}
|
|
2552
|
+
// Subtract total fees from weighted PNL
|
|
2553
|
+
const pnlPercentage = totalWeightedPnl - totalFees;
|
|
2554
|
+
return {
|
|
2555
|
+
pnlPercentage,
|
|
2556
|
+
priceOpen,
|
|
2557
|
+
priceClose,
|
|
2193
2558
|
};
|
|
2194
2559
|
}
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
* ```
|
|
2208
|
-
*/
|
|
2209
|
-
usePersistBreakevenAdapter(Ctor) {
|
|
2210
|
-
bt.loggerService.info(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_PERSIST_BREAKEVEN_ADAPTER);
|
|
2211
|
-
this.PersistBreakevenFactory = Ctor;
|
|
2560
|
+
// Original logic for signals without partial closes
|
|
2561
|
+
let priceOpenWithSlippage;
|
|
2562
|
+
let priceCloseWithSlippage;
|
|
2563
|
+
if (signal.position === "long") {
|
|
2564
|
+
// LONG: покупаем дороже, продаем дешевле
|
|
2565
|
+
priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2566
|
+
priceCloseWithSlippage = priceClose * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2567
|
+
}
|
|
2568
|
+
else {
|
|
2569
|
+
// SHORT: продаем дешевле, покупаем дороже
|
|
2570
|
+
priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2571
|
+
priceCloseWithSlippage = priceClose * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2212
2572
|
}
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2573
|
+
// Применяем комиссию дважды (при открытии и закрытии)
|
|
2574
|
+
const totalFee = GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
|
|
2575
|
+
let pnlPercentage;
|
|
2576
|
+
if (signal.position === "long") {
|
|
2577
|
+
// LONG: прибыль при росте цены
|
|
2578
|
+
pnlPercentage =
|
|
2579
|
+
((priceCloseWithSlippage - priceOpenWithSlippage) /
|
|
2580
|
+
priceOpenWithSlippage) *
|
|
2581
|
+
100;
|
|
2220
2582
|
}
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
this.usePersistBreakevenAdapter(PersistDummy);
|
|
2583
|
+
else {
|
|
2584
|
+
// SHORT: прибыль при падении цены
|
|
2585
|
+
pnlPercentage =
|
|
2586
|
+
((priceOpenWithSlippage - priceCloseWithSlippage) /
|
|
2587
|
+
priceOpenWithSlippage) *
|
|
2588
|
+
100;
|
|
2228
2589
|
}
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
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();
|
|
2590
|
+
// Вычитаем комиссии
|
|
2591
|
+
pnlPercentage -= totalFee;
|
|
2592
|
+
return {
|
|
2593
|
+
pnlPercentage,
|
|
2594
|
+
priceOpen,
|
|
2595
|
+
priceClose,
|
|
2596
|
+
};
|
|
2597
|
+
};
|
|
2247
2598
|
|
|
2248
2599
|
/**
|
|
2249
2600
|
* Converts markdown content to plain text with minimal formatting
|
|
@@ -4410,6 +4761,7 @@ class ClientStrategy {
|
|
|
4410
4761
|
this._lastSignalTimestamp = null;
|
|
4411
4762
|
this._scheduledSignal = null;
|
|
4412
4763
|
this._cancelledSignal = null;
|
|
4764
|
+
this._closedSignal = null;
|
|
4413
4765
|
/**
|
|
4414
4766
|
* Initializes strategy state by loading persisted signal from disk.
|
|
4415
4767
|
*
|
|
@@ -4686,6 +5038,41 @@ class ClientStrategy {
|
|
|
4686
5038
|
reason: "user",
|
|
4687
5039
|
cancelId: cancelledSignal.cancelId,
|
|
4688
5040
|
};
|
|
5041
|
+
await CALL_TICK_CALLBACKS_FN(this, this.params.execution.context.symbol, result, currentTime, this.params.execution.context.backtest);
|
|
5042
|
+
return result;
|
|
5043
|
+
}
|
|
5044
|
+
// Check if pending signal was closed - emit closed event once
|
|
5045
|
+
if (this._closedSignal) {
|
|
5046
|
+
const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
|
|
5047
|
+
const closedSignal = this._closedSignal;
|
|
5048
|
+
this._closedSignal = null; // Clear after emitting
|
|
5049
|
+
this.params.logger.info("ClientStrategy tick: pending signal was closed", {
|
|
5050
|
+
symbol: this.params.execution.context.symbol,
|
|
5051
|
+
signalId: closedSignal.id,
|
|
5052
|
+
});
|
|
5053
|
+
// Call onClose callback
|
|
5054
|
+
await CALL_CLOSE_CALLBACKS_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, currentTime, this.params.execution.context.backtest);
|
|
5055
|
+
// КРИТИЧНО: Очищаем состояние ClientPartial при закрытии позиции
|
|
5056
|
+
await CALL_PARTIAL_CLEAR_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, currentTime, this.params.execution.context.backtest);
|
|
5057
|
+
// КРИТИЧНО: Очищаем состояние ClientBreakeven при закрытии позиции
|
|
5058
|
+
await CALL_BREAKEVEN_CLEAR_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, currentTime, this.params.execution.context.backtest);
|
|
5059
|
+
await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, currentTime, this.params.execution.context.backtest);
|
|
5060
|
+
const pnl = toProfitLossDto(closedSignal, currentPrice);
|
|
5061
|
+
const result = {
|
|
5062
|
+
action: "closed",
|
|
5063
|
+
signal: TO_PUBLIC_SIGNAL(closedSignal),
|
|
5064
|
+
currentPrice,
|
|
5065
|
+
closeReason: "closed",
|
|
5066
|
+
closeTimestamp: currentTime,
|
|
5067
|
+
pnl,
|
|
5068
|
+
strategyName: this.params.method.context.strategyName,
|
|
5069
|
+
exchangeName: this.params.method.context.exchangeName,
|
|
5070
|
+
frameName: this.params.method.context.frameName,
|
|
5071
|
+
symbol: this.params.execution.context.symbol,
|
|
5072
|
+
backtest: this.params.execution.context.backtest,
|
|
5073
|
+
closeId: closedSignal.closeId,
|
|
5074
|
+
};
|
|
5075
|
+
await CALL_TICK_CALLBACKS_FN(this, this.params.execution.context.symbol, result, currentTime, this.params.execution.context.backtest);
|
|
4689
5076
|
return result;
|
|
4690
5077
|
}
|
|
4691
5078
|
// Monitor scheduled signal
|
|
@@ -4799,8 +5186,40 @@ class ClientStrategy {
|
|
|
4799
5186
|
reason: "user",
|
|
4800
5187
|
cancelId: cancelledSignal.cancelId,
|
|
4801
5188
|
};
|
|
5189
|
+
await CALL_TICK_CALLBACKS_FN(this, this.params.execution.context.symbol, cancelledResult, closeTimestamp, this.params.execution.context.backtest);
|
|
4802
5190
|
return cancelledResult;
|
|
4803
5191
|
}
|
|
5192
|
+
// If signal was closed - return closed
|
|
5193
|
+
if (this._closedSignal) {
|
|
5194
|
+
this.params.logger.debug("ClientStrategy backtest: pending signal was closed");
|
|
5195
|
+
const currentPrice = await this.params.exchange.getAveragePrice(symbol);
|
|
5196
|
+
const closedSignal = this._closedSignal;
|
|
5197
|
+
this._closedSignal = null; // Clear after using
|
|
5198
|
+
const closeTimestamp = this.params.execution.context.when.getTime();
|
|
5199
|
+
await CALL_CLOSE_CALLBACKS_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, closeTimestamp, this.params.execution.context.backtest);
|
|
5200
|
+
// КРИТИЧНО: Очищаем состояние ClientPartial при закрытии позиции
|
|
5201
|
+
await CALL_PARTIAL_CLEAR_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, closeTimestamp, this.params.execution.context.backtest);
|
|
5202
|
+
// КРИТИЧНО: Очищаем состояние ClientBreakeven при закрытии позиции
|
|
5203
|
+
await CALL_BREAKEVEN_CLEAR_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, closeTimestamp, this.params.execution.context.backtest);
|
|
5204
|
+
await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, closeTimestamp, this.params.execution.context.backtest);
|
|
5205
|
+
const pnl = toProfitLossDto(closedSignal, currentPrice);
|
|
5206
|
+
const closedResult = {
|
|
5207
|
+
action: "closed",
|
|
5208
|
+
signal: TO_PUBLIC_SIGNAL(closedSignal),
|
|
5209
|
+
currentPrice,
|
|
5210
|
+
closeReason: "closed",
|
|
5211
|
+
closeTimestamp: closeTimestamp,
|
|
5212
|
+
pnl,
|
|
5213
|
+
strategyName: this.params.method.context.strategyName,
|
|
5214
|
+
exchangeName: this.params.method.context.exchangeName,
|
|
5215
|
+
frameName: this.params.method.context.frameName,
|
|
5216
|
+
symbol: this.params.execution.context.symbol,
|
|
5217
|
+
backtest: true,
|
|
5218
|
+
closeId: closedSignal.closeId,
|
|
5219
|
+
};
|
|
5220
|
+
await CALL_TICK_CALLBACKS_FN(this, this.params.execution.context.symbol, closedResult, closeTimestamp, this.params.execution.context.backtest);
|
|
5221
|
+
return closedResult;
|
|
5222
|
+
}
|
|
4804
5223
|
if (!this._pendingSignal && !this._scheduledSignal) {
|
|
4805
5224
|
throw new Error("ClientStrategy backtest: no pending or scheduled signal");
|
|
4806
5225
|
}
|
|
@@ -4927,12 +5346,12 @@ class ClientStrategy {
|
|
|
4927
5346
|
* @example
|
|
4928
5347
|
* ```typescript
|
|
4929
5348
|
* // In Live.background() cancellation
|
|
4930
|
-
* await strategy.
|
|
5349
|
+
* await strategy.stopStrategy();
|
|
4931
5350
|
* // Existing signal will continue until natural close
|
|
4932
5351
|
* ```
|
|
4933
5352
|
*/
|
|
4934
|
-
async
|
|
4935
|
-
this.params.logger.debug("ClientStrategy
|
|
5353
|
+
async stopStrategy(symbol, backtest) {
|
|
5354
|
+
this.params.logger.debug("ClientStrategy stopStrategy", {
|
|
4936
5355
|
symbol,
|
|
4937
5356
|
hasPendingSignal: this._pendingSignal !== null,
|
|
4938
5357
|
hasScheduledSignal: this._scheduledSignal !== null,
|
|
@@ -4965,12 +5384,12 @@ class ClientStrategy {
|
|
|
4965
5384
|
* @example
|
|
4966
5385
|
* ```typescript
|
|
4967
5386
|
* // Cancel scheduled signal without stopping strategy
|
|
4968
|
-
* await strategy.
|
|
5387
|
+
* await strategy.cancelScheduled("BTCUSDT", "my-strategy", false);
|
|
4969
5388
|
* // Strategy continues, can generate new signals
|
|
4970
5389
|
* ```
|
|
4971
5390
|
*/
|
|
4972
|
-
async
|
|
4973
|
-
this.params.logger.debug("ClientStrategy
|
|
5391
|
+
async cancelScheduled(symbol, backtest, cancelId) {
|
|
5392
|
+
this.params.logger.debug("ClientStrategy cancelScheduled", {
|
|
4974
5393
|
symbol,
|
|
4975
5394
|
hasScheduledSignal: this._scheduledSignal !== null,
|
|
4976
5395
|
cancelId,
|
|
@@ -4987,6 +5406,45 @@ class ClientStrategy {
|
|
|
4987
5406
|
}
|
|
4988
5407
|
await PersistScheduleAdapter.writeScheduleData(this._scheduledSignal, symbol, this.params.method.context.strategyName, this.params.method.context.exchangeName);
|
|
4989
5408
|
}
|
|
5409
|
+
/**
|
|
5410
|
+
* Closes the pending signal without stopping the strategy.
|
|
5411
|
+
*
|
|
5412
|
+
* Clears the pending signal (active position).
|
|
5413
|
+
* Does NOT affect scheduled signals or strategy operation.
|
|
5414
|
+
* Does NOT set stop flag - strategy can continue generating new signals.
|
|
5415
|
+
*
|
|
5416
|
+
* Use case: Close an active position that is no longer desired without stopping the entire strategy.
|
|
5417
|
+
*
|
|
5418
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
5419
|
+
* @param backtest - Whether running in backtest mode
|
|
5420
|
+
* @param closeId - Optional identifier for this close operation
|
|
5421
|
+
* @returns Promise that resolves when pending signal is cleared
|
|
5422
|
+
*
|
|
5423
|
+
* @example
|
|
5424
|
+
* ```typescript
|
|
5425
|
+
* // Close pending signal without stopping strategy
|
|
5426
|
+
* await strategy.closePending("BTCUSDT", false, "user-close-123");
|
|
5427
|
+
* // Strategy continues, can generate new signals
|
|
5428
|
+
* ```
|
|
5429
|
+
*/
|
|
5430
|
+
async closePending(symbol, backtest, closeId) {
|
|
5431
|
+
this.params.logger.debug("ClientStrategy closePending", {
|
|
5432
|
+
symbol,
|
|
5433
|
+
hasPendingSignal: this._pendingSignal !== null,
|
|
5434
|
+
closeId,
|
|
5435
|
+
});
|
|
5436
|
+
// Save closed signal for next tick to emit closed event
|
|
5437
|
+
if (this._pendingSignal) {
|
|
5438
|
+
this._closedSignal = Object.assign({}, this._pendingSignal, {
|
|
5439
|
+
closeId,
|
|
5440
|
+
});
|
|
5441
|
+
this._pendingSignal = null;
|
|
5442
|
+
}
|
|
5443
|
+
if (backtest) {
|
|
5444
|
+
return;
|
|
5445
|
+
}
|
|
5446
|
+
await PersistSignalAdapter.writeSignalData(this._pendingSignal, symbol, this.params.strategyName, this.params.exchangeName);
|
|
5447
|
+
}
|
|
4990
5448
|
/**
|
|
4991
5449
|
* Executes partial close at profit level (moving toward TP).
|
|
4992
5450
|
*
|
|
@@ -6439,7 +6897,7 @@ class StrategyConnectionService {
|
|
|
6439
6897
|
/**
|
|
6440
6898
|
* Stops the specified strategy from generating new signals.
|
|
6441
6899
|
*
|
|
6442
|
-
* Delegates to ClientStrategy.
|
|
6900
|
+
* Delegates to ClientStrategy.stopStrategy() which sets internal flag to prevent
|
|
6443
6901
|
* getSignal from being called on subsequent ticks.
|
|
6444
6902
|
*
|
|
6445
6903
|
* @param backtest - Whether running in backtest mode
|
|
@@ -6447,13 +6905,13 @@ class StrategyConnectionService {
|
|
|
6447
6905
|
* @param ctx - Context with strategyName, exchangeName, frameName
|
|
6448
6906
|
* @returns Promise that resolves when stop flag is set
|
|
6449
6907
|
*/
|
|
6450
|
-
this.
|
|
6451
|
-
this.loggerService.log("strategyConnectionService
|
|
6908
|
+
this.stopStrategy = async (backtest, symbol, context) => {
|
|
6909
|
+
this.loggerService.log("strategyConnectionService stopStrategy", {
|
|
6452
6910
|
symbol,
|
|
6453
6911
|
context,
|
|
6454
6912
|
});
|
|
6455
6913
|
const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
|
|
6456
|
-
await strategy.
|
|
6914
|
+
await strategy.stopStrategy(symbol, backtest);
|
|
6457
6915
|
};
|
|
6458
6916
|
/**
|
|
6459
6917
|
* Disposes the ClientStrategy instance for the given context.
|
|
@@ -6505,7 +6963,7 @@ class StrategyConnectionService {
|
|
|
6505
6963
|
/**
|
|
6506
6964
|
* Cancels the scheduled signal for the specified strategy.
|
|
6507
6965
|
*
|
|
6508
|
-
* Delegates to ClientStrategy.
|
|
6966
|
+
* Delegates to ClientStrategy.cancelScheduled() which clears the scheduled signal
|
|
6509
6967
|
* without stopping the strategy or affecting pending signals.
|
|
6510
6968
|
*
|
|
6511
6969
|
* Note: Cancelled event will be emitted on next tick() call when strategy
|
|
@@ -6517,14 +6975,39 @@ class StrategyConnectionService {
|
|
|
6517
6975
|
* @param cancelId - Optional cancellation ID for user-initiated cancellations
|
|
6518
6976
|
* @returns Promise that resolves when scheduled signal is cancelled
|
|
6519
6977
|
*/
|
|
6520
|
-
this.
|
|
6521
|
-
this.loggerService.log("strategyConnectionService
|
|
6978
|
+
this.cancelScheduled = async (backtest, symbol, context, cancelId) => {
|
|
6979
|
+
this.loggerService.log("strategyConnectionService cancelScheduled", {
|
|
6522
6980
|
symbol,
|
|
6523
6981
|
context,
|
|
6524
6982
|
cancelId,
|
|
6525
6983
|
});
|
|
6526
6984
|
const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
|
|
6527
|
-
await strategy.
|
|
6985
|
+
await strategy.cancelScheduled(symbol, backtest, cancelId);
|
|
6986
|
+
};
|
|
6987
|
+
/**
|
|
6988
|
+
* Closes the pending signal without stopping the strategy.
|
|
6989
|
+
*
|
|
6990
|
+
* Clears the pending signal (active position).
|
|
6991
|
+
* Does NOT affect scheduled signals or strategy operation.
|
|
6992
|
+
* Does NOT set stop flag - strategy can continue generating new signals.
|
|
6993
|
+
*
|
|
6994
|
+
* Note: Closed event will be emitted on next tick() call when strategy
|
|
6995
|
+
* detects the pending signal was closed.
|
|
6996
|
+
*
|
|
6997
|
+
* @param backtest - Whether running in backtest mode
|
|
6998
|
+
* @param symbol - Trading pair symbol
|
|
6999
|
+
* @param context - Context with strategyName, exchangeName, frameName
|
|
7000
|
+
* @param closeId - Optional close ID for user-initiated closes
|
|
7001
|
+
* @returns Promise that resolves when pending signal is closed
|
|
7002
|
+
*/
|
|
7003
|
+
this.closePending = async (backtest, symbol, context, closeId) => {
|
|
7004
|
+
this.loggerService.log("strategyConnectionService closePending", {
|
|
7005
|
+
symbol,
|
|
7006
|
+
context,
|
|
7007
|
+
closeId,
|
|
7008
|
+
});
|
|
7009
|
+
const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
|
|
7010
|
+
await strategy.closePending(symbol, backtest, closeId);
|
|
6528
7011
|
};
|
|
6529
7012
|
/**
|
|
6530
7013
|
* Executes partial close at profit level (moving toward TP).
|
|
@@ -9711,19 +10194,19 @@ class StrategyCoreService {
|
|
|
9711
10194
|
* @param ctx - Context with strategyName, exchangeName, frameName
|
|
9712
10195
|
* @returns Promise that resolves when stop flag is set
|
|
9713
10196
|
*/
|
|
9714
|
-
this.
|
|
9715
|
-
this.loggerService.log("strategyCoreService
|
|
10197
|
+
this.stopStrategy = async (backtest, symbol, context) => {
|
|
10198
|
+
this.loggerService.log("strategyCoreService stopStrategy", {
|
|
9716
10199
|
symbol,
|
|
9717
10200
|
context,
|
|
9718
10201
|
backtest,
|
|
9719
10202
|
});
|
|
9720
10203
|
await this.validate(context);
|
|
9721
|
-
return await this.strategyConnectionService.
|
|
10204
|
+
return await this.strategyConnectionService.stopStrategy(backtest, symbol, context);
|
|
9722
10205
|
};
|
|
9723
10206
|
/**
|
|
9724
10207
|
* Cancels the scheduled signal without stopping the strategy.
|
|
9725
10208
|
*
|
|
9726
|
-
* Delegates to StrategyConnectionService.
|
|
10209
|
+
* Delegates to StrategyConnectionService.cancelScheduled() to clear scheduled signal
|
|
9727
10210
|
* and emit cancelled event through emitters.
|
|
9728
10211
|
* Does not require execution context.
|
|
9729
10212
|
*
|
|
@@ -9733,15 +10216,42 @@ class StrategyCoreService {
|
|
|
9733
10216
|
* @param cancelId - Optional cancellation ID for user-initiated cancellations
|
|
9734
10217
|
* @returns Promise that resolves when scheduled signal is cancelled
|
|
9735
10218
|
*/
|
|
9736
|
-
this.
|
|
9737
|
-
this.loggerService.log("strategyCoreService
|
|
10219
|
+
this.cancelScheduled = async (backtest, symbol, context, cancelId) => {
|
|
10220
|
+
this.loggerService.log("strategyCoreService cancelScheduled", {
|
|
9738
10221
|
symbol,
|
|
9739
10222
|
context,
|
|
9740
10223
|
backtest,
|
|
9741
10224
|
cancelId,
|
|
9742
10225
|
});
|
|
9743
10226
|
await this.validate(context);
|
|
9744
|
-
return await this.strategyConnectionService.
|
|
10227
|
+
return await this.strategyConnectionService.cancelScheduled(backtest, symbol, context, cancelId);
|
|
10228
|
+
};
|
|
10229
|
+
/**
|
|
10230
|
+
* Closes the pending signal without stopping the strategy.
|
|
10231
|
+
*
|
|
10232
|
+
* Clears the pending signal (active position).
|
|
10233
|
+
* Does NOT affect scheduled signals or strategy operation.
|
|
10234
|
+
* Does NOT set stop flag - strategy can continue generating new signals.
|
|
10235
|
+
*
|
|
10236
|
+
* Delegates to StrategyConnectionService.closePending() to clear pending signal
|
|
10237
|
+
* and emit closed event through emitters.
|
|
10238
|
+
* Does not require execution context.
|
|
10239
|
+
*
|
|
10240
|
+
* @param backtest - Whether running in backtest mode
|
|
10241
|
+
* @param symbol - Trading pair symbol
|
|
10242
|
+
* @param context - Context with strategyName, exchangeName, frameName
|
|
10243
|
+
* @param closeId - Optional close ID for user-initiated closes
|
|
10244
|
+
* @returns Promise that resolves when pending signal is closed
|
|
10245
|
+
*/
|
|
10246
|
+
this.closePending = async (backtest, symbol, context, closeId) => {
|
|
10247
|
+
this.loggerService.log("strategyCoreService closePending", {
|
|
10248
|
+
symbol,
|
|
10249
|
+
context,
|
|
10250
|
+
backtest,
|
|
10251
|
+
closeId,
|
|
10252
|
+
});
|
|
10253
|
+
await this.validate(context);
|
|
10254
|
+
return await this.strategyConnectionService.closePending(backtest, symbol, context, closeId);
|
|
9745
10255
|
};
|
|
9746
10256
|
/**
|
|
9747
10257
|
* Disposes the ClientStrategy instance for the given context.
|
|
@@ -25027,7 +25537,8 @@ async function getOrderBook(symbol, depth) {
|
|
|
25027
25537
|
return await bt.exchangeConnectionService.getOrderBook(symbol, depth);
|
|
25028
25538
|
}
|
|
25029
25539
|
|
|
25030
|
-
const
|
|
25540
|
+
const CANCEL_SCHEDULED_METHOD_NAME = "strategy.commitCancelScheduled";
|
|
25541
|
+
const CLOSE_PENDING_METHOD_NAME = "strategy.commitClosePending";
|
|
25031
25542
|
const PARTIAL_PROFIT_METHOD_NAME = "strategy.commitPartialProfit";
|
|
25032
25543
|
const PARTIAL_LOSS_METHOD_NAME = "strategy.commitPartialLoss";
|
|
25033
25544
|
const TRAILING_STOP_METHOD_NAME = "strategy.commitTrailingStop";
|
|
@@ -25049,26 +25560,62 @@ const BREAKEVEN_METHOD_NAME = "strategy.commitBreakeven";
|
|
|
25049
25560
|
*
|
|
25050
25561
|
* @example
|
|
25051
25562
|
* ```typescript
|
|
25052
|
-
* import {
|
|
25563
|
+
* import { commitCancelScheduled } from "backtest-kit";
|
|
25053
25564
|
*
|
|
25054
25565
|
* // Cancel scheduled signal with custom ID
|
|
25055
|
-
* await
|
|
25566
|
+
* await commitCancelScheduled("BTCUSDT", "manual-cancel-001");
|
|
25056
25567
|
* ```
|
|
25057
25568
|
*/
|
|
25058
|
-
async function
|
|
25059
|
-
bt.loggerService.info(
|
|
25569
|
+
async function commitCancelScheduled(symbol, cancelId) {
|
|
25570
|
+
bt.loggerService.info(CANCEL_SCHEDULED_METHOD_NAME, {
|
|
25060
25571
|
symbol,
|
|
25061
25572
|
cancelId,
|
|
25062
25573
|
});
|
|
25063
25574
|
if (!ExecutionContextService.hasContext()) {
|
|
25064
|
-
throw new Error("
|
|
25575
|
+
throw new Error("commitCancelScheduled requires an execution context");
|
|
25576
|
+
}
|
|
25577
|
+
if (!MethodContextService.hasContext()) {
|
|
25578
|
+
throw new Error("commitCancelScheduled requires a method context");
|
|
25579
|
+
}
|
|
25580
|
+
const { backtest: isBacktest } = bt.executionContextService.context;
|
|
25581
|
+
const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
|
|
25582
|
+
await bt.strategyCoreService.cancelScheduled(isBacktest, symbol, { exchangeName, frameName, strategyName }, cancelId);
|
|
25583
|
+
}
|
|
25584
|
+
/**
|
|
25585
|
+
* Closes the pending signal without stopping the strategy.
|
|
25586
|
+
*
|
|
25587
|
+
* Clears the pending signal (active position).
|
|
25588
|
+
* Does NOT affect scheduled signals or strategy operation.
|
|
25589
|
+
* Does NOT set stop flag - strategy can continue generating new signals.
|
|
25590
|
+
*
|
|
25591
|
+
* Automatically detects backtest/live mode from execution context.
|
|
25592
|
+
*
|
|
25593
|
+
* @param symbol - Trading pair symbol
|
|
25594
|
+
* @param closeId - Optional close ID for tracking user-initiated closes
|
|
25595
|
+
* @returns Promise that resolves when pending signal is closed
|
|
25596
|
+
*
|
|
25597
|
+
* @example
|
|
25598
|
+
* ```typescript
|
|
25599
|
+
* import { commitClosePending } from "backtest-kit";
|
|
25600
|
+
*
|
|
25601
|
+
* // Close pending signal with custom ID
|
|
25602
|
+
* await commitClosePending("BTCUSDT", "manual-close-001");
|
|
25603
|
+
* ```
|
|
25604
|
+
*/
|
|
25605
|
+
async function commitClosePending(symbol, closeId) {
|
|
25606
|
+
bt.loggerService.info(CLOSE_PENDING_METHOD_NAME, {
|
|
25607
|
+
symbol,
|
|
25608
|
+
closeId,
|
|
25609
|
+
});
|
|
25610
|
+
if (!ExecutionContextService.hasContext()) {
|
|
25611
|
+
throw new Error("commitClosePending requires an execution context");
|
|
25065
25612
|
}
|
|
25066
25613
|
if (!MethodContextService.hasContext()) {
|
|
25067
|
-
throw new Error("
|
|
25614
|
+
throw new Error("commitClosePending requires a method context");
|
|
25068
25615
|
}
|
|
25069
25616
|
const { backtest: isBacktest } = bt.executionContextService.context;
|
|
25070
25617
|
const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
|
|
25071
|
-
await bt.strategyCoreService.
|
|
25618
|
+
await bt.strategyCoreService.closePending(isBacktest, symbol, { exchangeName, frameName, strategyName }, closeId);
|
|
25072
25619
|
}
|
|
25073
25620
|
/**
|
|
25074
25621
|
* Executes partial close at profit level (moving toward TP).
|
|
@@ -25315,7 +25862,7 @@ async function commitBreakeven(symbol) {
|
|
|
25315
25862
|
return await bt.strategyCoreService.breakeven(isBacktest, symbol, currentPrice, { exchangeName, frameName, strategyName });
|
|
25316
25863
|
}
|
|
25317
25864
|
|
|
25318
|
-
const
|
|
25865
|
+
const STOP_STRATEGY_METHOD_NAME = "control.stopStrategy";
|
|
25319
25866
|
/**
|
|
25320
25867
|
* Stops the strategy from generating new signals.
|
|
25321
25868
|
*
|
|
@@ -25337,8 +25884,8 @@ const STOP_METHOD_NAME = "control.stop";
|
|
|
25337
25884
|
* await stop("BTCUSDT", "my-strategy");
|
|
25338
25885
|
* ```
|
|
25339
25886
|
*/
|
|
25340
|
-
async function
|
|
25341
|
-
bt.loggerService.info(
|
|
25887
|
+
async function stopStrategy(symbol) {
|
|
25888
|
+
bt.loggerService.info(STOP_STRATEGY_METHOD_NAME, {
|
|
25342
25889
|
symbol,
|
|
25343
25890
|
});
|
|
25344
25891
|
if (!ExecutionContextService.hasContext()) {
|
|
@@ -25349,7 +25896,7 @@ async function stop(symbol) {
|
|
|
25349
25896
|
}
|
|
25350
25897
|
const { backtest: isBacktest } = bt.executionContextService.context;
|
|
25351
25898
|
const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
|
|
25352
|
-
await bt.strategyCoreService.
|
|
25899
|
+
await bt.strategyCoreService.stopStrategy(isBacktest, symbol, {
|
|
25353
25900
|
exchangeName,
|
|
25354
25901
|
frameName,
|
|
25355
25902
|
strategyName,
|
|
@@ -27736,7 +28283,8 @@ const BACKTEST_METHOD_NAME_GET_PENDING_SIGNAL = "BacktestUtils.getPendingSignal"
|
|
|
27736
28283
|
const BACKTEST_METHOD_NAME_GET_SCHEDULED_SIGNAL = "BacktestUtils.getScheduledSignal";
|
|
27737
28284
|
const BACKTEST_METHOD_NAME_GET_BREAKEVEN = "BacktestUtils.getBreakeven";
|
|
27738
28285
|
const BACKTEST_METHOD_NAME_BREAKEVEN = "Backtest.commitBreakeven";
|
|
27739
|
-
const
|
|
28286
|
+
const BACKTEST_METHOD_NAME_CANCEL_SCHEDULED = "Backtest.commitCancelScheduled";
|
|
28287
|
+
const BACKTEST_METHOD_NAME_CLOSE_PENDING = "Backtest.commitClosePending";
|
|
27740
28288
|
const BACKTEST_METHOD_NAME_PARTIAL_PROFIT = "BacktestUtils.commitPartialProfit";
|
|
27741
28289
|
const BACKTEST_METHOD_NAME_PARTIAL_LOSS = "BacktestUtils.commitPartialLoss";
|
|
27742
28290
|
const BACKTEST_METHOD_NAME_TRAILING_STOP = "BacktestUtils.commitTrailingStop";
|
|
@@ -27978,7 +28526,7 @@ class BacktestInstance {
|
|
|
27978
28526
|
}
|
|
27979
28527
|
this.task(symbol, context).catch((error) => exitEmitter.next(new Error(functoolsKit.getErrorMessage(error))));
|
|
27980
28528
|
return () => {
|
|
27981
|
-
bt.strategyCoreService.
|
|
28529
|
+
bt.strategyCoreService.stopStrategy(true, symbol, {
|
|
27982
28530
|
strategyName: context.strategyName,
|
|
27983
28531
|
exchangeName: context.exchangeName,
|
|
27984
28532
|
frameName: context.frameName,
|
|
@@ -28246,7 +28794,7 @@ class BacktestUtils {
|
|
|
28246
28794
|
actions &&
|
|
28247
28795
|
actions.forEach((actionName) => bt.actionValidationService.validate(actionName, BACKTEST_METHOD_NAME_STOP));
|
|
28248
28796
|
}
|
|
28249
|
-
await bt.strategyCoreService.
|
|
28797
|
+
await bt.strategyCoreService.stopStrategy(true, symbol, context);
|
|
28250
28798
|
};
|
|
28251
28799
|
/**
|
|
28252
28800
|
* Cancels the scheduled signal without stopping the strategy.
|
|
@@ -28271,24 +28819,65 @@ class BacktestUtils {
|
|
|
28271
28819
|
* }, "manual-cancel-001");
|
|
28272
28820
|
* ```
|
|
28273
28821
|
*/
|
|
28274
|
-
this.
|
|
28275
|
-
bt.loggerService.info(
|
|
28822
|
+
this.commitCancelScheduled = async (symbol, context, cancelId) => {
|
|
28823
|
+
bt.loggerService.info(BACKTEST_METHOD_NAME_CANCEL_SCHEDULED, {
|
|
28276
28824
|
symbol,
|
|
28277
28825
|
context,
|
|
28278
28826
|
cancelId,
|
|
28279
28827
|
});
|
|
28280
|
-
bt.strategyValidationService.validate(context.strategyName,
|
|
28281
|
-
bt.exchangeValidationService.validate(context.exchangeName,
|
|
28828
|
+
bt.strategyValidationService.validate(context.strategyName, BACKTEST_METHOD_NAME_CANCEL_SCHEDULED);
|
|
28829
|
+
bt.exchangeValidationService.validate(context.exchangeName, BACKTEST_METHOD_NAME_CANCEL_SCHEDULED);
|
|
28830
|
+
{
|
|
28831
|
+
const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
|
|
28832
|
+
riskName &&
|
|
28833
|
+
bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_CANCEL_SCHEDULED);
|
|
28834
|
+
riskList &&
|
|
28835
|
+
riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_CANCEL_SCHEDULED));
|
|
28836
|
+
actions &&
|
|
28837
|
+
actions.forEach((actionName) => bt.actionValidationService.validate(actionName, BACKTEST_METHOD_NAME_CANCEL_SCHEDULED));
|
|
28838
|
+
}
|
|
28839
|
+
await bt.strategyCoreService.cancelScheduled(true, symbol, context, cancelId);
|
|
28840
|
+
};
|
|
28841
|
+
/**
|
|
28842
|
+
* Closes the pending signal without stopping the strategy.
|
|
28843
|
+
*
|
|
28844
|
+
* Clears the pending signal (active position).
|
|
28845
|
+
* Does NOT affect scheduled signals or strategy operation.
|
|
28846
|
+
* Does NOT set stop flag - strategy can continue generating new signals.
|
|
28847
|
+
*
|
|
28848
|
+
* @param symbol - Trading pair symbol
|
|
28849
|
+
* @param context - Execution context with strategyName, exchangeName, and frameName
|
|
28850
|
+
* @param closeId - Optional close ID for user-initiated closes
|
|
28851
|
+
* @returns Promise that resolves when pending signal is closed
|
|
28852
|
+
*
|
|
28853
|
+
* @example
|
|
28854
|
+
* ```typescript
|
|
28855
|
+
* // Close pending signal with custom ID
|
|
28856
|
+
* await Backtest.commitClose("BTCUSDT", {
|
|
28857
|
+
* exchangeName: "binance",
|
|
28858
|
+
* strategyName: "my-strategy",
|
|
28859
|
+
* frameName: "1m"
|
|
28860
|
+
* }, "manual-close-001");
|
|
28861
|
+
* ```
|
|
28862
|
+
*/
|
|
28863
|
+
this.commitClosePending = async (symbol, context, closeId) => {
|
|
28864
|
+
bt.loggerService.info(BACKTEST_METHOD_NAME_CLOSE_PENDING, {
|
|
28865
|
+
symbol,
|
|
28866
|
+
context,
|
|
28867
|
+
closeId,
|
|
28868
|
+
});
|
|
28869
|
+
bt.strategyValidationService.validate(context.strategyName, BACKTEST_METHOD_NAME_CLOSE_PENDING);
|
|
28870
|
+
bt.exchangeValidationService.validate(context.exchangeName, BACKTEST_METHOD_NAME_CLOSE_PENDING);
|
|
28282
28871
|
{
|
|
28283
28872
|
const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
|
|
28284
28873
|
riskName &&
|
|
28285
|
-
bt.riskValidationService.validate(riskName,
|
|
28874
|
+
bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_CLOSE_PENDING);
|
|
28286
28875
|
riskList &&
|
|
28287
|
-
riskList.forEach((riskName) => bt.riskValidationService.validate(riskName,
|
|
28876
|
+
riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_CLOSE_PENDING));
|
|
28288
28877
|
actions &&
|
|
28289
|
-
actions.forEach((actionName) => bt.actionValidationService.validate(actionName,
|
|
28878
|
+
actions.forEach((actionName) => bt.actionValidationService.validate(actionName, BACKTEST_METHOD_NAME_CLOSE_PENDING));
|
|
28290
28879
|
}
|
|
28291
|
-
await bt.strategyCoreService.
|
|
28880
|
+
await bt.strategyCoreService.closePending(true, symbol, context, closeId);
|
|
28292
28881
|
};
|
|
28293
28882
|
/**
|
|
28294
28883
|
* Executes partial close at profit level (moving toward TP).
|
|
@@ -28725,7 +29314,8 @@ const LIVE_METHOD_NAME_GET_PENDING_SIGNAL = "LiveUtils.getPendingSignal";
|
|
|
28725
29314
|
const LIVE_METHOD_NAME_GET_SCHEDULED_SIGNAL = "LiveUtils.getScheduledSignal";
|
|
28726
29315
|
const LIVE_METHOD_NAME_GET_BREAKEVEN = "LiveUtils.getBreakeven";
|
|
28727
29316
|
const LIVE_METHOD_NAME_BREAKEVEN = "Live.commitBreakeven";
|
|
28728
|
-
const
|
|
29317
|
+
const LIVE_METHOD_NAME_CANCEL_SCHEDULED = "Live.cancelScheduled";
|
|
29318
|
+
const LIVE_METHOD_NAME_CLOSE_PENDING = "Live.closePending";
|
|
28729
29319
|
const LIVE_METHOD_NAME_PARTIAL_PROFIT = "LiveUtils.commitPartialProfit";
|
|
28730
29320
|
const LIVE_METHOD_NAME_PARTIAL_LOSS = "LiveUtils.commitPartialLoss";
|
|
28731
29321
|
const LIVE_METHOD_NAME_TRAILING_STOP = "LiveUtils.commitTrailingStop";
|
|
@@ -28930,7 +29520,7 @@ class LiveInstance {
|
|
|
28930
29520
|
}
|
|
28931
29521
|
this.task(symbol, context).catch((error) => exitEmitter.next(new Error(functoolsKit.getErrorMessage(error))));
|
|
28932
29522
|
return () => {
|
|
28933
|
-
bt.strategyCoreService.
|
|
29523
|
+
bt.strategyCoreService.stopStrategy(false, symbol, {
|
|
28934
29524
|
strategyName: context.strategyName,
|
|
28935
29525
|
exchangeName: context.exchangeName,
|
|
28936
29526
|
frameName: ""
|
|
@@ -29197,7 +29787,7 @@ class LiveUtils {
|
|
|
29197
29787
|
riskList && riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_STOP));
|
|
29198
29788
|
actions && actions.forEach((actionName) => bt.actionValidationService.validate(actionName, LIVE_METHOD_NAME_STOP));
|
|
29199
29789
|
}
|
|
29200
|
-
await bt.strategyCoreService.
|
|
29790
|
+
await bt.strategyCoreService.stopStrategy(false, symbol, {
|
|
29201
29791
|
strategyName: context.strategyName,
|
|
29202
29792
|
exchangeName: context.exchangeName,
|
|
29203
29793
|
frameName: "",
|
|
@@ -29226,26 +29816,67 @@ class LiveUtils {
|
|
|
29226
29816
|
* }, "manual-cancel-001");
|
|
29227
29817
|
* ```
|
|
29228
29818
|
*/
|
|
29229
|
-
this.
|
|
29230
|
-
bt.loggerService.info(
|
|
29819
|
+
this.commitCancelScheduled = async (symbol, context, cancelId) => {
|
|
29820
|
+
bt.loggerService.info(LIVE_METHOD_NAME_CANCEL_SCHEDULED, {
|
|
29231
29821
|
symbol,
|
|
29232
29822
|
context,
|
|
29233
29823
|
cancelId,
|
|
29234
29824
|
});
|
|
29235
|
-
bt.strategyValidationService.validate(context.strategyName,
|
|
29236
|
-
bt.exchangeValidationService.validate(context.exchangeName,
|
|
29825
|
+
bt.strategyValidationService.validate(context.strategyName, LIVE_METHOD_NAME_CANCEL_SCHEDULED);
|
|
29826
|
+
bt.exchangeValidationService.validate(context.exchangeName, LIVE_METHOD_NAME_CANCEL_SCHEDULED);
|
|
29237
29827
|
{
|
|
29238
29828
|
const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
|
|
29239
|
-
riskName && bt.riskValidationService.validate(riskName,
|
|
29240
|
-
riskList && riskList.forEach((riskName) => bt.riskValidationService.validate(riskName,
|
|
29241
|
-
actions && actions.forEach((actionName) => bt.actionValidationService.validate(actionName,
|
|
29829
|
+
riskName && bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_CANCEL_SCHEDULED);
|
|
29830
|
+
riskList && riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_CANCEL_SCHEDULED));
|
|
29831
|
+
actions && actions.forEach((actionName) => bt.actionValidationService.validate(actionName, LIVE_METHOD_NAME_CANCEL_SCHEDULED));
|
|
29242
29832
|
}
|
|
29243
|
-
await bt.strategyCoreService.
|
|
29833
|
+
await bt.strategyCoreService.cancelScheduled(false, symbol, {
|
|
29244
29834
|
strategyName: context.strategyName,
|
|
29245
29835
|
exchangeName: context.exchangeName,
|
|
29246
29836
|
frameName: "",
|
|
29247
29837
|
}, cancelId);
|
|
29248
29838
|
};
|
|
29839
|
+
/**
|
|
29840
|
+
* Closes the pending signal without stopping the strategy.
|
|
29841
|
+
*
|
|
29842
|
+
* Clears the pending signal (active position).
|
|
29843
|
+
* Does NOT affect scheduled signals or strategy operation.
|
|
29844
|
+
* Does NOT set stop flag - strategy can continue generating new signals.
|
|
29845
|
+
*
|
|
29846
|
+
* @param symbol - Trading pair symbol
|
|
29847
|
+
* @param context - Execution context with strategyName and exchangeName
|
|
29848
|
+
* @param closeId - Optional close ID for user-initiated closes
|
|
29849
|
+
* @returns Promise that resolves when pending signal is closed
|
|
29850
|
+
*
|
|
29851
|
+
* @example
|
|
29852
|
+
* ```typescript
|
|
29853
|
+
* // Close pending signal with custom ID
|
|
29854
|
+
* await Live.commitClose("BTCUSDT", {
|
|
29855
|
+
* exchangeName: "binance",
|
|
29856
|
+
* strategyName: "my-strategy"
|
|
29857
|
+
* }, "manual-close-001");
|
|
29858
|
+
* ```
|
|
29859
|
+
*/
|
|
29860
|
+
this.commitClosePending = async (symbol, context, closeId) => {
|
|
29861
|
+
bt.loggerService.info(LIVE_METHOD_NAME_CLOSE_PENDING, {
|
|
29862
|
+
symbol,
|
|
29863
|
+
context,
|
|
29864
|
+
closeId,
|
|
29865
|
+
});
|
|
29866
|
+
bt.strategyValidationService.validate(context.strategyName, LIVE_METHOD_NAME_CLOSE_PENDING);
|
|
29867
|
+
bt.exchangeValidationService.validate(context.exchangeName, LIVE_METHOD_NAME_CLOSE_PENDING);
|
|
29868
|
+
{
|
|
29869
|
+
const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
|
|
29870
|
+
riskName && bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_CLOSE_PENDING);
|
|
29871
|
+
riskList && riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_CLOSE_PENDING));
|
|
29872
|
+
actions && actions.forEach((actionName) => bt.actionValidationService.validate(actionName, LIVE_METHOD_NAME_CLOSE_PENDING));
|
|
29873
|
+
}
|
|
29874
|
+
await bt.strategyCoreService.closePending(false, symbol, {
|
|
29875
|
+
strategyName: context.strategyName,
|
|
29876
|
+
exchangeName: context.exchangeName,
|
|
29877
|
+
frameName: "",
|
|
29878
|
+
}, closeId);
|
|
29879
|
+
};
|
|
29249
29880
|
/**
|
|
29250
29881
|
* Executes partial close at profit level (moving toward TP).
|
|
29251
29882
|
*
|
|
@@ -30162,7 +30793,7 @@ class WalkerInstance {
|
|
|
30162
30793
|
this.task(symbol, context).catch((error) => exitEmitter.next(new Error(functoolsKit.getErrorMessage(error))));
|
|
30163
30794
|
return () => {
|
|
30164
30795
|
for (const strategyName of walkerSchema.strategies) {
|
|
30165
|
-
bt.strategyCoreService.
|
|
30796
|
+
bt.strategyCoreService.stopStrategy(true, symbol, {
|
|
30166
30797
|
strategyName,
|
|
30167
30798
|
exchangeName: walkerSchema.exchangeName,
|
|
30168
30799
|
frameName: walkerSchema.frameName
|
|
@@ -30318,7 +30949,7 @@ class WalkerUtils {
|
|
|
30318
30949
|
}
|
|
30319
30950
|
for (const strategyName of walkerSchema.strategies) {
|
|
30320
30951
|
await walkerStopSubject.next({ symbol, strategyName, walkerName: context.walkerName });
|
|
30321
|
-
await bt.strategyCoreService.
|
|
30952
|
+
await bt.strategyCoreService.stopStrategy(true, symbol, {
|
|
30322
30953
|
strategyName,
|
|
30323
30954
|
exchangeName: walkerSchema.exchangeName,
|
|
30324
30955
|
frameName: walkerSchema.frameName
|
|
@@ -31251,6 +31882,61 @@ const CREATE_EXCHANGE_INSTANCE_FN = (schema) => {
|
|
|
31251
31882
|
getOrderBook,
|
|
31252
31883
|
};
|
|
31253
31884
|
};
|
|
31885
|
+
/**
|
|
31886
|
+
* Attempts to read candles from cache.
|
|
31887
|
+
* Validates cache consistency (no gaps in timestamps) before returning.
|
|
31888
|
+
*
|
|
31889
|
+
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
31890
|
+
* @param sinceTimestamp - Start timestamp in milliseconds
|
|
31891
|
+
* @param untilTimestamp - End timestamp in milliseconds
|
|
31892
|
+
* @param exchangeName - Exchange name
|
|
31893
|
+
* @returns Cached candles array or null if cache miss or inconsistent
|
|
31894
|
+
*/
|
|
31895
|
+
const READ_CANDLES_CACHE_FN = functoolsKit.trycatch(async (dto, sinceTimestamp, untilTimestamp, exchangeName) => {
|
|
31896
|
+
const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
|
|
31897
|
+
// Return cached data only if we have exactly the requested limit
|
|
31898
|
+
if (cachedCandles.length === dto.limit) {
|
|
31899
|
+
bt.loggerService.debug(`ExchangeInstance READ_CANDLES_CACHE_FN: cache hit for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
|
|
31900
|
+
return cachedCandles;
|
|
31901
|
+
}
|
|
31902
|
+
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}`);
|
|
31903
|
+
return null;
|
|
31904
|
+
}, {
|
|
31905
|
+
fallback: async (error) => {
|
|
31906
|
+
const message = `ExchangeInstance READ_CANDLES_CACHE_FN: cache read failed`;
|
|
31907
|
+
const payload = {
|
|
31908
|
+
error: functoolsKit.errorData(error),
|
|
31909
|
+
message: functoolsKit.getErrorMessage(error),
|
|
31910
|
+
};
|
|
31911
|
+
bt.loggerService.warn(message, payload);
|
|
31912
|
+
console.warn(message, payload);
|
|
31913
|
+
errorEmitter.next(error);
|
|
31914
|
+
},
|
|
31915
|
+
defaultValue: null,
|
|
31916
|
+
});
|
|
31917
|
+
/**
|
|
31918
|
+
* Writes candles to cache with error handling.
|
|
31919
|
+
*
|
|
31920
|
+
* @param candles - Array of candle data to cache
|
|
31921
|
+
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
31922
|
+
* @param exchangeName - Exchange name
|
|
31923
|
+
*/
|
|
31924
|
+
const WRITE_CANDLES_CACHE_FN = functoolsKit.trycatch(functoolsKit.queued(async (candles, dto, exchangeName) => {
|
|
31925
|
+
await PersistCandleAdapter.writeCandlesData(candles, dto.symbol, dto.interval, exchangeName);
|
|
31926
|
+
bt.loggerService.debug(`ExchangeInstance WRITE_CANDLES_CACHE_FN: cache updated for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, count=${candles.length}`);
|
|
31927
|
+
}), {
|
|
31928
|
+
fallback: async (error) => {
|
|
31929
|
+
const message = `ExchangeInstance WRITE_CANDLES_CACHE_FN: cache write failed`;
|
|
31930
|
+
const payload = {
|
|
31931
|
+
error: functoolsKit.errorData(error),
|
|
31932
|
+
message: functoolsKit.getErrorMessage(error),
|
|
31933
|
+
};
|
|
31934
|
+
bt.loggerService.warn(message, payload);
|
|
31935
|
+
console.warn(message, payload);
|
|
31936
|
+
errorEmitter.next(error);
|
|
31937
|
+
},
|
|
31938
|
+
defaultValue: null,
|
|
31939
|
+
});
|
|
31254
31940
|
/**
|
|
31255
31941
|
* Instance class for exchange operations on a specific exchange.
|
|
31256
31942
|
*
|
|
@@ -31308,6 +31994,13 @@ class ExchangeInstance {
|
|
|
31308
31994
|
}
|
|
31309
31995
|
const when = new Date(Date.now());
|
|
31310
31996
|
const since = new Date(when.getTime() - adjust * 60 * 1000);
|
|
31997
|
+
const sinceTimestamp = since.getTime();
|
|
31998
|
+
const untilTimestamp = sinceTimestamp + limit * step * 60 * 1000;
|
|
31999
|
+
// Try to read from cache first
|
|
32000
|
+
const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
|
|
32001
|
+
if (cachedCandles !== null) {
|
|
32002
|
+
return cachedCandles;
|
|
32003
|
+
}
|
|
31311
32004
|
let allData = [];
|
|
31312
32005
|
// If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
|
|
31313
32006
|
if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
|
|
@@ -31330,7 +32023,6 @@ class ExchangeInstance {
|
|
|
31330
32023
|
allData = await getCandles(symbol, interval, since, limit, isBacktest);
|
|
31331
32024
|
}
|
|
31332
32025
|
// Filter candles to strictly match the requested range
|
|
31333
|
-
const sinceTimestamp = since.getTime();
|
|
31334
32026
|
const whenTimestamp = when.getTime();
|
|
31335
32027
|
const stepMs = step * 60 * 1000;
|
|
31336
32028
|
const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp < whenTimestamp + stepMs);
|
|
@@ -31342,6 +32034,8 @@ class ExchangeInstance {
|
|
|
31342
32034
|
if (uniqueData.length < limit) {
|
|
31343
32035
|
bt.loggerService.warn(`ExchangeInstance Expected ${limit} candles, got ${uniqueData.length}`);
|
|
31344
32036
|
}
|
|
32037
|
+
// Write to cache after successful fetch
|
|
32038
|
+
await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
|
|
31345
32039
|
return uniqueData;
|
|
31346
32040
|
};
|
|
31347
32041
|
/**
|
|
@@ -32720,6 +33414,7 @@ exports.Partial = Partial;
|
|
|
32720
33414
|
exports.Performance = Performance;
|
|
32721
33415
|
exports.PersistBase = PersistBase;
|
|
32722
33416
|
exports.PersistBreakevenAdapter = PersistBreakevenAdapter;
|
|
33417
|
+
exports.PersistCandleAdapter = PersistCandleAdapter;
|
|
32723
33418
|
exports.PersistPartialAdapter = PersistPartialAdapter;
|
|
32724
33419
|
exports.PersistRiskAdapter = PersistRiskAdapter;
|
|
32725
33420
|
exports.PersistScheduleAdapter = PersistScheduleAdapter;
|
|
@@ -32739,7 +33434,8 @@ exports.addSizingSchema = addSizingSchema;
|
|
|
32739
33434
|
exports.addStrategySchema = addStrategySchema;
|
|
32740
33435
|
exports.addWalkerSchema = addWalkerSchema;
|
|
32741
33436
|
exports.commitBreakeven = commitBreakeven;
|
|
32742
|
-
exports.
|
|
33437
|
+
exports.commitCancelScheduled = commitCancelScheduled;
|
|
33438
|
+
exports.commitClosePending = commitClosePending;
|
|
32743
33439
|
exports.commitPartialLoss = commitPartialLoss;
|
|
32744
33440
|
exports.commitPartialProfit = commitPartialProfit;
|
|
32745
33441
|
exports.commitSignalPromptHistory = commitSignalPromptHistory;
|
|
@@ -32827,5 +33523,5 @@ exports.set = set;
|
|
|
32827
33523
|
exports.setColumns = setColumns;
|
|
32828
33524
|
exports.setConfig = setConfig;
|
|
32829
33525
|
exports.setLogger = setLogger;
|
|
32830
|
-
exports.
|
|
33526
|
+
exports.stopStrategy = stopStrategy;
|
|
32831
33527
|
exports.validate = validate;
|