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.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createActivator } from 'di-kit';
|
|
2
2
|
import { scoped } from 'di-scoped';
|
|
3
|
-
import { Subject,
|
|
3
|
+
import { Subject, makeExtendable, singleshot, getErrorMessage, memoize, not, trycatch, retry, errorData, queued, sleep, randomString, str, isObject, ToolRegistry, typo, and, resolveDocuments, timeout, TIMEOUT_SYMBOL as TIMEOUT_SYMBOL$1, compose, iterateDocuments, distinctDocuments, singlerun } from 'functools-kit';
|
|
4
4
|
import * as fs from 'fs/promises';
|
|
5
5
|
import fs__default, { mkdir, writeFile } from 'fs/promises';
|
|
6
6
|
import path, { join, dirname } from 'path';
|
|
@@ -565,1119 +565,899 @@ var emitters = /*#__PURE__*/Object.freeze({
|
|
|
565
565
|
walkerStopSubject: walkerStopSubject
|
|
566
566
|
});
|
|
567
567
|
|
|
568
|
-
const
|
|
569
|
-
"1m": 1,
|
|
570
|
-
"3m": 3,
|
|
571
|
-
"5m": 5,
|
|
572
|
-
"15m": 15,
|
|
573
|
-
"30m": 30,
|
|
574
|
-
"1h": 60,
|
|
575
|
-
"2h": 120,
|
|
576
|
-
"4h": 240,
|
|
577
|
-
"6h": 360,
|
|
578
|
-
"8h": 480,
|
|
579
|
-
};
|
|
568
|
+
const IS_WINDOWS = os.platform() === "win32";
|
|
580
569
|
/**
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
* Incomplete candles often have prices like 0.1 instead of normal 100,000 or zero volume.
|
|
570
|
+
* Atomically writes data to a file, ensuring the operation either fully completes or leaves the original file unchanged.
|
|
571
|
+
* Uses a temporary file with a rename strategy on POSIX systems for atomicity, or direct writing with sync on Windows (or when POSIX rename is skipped).
|
|
584
572
|
*
|
|
585
|
-
*
|
|
586
|
-
* @
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
573
|
+
*
|
|
574
|
+
* @param {string} file - The file parameter.
|
|
575
|
+
* @param {string | Buffer} data - The data to be processed or validated.
|
|
576
|
+
* @param {Options | BufferEncoding} options - The options parameter (optional).
|
|
577
|
+
* @throws {Error} Throws an error if the write, sync, or rename operation fails, after attempting cleanup of temporary files.
|
|
578
|
+
*
|
|
579
|
+
* @example
|
|
580
|
+
* // Basic usage with default options
|
|
581
|
+
* await writeFileAtomic("output.txt", "Hello, world!");
|
|
582
|
+
* // Writes "Hello, world!" to "output.txt" atomically
|
|
583
|
+
*
|
|
584
|
+
* @example
|
|
585
|
+
* // Custom options and Buffer data
|
|
586
|
+
* const buffer = Buffer.from("Binary data");
|
|
587
|
+
* await writeFileAtomic("data.bin", buffer, { encoding: "binary", mode: 0o644, tmpPrefix: "temp-" });
|
|
588
|
+
* // Writes binary data to "data.bin" with custom permissions and temp prefix
|
|
589
|
+
*
|
|
590
|
+
* @example
|
|
591
|
+
* // Using encoding shorthand
|
|
592
|
+
* await writeFileAtomic("log.txt", "Log entry", "utf16le");
|
|
593
|
+
* // Writes "Log entry" to "log.txt" in UTF-16LE encoding
|
|
594
|
+
*
|
|
595
|
+
* @remarks
|
|
596
|
+
* This function ensures atomicity to prevent partial writes:
|
|
597
|
+
* - On POSIX systems (non-Windows, unless `GLOBAL_CONFIG.CC_SKIP_POSIX_RENAME` is true):
|
|
598
|
+
* - Writes data to a temporary file (e.g., `.tmp-<random>-filename`) in the same directory.
|
|
599
|
+
* - Uses `crypto.randomBytes` to generate a unique temporary name, reducing collision risk.
|
|
600
|
+
* - Syncs the data to disk and renames the temporary file to the target file atomically with `fs.rename`.
|
|
601
|
+
* - Cleans up the temporary file on failure, swallowing cleanup errors to prioritize throwing the original error.
|
|
602
|
+
* - On Windows (or when POSIX rename is skipped):
|
|
603
|
+
* - Writes directly to the target file, syncing data to disk to minimize corruption risk (though not fully atomic).
|
|
604
|
+
* - Closes the file handle on failure without additional cleanup.
|
|
605
|
+
* - Accepts `options` as an object or a string (interpreted as `encoding`), defaulting to `{ encoding: "utf8", mode: 0o666, tmpPrefix: ".tmp-" }`.
|
|
606
|
+
* Useful in the agent swarm system for safely writing configuration files, logs, or state data where partial writes could cause corruption.
|
|
607
|
+
*
|
|
608
|
+
* @see {@link https://nodejs.org/api/fs.html#fspromiseswritefilefile-data-options|fs.promises.writeFile} for file writing details.
|
|
609
|
+
* @see {@link https://nodejs.org/api/crypto.html#cryptorandombytessize-callback|crypto.randomBytes} for temporary file naming.
|
|
610
|
+
* @see {@link ../config/params|GLOBAL_CONFIG} for configuration impacting POSIX behavior.
|
|
611
|
+
*/
|
|
612
|
+
async function writeFileAtomic(file, data, options = {}) {
|
|
613
|
+
if (typeof options === "string") {
|
|
614
|
+
options = { encoding: options };
|
|
591
615
|
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const validPrices = allPrices.filter(p => p > 0);
|
|
595
|
-
let referencePrice;
|
|
596
|
-
if (candles.length >= GLOBAL_CONFIG.CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN) {
|
|
597
|
-
// Use median for reliable statistics with enough data
|
|
598
|
-
const sortedPrices = [...validPrices].sort((a, b) => a - b);
|
|
599
|
-
referencePrice = sortedPrices[Math.floor(sortedPrices.length / 2)] || 0;
|
|
616
|
+
else if (!options) {
|
|
617
|
+
options = {};
|
|
600
618
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
619
|
+
const { encoding = "utf8", mode = 0o666, tmpPrefix = ".tmp-" } = options;
|
|
620
|
+
let fileHandle = null;
|
|
621
|
+
if (IS_WINDOWS) {
|
|
622
|
+
try {
|
|
623
|
+
// Create and write to temporary file
|
|
624
|
+
fileHandle = await fs__default.open(file, "w", mode);
|
|
625
|
+
// Write data to the temp file
|
|
626
|
+
await fileHandle.writeFile(data, { encoding });
|
|
627
|
+
// Ensure data is flushed to disk
|
|
628
|
+
await fileHandle.sync();
|
|
629
|
+
// Close the file before rename
|
|
630
|
+
await fileHandle.close();
|
|
631
|
+
}
|
|
632
|
+
catch (error) {
|
|
633
|
+
// Clean up if something went wrong
|
|
634
|
+
if (fileHandle) {
|
|
635
|
+
await fileHandle.close().catch(() => { });
|
|
636
|
+
}
|
|
637
|
+
throw error; // Re-throw the original error
|
|
638
|
+
}
|
|
639
|
+
return;
|
|
605
640
|
}
|
|
606
|
-
|
|
607
|
-
|
|
641
|
+
// Create a temporary filename in the same directory
|
|
642
|
+
const dir = path.dirname(file);
|
|
643
|
+
const filename = path.basename(file);
|
|
644
|
+
const tmpFile = path.join(dir, `${tmpPrefix}${crypto.randomBytes(6).toString("hex")}-${filename}`);
|
|
645
|
+
try {
|
|
646
|
+
// Create and write to temporary file
|
|
647
|
+
fileHandle = await fs__default.open(tmpFile, "w", mode);
|
|
648
|
+
// Write data to the temp file
|
|
649
|
+
await fileHandle.writeFile(data, { encoding });
|
|
650
|
+
// Ensure data is flushed to disk
|
|
651
|
+
await fileHandle.sync();
|
|
652
|
+
// Close the file before rename
|
|
653
|
+
await fileHandle.close();
|
|
654
|
+
fileHandle = null;
|
|
655
|
+
// Atomically replace the target file with our temp file
|
|
656
|
+
await fs__default.rename(tmpFile, file);
|
|
608
657
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
if (!Number.isFinite(candle.open) ||
|
|
614
|
-
!Number.isFinite(candle.high) ||
|
|
615
|
-
!Number.isFinite(candle.low) ||
|
|
616
|
-
!Number.isFinite(candle.close) ||
|
|
617
|
-
!Number.isFinite(candle.volume) ||
|
|
618
|
-
!Number.isFinite(candle.timestamp)) {
|
|
619
|
-
throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has invalid numeric values (NaN or Infinity)`);
|
|
658
|
+
catch (error) {
|
|
659
|
+
// Clean up if something went wrong
|
|
660
|
+
if (fileHandle) {
|
|
661
|
+
await fileHandle.close().catch(() => { });
|
|
620
662
|
}
|
|
621
|
-
//
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
candle.low <= 0 ||
|
|
625
|
-
candle.close <= 0 ||
|
|
626
|
-
candle.volume < 0) {
|
|
627
|
-
throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has zero or negative values`);
|
|
663
|
+
// Try to remove the temporary file
|
|
664
|
+
try {
|
|
665
|
+
await fs__default.unlink(tmpFile).catch(() => { });
|
|
628
666
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
candle.high < minValidPrice ||
|
|
632
|
-
candle.low < minValidPrice ||
|
|
633
|
-
candle.close < minValidPrice) {
|
|
634
|
-
throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has anomalously low price. ` +
|
|
635
|
-
`OHLC: [${candle.open}, ${candle.high}, ${candle.low}, ${candle.close}], ` +
|
|
636
|
-
`reference: ${referencePrice}, threshold: ${minValidPrice}`);
|
|
667
|
+
catch (_) {
|
|
668
|
+
// Ignore errors during cleanup
|
|
637
669
|
}
|
|
670
|
+
throw error;
|
|
638
671
|
}
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
var _a$2;
|
|
675
|
+
const BASE_WAIT_FOR_INIT_SYMBOL = Symbol("wait-for-init");
|
|
676
|
+
const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_PERSIST_SIGNAL_ADAPTER = "PersistSignalUtils.usePersistSignalAdapter";
|
|
677
|
+
const PERSIST_SIGNAL_UTILS_METHOD_NAME_READ_DATA = "PersistSignalUtils.readSignalData";
|
|
678
|
+
const PERSIST_SIGNAL_UTILS_METHOD_NAME_WRITE_DATA = "PersistSignalUtils.writeSignalData";
|
|
679
|
+
const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_JSON = "PersistSignalUtils.useJson";
|
|
680
|
+
const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_DUMMY = "PersistSignalUtils.useDummy";
|
|
681
|
+
const PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_PERSIST_SCHEDULE_ADAPTER = "PersistScheduleUtils.usePersistScheduleAdapter";
|
|
682
|
+
const PERSIST_SCHEDULE_UTILS_METHOD_NAME_READ_DATA = "PersistScheduleUtils.readScheduleData";
|
|
683
|
+
const PERSIST_SCHEDULE_UTILS_METHOD_NAME_WRITE_DATA = "PersistScheduleUtils.writeScheduleData";
|
|
684
|
+
const PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_JSON = "PersistScheduleUtils.useJson";
|
|
685
|
+
const PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_DUMMY = "PersistScheduleUtils.useDummy";
|
|
686
|
+
const PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_PERSIST_PARTIAL_ADAPTER = "PersistPartialUtils.usePersistPartialAdapter";
|
|
687
|
+
const PERSIST_PARTIAL_UTILS_METHOD_NAME_READ_DATA = "PersistPartialUtils.readPartialData";
|
|
688
|
+
const PERSIST_PARTIAL_UTILS_METHOD_NAME_WRITE_DATA = "PersistPartialUtils.writePartialData";
|
|
689
|
+
const PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_JSON = "PersistPartialUtils.useJson";
|
|
690
|
+
const PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_DUMMY = "PersistPartialUtils.useDummy";
|
|
691
|
+
const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_PERSIST_BREAKEVEN_ADAPTER = "PersistBreakevenUtils.usePersistBreakevenAdapter";
|
|
692
|
+
const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_READ_DATA = "PersistBreakevenUtils.readBreakevenData";
|
|
693
|
+
const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_WRITE_DATA = "PersistBreakevenUtils.writeBreakevenData";
|
|
694
|
+
const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_JSON = "PersistBreakevenUtils.useJson";
|
|
695
|
+
const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_DUMMY = "PersistBreakevenUtils.useDummy";
|
|
696
|
+
const PERSIST_RISK_UTILS_METHOD_NAME_USE_PERSIST_RISK_ADAPTER = "PersistRiskUtils.usePersistRiskAdapter";
|
|
697
|
+
const PERSIST_RISK_UTILS_METHOD_NAME_READ_DATA = "PersistRiskUtils.readPositionData";
|
|
698
|
+
const PERSIST_RISK_UTILS_METHOD_NAME_WRITE_DATA = "PersistRiskUtils.writePositionData";
|
|
699
|
+
const PERSIST_RISK_UTILS_METHOD_NAME_USE_JSON = "PersistRiskUtils.useJson";
|
|
700
|
+
const PERSIST_RISK_UTILS_METHOD_NAME_USE_DUMMY = "PersistRiskUtils.useDummy";
|
|
701
|
+
const PERSIST_BASE_METHOD_NAME_CTOR = "PersistBase.CTOR";
|
|
702
|
+
const PERSIST_BASE_METHOD_NAME_WAIT_FOR_INIT = "PersistBase.waitForInit";
|
|
703
|
+
const PERSIST_BASE_METHOD_NAME_READ_VALUE = "PersistBase.readValue";
|
|
704
|
+
const PERSIST_BASE_METHOD_NAME_WRITE_VALUE = "PersistBase.writeValue";
|
|
705
|
+
const PERSIST_BASE_METHOD_NAME_HAS_VALUE = "PersistBase.hasValue";
|
|
706
|
+
const PERSIST_BASE_METHOD_NAME_KEYS = "PersistBase.keys";
|
|
707
|
+
const BASE_WAIT_FOR_INIT_FN_METHOD_NAME = "PersistBase.waitForInitFn";
|
|
708
|
+
const BASE_UNLINK_RETRY_COUNT = 5;
|
|
709
|
+
const BASE_UNLINK_RETRY_DELAY = 1000;
|
|
710
|
+
const BASE_WAIT_FOR_INIT_FN = async (self) => {
|
|
711
|
+
bt.loggerService.debug(BASE_WAIT_FOR_INIT_FN_METHOD_NAME, {
|
|
712
|
+
entityName: self.entityName,
|
|
713
|
+
directory: self._directory,
|
|
714
|
+
});
|
|
715
|
+
await fs__default.mkdir(self._directory, { recursive: true });
|
|
716
|
+
for await (const key of self.keys()) {
|
|
650
717
|
try {
|
|
651
|
-
|
|
652
|
-
VALIDATE_NO_INCOMPLETE_CANDLES_FN(result);
|
|
653
|
-
return result;
|
|
718
|
+
await self.readValue(key);
|
|
654
719
|
}
|
|
655
|
-
catch
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
}
|
|
661
|
-
self.params.logger.warn(message, payload);
|
|
662
|
-
console.warn(message, payload);
|
|
663
|
-
lastError = err;
|
|
664
|
-
await sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
|
|
720
|
+
catch {
|
|
721
|
+
const filePath = self._getFilePath(key);
|
|
722
|
+
console.error(`backtest-kit PersistBase found invalid document for filePath=${filePath} entityName=${self.entityName}`);
|
|
723
|
+
if (await not(BASE_WAIT_FOR_INIT_UNLINK_FN(filePath))) {
|
|
724
|
+
console.error(`backtest-kit PersistBase failed to remove invalid document for filePath=${filePath} entityName=${self.entityName}`);
|
|
725
|
+
}
|
|
665
726
|
}
|
|
666
727
|
}
|
|
667
|
-
throw lastError;
|
|
668
728
|
};
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
* @param self - ClientExchange instance reference
|
|
674
|
-
* @param symbol - Trading pair symbol
|
|
675
|
-
* @param interval - Candle interval
|
|
676
|
-
* @param since - Start date for candle data
|
|
677
|
-
* @param limit - Number of candles
|
|
678
|
-
* @param data - Array of candle data
|
|
679
|
-
*/
|
|
680
|
-
const CALL_CANDLE_DATA_CALLBACKS_FN = trycatch(async (self, symbol, interval, since, limit, data) => {
|
|
681
|
-
if (self.params.callbacks?.onCandleData) {
|
|
682
|
-
await self.params.callbacks.onCandleData(symbol, interval, since, limit, data);
|
|
729
|
+
const BASE_WAIT_FOR_INIT_UNLINK_FN = async (filePath) => trycatch(retry(async () => {
|
|
730
|
+
try {
|
|
731
|
+
await fs__default.unlink(filePath);
|
|
732
|
+
return true;
|
|
683
733
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
};
|
|
691
|
-
bt.loggerService.warn(message, payload);
|
|
692
|
-
console.warn(message, payload);
|
|
693
|
-
errorEmitter.next(error);
|
|
694
|
-
},
|
|
734
|
+
catch (error) {
|
|
735
|
+
console.error(`backtest-kit PersistBase unlink failed for filePath=${filePath} error=${getErrorMessage(error)}`);
|
|
736
|
+
throw error;
|
|
737
|
+
}
|
|
738
|
+
}, BASE_UNLINK_RETRY_COUNT, BASE_UNLINK_RETRY_DELAY), {
|
|
739
|
+
defaultValue: false,
|
|
695
740
|
});
|
|
696
741
|
/**
|
|
697
|
-
*
|
|
742
|
+
* Base class for file-based persistence with atomic writes.
|
|
698
743
|
*
|
|
699
744
|
* Features:
|
|
700
|
-
* -
|
|
701
|
-
* -
|
|
702
|
-
* -
|
|
703
|
-
* -
|
|
704
|
-
*
|
|
705
|
-
* All methods use prototype functions for memory efficiency.
|
|
745
|
+
* - Atomic file writes using writeFileAtomic
|
|
746
|
+
* - Auto-validation and cleanup of corrupted files
|
|
747
|
+
* - Async generator support for iteration
|
|
748
|
+
* - Retry logic for file deletion
|
|
706
749
|
*
|
|
707
750
|
* @example
|
|
708
751
|
* ```typescript
|
|
709
|
-
* const
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
* formatQuantity: async (symbol, quantity) => quantity.toFixed(8),
|
|
714
|
-
* execution: executionService,
|
|
715
|
-
* logger: loggerService,
|
|
716
|
-
* });
|
|
717
|
-
*
|
|
718
|
-
* const candles = await exchange.getCandles("BTCUSDT", "1m", 100);
|
|
719
|
-
* const vwap = await exchange.getAveragePrice("BTCUSDT");
|
|
752
|
+
* const persist = new PersistBase("my-entity", "./data");
|
|
753
|
+
* await persist.waitForInit(true);
|
|
754
|
+
* await persist.writeValue("key1", { data: "value" });
|
|
755
|
+
* const value = await persist.readValue("key1");
|
|
720
756
|
* ```
|
|
721
757
|
*/
|
|
722
|
-
class
|
|
723
|
-
constructor(params) {
|
|
724
|
-
this.params = params;
|
|
725
|
-
}
|
|
758
|
+
class PersistBase {
|
|
726
759
|
/**
|
|
727
|
-
*
|
|
760
|
+
* Creates new persistence instance.
|
|
728
761
|
*
|
|
729
|
-
* @param
|
|
730
|
-
* @param
|
|
731
|
-
* @param limit - Number of candles to fetch
|
|
732
|
-
* @returns Promise resolving to array of candles
|
|
762
|
+
* @param entityName - Unique entity type identifier
|
|
763
|
+
* @param baseDir - Base directory for all entities (default: ./dump/data)
|
|
733
764
|
*/
|
|
734
|
-
|
|
735
|
-
this.
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
765
|
+
constructor(entityName, baseDir = join(process.cwd(), "logs/data")) {
|
|
766
|
+
this.entityName = entityName;
|
|
767
|
+
this.baseDir = baseDir;
|
|
768
|
+
this[_a$2] = singleshot(async () => await BASE_WAIT_FOR_INIT_FN(this));
|
|
769
|
+
bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_CTOR, {
|
|
770
|
+
entityName: this.entityName,
|
|
771
|
+
baseDir,
|
|
739
772
|
});
|
|
740
|
-
|
|
741
|
-
const adjust = step * limit;
|
|
742
|
-
if (!adjust) {
|
|
743
|
-
throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
|
|
744
|
-
}
|
|
745
|
-
const since = new Date(this.params.execution.context.when.getTime() - adjust * 60 * 1000);
|
|
746
|
-
let allData = [];
|
|
747
|
-
// If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
|
|
748
|
-
if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
|
|
749
|
-
let remaining = limit;
|
|
750
|
-
let currentSince = new Date(since.getTime());
|
|
751
|
-
while (remaining > 0) {
|
|
752
|
-
const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
|
|
753
|
-
const chunkData = await GET_CANDLES_FN({ symbol, interval, limit: chunkLimit }, currentSince, this);
|
|
754
|
-
allData.push(...chunkData);
|
|
755
|
-
remaining -= chunkLimit;
|
|
756
|
-
if (remaining > 0) {
|
|
757
|
-
// Move currentSince forward by the number of candles fetched
|
|
758
|
-
currentSince = new Date(currentSince.getTime() + chunkLimit * step * 60 * 1000);
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
else {
|
|
763
|
-
allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
|
|
764
|
-
}
|
|
765
|
-
// Filter candles to strictly match the requested range
|
|
766
|
-
const whenTimestamp = this.params.execution.context.when.getTime();
|
|
767
|
-
const sinceTimestamp = since.getTime();
|
|
768
|
-
const stepMs = step * 60 * 1000;
|
|
769
|
-
const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp < whenTimestamp + stepMs);
|
|
770
|
-
// Apply distinct by timestamp to remove duplicates
|
|
771
|
-
const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
|
|
772
|
-
if (filteredData.length !== uniqueData.length) {
|
|
773
|
-
const msg = `ClientExchange Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
|
|
774
|
-
this.params.logger.warn(msg);
|
|
775
|
-
console.warn(msg);
|
|
776
|
-
}
|
|
777
|
-
if (uniqueData.length < limit) {
|
|
778
|
-
const msg = `ClientExchange Expected ${limit} candles, got ${uniqueData.length}`;
|
|
779
|
-
this.params.logger.warn(msg);
|
|
780
|
-
console.warn(msg);
|
|
781
|
-
}
|
|
782
|
-
await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
|
|
783
|
-
return uniqueData;
|
|
773
|
+
this._directory = join(this.baseDir, this.entityName);
|
|
784
774
|
}
|
|
785
775
|
/**
|
|
786
|
-
*
|
|
787
|
-
* Used in backtest mode to get candles for signal duration.
|
|
776
|
+
* Computes file path for entity ID.
|
|
788
777
|
*
|
|
789
|
-
* @param
|
|
790
|
-
* @
|
|
791
|
-
* @param limit - Number of candles to fetch
|
|
792
|
-
* @returns Promise resolving to array of candles
|
|
793
|
-
* @throws Error if trying to fetch future candles in live mode
|
|
778
|
+
* @param entityId - Entity identifier
|
|
779
|
+
* @returns Full file path to entity JSON file
|
|
794
780
|
*/
|
|
795
|
-
|
|
796
|
-
this.
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
781
|
+
_getFilePath(entityId) {
|
|
782
|
+
return join(this.baseDir, this.entityName, `${entityId}.json`);
|
|
783
|
+
}
|
|
784
|
+
async waitForInit(initial) {
|
|
785
|
+
bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_WAIT_FOR_INIT, {
|
|
786
|
+
entityName: this.entityName,
|
|
787
|
+
initial,
|
|
800
788
|
});
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
789
|
+
await this[BASE_WAIT_FOR_INIT_SYMBOL]();
|
|
790
|
+
}
|
|
791
|
+
async readValue(entityId) {
|
|
792
|
+
bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_READ_VALUE, {
|
|
793
|
+
entityName: this.entityName,
|
|
794
|
+
entityId,
|
|
795
|
+
});
|
|
796
|
+
try {
|
|
797
|
+
const filePath = this._getFilePath(entityId);
|
|
798
|
+
const fileContent = await fs__default.readFile(filePath, "utf-8");
|
|
799
|
+
return JSON.parse(fileContent);
|
|
809
800
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
let remaining = limit;
|
|
814
|
-
let currentSince = new Date(since.getTime());
|
|
815
|
-
while (remaining > 0) {
|
|
816
|
-
const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
|
|
817
|
-
const chunkData = await GET_CANDLES_FN({ symbol, interval, limit: chunkLimit }, currentSince, this);
|
|
818
|
-
allData.push(...chunkData);
|
|
819
|
-
remaining -= chunkLimit;
|
|
820
|
-
if (remaining > 0) {
|
|
821
|
-
// Move currentSince forward by the number of candles fetched
|
|
822
|
-
currentSince = new Date(currentSince.getTime() + chunkLimit * step * 60 * 1000);
|
|
823
|
-
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
if (error?.code === "ENOENT") {
|
|
803
|
+
throw new Error(`Entity ${this.entityName}:${entityId} not found`);
|
|
824
804
|
}
|
|
805
|
+
throw new Error(`Failed to read entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`);
|
|
825
806
|
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
this.params.logger.warn(msg);
|
|
837
|
-
console.warn(msg);
|
|
807
|
+
}
|
|
808
|
+
async hasValue(entityId) {
|
|
809
|
+
bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_HAS_VALUE, {
|
|
810
|
+
entityName: this.entityName,
|
|
811
|
+
entityId,
|
|
812
|
+
});
|
|
813
|
+
try {
|
|
814
|
+
const filePath = this._getFilePath(entityId);
|
|
815
|
+
await fs__default.access(filePath);
|
|
816
|
+
return true;
|
|
838
817
|
}
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
818
|
+
catch (error) {
|
|
819
|
+
if (error?.code === "ENOENT") {
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
throw new Error(`Failed to check existence of entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`);
|
|
843
823
|
}
|
|
844
|
-
await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
|
|
845
|
-
return uniqueData;
|
|
846
824
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
* Formula:
|
|
852
|
-
* - Typical Price = (high + low + close) / 3
|
|
853
|
-
* - VWAP = sum(typical_price * volume) / sum(volume)
|
|
854
|
-
*
|
|
855
|
-
* If volume is zero, returns simple average of close prices.
|
|
856
|
-
*
|
|
857
|
-
* @param symbol - Trading pair symbol
|
|
858
|
-
* @returns Promise resolving to VWAP price
|
|
859
|
-
* @throws Error if no candles available
|
|
860
|
-
*/
|
|
861
|
-
async getAveragePrice(symbol) {
|
|
862
|
-
this.params.logger.debug(`ClientExchange getAveragePrice`, {
|
|
863
|
-
symbol,
|
|
825
|
+
async writeValue(entityId, entity) {
|
|
826
|
+
bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_WRITE_VALUE, {
|
|
827
|
+
entityName: this.entityName,
|
|
828
|
+
entityId,
|
|
864
829
|
});
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
830
|
+
try {
|
|
831
|
+
const filePath = this._getFilePath(entityId);
|
|
832
|
+
const serializedData = JSON.stringify(entity);
|
|
833
|
+
await writeFileAtomic(filePath, serializedData, "utf-8");
|
|
868
834
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
const sumPriceVolume = candles.reduce((acc, candle) => {
|
|
872
|
-
const typicalPrice = (candle.high + candle.low + candle.close) / 3;
|
|
873
|
-
return acc + typicalPrice * candle.volume;
|
|
874
|
-
}, 0);
|
|
875
|
-
const totalVolume = candles.reduce((acc, candle) => acc + candle.volume, 0);
|
|
876
|
-
if (totalVolume === 0) {
|
|
877
|
-
// Если объем нулевой, возвращаем простое среднее close цен
|
|
878
|
-
const sum = candles.reduce((acc, candle) => acc + candle.close, 0);
|
|
879
|
-
return sum / candles.length;
|
|
835
|
+
catch (error) {
|
|
836
|
+
throw new Error(`Failed to write entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`);
|
|
880
837
|
}
|
|
881
|
-
const vwap = sumPriceVolume / totalVolume;
|
|
882
|
-
return vwap;
|
|
883
838
|
}
|
|
884
839
|
/**
|
|
885
|
-
*
|
|
886
|
-
*
|
|
840
|
+
* Async generator yielding all entity IDs.
|
|
841
|
+
* Sorted alphanumerically.
|
|
842
|
+
* Used internally by waitForInit for validation.
|
|
887
843
|
*
|
|
888
|
-
* @
|
|
889
|
-
* @
|
|
890
|
-
* @returns Promise resolving to formatted quantity as string
|
|
844
|
+
* @returns AsyncGenerator yielding entity IDs
|
|
845
|
+
* @throws Error if reading fails
|
|
891
846
|
*/
|
|
892
|
-
async
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
847
|
+
async *keys() {
|
|
848
|
+
bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_KEYS, {
|
|
849
|
+
entityName: this.entityName,
|
|
850
|
+
});
|
|
851
|
+
try {
|
|
852
|
+
const files = await fs__default.readdir(this._directory);
|
|
853
|
+
const entityIds = files
|
|
854
|
+
.filter((file) => file.endsWith(".json"))
|
|
855
|
+
.map((file) => file.slice(0, -5))
|
|
856
|
+
.sort((a, b) => a.localeCompare(b, undefined, {
|
|
857
|
+
numeric: true,
|
|
858
|
+
sensitivity: "base",
|
|
859
|
+
}));
|
|
860
|
+
for (const entityId of entityIds) {
|
|
861
|
+
yield entityId;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
throw new Error(`Failed to read keys for ${this.entityName}: ${getErrorMessage(error)}`);
|
|
866
|
+
}
|
|
898
867
|
}
|
|
868
|
+
}
|
|
869
|
+
_a$2 = BASE_WAIT_FOR_INIT_SYMBOL;
|
|
870
|
+
// @ts-ignore
|
|
871
|
+
PersistBase = makeExtendable(PersistBase);
|
|
872
|
+
/**
|
|
873
|
+
* Dummy persist adapter that discards all writes.
|
|
874
|
+
* Used for disabling persistence.
|
|
875
|
+
*/
|
|
876
|
+
class PersistDummy {
|
|
899
877
|
/**
|
|
900
|
-
*
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
* @param symbol - Trading pair symbol
|
|
904
|
-
* @param price - Raw price to format
|
|
905
|
-
* @returns Promise resolving to formatted price as string
|
|
878
|
+
* No-op initialization function.
|
|
879
|
+
* @returns Promise that resolves immediately
|
|
906
880
|
*/
|
|
907
|
-
async
|
|
908
|
-
this.params.logger.debug("binanceService formatPrice", {
|
|
909
|
-
symbol,
|
|
910
|
-
price,
|
|
911
|
-
});
|
|
912
|
-
return await this.params.formatPrice(symbol, price, this.params.execution.context.backtest);
|
|
881
|
+
async waitForInit() {
|
|
913
882
|
}
|
|
914
883
|
/**
|
|
915
|
-
*
|
|
916
|
-
*
|
|
917
|
-
* Calculates time range based on execution context time (when) and
|
|
918
|
-
* CC_ORDER_BOOK_TIME_OFFSET_MINUTES, then delegates to the exchange
|
|
919
|
-
* schema implementation which may use or ignore the time range.
|
|
920
|
-
*
|
|
921
|
-
* @param symbol - Trading pair symbol
|
|
922
|
-
* @param depth - Maximum depth levels (default: CC_ORDER_BOOK_MAX_DEPTH_LEVELS)
|
|
923
|
-
* @returns Promise resolving to order book data
|
|
924
|
-
* @throws Error if getOrderBook is not implemented
|
|
884
|
+
* No-op read function.
|
|
885
|
+
* @returns Promise that resolves with empty object
|
|
925
886
|
*/
|
|
926
|
-
async
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
887
|
+
async readValue() {
|
|
888
|
+
return {};
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* No-op has value check.
|
|
892
|
+
* @returns Promise that resolves to false
|
|
893
|
+
*/
|
|
894
|
+
async hasValue() {
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* No-op write function.
|
|
899
|
+
* @returns Promise that resolves immediately
|
|
900
|
+
*/
|
|
901
|
+
async writeValue() {
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* No-op keys generator.
|
|
905
|
+
* @returns Empty async generator
|
|
906
|
+
*/
|
|
907
|
+
async *keys() {
|
|
908
|
+
// Empty generator - no keys
|
|
934
909
|
}
|
|
935
910
|
}
|
|
936
|
-
|
|
937
|
-
/**
|
|
938
|
-
* Default implementation for getCandles.
|
|
939
|
-
* Throws an error indicating the method is not implemented.
|
|
940
|
-
*/
|
|
941
|
-
const DEFAULT_GET_CANDLES_FN$1 = async (_symbol, _interval, _since, _limit, _backtest) => {
|
|
942
|
-
throw new Error(`getCandles is not implemented for this exchange`);
|
|
943
|
-
};
|
|
944
|
-
/**
|
|
945
|
-
* Default implementation for formatQuantity.
|
|
946
|
-
* Returns Bitcoin precision on Binance (8 decimal places).
|
|
947
|
-
*/
|
|
948
|
-
const DEFAULT_FORMAT_QUANTITY_FN$1 = async (_symbol, quantity, _backtest) => {
|
|
949
|
-
return quantity.toFixed(8);
|
|
950
|
-
};
|
|
951
|
-
/**
|
|
952
|
-
* Default implementation for formatPrice.
|
|
953
|
-
* Returns Bitcoin precision on Binance (2 decimal places).
|
|
954
|
-
*/
|
|
955
|
-
const DEFAULT_FORMAT_PRICE_FN$1 = async (_symbol, price, _backtest) => {
|
|
956
|
-
return price.toFixed(2);
|
|
957
|
-
};
|
|
958
|
-
/**
|
|
959
|
-
* Default implementation for getOrderBook.
|
|
960
|
-
* Throws an error indicating the method is not implemented.
|
|
961
|
-
*
|
|
962
|
-
* @param _symbol - Trading pair symbol (unused)
|
|
963
|
-
* @param _depth - Maximum depth levels (unused)
|
|
964
|
-
* @param _from - Start of time range (unused - can be ignored in live implementations)
|
|
965
|
-
* @param _to - End of time range (unused - can be ignored in live implementations)
|
|
966
|
-
* @param _backtest - Whether running in backtest mode (unused)
|
|
967
|
-
*/
|
|
968
|
-
const DEFAULT_GET_ORDER_BOOK_FN$1 = async (_symbol, _depth, _from, _to, _backtest) => {
|
|
969
|
-
throw new Error(`getOrderBook is not implemented for this exchange`);
|
|
970
|
-
};
|
|
971
911
|
/**
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
* Routes all IExchange method calls to the appropriate exchange implementation
|
|
975
|
-
* based on methodContextService.context.exchangeName. Uses memoization to cache
|
|
976
|
-
* ClientExchange instances for performance.
|
|
912
|
+
* Utility class for managing signal persistence.
|
|
977
913
|
*
|
|
978
|
-
*
|
|
979
|
-
* -
|
|
980
|
-
* -
|
|
981
|
-
* -
|
|
982
|
-
* -
|
|
914
|
+
* Features:
|
|
915
|
+
* - Memoized storage instances per strategy
|
|
916
|
+
* - Custom adapter support
|
|
917
|
+
* - Atomic read/write operations
|
|
918
|
+
* - Crash-safe signal state management
|
|
983
919
|
*
|
|
984
|
-
*
|
|
985
|
-
* ```typescript
|
|
986
|
-
* // Used internally by framework
|
|
987
|
-
* const candles = await exchangeConnectionService.getCandles(
|
|
988
|
-
* "BTCUSDT", "1h", 100
|
|
989
|
-
* );
|
|
990
|
-
* // Automatically routes to correct exchange based on methodContext
|
|
991
|
-
* ```
|
|
920
|
+
* Used by ClientStrategy for live mode persistence.
|
|
992
921
|
*/
|
|
993
|
-
class
|
|
922
|
+
class PersistSignalUtils {
|
|
994
923
|
constructor() {
|
|
995
|
-
this.
|
|
996
|
-
this.
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
* Retrieves memoized ClientExchange instance for given exchange name.
|
|
1001
|
-
*
|
|
1002
|
-
* Creates ClientExchange on first call, returns cached instance on subsequent calls.
|
|
1003
|
-
* Cache key is exchangeName string.
|
|
1004
|
-
*
|
|
1005
|
-
* @param exchangeName - Name of registered exchange schema
|
|
1006
|
-
* @returns Configured ClientExchange instance
|
|
1007
|
-
*/
|
|
1008
|
-
this.getExchange = memoize(([exchangeName]) => `${exchangeName}`, (exchangeName) => {
|
|
1009
|
-
const { getCandles = DEFAULT_GET_CANDLES_FN$1, formatPrice = DEFAULT_FORMAT_PRICE_FN$1, formatQuantity = DEFAULT_FORMAT_QUANTITY_FN$1, getOrderBook = DEFAULT_GET_ORDER_BOOK_FN$1, callbacks } = this.exchangeSchemaService.get(exchangeName);
|
|
1010
|
-
return new ClientExchange({
|
|
1011
|
-
execution: this.executionContextService,
|
|
1012
|
-
logger: this.loggerService,
|
|
1013
|
-
exchangeName,
|
|
1014
|
-
getCandles,
|
|
1015
|
-
formatPrice,
|
|
1016
|
-
formatQuantity,
|
|
1017
|
-
getOrderBook,
|
|
1018
|
-
callbacks,
|
|
1019
|
-
});
|
|
1020
|
-
});
|
|
924
|
+
this.PersistSignalFactory = PersistBase;
|
|
925
|
+
this.getSignalStorage = memoize(([symbol, strategyName, exchangeName]) => `${symbol}:${strategyName}:${exchangeName}`, (symbol, strategyName, exchangeName) => Reflect.construct(this.PersistSignalFactory, [
|
|
926
|
+
`${symbol}_${strategyName}_${exchangeName}`,
|
|
927
|
+
`./dump/data/signal/`,
|
|
928
|
+
]));
|
|
1021
929
|
/**
|
|
1022
|
-
*
|
|
930
|
+
* Reads persisted signal data for a symbol and strategy.
|
|
1023
931
|
*
|
|
1024
|
-
*
|
|
932
|
+
* Called by ClientStrategy.waitForInit() to restore state.
|
|
933
|
+
* Returns null if no signal exists.
|
|
1025
934
|
*
|
|
1026
|
-
* @param symbol - Trading pair symbol
|
|
1027
|
-
* @param
|
|
1028
|
-
* @param
|
|
1029
|
-
* @returns Promise resolving to
|
|
935
|
+
* @param symbol - Trading pair symbol
|
|
936
|
+
* @param strategyName - Strategy identifier
|
|
937
|
+
* @param exchangeName - Exchange identifier
|
|
938
|
+
* @returns Promise resolving to signal or null
|
|
1030
939
|
*/
|
|
1031
|
-
this.
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
940
|
+
this.readSignalData = async (symbol, strategyName, exchangeName) => {
|
|
941
|
+
bt.loggerService.info(PERSIST_SIGNAL_UTILS_METHOD_NAME_READ_DATA);
|
|
942
|
+
const key = `${symbol}:${strategyName}:${exchangeName}`;
|
|
943
|
+
const isInitial = !this.getSignalStorage.has(key);
|
|
944
|
+
const stateStorage = this.getSignalStorage(symbol, strategyName, exchangeName);
|
|
945
|
+
await stateStorage.waitForInit(isInitial);
|
|
946
|
+
if (await stateStorage.hasValue(symbol)) {
|
|
947
|
+
return await stateStorage.readValue(symbol);
|
|
948
|
+
}
|
|
949
|
+
return null;
|
|
1038
950
|
};
|
|
1039
951
|
/**
|
|
1040
|
-
*
|
|
952
|
+
* Writes signal data to disk with atomic file writes.
|
|
1041
953
|
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
954
|
+
* Called by ClientStrategy.setPendingSignal() to persist state.
|
|
955
|
+
* Uses atomic writes to prevent corruption on crashes.
|
|
1044
956
|
*
|
|
1045
|
-
* @param
|
|
1046
|
-
* @param
|
|
1047
|
-
* @param
|
|
1048
|
-
* @
|
|
957
|
+
* @param signalRow - Signal data (null to clear)
|
|
958
|
+
* @param symbol - Trading pair symbol
|
|
959
|
+
* @param strategyName - Strategy identifier
|
|
960
|
+
* @param exchangeName - Exchange identifier
|
|
961
|
+
* @returns Promise that resolves when write is complete
|
|
1049
962
|
*/
|
|
1050
|
-
this.
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
963
|
+
this.writeSignalData = async (signalRow, symbol, strategyName, exchangeName) => {
|
|
964
|
+
bt.loggerService.info(PERSIST_SIGNAL_UTILS_METHOD_NAME_WRITE_DATA);
|
|
965
|
+
const key = `${symbol}:${strategyName}:${exchangeName}`;
|
|
966
|
+
const isInitial = !this.getSignalStorage.has(key);
|
|
967
|
+
const stateStorage = this.getSignalStorage(symbol, strategyName, exchangeName);
|
|
968
|
+
await stateStorage.waitForInit(isInitial);
|
|
969
|
+
await stateStorage.writeValue(symbol, signalRow);
|
|
1057
970
|
};
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Registers a custom persistence adapter.
|
|
974
|
+
*
|
|
975
|
+
* @param Ctor - Custom PersistBase constructor
|
|
976
|
+
*
|
|
977
|
+
* @example
|
|
978
|
+
* ```typescript
|
|
979
|
+
* class RedisPersist extends PersistBase {
|
|
980
|
+
* async readValue(id) { return JSON.parse(await redis.get(id)); }
|
|
981
|
+
* async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
|
|
982
|
+
* }
|
|
983
|
+
* PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
|
|
984
|
+
* ```
|
|
985
|
+
*/
|
|
986
|
+
usePersistSignalAdapter(Ctor) {
|
|
987
|
+
bt.loggerService.info(PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_PERSIST_SIGNAL_ADAPTER);
|
|
988
|
+
this.PersistSignalFactory = Ctor;
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Switches to the default JSON persist adapter.
|
|
992
|
+
* All future persistence writes will use JSON storage.
|
|
993
|
+
*/
|
|
994
|
+
useJson() {
|
|
995
|
+
bt.loggerService.log(PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_JSON);
|
|
996
|
+
this.usePersistSignalAdapter(PersistBase);
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Switches to a dummy persist adapter that discards all writes.
|
|
1000
|
+
* All future persistence writes will be no-ops.
|
|
1001
|
+
*/
|
|
1002
|
+
useDummy() {
|
|
1003
|
+
bt.loggerService.log(PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_DUMMY);
|
|
1004
|
+
this.usePersistSignalAdapter(PersistDummy);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Global singleton instance of PersistSignalUtils.
|
|
1009
|
+
* Used by ClientStrategy for signal persistence.
|
|
1010
|
+
*
|
|
1011
|
+
* @example
|
|
1012
|
+
* ```typescript
|
|
1013
|
+
* // Custom adapter
|
|
1014
|
+
* PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
|
|
1015
|
+
*
|
|
1016
|
+
* // Read signal
|
|
1017
|
+
* const signal = await PersistSignalAdapter.readSignalData("my-strategy", "BTCUSDT");
|
|
1018
|
+
*
|
|
1019
|
+
* // Write signal
|
|
1020
|
+
* await PersistSignalAdapter.writeSignalData(signal, "my-strategy", "BTCUSDT");
|
|
1021
|
+
* ```
|
|
1022
|
+
*/
|
|
1023
|
+
const PersistSignalAdapter = new PersistSignalUtils();
|
|
1024
|
+
/**
|
|
1025
|
+
* Utility class for managing risk active positions persistence.
|
|
1026
|
+
*
|
|
1027
|
+
* Features:
|
|
1028
|
+
* - Memoized storage instances per risk profile
|
|
1029
|
+
* - Custom adapter support
|
|
1030
|
+
* - Atomic read/write operations for RiskData
|
|
1031
|
+
* - Crash-safe position state management
|
|
1032
|
+
*
|
|
1033
|
+
* Used by ClientRisk for live mode persistence of active positions.
|
|
1034
|
+
*/
|
|
1035
|
+
class PersistRiskUtils {
|
|
1036
|
+
constructor() {
|
|
1037
|
+
this.PersistRiskFactory = PersistBase;
|
|
1038
|
+
this.getRiskStorage = memoize(([riskName, exchangeName]) => `${riskName}:${exchangeName}`, (riskName, exchangeName) => Reflect.construct(this.PersistRiskFactory, [
|
|
1039
|
+
`${riskName}_${exchangeName}`,
|
|
1040
|
+
`./dump/data/risk/`,
|
|
1041
|
+
]));
|
|
1058
1042
|
/**
|
|
1059
|
-
*
|
|
1043
|
+
* Reads persisted active positions for a risk profile.
|
|
1060
1044
|
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1045
|
+
* Called by ClientRisk.waitForInit() to restore state.
|
|
1046
|
+
* Returns empty Map if no positions exist.
|
|
1063
1047
|
*
|
|
1064
|
-
* @param
|
|
1065
|
-
* @
|
|
1048
|
+
* @param riskName - Risk profile identifier
|
|
1049
|
+
* @param exchangeName - Exchange identifier
|
|
1050
|
+
* @returns Promise resolving to Map of active positions
|
|
1066
1051
|
*/
|
|
1067
|
-
this.
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1052
|
+
this.readPositionData = async (riskName, exchangeName) => {
|
|
1053
|
+
bt.loggerService.info(PERSIST_RISK_UTILS_METHOD_NAME_READ_DATA);
|
|
1054
|
+
const key = `${riskName}:${exchangeName}`;
|
|
1055
|
+
const isInitial = !this.getRiskStorage.has(key);
|
|
1056
|
+
const stateStorage = this.getRiskStorage(riskName, exchangeName);
|
|
1057
|
+
await stateStorage.waitForInit(isInitial);
|
|
1058
|
+
const RISK_STORAGE_KEY = "positions";
|
|
1059
|
+
if (await stateStorage.hasValue(RISK_STORAGE_KEY)) {
|
|
1060
|
+
return await stateStorage.readValue(RISK_STORAGE_KEY);
|
|
1061
|
+
}
|
|
1062
|
+
return [];
|
|
1072
1063
|
};
|
|
1073
1064
|
/**
|
|
1074
|
-
*
|
|
1065
|
+
* Writes active positions to disk with atomic file writes.
|
|
1075
1066
|
*
|
|
1076
|
-
*
|
|
1067
|
+
* Called by ClientRisk after addSignal/removeSignal to persist state.
|
|
1068
|
+
* Uses atomic writes to prevent corruption on crashes.
|
|
1077
1069
|
*
|
|
1078
|
-
* @param
|
|
1079
|
-
* @param
|
|
1080
|
-
* @
|
|
1070
|
+
* @param positions - Map of active positions
|
|
1071
|
+
* @param riskName - Risk profile identifier
|
|
1072
|
+
* @param exchangeName - Exchange identifier
|
|
1073
|
+
* @returns Promise that resolves when write is complete
|
|
1081
1074
|
*/
|
|
1082
|
-
this.
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1075
|
+
this.writePositionData = async (riskRow, riskName, exchangeName) => {
|
|
1076
|
+
bt.loggerService.info(PERSIST_RISK_UTILS_METHOD_NAME_WRITE_DATA);
|
|
1077
|
+
const key = `${riskName}:${exchangeName}`;
|
|
1078
|
+
const isInitial = !this.getRiskStorage.has(key);
|
|
1079
|
+
const stateStorage = this.getRiskStorage(riskName, exchangeName);
|
|
1080
|
+
await stateStorage.waitForInit(isInitial);
|
|
1081
|
+
const RISK_STORAGE_KEY = "positions";
|
|
1082
|
+
await stateStorage.writeValue(RISK_STORAGE_KEY, riskRow);
|
|
1088
1083
|
};
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Registers a custom persistence adapter.
|
|
1087
|
+
*
|
|
1088
|
+
* @param Ctor - Custom PersistBase constructor
|
|
1089
|
+
*
|
|
1090
|
+
* @example
|
|
1091
|
+
* ```typescript
|
|
1092
|
+
* class RedisPersist extends PersistBase {
|
|
1093
|
+
* async readValue(id) { return JSON.parse(await redis.get(id)); }
|
|
1094
|
+
* async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
|
|
1095
|
+
* }
|
|
1096
|
+
* PersistRiskAdapter.usePersistRiskAdapter(RedisPersist);
|
|
1097
|
+
* ```
|
|
1098
|
+
*/
|
|
1099
|
+
usePersistRiskAdapter(Ctor) {
|
|
1100
|
+
bt.loggerService.info(PERSIST_RISK_UTILS_METHOD_NAME_USE_PERSIST_RISK_ADAPTER);
|
|
1101
|
+
this.PersistRiskFactory = Ctor;
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Switches to the default JSON persist adapter.
|
|
1105
|
+
* All future persistence writes will use JSON storage.
|
|
1106
|
+
*/
|
|
1107
|
+
useJson() {
|
|
1108
|
+
bt.loggerService.log(PERSIST_RISK_UTILS_METHOD_NAME_USE_JSON);
|
|
1109
|
+
this.usePersistRiskAdapter(PersistBase);
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Switches to a dummy persist adapter that discards all writes.
|
|
1113
|
+
* All future persistence writes will be no-ops.
|
|
1114
|
+
*/
|
|
1115
|
+
useDummy() {
|
|
1116
|
+
bt.loggerService.log(PERSIST_RISK_UTILS_METHOD_NAME_USE_DUMMY);
|
|
1117
|
+
this.usePersistRiskAdapter(PersistDummy);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Global singleton instance of PersistRiskUtils.
|
|
1122
|
+
* Used by ClientRisk for active positions persistence.
|
|
1123
|
+
*
|
|
1124
|
+
* @example
|
|
1125
|
+
* ```typescript
|
|
1126
|
+
* // Custom adapter
|
|
1127
|
+
* PersistRiskAdapter.usePersistRiskAdapter(RedisPersist);
|
|
1128
|
+
*
|
|
1129
|
+
* // Read positions
|
|
1130
|
+
* const positions = await PersistRiskAdapter.readPositionData("my-risk");
|
|
1131
|
+
*
|
|
1132
|
+
* // Write positions
|
|
1133
|
+
* await PersistRiskAdapter.writePositionData(positionsMap, "my-risk");
|
|
1134
|
+
* ```
|
|
1135
|
+
*/
|
|
1136
|
+
const PersistRiskAdapter = new PersistRiskUtils();
|
|
1137
|
+
/**
|
|
1138
|
+
* Utility class for managing scheduled signal persistence.
|
|
1139
|
+
*
|
|
1140
|
+
* Features:
|
|
1141
|
+
* - Memoized storage instances per strategy
|
|
1142
|
+
* - Custom adapter support
|
|
1143
|
+
* - Atomic read/write operations for scheduled signals
|
|
1144
|
+
* - Crash-safe scheduled signal state management
|
|
1145
|
+
*
|
|
1146
|
+
* Used by ClientStrategy for live mode persistence of scheduled signals (_scheduledSignal).
|
|
1147
|
+
*/
|
|
1148
|
+
class PersistScheduleUtils {
|
|
1149
|
+
constructor() {
|
|
1150
|
+
this.PersistScheduleFactory = PersistBase;
|
|
1151
|
+
this.getScheduleStorage = memoize(([symbol, strategyName, exchangeName]) => `${symbol}:${strategyName}:${exchangeName}`, (symbol, strategyName, exchangeName) => Reflect.construct(this.PersistScheduleFactory, [
|
|
1152
|
+
`${symbol}_${strategyName}_${exchangeName}`,
|
|
1153
|
+
`./dump/data/schedule/`,
|
|
1154
|
+
]));
|
|
1089
1155
|
/**
|
|
1090
|
-
*
|
|
1156
|
+
* Reads persisted scheduled signal data for a symbol and strategy.
|
|
1091
1157
|
*
|
|
1092
|
-
*
|
|
1158
|
+
* Called by ClientStrategy.waitForInit() to restore scheduled signal state.
|
|
1159
|
+
* Returns null if no scheduled signal exists.
|
|
1093
1160
|
*
|
|
1094
|
-
* @param symbol - Trading pair symbol
|
|
1095
|
-
* @param
|
|
1096
|
-
* @
|
|
1161
|
+
* @param symbol - Trading pair symbol
|
|
1162
|
+
* @param strategyName - Strategy identifier
|
|
1163
|
+
* @param exchangeName - Exchange identifier
|
|
1164
|
+
* @returns Promise resolving to scheduled signal or null
|
|
1097
1165
|
*/
|
|
1098
|
-
this.
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1166
|
+
this.readScheduleData = async (symbol, strategyName, exchangeName) => {
|
|
1167
|
+
bt.loggerService.info(PERSIST_SCHEDULE_UTILS_METHOD_NAME_READ_DATA);
|
|
1168
|
+
const key = `${symbol}:${strategyName}:${exchangeName}`;
|
|
1169
|
+
const isInitial = !this.getScheduleStorage.has(key);
|
|
1170
|
+
const stateStorage = this.getScheduleStorage(symbol, strategyName, exchangeName);
|
|
1171
|
+
await stateStorage.waitForInit(isInitial);
|
|
1172
|
+
if (await stateStorage.hasValue(symbol)) {
|
|
1173
|
+
return await stateStorage.readValue(symbol);
|
|
1174
|
+
}
|
|
1175
|
+
return null;
|
|
1104
1176
|
};
|
|
1105
1177
|
/**
|
|
1106
|
-
*
|
|
1178
|
+
* Writes scheduled signal data to disk with atomic file writes.
|
|
1107
1179
|
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
* implementation, which may use (backtest) or ignore (live) the parameters.
|
|
1180
|
+
* Called by ClientStrategy.setScheduledSignal() to persist state.
|
|
1181
|
+
* Uses atomic writes to prevent corruption on crashes.
|
|
1111
1182
|
*
|
|
1112
|
-
* @param
|
|
1113
|
-
* @param
|
|
1114
|
-
* @
|
|
1183
|
+
* @param scheduledSignalRow - Scheduled signal data (null to clear)
|
|
1184
|
+
* @param symbol - Trading pair symbol
|
|
1185
|
+
* @param strategyName - Strategy identifier
|
|
1186
|
+
* @param exchangeName - Exchange identifier
|
|
1187
|
+
* @returns Promise that resolves when write is complete
|
|
1115
1188
|
*/
|
|
1116
|
-
this.
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1189
|
+
this.writeScheduleData = async (scheduledSignalRow, symbol, strategyName, exchangeName) => {
|
|
1190
|
+
bt.loggerService.info(PERSIST_SCHEDULE_UTILS_METHOD_NAME_WRITE_DATA);
|
|
1191
|
+
const key = `${symbol}:${strategyName}:${exchangeName}`;
|
|
1192
|
+
const isInitial = !this.getScheduleStorage.has(key);
|
|
1193
|
+
const stateStorage = this.getScheduleStorage(symbol, strategyName, exchangeName);
|
|
1194
|
+
await stateStorage.waitForInit(isInitial);
|
|
1195
|
+
await stateStorage.writeValue(symbol, scheduledSignalRow);
|
|
1122
1196
|
};
|
|
1123
1197
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
* 3. Subtract total fees (0.1% * 2 = 0.2% per transaction)
|
|
1142
|
-
*
|
|
1143
|
-
* @param signal - Closed signal with position details and optional partial history
|
|
1144
|
-
* @param priceClose - Actual close price at final exit
|
|
1145
|
-
* @returns PNL data with percentage and prices
|
|
1146
|
-
*
|
|
1147
|
-
* @example
|
|
1148
|
-
* ```typescript
|
|
1149
|
-
* // Signal without partial closes
|
|
1150
|
-
* const pnl = toProfitLossDto(
|
|
1151
|
-
* {
|
|
1152
|
-
* position: "long",
|
|
1153
|
-
* priceOpen: 100,
|
|
1154
|
-
* },
|
|
1155
|
-
* 110 // close at +10%
|
|
1156
|
-
* );
|
|
1157
|
-
* console.log(pnl.pnlPercentage); // ~9.6% (after slippage and fees)
|
|
1158
|
-
*
|
|
1159
|
-
* // Signal with partial closes
|
|
1160
|
-
* const pnlPartial = toProfitLossDto(
|
|
1161
|
-
* {
|
|
1162
|
-
* position: "long",
|
|
1163
|
-
* priceOpen: 100,
|
|
1164
|
-
* _partial: [
|
|
1165
|
-
* { type: "profit", percent: 30, price: 120 }, // +20% on 30%
|
|
1166
|
-
* { type: "profit", percent: 40, price: 115 }, // +15% on 40%
|
|
1167
|
-
* ],
|
|
1168
|
-
* },
|
|
1169
|
-
* 105 // final close at +5% for remaining 30%
|
|
1170
|
-
* );
|
|
1171
|
-
* // Weighted PNL = 30% × 20% + 40% × 15% + 30% × 5% = 6% + 6% + 1.5% = 13.5% (before fees)
|
|
1172
|
-
* ```
|
|
1173
|
-
*/
|
|
1174
|
-
const toProfitLossDto = (signal, priceClose) => {
|
|
1175
|
-
const priceOpen = signal.priceOpen;
|
|
1176
|
-
// Calculate weighted PNL with partial closes
|
|
1177
|
-
if (signal._partial && signal._partial.length > 0) {
|
|
1178
|
-
let totalWeightedPnl = 0;
|
|
1179
|
-
let totalFees = 0;
|
|
1180
|
-
// Calculate PNL for each partial close
|
|
1181
|
-
for (const partial of signal._partial) {
|
|
1182
|
-
const partialPercent = partial.percent;
|
|
1183
|
-
const partialPrice = partial.price;
|
|
1184
|
-
// Apply slippage to prices
|
|
1185
|
-
let priceOpenWithSlippage;
|
|
1186
|
-
let priceCloseWithSlippage;
|
|
1187
|
-
if (signal.position === "long") {
|
|
1188
|
-
priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
1189
|
-
priceCloseWithSlippage = partialPrice * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
1190
|
-
}
|
|
1191
|
-
else {
|
|
1192
|
-
priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
1193
|
-
priceCloseWithSlippage = partialPrice * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
1194
|
-
}
|
|
1195
|
-
// Calculate PNL for this partial
|
|
1196
|
-
let partialPnl;
|
|
1197
|
-
if (signal.position === "long") {
|
|
1198
|
-
partialPnl = ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100;
|
|
1199
|
-
}
|
|
1200
|
-
else {
|
|
1201
|
-
partialPnl = ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
|
|
1202
|
-
}
|
|
1203
|
-
// Weight by percentage of position closed
|
|
1204
|
-
const weightedPnl = (partialPercent / 100) * partialPnl;
|
|
1205
|
-
totalWeightedPnl += weightedPnl;
|
|
1206
|
-
// Each partial has fees for open + close (2 transactions)
|
|
1207
|
-
totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
|
|
1208
|
-
}
|
|
1209
|
-
// Calculate PNL for remaining position (if any)
|
|
1210
|
-
// Compute totalClosed from _partial array
|
|
1211
|
-
const totalClosed = signal._partial.reduce((sum, p) => sum + p.percent, 0);
|
|
1212
|
-
const remainingPercent = 100 - totalClosed;
|
|
1213
|
-
if (remainingPercent > 0) {
|
|
1214
|
-
// Apply slippage
|
|
1215
|
-
let priceOpenWithSlippage;
|
|
1216
|
-
let priceCloseWithSlippage;
|
|
1217
|
-
if (signal.position === "long") {
|
|
1218
|
-
priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
1219
|
-
priceCloseWithSlippage = priceClose * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
1220
|
-
}
|
|
1221
|
-
else {
|
|
1222
|
-
priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
1223
|
-
priceCloseWithSlippage = priceClose * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
1224
|
-
}
|
|
1225
|
-
// Calculate PNL for remaining
|
|
1226
|
-
let remainingPnl;
|
|
1227
|
-
if (signal.position === "long") {
|
|
1228
|
-
remainingPnl = ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100;
|
|
1229
|
-
}
|
|
1230
|
-
else {
|
|
1231
|
-
remainingPnl = ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
|
|
1232
|
-
}
|
|
1233
|
-
// Weight by remaining percentage
|
|
1234
|
-
const weightedRemainingPnl = (remainingPercent / 100) * remainingPnl;
|
|
1235
|
-
totalWeightedPnl += weightedRemainingPnl;
|
|
1236
|
-
// Final close also has fees
|
|
1237
|
-
totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
|
|
1238
|
-
}
|
|
1239
|
-
// Subtract total fees from weighted PNL
|
|
1240
|
-
const pnlPercentage = totalWeightedPnl - totalFees;
|
|
1241
|
-
return {
|
|
1242
|
-
pnlPercentage,
|
|
1243
|
-
priceOpen,
|
|
1244
|
-
priceClose,
|
|
1245
|
-
};
|
|
1246
|
-
}
|
|
1247
|
-
// Original logic for signals without partial closes
|
|
1248
|
-
let priceOpenWithSlippage;
|
|
1249
|
-
let priceCloseWithSlippage;
|
|
1250
|
-
if (signal.position === "long") {
|
|
1251
|
-
// LONG: покупаем дороже, продаем дешевле
|
|
1252
|
-
priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
1253
|
-
priceCloseWithSlippage = priceClose * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
1254
|
-
}
|
|
1255
|
-
else {
|
|
1256
|
-
// SHORT: продаем дешевле, покупаем дороже
|
|
1257
|
-
priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
1258
|
-
priceCloseWithSlippage = priceClose * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
1198
|
+
/**
|
|
1199
|
+
* Registers a custom persistence adapter.
|
|
1200
|
+
*
|
|
1201
|
+
* @param Ctor - Custom PersistBase constructor
|
|
1202
|
+
*
|
|
1203
|
+
* @example
|
|
1204
|
+
* ```typescript
|
|
1205
|
+
* class RedisPersist extends PersistBase {
|
|
1206
|
+
* async readValue(id) { return JSON.parse(await redis.get(id)); }
|
|
1207
|
+
* async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
|
|
1208
|
+
* }
|
|
1209
|
+
* PersistScheduleAdapter.usePersistScheduleAdapter(RedisPersist);
|
|
1210
|
+
* ```
|
|
1211
|
+
*/
|
|
1212
|
+
usePersistScheduleAdapter(Ctor) {
|
|
1213
|
+
bt.loggerService.info(PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_PERSIST_SCHEDULE_ADAPTER);
|
|
1214
|
+
this.PersistScheduleFactory = Ctor;
|
|
1259
1215
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
priceOpenWithSlippage) *
|
|
1268
|
-
100;
|
|
1216
|
+
/**
|
|
1217
|
+
* Switches to the default JSON persist adapter.
|
|
1218
|
+
* All future persistence writes will use JSON storage.
|
|
1219
|
+
*/
|
|
1220
|
+
useJson() {
|
|
1221
|
+
bt.loggerService.log(PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_JSON);
|
|
1222
|
+
this.usePersistScheduleAdapter(PersistBase);
|
|
1269
1223
|
}
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1224
|
+
/**
|
|
1225
|
+
* Switches to a dummy persist adapter that discards all writes.
|
|
1226
|
+
* All future persistence writes will be no-ops.
|
|
1227
|
+
*/
|
|
1228
|
+
useDummy() {
|
|
1229
|
+
bt.loggerService.log(PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_DUMMY);
|
|
1230
|
+
this.usePersistScheduleAdapter(PersistDummy);
|
|
1276
1231
|
}
|
|
1277
|
-
|
|
1278
|
-
pnlPercentage -= totalFee;
|
|
1279
|
-
return {
|
|
1280
|
-
pnlPercentage,
|
|
1281
|
-
priceOpen,
|
|
1282
|
-
priceClose,
|
|
1283
|
-
};
|
|
1284
|
-
};
|
|
1285
|
-
|
|
1286
|
-
const IS_WINDOWS = os.platform() === "win32";
|
|
1232
|
+
}
|
|
1287
1233
|
/**
|
|
1288
|
-
*
|
|
1289
|
-
*
|
|
1290
|
-
*
|
|
1291
|
-
*
|
|
1292
|
-
* @param {string} file - The file parameter.
|
|
1293
|
-
* @param {string | Buffer} data - The data to be processed or validated.
|
|
1294
|
-
* @param {Options | BufferEncoding} options - The options parameter (optional).
|
|
1295
|
-
* @throws {Error} Throws an error if the write, sync, or rename operation fails, after attempting cleanup of temporary files.
|
|
1296
|
-
*
|
|
1297
|
-
* @example
|
|
1298
|
-
* // Basic usage with default options
|
|
1299
|
-
* await writeFileAtomic("output.txt", "Hello, world!");
|
|
1300
|
-
* // Writes "Hello, world!" to "output.txt" atomically
|
|
1301
|
-
*
|
|
1302
|
-
* @example
|
|
1303
|
-
* // Custom options and Buffer data
|
|
1304
|
-
* const buffer = Buffer.from("Binary data");
|
|
1305
|
-
* await writeFileAtomic("data.bin", buffer, { encoding: "binary", mode: 0o644, tmpPrefix: "temp-" });
|
|
1306
|
-
* // Writes binary data to "data.bin" with custom permissions and temp prefix
|
|
1234
|
+
* Global singleton instance of PersistScheduleUtils.
|
|
1235
|
+
* Used by ClientStrategy for scheduled signal persistence.
|
|
1307
1236
|
*
|
|
1308
1237
|
* @example
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1238
|
+
* ```typescript
|
|
1239
|
+
* // Custom adapter
|
|
1240
|
+
* PersistScheduleAdapter.usePersistScheduleAdapter(RedisPersist);
|
|
1312
1241
|
*
|
|
1313
|
-
*
|
|
1314
|
-
*
|
|
1315
|
-
* - On POSIX systems (non-Windows, unless `GLOBAL_CONFIG.CC_SKIP_POSIX_RENAME` is true):
|
|
1316
|
-
* - Writes data to a temporary file (e.g., `.tmp-<random>-filename`) in the same directory.
|
|
1317
|
-
* - Uses `crypto.randomBytes` to generate a unique temporary name, reducing collision risk.
|
|
1318
|
-
* - Syncs the data to disk and renames the temporary file to the target file atomically with `fs.rename`.
|
|
1319
|
-
* - Cleans up the temporary file on failure, swallowing cleanup errors to prioritize throwing the original error.
|
|
1320
|
-
* - On Windows (or when POSIX rename is skipped):
|
|
1321
|
-
* - Writes directly to the target file, syncing data to disk to minimize corruption risk (though not fully atomic).
|
|
1322
|
-
* - Closes the file handle on failure without additional cleanup.
|
|
1323
|
-
* - Accepts `options` as an object or a string (interpreted as `encoding`), defaulting to `{ encoding: "utf8", mode: 0o666, tmpPrefix: ".tmp-" }`.
|
|
1324
|
-
* Useful in the agent swarm system for safely writing configuration files, logs, or state data where partial writes could cause corruption.
|
|
1242
|
+
* // Read scheduled signal
|
|
1243
|
+
* const scheduled = await PersistScheduleAdapter.readScheduleData("my-strategy", "BTCUSDT");
|
|
1325
1244
|
*
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1328
|
-
*
|
|
1329
|
-
*/
|
|
1330
|
-
|
|
1331
|
-
if (typeof options === "string") {
|
|
1332
|
-
options = { encoding: options };
|
|
1333
|
-
}
|
|
1334
|
-
else if (!options) {
|
|
1335
|
-
options = {};
|
|
1336
|
-
}
|
|
1337
|
-
const { encoding = "utf8", mode = 0o666, tmpPrefix = ".tmp-" } = options;
|
|
1338
|
-
let fileHandle = null;
|
|
1339
|
-
if (IS_WINDOWS) {
|
|
1340
|
-
try {
|
|
1341
|
-
// Create and write to temporary file
|
|
1342
|
-
fileHandle = await fs__default.open(file, "w", mode);
|
|
1343
|
-
// Write data to the temp file
|
|
1344
|
-
await fileHandle.writeFile(data, { encoding });
|
|
1345
|
-
// Ensure data is flushed to disk
|
|
1346
|
-
await fileHandle.sync();
|
|
1347
|
-
// Close the file before rename
|
|
1348
|
-
await fileHandle.close();
|
|
1349
|
-
}
|
|
1350
|
-
catch (error) {
|
|
1351
|
-
// Clean up if something went wrong
|
|
1352
|
-
if (fileHandle) {
|
|
1353
|
-
await fileHandle.close().catch(() => { });
|
|
1354
|
-
}
|
|
1355
|
-
throw error; // Re-throw the original error
|
|
1356
|
-
}
|
|
1357
|
-
return;
|
|
1358
|
-
}
|
|
1359
|
-
// Create a temporary filename in the same directory
|
|
1360
|
-
const dir = path.dirname(file);
|
|
1361
|
-
const filename = path.basename(file);
|
|
1362
|
-
const tmpFile = path.join(dir, `${tmpPrefix}${crypto.randomBytes(6).toString("hex")}-${filename}`);
|
|
1363
|
-
try {
|
|
1364
|
-
// Create and write to temporary file
|
|
1365
|
-
fileHandle = await fs__default.open(tmpFile, "w", mode);
|
|
1366
|
-
// Write data to the temp file
|
|
1367
|
-
await fileHandle.writeFile(data, { encoding });
|
|
1368
|
-
// Ensure data is flushed to disk
|
|
1369
|
-
await fileHandle.sync();
|
|
1370
|
-
// Close the file before rename
|
|
1371
|
-
await fileHandle.close();
|
|
1372
|
-
fileHandle = null;
|
|
1373
|
-
// Atomically replace the target file with our temp file
|
|
1374
|
-
await fs__default.rename(tmpFile, file);
|
|
1375
|
-
}
|
|
1376
|
-
catch (error) {
|
|
1377
|
-
// Clean up if something went wrong
|
|
1378
|
-
if (fileHandle) {
|
|
1379
|
-
await fileHandle.close().catch(() => { });
|
|
1380
|
-
}
|
|
1381
|
-
// Try to remove the temporary file
|
|
1382
|
-
try {
|
|
1383
|
-
await fs__default.unlink(tmpFile).catch(() => { });
|
|
1384
|
-
}
|
|
1385
|
-
catch (_) {
|
|
1386
|
-
// Ignore errors during cleanup
|
|
1387
|
-
}
|
|
1388
|
-
throw error;
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
var _a$2;
|
|
1393
|
-
const BASE_WAIT_FOR_INIT_SYMBOL = Symbol("wait-for-init");
|
|
1394
|
-
const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_PERSIST_SIGNAL_ADAPTER = "PersistSignalUtils.usePersistSignalAdapter";
|
|
1395
|
-
const PERSIST_SIGNAL_UTILS_METHOD_NAME_READ_DATA = "PersistSignalUtils.readSignalData";
|
|
1396
|
-
const PERSIST_SIGNAL_UTILS_METHOD_NAME_WRITE_DATA = "PersistSignalUtils.writeSignalData";
|
|
1397
|
-
const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_JSON = "PersistSignalUtils.useJson";
|
|
1398
|
-
const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_DUMMY = "PersistSignalUtils.useDummy";
|
|
1399
|
-
const PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_PERSIST_SCHEDULE_ADAPTER = "PersistScheduleUtils.usePersistScheduleAdapter";
|
|
1400
|
-
const PERSIST_SCHEDULE_UTILS_METHOD_NAME_READ_DATA = "PersistScheduleUtils.readScheduleData";
|
|
1401
|
-
const PERSIST_SCHEDULE_UTILS_METHOD_NAME_WRITE_DATA = "PersistScheduleUtils.writeScheduleData";
|
|
1402
|
-
const PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_JSON = "PersistScheduleUtils.useJson";
|
|
1403
|
-
const PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_DUMMY = "PersistScheduleUtils.useDummy";
|
|
1404
|
-
const PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_PERSIST_PARTIAL_ADAPTER = "PersistPartialUtils.usePersistPartialAdapter";
|
|
1405
|
-
const PERSIST_PARTIAL_UTILS_METHOD_NAME_READ_DATA = "PersistPartialUtils.readPartialData";
|
|
1406
|
-
const PERSIST_PARTIAL_UTILS_METHOD_NAME_WRITE_DATA = "PersistPartialUtils.writePartialData";
|
|
1407
|
-
const PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_JSON = "PersistPartialUtils.useJson";
|
|
1408
|
-
const PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_DUMMY = "PersistPartialUtils.useDummy";
|
|
1409
|
-
const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_PERSIST_BREAKEVEN_ADAPTER = "PersistBreakevenUtils.usePersistBreakevenAdapter";
|
|
1410
|
-
const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_READ_DATA = "PersistBreakevenUtils.readBreakevenData";
|
|
1411
|
-
const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_WRITE_DATA = "PersistBreakevenUtils.writeBreakevenData";
|
|
1412
|
-
const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_JSON = "PersistBreakevenUtils.useJson";
|
|
1413
|
-
const PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_DUMMY = "PersistBreakevenUtils.useDummy";
|
|
1414
|
-
const PERSIST_RISK_UTILS_METHOD_NAME_USE_PERSIST_RISK_ADAPTER = "PersistRiskUtils.usePersistRiskAdapter";
|
|
1415
|
-
const PERSIST_RISK_UTILS_METHOD_NAME_READ_DATA = "PersistRiskUtils.readPositionData";
|
|
1416
|
-
const PERSIST_RISK_UTILS_METHOD_NAME_WRITE_DATA = "PersistRiskUtils.writePositionData";
|
|
1417
|
-
const PERSIST_RISK_UTILS_METHOD_NAME_USE_JSON = "PersistRiskUtils.useJson";
|
|
1418
|
-
const PERSIST_RISK_UTILS_METHOD_NAME_USE_DUMMY = "PersistRiskUtils.useDummy";
|
|
1419
|
-
const PERSIST_BASE_METHOD_NAME_CTOR = "PersistBase.CTOR";
|
|
1420
|
-
const PERSIST_BASE_METHOD_NAME_WAIT_FOR_INIT = "PersistBase.waitForInit";
|
|
1421
|
-
const PERSIST_BASE_METHOD_NAME_READ_VALUE = "PersistBase.readValue";
|
|
1422
|
-
const PERSIST_BASE_METHOD_NAME_WRITE_VALUE = "PersistBase.writeValue";
|
|
1423
|
-
const PERSIST_BASE_METHOD_NAME_HAS_VALUE = "PersistBase.hasValue";
|
|
1424
|
-
const PERSIST_BASE_METHOD_NAME_KEYS = "PersistBase.keys";
|
|
1425
|
-
const BASE_WAIT_FOR_INIT_FN_METHOD_NAME = "PersistBase.waitForInitFn";
|
|
1426
|
-
const BASE_UNLINK_RETRY_COUNT = 5;
|
|
1427
|
-
const BASE_UNLINK_RETRY_DELAY = 1000;
|
|
1428
|
-
const BASE_WAIT_FOR_INIT_FN = async (self) => {
|
|
1429
|
-
bt.loggerService.debug(BASE_WAIT_FOR_INIT_FN_METHOD_NAME, {
|
|
1430
|
-
entityName: self.entityName,
|
|
1431
|
-
directory: self._directory,
|
|
1432
|
-
});
|
|
1433
|
-
await fs__default.mkdir(self._directory, { recursive: true });
|
|
1434
|
-
for await (const key of self.keys()) {
|
|
1435
|
-
try {
|
|
1436
|
-
await self.readValue(key);
|
|
1437
|
-
}
|
|
1438
|
-
catch {
|
|
1439
|
-
const filePath = self._getFilePath(key);
|
|
1440
|
-
console.error(`backtest-kit PersistBase found invalid document for filePath=${filePath} entityName=${self.entityName}`);
|
|
1441
|
-
if (await not(BASE_WAIT_FOR_INIT_UNLINK_FN(filePath))) {
|
|
1442
|
-
console.error(`backtest-kit PersistBase failed to remove invalid document for filePath=${filePath} entityName=${self.entityName}`);
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
};
|
|
1447
|
-
const BASE_WAIT_FOR_INIT_UNLINK_FN = async (filePath) => trycatch(retry(async () => {
|
|
1448
|
-
try {
|
|
1449
|
-
await fs__default.unlink(filePath);
|
|
1450
|
-
return true;
|
|
1451
|
-
}
|
|
1452
|
-
catch (error) {
|
|
1453
|
-
console.error(`backtest-kit PersistBase unlink failed for filePath=${filePath} error=${getErrorMessage(error)}`);
|
|
1454
|
-
throw error;
|
|
1455
|
-
}
|
|
1456
|
-
}, BASE_UNLINK_RETRY_COUNT, BASE_UNLINK_RETRY_DELAY), {
|
|
1457
|
-
defaultValue: false,
|
|
1458
|
-
});
|
|
1245
|
+
* // Write scheduled signal
|
|
1246
|
+
* await PersistScheduleAdapter.writeScheduleData(scheduled, "my-strategy", "BTCUSDT");
|
|
1247
|
+
* ```
|
|
1248
|
+
*/
|
|
1249
|
+
const PersistScheduleAdapter = new PersistScheduleUtils();
|
|
1459
1250
|
/**
|
|
1460
|
-
*
|
|
1251
|
+
* Utility class for managing partial profit/loss levels persistence.
|
|
1461
1252
|
*
|
|
1462
1253
|
* Features:
|
|
1463
|
-
* -
|
|
1464
|
-
* -
|
|
1465
|
-
* -
|
|
1466
|
-
* -
|
|
1254
|
+
* - Memoized storage instances per symbol:strategyName
|
|
1255
|
+
* - Custom adapter support
|
|
1256
|
+
* - Atomic read/write operations for partial data
|
|
1257
|
+
* - Crash-safe partial state management
|
|
1467
1258
|
*
|
|
1468
|
-
*
|
|
1469
|
-
* ```typescript
|
|
1470
|
-
* const persist = new PersistBase("my-entity", "./data");
|
|
1471
|
-
* await persist.waitForInit(true);
|
|
1472
|
-
* await persist.writeValue("key1", { data: "value" });
|
|
1473
|
-
* const value = await persist.readValue("key1");
|
|
1474
|
-
* ```
|
|
1259
|
+
* Used by ClientPartial for live mode persistence of profit/loss levels.
|
|
1475
1260
|
*/
|
|
1476
|
-
class
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_WAIT_FOR_INIT, {
|
|
1504
|
-
entityName: this.entityName,
|
|
1505
|
-
initial,
|
|
1506
|
-
});
|
|
1507
|
-
await this[BASE_WAIT_FOR_INIT_SYMBOL]();
|
|
1508
|
-
}
|
|
1509
|
-
async readValue(entityId) {
|
|
1510
|
-
bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_READ_VALUE, {
|
|
1511
|
-
entityName: this.entityName,
|
|
1512
|
-
entityId,
|
|
1513
|
-
});
|
|
1514
|
-
try {
|
|
1515
|
-
const filePath = this._getFilePath(entityId);
|
|
1516
|
-
const fileContent = await fs__default.readFile(filePath, "utf-8");
|
|
1517
|
-
return JSON.parse(fileContent);
|
|
1518
|
-
}
|
|
1519
|
-
catch (error) {
|
|
1520
|
-
if (error?.code === "ENOENT") {
|
|
1521
|
-
throw new Error(`Entity ${this.entityName}:${entityId} not found`);
|
|
1522
|
-
}
|
|
1523
|
-
throw new Error(`Failed to read entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`);
|
|
1524
|
-
}
|
|
1525
|
-
}
|
|
1526
|
-
async hasValue(entityId) {
|
|
1527
|
-
bt.loggerService.debug(PERSIST_BASE_METHOD_NAME_HAS_VALUE, {
|
|
1528
|
-
entityName: this.entityName,
|
|
1529
|
-
entityId,
|
|
1530
|
-
});
|
|
1531
|
-
try {
|
|
1532
|
-
const filePath = this._getFilePath(entityId);
|
|
1533
|
-
await fs__default.access(filePath);
|
|
1534
|
-
return true;
|
|
1535
|
-
}
|
|
1536
|
-
catch (error) {
|
|
1537
|
-
if (error?.code === "ENOENT") {
|
|
1538
|
-
return false;
|
|
1261
|
+
class PersistPartialUtils {
|
|
1262
|
+
constructor() {
|
|
1263
|
+
this.PersistPartialFactory = PersistBase;
|
|
1264
|
+
this.getPartialStorage = memoize(([symbol, strategyName, exchangeName]) => `${symbol}:${strategyName}:${exchangeName}`, (symbol, strategyName, exchangeName) => Reflect.construct(this.PersistPartialFactory, [
|
|
1265
|
+
`${symbol}_${strategyName}_${exchangeName}`,
|
|
1266
|
+
`./dump/data/partial/`,
|
|
1267
|
+
]));
|
|
1268
|
+
/**
|
|
1269
|
+
* Reads persisted partial data for a symbol and strategy.
|
|
1270
|
+
*
|
|
1271
|
+
* Called by ClientPartial.waitForInit() to restore state.
|
|
1272
|
+
* Returns empty object if no partial data exists.
|
|
1273
|
+
*
|
|
1274
|
+
* @param symbol - Trading pair symbol
|
|
1275
|
+
* @param strategyName - Strategy identifier
|
|
1276
|
+
* @param signalId - Signal identifier
|
|
1277
|
+
* @param exchangeName - Exchange identifier
|
|
1278
|
+
* @returns Promise resolving to partial data record
|
|
1279
|
+
*/
|
|
1280
|
+
this.readPartialData = async (symbol, strategyName, signalId, exchangeName) => {
|
|
1281
|
+
bt.loggerService.info(PERSIST_PARTIAL_UTILS_METHOD_NAME_READ_DATA);
|
|
1282
|
+
const key = `${symbol}:${strategyName}:${exchangeName}`;
|
|
1283
|
+
const isInitial = !this.getPartialStorage.has(key);
|
|
1284
|
+
const stateStorage = this.getPartialStorage(symbol, strategyName, exchangeName);
|
|
1285
|
+
await stateStorage.waitForInit(isInitial);
|
|
1286
|
+
if (await stateStorage.hasValue(signalId)) {
|
|
1287
|
+
return await stateStorage.readValue(signalId);
|
|
1539
1288
|
}
|
|
1540
|
-
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1289
|
+
return {};
|
|
1290
|
+
};
|
|
1291
|
+
/**
|
|
1292
|
+
* Writes partial data to disk with atomic file writes.
|
|
1293
|
+
*
|
|
1294
|
+
* Called by ClientPartial after profit/loss level changes to persist state.
|
|
1295
|
+
* Uses atomic writes to prevent corruption on crashes.
|
|
1296
|
+
*
|
|
1297
|
+
* @param partialData - Record of signal IDs to partial data
|
|
1298
|
+
* @param symbol - Trading pair symbol
|
|
1299
|
+
* @param strategyName - Strategy identifier
|
|
1300
|
+
* @param signalId - Signal identifier
|
|
1301
|
+
* @param exchangeName - Exchange identifier
|
|
1302
|
+
* @returns Promise that resolves when write is complete
|
|
1303
|
+
*/
|
|
1304
|
+
this.writePartialData = async (partialData, symbol, strategyName, signalId, exchangeName) => {
|
|
1305
|
+
bt.loggerService.info(PERSIST_PARTIAL_UTILS_METHOD_NAME_WRITE_DATA);
|
|
1306
|
+
const key = `${symbol}:${strategyName}:${exchangeName}`;
|
|
1307
|
+
const isInitial = !this.getPartialStorage.has(key);
|
|
1308
|
+
const stateStorage = this.getPartialStorage(symbol, strategyName, exchangeName);
|
|
1309
|
+
await stateStorage.waitForInit(isInitial);
|
|
1310
|
+
await stateStorage.writeValue(signalId, partialData);
|
|
1311
|
+
};
|
|
1556
1312
|
}
|
|
1557
1313
|
/**
|
|
1558
|
-
*
|
|
1559
|
-
* Sorted alphanumerically.
|
|
1560
|
-
* Used internally by waitForInit for validation.
|
|
1314
|
+
* Registers a custom persistence adapter.
|
|
1561
1315
|
*
|
|
1562
|
-
* @
|
|
1563
|
-
*
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
.filter((file) => file.endsWith(".json"))
|
|
1573
|
-
.map((file) => file.slice(0, -5))
|
|
1574
|
-
.sort((a, b) => a.localeCompare(b, undefined, {
|
|
1575
|
-
numeric: true,
|
|
1576
|
-
sensitivity: "base",
|
|
1577
|
-
}));
|
|
1578
|
-
for (const entityId of entityIds) {
|
|
1579
|
-
yield entityId;
|
|
1580
|
-
}
|
|
1581
|
-
}
|
|
1582
|
-
catch (error) {
|
|
1583
|
-
throw new Error(`Failed to read keys for ${this.entityName}: ${getErrorMessage(error)}`);
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
}
|
|
1587
|
-
_a$2 = BASE_WAIT_FOR_INIT_SYMBOL;
|
|
1588
|
-
// @ts-ignore
|
|
1589
|
-
PersistBase = makeExtendable(PersistBase);
|
|
1590
|
-
/**
|
|
1591
|
-
* Dummy persist adapter that discards all writes.
|
|
1592
|
-
* Used for disabling persistence.
|
|
1593
|
-
*/
|
|
1594
|
-
class PersistDummy {
|
|
1595
|
-
/**
|
|
1596
|
-
* No-op initialization function.
|
|
1597
|
-
* @returns Promise that resolves immediately
|
|
1598
|
-
*/
|
|
1599
|
-
async waitForInit() {
|
|
1600
|
-
}
|
|
1601
|
-
/**
|
|
1602
|
-
* No-op read function.
|
|
1603
|
-
* @returns Promise that resolves with empty object
|
|
1316
|
+
* @param Ctor - Custom PersistBase constructor
|
|
1317
|
+
*
|
|
1318
|
+
* @example
|
|
1319
|
+
* ```typescript
|
|
1320
|
+
* class RedisPersist extends PersistBase {
|
|
1321
|
+
* async readValue(id) { return JSON.parse(await redis.get(id)); }
|
|
1322
|
+
* async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
|
|
1323
|
+
* }
|
|
1324
|
+
* PersistPartialAdapter.usePersistPartialAdapter(RedisPersist);
|
|
1325
|
+
* ```
|
|
1604
1326
|
*/
|
|
1605
|
-
|
|
1606
|
-
|
|
1327
|
+
usePersistPartialAdapter(Ctor) {
|
|
1328
|
+
bt.loggerService.info(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_PERSIST_PARTIAL_ADAPTER);
|
|
1329
|
+
this.PersistPartialFactory = Ctor;
|
|
1607
1330
|
}
|
|
1608
1331
|
/**
|
|
1609
|
-
*
|
|
1610
|
-
*
|
|
1332
|
+
* Switches to the default JSON persist adapter.
|
|
1333
|
+
* All future persistence writes will use JSON storage.
|
|
1611
1334
|
*/
|
|
1612
|
-
|
|
1613
|
-
|
|
1335
|
+
useJson() {
|
|
1336
|
+
bt.loggerService.log(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_JSON);
|
|
1337
|
+
this.usePersistPartialAdapter(PersistBase);
|
|
1614
1338
|
}
|
|
1615
|
-
/**
|
|
1616
|
-
*
|
|
1617
|
-
*
|
|
1339
|
+
/**
|
|
1340
|
+
* Switches to a dummy persist adapter that discards all writes.
|
|
1341
|
+
* All future persistence writes will be no-ops.
|
|
1618
1342
|
*/
|
|
1619
|
-
|
|
1343
|
+
useDummy() {
|
|
1344
|
+
bt.loggerService.log(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_DUMMY);
|
|
1345
|
+
this.usePersistPartialAdapter(PersistDummy);
|
|
1620
1346
|
}
|
|
1621
1347
|
}
|
|
1622
1348
|
/**
|
|
1623
|
-
*
|
|
1349
|
+
* Global singleton instance of PersistPartialUtils.
|
|
1350
|
+
* Used by ClientPartial for partial profit/loss levels persistence.
|
|
1351
|
+
*
|
|
1352
|
+
* @example
|
|
1353
|
+
* ```typescript
|
|
1354
|
+
* // Custom adapter
|
|
1355
|
+
* PersistPartialAdapter.usePersistPartialAdapter(RedisPersist);
|
|
1356
|
+
*
|
|
1357
|
+
* // Read partial data
|
|
1358
|
+
* const partialData = await PersistPartialAdapter.readPartialData("BTCUSDT", "my-strategy");
|
|
1359
|
+
*
|
|
1360
|
+
* // Write partial data
|
|
1361
|
+
* await PersistPartialAdapter.writePartialData(partialData, "BTCUSDT", "my-strategy");
|
|
1362
|
+
* ```
|
|
1363
|
+
*/
|
|
1364
|
+
const PersistPartialAdapter = new PersistPartialUtils();
|
|
1365
|
+
/**
|
|
1366
|
+
* Persistence utility class for breakeven state management.
|
|
1367
|
+
*
|
|
1368
|
+
* Handles reading and writing breakeven state to disk.
|
|
1369
|
+
* Uses memoized PersistBase instances per symbol-strategy pair.
|
|
1624
1370
|
*
|
|
1625
1371
|
* Features:
|
|
1626
|
-
* -
|
|
1627
|
-
* -
|
|
1628
|
-
* -
|
|
1629
|
-
* -
|
|
1372
|
+
* - Atomic file writes via PersistBase.writeValue()
|
|
1373
|
+
* - Lazy initialization on first access
|
|
1374
|
+
* - Singleton pattern for global access
|
|
1375
|
+
* - Custom adapter support via usePersistBreakevenAdapter()
|
|
1630
1376
|
*
|
|
1631
|
-
*
|
|
1377
|
+
* File structure:
|
|
1378
|
+
* ```
|
|
1379
|
+
* ./dump/data/breakeven/
|
|
1380
|
+
* ├── BTCUSDT_my-strategy/
|
|
1381
|
+
* │ └── state.json // { "signal-id-1": { reached: true }, ... }
|
|
1382
|
+
* └── ETHUSDT_other-strategy/
|
|
1383
|
+
* └── state.json
|
|
1384
|
+
* ```
|
|
1385
|
+
*
|
|
1386
|
+
* @example
|
|
1387
|
+
* ```typescript
|
|
1388
|
+
* // Read breakeven data
|
|
1389
|
+
* const breakevenData = await PersistBreakevenAdapter.readBreakevenData("BTCUSDT", "my-strategy");
|
|
1390
|
+
* // Returns: { "signal-id": { reached: true }, ... }
|
|
1391
|
+
*
|
|
1392
|
+
* // Write breakeven data
|
|
1393
|
+
* await PersistBreakevenAdapter.writeBreakevenData(breakevenData, "BTCUSDT", "my-strategy");
|
|
1394
|
+
* ```
|
|
1632
1395
|
*/
|
|
1633
|
-
class
|
|
1396
|
+
class PersistBreakevenUtils {
|
|
1634
1397
|
constructor() {
|
|
1635
|
-
|
|
1636
|
-
|
|
1398
|
+
/**
|
|
1399
|
+
* Factory for creating PersistBase instances.
|
|
1400
|
+
* Can be replaced via usePersistBreakevenAdapter().
|
|
1401
|
+
*/
|
|
1402
|
+
this.PersistBreakevenFactory = PersistBase;
|
|
1403
|
+
/**
|
|
1404
|
+
* Memoized storage factory for breakeven data.
|
|
1405
|
+
* Creates one PersistBase instance per symbol-strategy-exchange combination.
|
|
1406
|
+
* Key format: "symbol:strategyName:exchangeName"
|
|
1407
|
+
*
|
|
1408
|
+
* @param symbol - Trading pair symbol
|
|
1409
|
+
* @param strategyName - Strategy identifier
|
|
1410
|
+
* @param exchangeName - Exchange identifier
|
|
1411
|
+
* @returns PersistBase instance for this symbol-strategy-exchange combination
|
|
1412
|
+
*/
|
|
1413
|
+
this.getBreakevenStorage = memoize(([symbol, strategyName, exchangeName]) => `${symbol}:${strategyName}:${exchangeName}`, (symbol, strategyName, exchangeName) => Reflect.construct(this.PersistBreakevenFactory, [
|
|
1637
1414
|
`${symbol}_${strategyName}_${exchangeName}`,
|
|
1638
|
-
`./dump/data/
|
|
1415
|
+
`./dump/data/breakeven/`,
|
|
1639
1416
|
]));
|
|
1640
1417
|
/**
|
|
1641
|
-
* Reads persisted
|
|
1418
|
+
* Reads persisted breakeven data for a symbol and strategy.
|
|
1642
1419
|
*
|
|
1643
|
-
* Called by
|
|
1644
|
-
* Returns
|
|
1420
|
+
* Called by ClientBreakeven.waitForInit() to restore state.
|
|
1421
|
+
* Returns empty object if no breakeven data exists.
|
|
1645
1422
|
*
|
|
1646
1423
|
* @param symbol - Trading pair symbol
|
|
1647
1424
|
* @param strategyName - Strategy identifier
|
|
1425
|
+
* @param signalId - Signal identifier
|
|
1648
1426
|
* @param exchangeName - Exchange identifier
|
|
1649
|
-
* @returns Promise resolving to
|
|
1427
|
+
* @returns Promise resolving to breakeven data record
|
|
1650
1428
|
*/
|
|
1651
|
-
this.
|
|
1652
|
-
bt.loggerService.info(
|
|
1429
|
+
this.readBreakevenData = async (symbol, strategyName, signalId, exchangeName) => {
|
|
1430
|
+
bt.loggerService.info(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_READ_DATA);
|
|
1653
1431
|
const key = `${symbol}:${strategyName}:${exchangeName}`;
|
|
1654
|
-
const isInitial = !this.
|
|
1655
|
-
const stateStorage = this.
|
|
1432
|
+
const isInitial = !this.getBreakevenStorage.has(key);
|
|
1433
|
+
const stateStorage = this.getBreakevenStorage(symbol, strategyName, exchangeName);
|
|
1656
1434
|
await stateStorage.waitForInit(isInitial);
|
|
1657
|
-
if (await stateStorage.hasValue(
|
|
1658
|
-
return await stateStorage.readValue(
|
|
1435
|
+
if (await stateStorage.hasValue(signalId)) {
|
|
1436
|
+
return await stateStorage.readValue(signalId);
|
|
1659
1437
|
}
|
|
1660
|
-
return
|
|
1438
|
+
return {};
|
|
1661
1439
|
};
|
|
1662
1440
|
/**
|
|
1663
|
-
* Writes
|
|
1441
|
+
* Writes breakeven data to disk.
|
|
1664
1442
|
*
|
|
1665
|
-
* Called by
|
|
1666
|
-
*
|
|
1443
|
+
* Called by ClientBreakeven._persistState() after state changes.
|
|
1444
|
+
* Creates directory and file if they don't exist.
|
|
1445
|
+
* Uses atomic writes to prevent data corruption.
|
|
1667
1446
|
*
|
|
1668
|
-
* @param
|
|
1447
|
+
* @param breakevenData - Breakeven data record to persist
|
|
1669
1448
|
* @param symbol - Trading pair symbol
|
|
1670
1449
|
* @param strategyName - Strategy identifier
|
|
1450
|
+
* @param signalId - Signal identifier
|
|
1671
1451
|
* @param exchangeName - Exchange identifier
|
|
1672
1452
|
* @returns Promise that resolves when write is complete
|
|
1673
1453
|
*/
|
|
1674
|
-
this.
|
|
1675
|
-
bt.loggerService.info(
|
|
1454
|
+
this.writeBreakevenData = async (breakevenData, symbol, strategyName, signalId, exchangeName) => {
|
|
1455
|
+
bt.loggerService.info(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_WRITE_DATA);
|
|
1676
1456
|
const key = `${symbol}:${strategyName}:${exchangeName}`;
|
|
1677
|
-
const isInitial = !this.
|
|
1678
|
-
const stateStorage = this.
|
|
1457
|
+
const isInitial = !this.getBreakevenStorage.has(key);
|
|
1458
|
+
const stateStorage = this.getBreakevenStorage(symbol, strategyName, exchangeName);
|
|
1679
1459
|
await stateStorage.waitForInit(isInitial);
|
|
1680
|
-
await stateStorage.writeValue(
|
|
1460
|
+
await stateStorage.writeValue(signalId, breakevenData);
|
|
1681
1461
|
};
|
|
1682
1462
|
}
|
|
1683
1463
|
/**
|
|
@@ -1691,538 +1471,1109 @@ class PersistSignalUtils {
|
|
|
1691
1471
|
* async readValue(id) { return JSON.parse(await redis.get(id)); }
|
|
1692
1472
|
* async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
|
|
1693
1473
|
* }
|
|
1694
|
-
*
|
|
1474
|
+
* PersistBreakevenAdapter.usePersistBreakevenAdapter(RedisPersist);
|
|
1695
1475
|
* ```
|
|
1696
1476
|
*/
|
|
1697
|
-
|
|
1698
|
-
bt.loggerService.info(
|
|
1699
|
-
this.
|
|
1477
|
+
usePersistBreakevenAdapter(Ctor) {
|
|
1478
|
+
bt.loggerService.info(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_PERSIST_BREAKEVEN_ADAPTER);
|
|
1479
|
+
this.PersistBreakevenFactory = Ctor;
|
|
1700
1480
|
}
|
|
1701
1481
|
/**
|
|
1702
1482
|
* Switches to the default JSON persist adapter.
|
|
1703
1483
|
* All future persistence writes will use JSON storage.
|
|
1704
1484
|
*/
|
|
1705
1485
|
useJson() {
|
|
1706
|
-
bt.loggerService.log(
|
|
1707
|
-
this.
|
|
1486
|
+
bt.loggerService.log(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_JSON);
|
|
1487
|
+
this.usePersistBreakevenAdapter(PersistBase);
|
|
1708
1488
|
}
|
|
1709
1489
|
/**
|
|
1710
1490
|
* Switches to a dummy persist adapter that discards all writes.
|
|
1711
1491
|
* All future persistence writes will be no-ops.
|
|
1712
1492
|
*/
|
|
1713
1493
|
useDummy() {
|
|
1714
|
-
bt.loggerService.log(
|
|
1715
|
-
this.
|
|
1494
|
+
bt.loggerService.log(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_DUMMY);
|
|
1495
|
+
this.usePersistBreakevenAdapter(PersistDummy);
|
|
1716
1496
|
}
|
|
1717
1497
|
}
|
|
1718
1498
|
/**
|
|
1719
|
-
* Global singleton instance of
|
|
1720
|
-
* Used by
|
|
1499
|
+
* Global singleton instance of PersistBreakevenUtils.
|
|
1500
|
+
* Used by ClientBreakeven for breakeven state persistence.
|
|
1721
1501
|
*
|
|
1722
1502
|
* @example
|
|
1723
1503
|
* ```typescript
|
|
1724
1504
|
* // Custom adapter
|
|
1725
|
-
*
|
|
1505
|
+
* PersistBreakevenAdapter.usePersistBreakevenAdapter(RedisPersist);
|
|
1726
1506
|
*
|
|
1727
|
-
* // Read
|
|
1728
|
-
* const
|
|
1507
|
+
* // Read breakeven data
|
|
1508
|
+
* const breakevenData = await PersistBreakevenAdapter.readBreakevenData("BTCUSDT", "my-strategy");
|
|
1729
1509
|
*
|
|
1730
|
-
* // Write
|
|
1731
|
-
* await
|
|
1510
|
+
* // Write breakeven data
|
|
1511
|
+
* await PersistBreakevenAdapter.writeBreakevenData(breakevenData, "BTCUSDT", "my-strategy");
|
|
1732
1512
|
* ```
|
|
1733
1513
|
*/
|
|
1734
|
-
const
|
|
1514
|
+
const PersistBreakevenAdapter = new PersistBreakevenUtils();
|
|
1735
1515
|
/**
|
|
1736
|
-
* Utility class for managing
|
|
1516
|
+
* Utility class for managing candles cache persistence.
|
|
1737
1517
|
*
|
|
1738
1518
|
* Features:
|
|
1739
|
-
* -
|
|
1740
|
-
* -
|
|
1741
|
-
* -
|
|
1742
|
-
* -
|
|
1519
|
+
* - Each candle stored as separate JSON file: ${exchangeName}/${symbol}/${interval}/${timestamp}.json
|
|
1520
|
+
* - Cache validation: returns cached data if file count matches requested limit
|
|
1521
|
+
* - Automatic cache invalidation and refresh when data is incomplete
|
|
1522
|
+
* - Atomic read/write operations
|
|
1743
1523
|
*
|
|
1744
|
-
* Used by
|
|
1524
|
+
* Used by ClientExchange for candle data caching.
|
|
1745
1525
|
*/
|
|
1746
|
-
class
|
|
1526
|
+
class PersistCandleUtils {
|
|
1747
1527
|
constructor() {
|
|
1748
|
-
this.
|
|
1749
|
-
this.
|
|
1750
|
-
`${
|
|
1751
|
-
`./dump/data/
|
|
1528
|
+
this.PersistCandlesFactory = PersistBase;
|
|
1529
|
+
this.getCandlesStorage = memoize(([symbol, interval, exchangeName]) => `${symbol}:${interval}:${exchangeName}`, (symbol, interval, exchangeName) => Reflect.construct(this.PersistCandlesFactory, [
|
|
1530
|
+
`${exchangeName}/${symbol}/${interval}`,
|
|
1531
|
+
`./dump/data/candles/`,
|
|
1752
1532
|
]));
|
|
1753
1533
|
/**
|
|
1754
|
-
* Reads
|
|
1534
|
+
* Reads cached candles for a specific exchange, symbol, and interval.
|
|
1535
|
+
* Returns candles only if cache contains exactly the requested limit.
|
|
1755
1536
|
*
|
|
1756
|
-
*
|
|
1757
|
-
*
|
|
1537
|
+
* @param symbol - Trading pair symbol
|
|
1538
|
+
* @param interval - Candle interval
|
|
1539
|
+
* @param exchangeName - Exchange identifier
|
|
1540
|
+
* @param limit - Number of candles requested
|
|
1541
|
+
* @param sinceTimestamp - Start timestamp (inclusive)
|
|
1542
|
+
* @param untilTimestamp - End timestamp (exclusive)
|
|
1543
|
+
* @returns Promise resolving to array of candles or null if cache is incomplete
|
|
1544
|
+
*/
|
|
1545
|
+
this.readCandlesData = async (symbol, interval, exchangeName, limit, sinceTimestamp, untilTimestamp) => {
|
|
1546
|
+
bt.loggerService.info("PersistCandleUtils.readCandlesData", {
|
|
1547
|
+
symbol,
|
|
1548
|
+
interval,
|
|
1549
|
+
exchangeName,
|
|
1550
|
+
limit,
|
|
1551
|
+
sinceTimestamp,
|
|
1552
|
+
untilTimestamp,
|
|
1553
|
+
});
|
|
1554
|
+
const key = `${symbol}:${interval}:${exchangeName}`;
|
|
1555
|
+
const isInitial = !this.getCandlesStorage.has(key);
|
|
1556
|
+
const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
|
|
1557
|
+
await stateStorage.waitForInit(isInitial);
|
|
1558
|
+
// Collect all cached candles within the time range
|
|
1559
|
+
const cachedCandles = [];
|
|
1560
|
+
for await (const timestamp of stateStorage.keys()) {
|
|
1561
|
+
const ts = Number(timestamp);
|
|
1562
|
+
if (ts >= sinceTimestamp && ts < untilTimestamp) {
|
|
1563
|
+
try {
|
|
1564
|
+
const candle = await stateStorage.readValue(timestamp);
|
|
1565
|
+
cachedCandles.push(candle);
|
|
1566
|
+
}
|
|
1567
|
+
catch {
|
|
1568
|
+
// Skip invalid candles
|
|
1569
|
+
continue;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
// Sort by timestamp ascending
|
|
1574
|
+
cachedCandles.sort((a, b) => a.timestamp - b.timestamp);
|
|
1575
|
+
return cachedCandles;
|
|
1576
|
+
};
|
|
1577
|
+
/**
|
|
1578
|
+
* Writes candles to cache with atomic file writes.
|
|
1579
|
+
* Each candle is stored as a separate JSON file named by its timestamp.
|
|
1758
1580
|
*
|
|
1759
|
-
* @param
|
|
1581
|
+
* @param candles - Array of candle data to cache
|
|
1582
|
+
* @param symbol - Trading pair symbol
|
|
1583
|
+
* @param interval - Candle interval
|
|
1760
1584
|
* @param exchangeName - Exchange identifier
|
|
1761
|
-
* @returns Promise
|
|
1585
|
+
* @returns Promise that resolves when all writes are complete
|
|
1762
1586
|
*/
|
|
1763
|
-
this.
|
|
1764
|
-
bt.loggerService.info(
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1587
|
+
this.writeCandlesData = async (candles, symbol, interval, exchangeName) => {
|
|
1588
|
+
bt.loggerService.info("PersistCandleUtils.writeCandlesData", {
|
|
1589
|
+
symbol,
|
|
1590
|
+
interval,
|
|
1591
|
+
exchangeName,
|
|
1592
|
+
candleCount: candles.length,
|
|
1593
|
+
});
|
|
1594
|
+
const key = `${symbol}:${interval}:${exchangeName}`;
|
|
1595
|
+
const isInitial = !this.getCandlesStorage.has(key);
|
|
1596
|
+
const stateStorage = this.getCandlesStorage(symbol, interval, exchangeName);
|
|
1768
1597
|
await stateStorage.waitForInit(isInitial);
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1598
|
+
// Write each candle as a separate file
|
|
1599
|
+
for (const candle of candles) {
|
|
1600
|
+
if (await not(stateStorage.hasValue(String(candle.timestamp)))) {
|
|
1601
|
+
await stateStorage.writeValue(String(candle.timestamp), candle);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Registers a custom persistence adapter.
|
|
1608
|
+
*
|
|
1609
|
+
* @param Ctor - Custom PersistBase constructor
|
|
1610
|
+
*/
|
|
1611
|
+
usePersistCandleAdapter(Ctor) {
|
|
1612
|
+
bt.loggerService.info("PersistCandleUtils.usePersistCandleAdapter");
|
|
1613
|
+
this.PersistCandlesFactory = Ctor;
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Switches to the default JSON persist adapter.
|
|
1617
|
+
* All future persistence writes will use JSON storage.
|
|
1618
|
+
*/
|
|
1619
|
+
useJson() {
|
|
1620
|
+
bt.loggerService.log("PersistCandleUtils.useJson");
|
|
1621
|
+
this.usePersistCandleAdapter(PersistBase);
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Switches to a dummy persist adapter that discards all writes.
|
|
1625
|
+
* All future persistence writes will be no-ops.
|
|
1626
|
+
*/
|
|
1627
|
+
useDummy() {
|
|
1628
|
+
bt.loggerService.log("PersistCandleUtils.useDummy");
|
|
1629
|
+
this.usePersistCandleAdapter(PersistDummy);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Global singleton instance of PersistCandleUtils.
|
|
1634
|
+
* Used by ClientExchange for candle data caching.
|
|
1635
|
+
*
|
|
1636
|
+
* @example
|
|
1637
|
+
* ```typescript
|
|
1638
|
+
* // Read cached candles
|
|
1639
|
+
* const candles = await PersistCandleAdapter.readCandlesData(
|
|
1640
|
+
* "BTCUSDT", "1m", "binance", 100, since.getTime(), until.getTime()
|
|
1641
|
+
* );
|
|
1642
|
+
*
|
|
1643
|
+
* // Write candles to cache
|
|
1644
|
+
* await PersistCandleAdapter.writeCandlesData(candles, "BTCUSDT", "1m", "binance");
|
|
1645
|
+
* ```
|
|
1646
|
+
*/
|
|
1647
|
+
const PersistCandleAdapter = new PersistCandleUtils();
|
|
1648
|
+
|
|
1649
|
+
const INTERVAL_MINUTES$4 = {
|
|
1650
|
+
"1m": 1,
|
|
1651
|
+
"3m": 3,
|
|
1652
|
+
"5m": 5,
|
|
1653
|
+
"15m": 15,
|
|
1654
|
+
"30m": 30,
|
|
1655
|
+
"1h": 60,
|
|
1656
|
+
"2h": 120,
|
|
1657
|
+
"4h": 240,
|
|
1658
|
+
"6h": 360,
|
|
1659
|
+
"8h": 480,
|
|
1660
|
+
};
|
|
1661
|
+
/**
|
|
1662
|
+
* Validates that all candles have valid OHLCV data without anomalies.
|
|
1663
|
+
* Detects incomplete candles from Binance API by checking for abnormally low prices or volumes.
|
|
1664
|
+
* Incomplete candles often have prices like 0.1 instead of normal 100,000 or zero volume.
|
|
1665
|
+
*
|
|
1666
|
+
* @param candles - Array of candle data to validate
|
|
1667
|
+
* @throws Error if any candles have anomalous OHLCV values
|
|
1668
|
+
*/
|
|
1669
|
+
const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => {
|
|
1670
|
+
if (candles.length === 0) {
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
// Calculate reference price (median or average depending on candle count)
|
|
1674
|
+
const allPrices = candles.flatMap((c) => [c.open, c.high, c.low, c.close]);
|
|
1675
|
+
const validPrices = allPrices.filter((p) => p > 0);
|
|
1676
|
+
let referencePrice;
|
|
1677
|
+
if (candles.length >= GLOBAL_CONFIG.CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN) {
|
|
1678
|
+
// Use median for reliable statistics with enough data
|
|
1679
|
+
const sortedPrices = [...validPrices].sort((a, b) => a - b);
|
|
1680
|
+
referencePrice = sortedPrices[Math.floor(sortedPrices.length / 2)] || 0;
|
|
1681
|
+
}
|
|
1682
|
+
else {
|
|
1683
|
+
// Use average for small datasets (more stable than median)
|
|
1684
|
+
const sum = validPrices.reduce((acc, p) => acc + p, 0);
|
|
1685
|
+
referencePrice = validPrices.length > 0 ? sum / validPrices.length : 0;
|
|
1686
|
+
}
|
|
1687
|
+
if (referencePrice === 0) {
|
|
1688
|
+
throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: cannot calculate reference price (all prices are zero)`);
|
|
1689
|
+
}
|
|
1690
|
+
const minValidPrice = referencePrice /
|
|
1691
|
+
GLOBAL_CONFIG.CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR;
|
|
1692
|
+
for (let i = 0; i < candles.length; i++) {
|
|
1693
|
+
const candle = candles[i];
|
|
1694
|
+
// Check for invalid numeric values
|
|
1695
|
+
if (!Number.isFinite(candle.open) ||
|
|
1696
|
+
!Number.isFinite(candle.high) ||
|
|
1697
|
+
!Number.isFinite(candle.low) ||
|
|
1698
|
+
!Number.isFinite(candle.close) ||
|
|
1699
|
+
!Number.isFinite(candle.volume) ||
|
|
1700
|
+
!Number.isFinite(candle.timestamp)) {
|
|
1701
|
+
throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has invalid numeric values (NaN or Infinity)`);
|
|
1702
|
+
}
|
|
1703
|
+
// Check for negative values
|
|
1704
|
+
if (candle.open <= 0 ||
|
|
1705
|
+
candle.high <= 0 ||
|
|
1706
|
+
candle.low <= 0 ||
|
|
1707
|
+
candle.close <= 0 ||
|
|
1708
|
+
candle.volume < 0) {
|
|
1709
|
+
throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has zero or negative values`);
|
|
1710
|
+
}
|
|
1711
|
+
// Check for anomalously low prices (incomplete candle indicator)
|
|
1712
|
+
if (candle.open < minValidPrice ||
|
|
1713
|
+
candle.high < minValidPrice ||
|
|
1714
|
+
candle.low < minValidPrice ||
|
|
1715
|
+
candle.close < minValidPrice) {
|
|
1716
|
+
throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has anomalously low price. ` +
|
|
1717
|
+
`OHLC: [${candle.open}, ${candle.high}, ${candle.low}, ${candle.close}], ` +
|
|
1718
|
+
`reference: ${referencePrice}, threshold: ${minValidPrice}`);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1722
|
+
/**
|
|
1723
|
+
* Attempts to read candles from cache.
|
|
1724
|
+
* Validates cache consistency (no gaps in timestamps) before returning.
|
|
1725
|
+
*
|
|
1726
|
+
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
1727
|
+
* @param sinceTimestamp - Start timestamp in milliseconds
|
|
1728
|
+
* @param untilTimestamp - End timestamp in milliseconds
|
|
1729
|
+
* @param self - Instance of ClientExchange
|
|
1730
|
+
* @returns Cached candles array or null if cache miss or inconsistent
|
|
1731
|
+
*/
|
|
1732
|
+
const READ_CANDLES_CACHE_FN$1 = trycatch(async (dto, sinceTimestamp, untilTimestamp, self) => {
|
|
1733
|
+
const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, self.params.exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
|
|
1734
|
+
// Return cached data only if we have exactly the requested limit
|
|
1735
|
+
if (cachedCandles.length === dto.limit) {
|
|
1736
|
+
self.params.logger.debug(`ClientExchange READ_CANDLES_CACHE_FN: cache hit for symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
|
|
1737
|
+
return cachedCandles;
|
|
1738
|
+
}
|
|
1739
|
+
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}`);
|
|
1740
|
+
return null;
|
|
1741
|
+
}, {
|
|
1742
|
+
fallback: async (error) => {
|
|
1743
|
+
const message = `ClientExchange READ_CANDLES_CACHE_FN: cache read failed`;
|
|
1744
|
+
const payload = {
|
|
1745
|
+
error: errorData(error),
|
|
1746
|
+
message: getErrorMessage(error),
|
|
1747
|
+
};
|
|
1748
|
+
bt.loggerService.warn(message, payload);
|
|
1749
|
+
console.warn(message, payload);
|
|
1750
|
+
errorEmitter.next(error);
|
|
1751
|
+
},
|
|
1752
|
+
defaultValue: null,
|
|
1753
|
+
});
|
|
1754
|
+
/**
|
|
1755
|
+
* Writes candles to cache with error handling.
|
|
1756
|
+
*
|
|
1757
|
+
* @param candles - Array of candle data to cache
|
|
1758
|
+
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
1759
|
+
* @param self - Instance of ClientExchange
|
|
1760
|
+
*/
|
|
1761
|
+
const WRITE_CANDLES_CACHE_FN$1 = trycatch(queued(async (candles, dto, self) => {
|
|
1762
|
+
await PersistCandleAdapter.writeCandlesData(candles, dto.symbol, dto.interval, self.params.exchangeName);
|
|
1763
|
+
self.params.logger.debug(`ClientExchange WRITE_CANDLES_CACHE_FN: cache updated for symbol=${dto.symbol}, interval=${dto.interval}, count=${candles.length}`);
|
|
1764
|
+
}), {
|
|
1765
|
+
fallback: async (error) => {
|
|
1766
|
+
const message = `ClientExchange WRITE_CANDLES_CACHE_FN: cache write failed`;
|
|
1767
|
+
const payload = {
|
|
1768
|
+
error: errorData(error),
|
|
1769
|
+
message: getErrorMessage(error),
|
|
1770
|
+
};
|
|
1771
|
+
bt.loggerService.warn(message, payload);
|
|
1772
|
+
console.warn(message, payload);
|
|
1773
|
+
errorEmitter.next(error);
|
|
1774
|
+
},
|
|
1775
|
+
defaultValue: null,
|
|
1776
|
+
});
|
|
1777
|
+
/**
|
|
1778
|
+
* Retries the getCandles function with specified retry count and delay.
|
|
1779
|
+
* Uses cache to avoid redundant API calls.
|
|
1780
|
+
*
|
|
1781
|
+
* Cache logic:
|
|
1782
|
+
* - Checks if cached candles exist for the time range
|
|
1783
|
+
* - If cache has exactly dto.limit candles, returns cached data
|
|
1784
|
+
* - Otherwise, fetches from API and updates cache
|
|
1785
|
+
*
|
|
1786
|
+
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
1787
|
+
* @param since - Date object representing the start time for fetching candles
|
|
1788
|
+
* @param self - Instance of ClientExchange
|
|
1789
|
+
* @returns Promise resolving to array of candle data
|
|
1790
|
+
*/
|
|
1791
|
+
const GET_CANDLES_FN = async (dto, since, self) => {
|
|
1792
|
+
const step = INTERVAL_MINUTES$4[dto.interval];
|
|
1793
|
+
const sinceTimestamp = since.getTime();
|
|
1794
|
+
const untilTimestamp = sinceTimestamp + dto.limit * step * 60 * 1000;
|
|
1795
|
+
// Try to read from cache first
|
|
1796
|
+
const cachedCandles = await READ_CANDLES_CACHE_FN$1(dto, sinceTimestamp, untilTimestamp, self);
|
|
1797
|
+
if (cachedCandles !== null) {
|
|
1798
|
+
return cachedCandles;
|
|
1799
|
+
}
|
|
1800
|
+
// Cache miss or error - fetch from API
|
|
1801
|
+
let lastError;
|
|
1802
|
+
for (let i = 0; i !== GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT; i++) {
|
|
1803
|
+
try {
|
|
1804
|
+
const result = await self.params.getCandles(dto.symbol, dto.interval, since, dto.limit, self.params.execution.context.backtest);
|
|
1805
|
+
VALIDATE_NO_INCOMPLETE_CANDLES_FN(result);
|
|
1806
|
+
// Write to cache after successful fetch
|
|
1807
|
+
await WRITE_CANDLES_CACHE_FN$1(result, dto, self);
|
|
1808
|
+
return result;
|
|
1809
|
+
}
|
|
1810
|
+
catch (err) {
|
|
1811
|
+
const message = `ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}}`;
|
|
1812
|
+
const payload = {
|
|
1813
|
+
error: errorData(err),
|
|
1814
|
+
message: getErrorMessage(err),
|
|
1815
|
+
};
|
|
1816
|
+
self.params.logger.warn(message, payload);
|
|
1817
|
+
console.warn(message, payload);
|
|
1818
|
+
lastError = err;
|
|
1819
|
+
await sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
throw lastError;
|
|
1823
|
+
};
|
|
1824
|
+
/**
|
|
1825
|
+
* Wrapper to call onCandleData callback with error handling.
|
|
1826
|
+
* Catches and logs any errors thrown by the user-provided callback.
|
|
1827
|
+
*
|
|
1828
|
+
* @param self - ClientExchange instance reference
|
|
1829
|
+
* @param symbol - Trading pair symbol
|
|
1830
|
+
* @param interval - Candle interval
|
|
1831
|
+
* @param since - Start date for candle data
|
|
1832
|
+
* @param limit - Number of candles
|
|
1833
|
+
* @param data - Array of candle data
|
|
1834
|
+
*/
|
|
1835
|
+
const CALL_CANDLE_DATA_CALLBACKS_FN = trycatch(async (self, symbol, interval, since, limit, data) => {
|
|
1836
|
+
if (self.params.callbacks?.onCandleData) {
|
|
1837
|
+
await self.params.callbacks.onCandleData(symbol, interval, since, limit, data);
|
|
1838
|
+
}
|
|
1839
|
+
}, {
|
|
1840
|
+
fallback: (error) => {
|
|
1841
|
+
const message = "ClientExchange CALL_CANDLE_DATA_CALLBACKS_FN thrown";
|
|
1842
|
+
const payload = {
|
|
1843
|
+
error: errorData(error),
|
|
1844
|
+
message: getErrorMessage(error),
|
|
1845
|
+
};
|
|
1846
|
+
bt.loggerService.warn(message, payload);
|
|
1847
|
+
console.warn(message, payload);
|
|
1848
|
+
errorEmitter.next(error);
|
|
1849
|
+
},
|
|
1850
|
+
});
|
|
1851
|
+
/**
|
|
1852
|
+
* Client implementation for exchange data access.
|
|
1853
|
+
*
|
|
1854
|
+
* Features:
|
|
1855
|
+
* - Historical candle fetching (backwards from execution context)
|
|
1856
|
+
* - Future candle fetching (forwards for backtest)
|
|
1857
|
+
* - VWAP calculation from last 5 1m candles
|
|
1858
|
+
* - Price/quantity formatting for exchange
|
|
1859
|
+
*
|
|
1860
|
+
* All methods use prototype functions for memory efficiency.
|
|
1861
|
+
*
|
|
1862
|
+
* @example
|
|
1863
|
+
* ```typescript
|
|
1864
|
+
* const exchange = new ClientExchange({
|
|
1865
|
+
* exchangeName: "binance",
|
|
1866
|
+
* getCandles: async (symbol, interval, since, limit) => [...],
|
|
1867
|
+
* formatPrice: async (symbol, price) => price.toFixed(2),
|
|
1868
|
+
* formatQuantity: async (symbol, quantity) => quantity.toFixed(8),
|
|
1869
|
+
* execution: executionService,
|
|
1870
|
+
* logger: loggerService,
|
|
1871
|
+
* });
|
|
1872
|
+
*
|
|
1873
|
+
* const candles = await exchange.getCandles("BTCUSDT", "1m", 100);
|
|
1874
|
+
* const vwap = await exchange.getAveragePrice("BTCUSDT");
|
|
1875
|
+
* ```
|
|
1876
|
+
*/
|
|
1877
|
+
class ClientExchange {
|
|
1878
|
+
constructor(params) {
|
|
1879
|
+
this.params = params;
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Fetches historical candles backwards from execution context time.
|
|
1883
|
+
*
|
|
1884
|
+
* @param symbol - Trading pair symbol
|
|
1885
|
+
* @param interval - Candle interval
|
|
1886
|
+
* @param limit - Number of candles to fetch
|
|
1887
|
+
* @returns Promise resolving to array of candles
|
|
1888
|
+
*/
|
|
1889
|
+
async getCandles(symbol, interval, limit) {
|
|
1890
|
+
this.params.logger.debug(`ClientExchange getCandles`, {
|
|
1891
|
+
symbol,
|
|
1892
|
+
interval,
|
|
1893
|
+
limit,
|
|
1894
|
+
});
|
|
1895
|
+
const step = INTERVAL_MINUTES$4[interval];
|
|
1896
|
+
const adjust = step * limit;
|
|
1897
|
+
if (!adjust) {
|
|
1898
|
+
throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
|
|
1899
|
+
}
|
|
1900
|
+
const since = new Date(this.params.execution.context.when.getTime() - adjust * 60 * 1000);
|
|
1901
|
+
let allData = [];
|
|
1902
|
+
// If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
|
|
1903
|
+
if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
|
|
1904
|
+
let remaining = limit;
|
|
1905
|
+
let currentSince = new Date(since.getTime());
|
|
1906
|
+
while (remaining > 0) {
|
|
1907
|
+
const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
|
|
1908
|
+
const chunkData = await GET_CANDLES_FN({ symbol, interval, limit: chunkLimit }, currentSince, this);
|
|
1909
|
+
allData.push(...chunkData);
|
|
1910
|
+
remaining -= chunkLimit;
|
|
1911
|
+
if (remaining > 0) {
|
|
1912
|
+
// Move currentSince forward by the number of candles fetched
|
|
1913
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * step * 60 * 1000);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
else {
|
|
1918
|
+
allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
|
|
1919
|
+
}
|
|
1920
|
+
// Filter candles to strictly match the requested range
|
|
1921
|
+
const whenTimestamp = this.params.execution.context.when.getTime();
|
|
1922
|
+
const sinceTimestamp = since.getTime();
|
|
1923
|
+
const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp &&
|
|
1924
|
+
candle.timestamp < whenTimestamp);
|
|
1925
|
+
// Apply distinct by timestamp to remove duplicates
|
|
1926
|
+
const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
|
|
1927
|
+
if (filteredData.length !== uniqueData.length) {
|
|
1928
|
+
const msg = `ClientExchange Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
|
|
1929
|
+
this.params.logger.warn(msg);
|
|
1930
|
+
console.warn(msg);
|
|
1931
|
+
}
|
|
1932
|
+
if (uniqueData.length < limit) {
|
|
1933
|
+
const msg = `ClientExchange Expected ${limit} candles, got ${uniqueData.length}`;
|
|
1934
|
+
this.params.logger.warn(msg);
|
|
1935
|
+
console.warn(msg);
|
|
1936
|
+
}
|
|
1937
|
+
await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
|
|
1938
|
+
return uniqueData;
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* Fetches future candles forwards from execution context time.
|
|
1942
|
+
* Used in backtest mode to get candles for signal duration.
|
|
1943
|
+
*
|
|
1944
|
+
* @param symbol - Trading pair symbol
|
|
1945
|
+
* @param interval - Candle interval
|
|
1946
|
+
* @param limit - Number of candles to fetch
|
|
1947
|
+
* @returns Promise resolving to array of candles
|
|
1948
|
+
* @throws Error if trying to fetch future candles in live mode
|
|
1949
|
+
*/
|
|
1950
|
+
async getNextCandles(symbol, interval, limit) {
|
|
1951
|
+
this.params.logger.debug(`ClientExchange getNextCandles`, {
|
|
1952
|
+
symbol,
|
|
1953
|
+
interval,
|
|
1954
|
+
limit,
|
|
1955
|
+
});
|
|
1956
|
+
const since = new Date(this.params.execution.context.when.getTime());
|
|
1957
|
+
const now = Date.now();
|
|
1958
|
+
// Вычисляем конечное время запроса
|
|
1959
|
+
const step = INTERVAL_MINUTES$4[interval];
|
|
1960
|
+
const endTime = since.getTime() + limit * step * 60 * 1000;
|
|
1961
|
+
// Проверяем что запрошенный период не заходит за Date.now()
|
|
1962
|
+
if (endTime > now) {
|
|
1963
|
+
return [];
|
|
1964
|
+
}
|
|
1965
|
+
let allData = [];
|
|
1966
|
+
// If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
|
|
1967
|
+
if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
|
|
1968
|
+
let remaining = limit;
|
|
1969
|
+
let currentSince = new Date(since.getTime());
|
|
1970
|
+
while (remaining > 0) {
|
|
1971
|
+
const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
|
|
1972
|
+
const chunkData = await GET_CANDLES_FN({ symbol, interval, limit: chunkLimit }, currentSince, this);
|
|
1973
|
+
allData.push(...chunkData);
|
|
1974
|
+
remaining -= chunkLimit;
|
|
1975
|
+
if (remaining > 0) {
|
|
1976
|
+
// Move currentSince forward by the number of candles fetched
|
|
1977
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * step * 60 * 1000);
|
|
1978
|
+
}
|
|
1772
1979
|
}
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
const
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
};
|
|
1980
|
+
}
|
|
1981
|
+
else {
|
|
1982
|
+
allData = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
|
|
1983
|
+
}
|
|
1984
|
+
// Filter candles to strictly match the requested range
|
|
1985
|
+
const sinceTimestamp = since.getTime();
|
|
1986
|
+
const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp < endTime);
|
|
1987
|
+
// Apply distinct by timestamp to remove duplicates
|
|
1988
|
+
const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
|
|
1989
|
+
if (filteredData.length !== uniqueData.length) {
|
|
1990
|
+
const msg = `ClientExchange getNextCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
|
|
1991
|
+
this.params.logger.warn(msg);
|
|
1992
|
+
console.warn(msg);
|
|
1993
|
+
}
|
|
1994
|
+
if (uniqueData.length < limit) {
|
|
1995
|
+
const msg = `ClientExchange getNextCandles: Expected ${limit} candles, got ${uniqueData.length}`;
|
|
1996
|
+
this.params.logger.warn(msg);
|
|
1997
|
+
console.warn(msg);
|
|
1998
|
+
}
|
|
1999
|
+
await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, limit, uniqueData);
|
|
2000
|
+
return uniqueData;
|
|
1795
2001
|
}
|
|
1796
2002
|
/**
|
|
1797
|
-
*
|
|
2003
|
+
* Calculates VWAP (Volume Weighted Average Price) from last N 1m candles.
|
|
2004
|
+
* The number of candles is configurable via GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT.
|
|
1798
2005
|
*
|
|
1799
|
-
*
|
|
2006
|
+
* Formula:
|
|
2007
|
+
* - Typical Price = (high + low + close) / 3
|
|
2008
|
+
* - VWAP = sum(typical_price * volume) / sum(volume)
|
|
1800
2009
|
*
|
|
1801
|
-
*
|
|
1802
|
-
*
|
|
1803
|
-
*
|
|
1804
|
-
*
|
|
1805
|
-
*
|
|
1806
|
-
* }
|
|
1807
|
-
* PersistRiskAdapter.usePersistRiskAdapter(RedisPersist);
|
|
1808
|
-
* ```
|
|
2010
|
+
* If volume is zero, returns simple average of close prices.
|
|
2011
|
+
*
|
|
2012
|
+
* @param symbol - Trading pair symbol
|
|
2013
|
+
* @returns Promise resolving to VWAP price
|
|
2014
|
+
* @throws Error if no candles available
|
|
1809
2015
|
*/
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
2016
|
+
async getAveragePrice(symbol) {
|
|
2017
|
+
this.params.logger.debug(`ClientExchange getAveragePrice`, {
|
|
2018
|
+
symbol,
|
|
2019
|
+
});
|
|
2020
|
+
const candles = await this.getCandles(symbol, "1m", GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT);
|
|
2021
|
+
if (candles.length === 0) {
|
|
2022
|
+
throw new Error(`ClientExchange getAveragePrice: no candles data for symbol=${symbol}`);
|
|
2023
|
+
}
|
|
2024
|
+
// VWAP (Volume Weighted Average Price)
|
|
2025
|
+
// Используем типичную цену (typical price) = (high + low + close) / 3
|
|
2026
|
+
const sumPriceVolume = candles.reduce((acc, candle) => {
|
|
2027
|
+
const typicalPrice = (candle.high + candle.low + candle.close) / 3;
|
|
2028
|
+
return acc + typicalPrice * candle.volume;
|
|
2029
|
+
}, 0);
|
|
2030
|
+
const totalVolume = candles.reduce((acc, candle) => acc + candle.volume, 0);
|
|
2031
|
+
if (totalVolume === 0) {
|
|
2032
|
+
// Если объем нулевой, возвращаем простое среднее close цен
|
|
2033
|
+
const sum = candles.reduce((acc, candle) => acc + candle.close, 0);
|
|
2034
|
+
return sum / candles.length;
|
|
2035
|
+
}
|
|
2036
|
+
const vwap = sumPriceVolume / totalVolume;
|
|
2037
|
+
return vwap;
|
|
1813
2038
|
}
|
|
1814
2039
|
/**
|
|
1815
|
-
*
|
|
1816
|
-
*
|
|
2040
|
+
* Formats quantity according to exchange-specific rules for the given symbol.
|
|
2041
|
+
* Applies proper decimal precision and rounding based on symbol's lot size filters.
|
|
2042
|
+
*
|
|
2043
|
+
* @param symbol - Trading pair symbol
|
|
2044
|
+
* @param quantity - Raw quantity to format
|
|
2045
|
+
* @returns Promise resolving to formatted quantity as string
|
|
1817
2046
|
*/
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
2047
|
+
async formatQuantity(symbol, quantity) {
|
|
2048
|
+
this.params.logger.debug("binanceService formatQuantity", {
|
|
2049
|
+
symbol,
|
|
2050
|
+
quantity,
|
|
2051
|
+
});
|
|
2052
|
+
return await this.params.formatQuantity(symbol, quantity, this.params.execution.context.backtest);
|
|
1821
2053
|
}
|
|
1822
2054
|
/**
|
|
1823
|
-
*
|
|
1824
|
-
*
|
|
2055
|
+
* Formats price according to exchange-specific rules for the given symbol.
|
|
2056
|
+
* Applies proper decimal precision and rounding based on symbol's price filters.
|
|
2057
|
+
*
|
|
2058
|
+
* @param symbol - Trading pair symbol
|
|
2059
|
+
* @param price - Raw price to format
|
|
2060
|
+
* @returns Promise resolving to formatted price as string
|
|
1825
2061
|
*/
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
}
|
|
1831
|
-
|
|
1832
|
-
* Global singleton instance of PersistRiskUtils.
|
|
1833
|
-
* Used by ClientRisk for active positions persistence.
|
|
1834
|
-
*
|
|
1835
|
-
* @example
|
|
1836
|
-
* ```typescript
|
|
1837
|
-
* // Custom adapter
|
|
1838
|
-
* PersistRiskAdapter.usePersistRiskAdapter(RedisPersist);
|
|
1839
|
-
*
|
|
1840
|
-
* // Read positions
|
|
1841
|
-
* const positions = await PersistRiskAdapter.readPositionData("my-risk");
|
|
1842
|
-
*
|
|
1843
|
-
* // Write positions
|
|
1844
|
-
* await PersistRiskAdapter.writePositionData(positionsMap, "my-risk");
|
|
1845
|
-
* ```
|
|
1846
|
-
*/
|
|
1847
|
-
const PersistRiskAdapter = new PersistRiskUtils();
|
|
1848
|
-
/**
|
|
1849
|
-
* Utility class for managing scheduled signal persistence.
|
|
1850
|
-
*
|
|
1851
|
-
* Features:
|
|
1852
|
-
* - Memoized storage instances per strategy
|
|
1853
|
-
* - Custom adapter support
|
|
1854
|
-
* - Atomic read/write operations for scheduled signals
|
|
1855
|
-
* - Crash-safe scheduled signal state management
|
|
1856
|
-
*
|
|
1857
|
-
* Used by ClientStrategy for live mode persistence of scheduled signals (_scheduledSignal).
|
|
1858
|
-
*/
|
|
1859
|
-
class PersistScheduleUtils {
|
|
1860
|
-
constructor() {
|
|
1861
|
-
this.PersistScheduleFactory = PersistBase;
|
|
1862
|
-
this.getScheduleStorage = memoize(([symbol, strategyName, exchangeName]) => `${symbol}:${strategyName}:${exchangeName}`, (symbol, strategyName, exchangeName) => Reflect.construct(this.PersistScheduleFactory, [
|
|
1863
|
-
`${symbol}_${strategyName}_${exchangeName}`,
|
|
1864
|
-
`./dump/data/schedule/`,
|
|
1865
|
-
]));
|
|
1866
|
-
/**
|
|
1867
|
-
* Reads persisted scheduled signal data for a symbol and strategy.
|
|
1868
|
-
*
|
|
1869
|
-
* Called by ClientStrategy.waitForInit() to restore scheduled signal state.
|
|
1870
|
-
* Returns null if no scheduled signal exists.
|
|
1871
|
-
*
|
|
1872
|
-
* @param symbol - Trading pair symbol
|
|
1873
|
-
* @param strategyName - Strategy identifier
|
|
1874
|
-
* @param exchangeName - Exchange identifier
|
|
1875
|
-
* @returns Promise resolving to scheduled signal or null
|
|
1876
|
-
*/
|
|
1877
|
-
this.readScheduleData = async (symbol, strategyName, exchangeName) => {
|
|
1878
|
-
bt.loggerService.info(PERSIST_SCHEDULE_UTILS_METHOD_NAME_READ_DATA);
|
|
1879
|
-
const key = `${symbol}:${strategyName}:${exchangeName}`;
|
|
1880
|
-
const isInitial = !this.getScheduleStorage.has(key);
|
|
1881
|
-
const stateStorage = this.getScheduleStorage(symbol, strategyName, exchangeName);
|
|
1882
|
-
await stateStorage.waitForInit(isInitial);
|
|
1883
|
-
if (await stateStorage.hasValue(symbol)) {
|
|
1884
|
-
return await stateStorage.readValue(symbol);
|
|
1885
|
-
}
|
|
1886
|
-
return null;
|
|
1887
|
-
};
|
|
1888
|
-
/**
|
|
1889
|
-
* Writes scheduled signal data to disk with atomic file writes.
|
|
1890
|
-
*
|
|
1891
|
-
* Called by ClientStrategy.setScheduledSignal() to persist state.
|
|
1892
|
-
* Uses atomic writes to prevent corruption on crashes.
|
|
1893
|
-
*
|
|
1894
|
-
* @param scheduledSignalRow - Scheduled signal data (null to clear)
|
|
1895
|
-
* @param symbol - Trading pair symbol
|
|
1896
|
-
* @param strategyName - Strategy identifier
|
|
1897
|
-
* @param exchangeName - Exchange identifier
|
|
1898
|
-
* @returns Promise that resolves when write is complete
|
|
1899
|
-
*/
|
|
1900
|
-
this.writeScheduleData = async (scheduledSignalRow, symbol, strategyName, exchangeName) => {
|
|
1901
|
-
bt.loggerService.info(PERSIST_SCHEDULE_UTILS_METHOD_NAME_WRITE_DATA);
|
|
1902
|
-
const key = `${symbol}:${strategyName}:${exchangeName}`;
|
|
1903
|
-
const isInitial = !this.getScheduleStorage.has(key);
|
|
1904
|
-
const stateStorage = this.getScheduleStorage(symbol, strategyName, exchangeName);
|
|
1905
|
-
await stateStorage.waitForInit(isInitial);
|
|
1906
|
-
await stateStorage.writeValue(symbol, scheduledSignalRow);
|
|
1907
|
-
};
|
|
2062
|
+
async formatPrice(symbol, price) {
|
|
2063
|
+
this.params.logger.debug("binanceService formatPrice", {
|
|
2064
|
+
symbol,
|
|
2065
|
+
price,
|
|
2066
|
+
});
|
|
2067
|
+
return await this.params.formatPrice(symbol, price, this.params.execution.context.backtest);
|
|
1908
2068
|
}
|
|
1909
2069
|
/**
|
|
1910
|
-
*
|
|
2070
|
+
* Fetches raw candles with flexible date/limit parameters.
|
|
1911
2071
|
*
|
|
1912
|
-
*
|
|
2072
|
+
* Compatibility layer that:
|
|
2073
|
+
* - RAW MODE (sDate + eDate + limit): fetches exactly as specified, NO look-ahead bias protection
|
|
2074
|
+
* - Other modes: respects execution context and prevents look-ahead bias
|
|
1913
2075
|
*
|
|
1914
|
-
*
|
|
1915
|
-
*
|
|
1916
|
-
*
|
|
1917
|
-
*
|
|
1918
|
-
*
|
|
1919
|
-
*
|
|
1920
|
-
*
|
|
1921
|
-
*
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
*
|
|
1929
|
-
*
|
|
2076
|
+
* Parameter combinations:
|
|
2077
|
+
* 1. sDate + eDate + limit: RAW MODE - fetches exactly as specified, no validation against when
|
|
2078
|
+
* 2. sDate + eDate: calculates limit from date range, validates endTimestamp <= when
|
|
2079
|
+
* 3. eDate + limit: calculates sDate backward, validates endTimestamp <= when
|
|
2080
|
+
* 4. sDate + limit: fetches forward, validates endTimestamp <= when
|
|
2081
|
+
* 5. Only limit: uses execution.context.when as reference (backward)
|
|
2082
|
+
*
|
|
2083
|
+
* Edge cases:
|
|
2084
|
+
* - If calculated limit is 0 or negative: throws error
|
|
2085
|
+
* - If sDate >= eDate: throws error
|
|
2086
|
+
* - If startTimestamp >= endTimestamp: throws error
|
|
2087
|
+
* - If endTimestamp > when (non-RAW modes only): throws error to prevent look-ahead bias
|
|
2088
|
+
*
|
|
2089
|
+
* @param symbol - Trading pair symbol
|
|
2090
|
+
* @param interval - Candle interval
|
|
2091
|
+
* @param limit - Optional number of candles to fetch
|
|
2092
|
+
* @param sDate - Optional start date in milliseconds
|
|
2093
|
+
* @param eDate - Optional end date in milliseconds
|
|
2094
|
+
* @returns Promise resolving to array of candles
|
|
2095
|
+
* @throws Error if parameters are invalid or conflicting
|
|
1930
2096
|
*/
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
2097
|
+
async getRawCandles(symbol, interval, limit, sDate, eDate) {
|
|
2098
|
+
this.params.logger.debug(`ClientExchange getRawCandles`, {
|
|
2099
|
+
symbol,
|
|
2100
|
+
interval,
|
|
2101
|
+
limit,
|
|
2102
|
+
sDate,
|
|
2103
|
+
eDate,
|
|
2104
|
+
});
|
|
2105
|
+
const step = INTERVAL_MINUTES$4[interval];
|
|
2106
|
+
const stepMs = step * 60 * 1000;
|
|
2107
|
+
const when = this.params.execution.context.when.getTime();
|
|
2108
|
+
let startTimestamp;
|
|
2109
|
+
let endTimestamp;
|
|
2110
|
+
let candleLimit;
|
|
2111
|
+
let isRawMode = false;
|
|
2112
|
+
// Case 1: sDate + eDate + limit - RAW MODE (no look-ahead bias protection)
|
|
2113
|
+
if (sDate !== undefined &&
|
|
2114
|
+
eDate !== undefined &&
|
|
2115
|
+
limit !== undefined) {
|
|
2116
|
+
isRawMode = true;
|
|
2117
|
+
if (sDate >= eDate) {
|
|
2118
|
+
throw new Error(`ClientExchange getRawCandles: sDate (${sDate}) must be less than eDate (${eDate})`);
|
|
2119
|
+
}
|
|
2120
|
+
startTimestamp = sDate;
|
|
2121
|
+
endTimestamp = eDate;
|
|
2122
|
+
candleLimit = limit;
|
|
2123
|
+
}
|
|
2124
|
+
// Case 2: sDate + eDate - calculate limit, respect backtest context
|
|
2125
|
+
else if (sDate !== undefined && eDate !== undefined && limit === undefined) {
|
|
2126
|
+
if (sDate >= eDate) {
|
|
2127
|
+
throw new Error(`ClientExchange getRawCandles: sDate (${sDate}) must be less than eDate (${eDate})`);
|
|
2128
|
+
}
|
|
2129
|
+
startTimestamp = sDate;
|
|
2130
|
+
endTimestamp = eDate;
|
|
2131
|
+
const rangeDuration = endTimestamp - startTimestamp;
|
|
2132
|
+
candleLimit = Math.floor(rangeDuration / stepMs);
|
|
2133
|
+
if (candleLimit <= 0) {
|
|
2134
|
+
throw new Error(`ClientExchange getRawCandles: calculated limit is ${candleLimit} for range [${sDate}, ${eDate}]`);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
// Case 3: eDate + limit - calculate sDate backward, respect backtest context
|
|
2138
|
+
else if (eDate !== undefined && limit !== undefined && sDate === undefined) {
|
|
2139
|
+
endTimestamp = eDate;
|
|
2140
|
+
startTimestamp = eDate - limit * stepMs;
|
|
2141
|
+
candleLimit = limit;
|
|
2142
|
+
}
|
|
2143
|
+
// Case 4: sDate + limit - fetch forward, respect backtest context
|
|
2144
|
+
else if (sDate !== undefined && limit !== undefined && eDate === undefined) {
|
|
2145
|
+
startTimestamp = sDate;
|
|
2146
|
+
endTimestamp = sDate + limit * stepMs;
|
|
2147
|
+
candleLimit = limit;
|
|
2148
|
+
}
|
|
2149
|
+
// Case 5: Only limit - use execution context (backward from when)
|
|
2150
|
+
else if (limit !== undefined && sDate === undefined && eDate === undefined) {
|
|
2151
|
+
endTimestamp = when;
|
|
2152
|
+
startTimestamp = when - limit * stepMs;
|
|
2153
|
+
candleLimit = limit;
|
|
2154
|
+
}
|
|
2155
|
+
// Invalid combination
|
|
2156
|
+
else {
|
|
2157
|
+
throw new Error(`ClientExchange getRawCandles: invalid parameter combination. Must provide either (limit), (eDate+limit), (sDate+limit), (sDate+eDate), or (sDate+eDate+limit)`);
|
|
2158
|
+
}
|
|
2159
|
+
// Validate timestamps
|
|
2160
|
+
if (startTimestamp >= endTimestamp) {
|
|
2161
|
+
throw new Error(`ClientExchange getRawCandles: startTimestamp (${startTimestamp}) >= endTimestamp (${endTimestamp})`);
|
|
2162
|
+
}
|
|
2163
|
+
// Check if trying to fetch future data (prevent look-ahead bias)
|
|
2164
|
+
// ONLY for non-RAW modes - RAW MODE bypasses this check
|
|
2165
|
+
if (!isRawMode && endTimestamp > when) {
|
|
2166
|
+
throw new Error(`ClientExchange getRawCandles: endTimestamp (${endTimestamp}) is beyond execution context when (${when}) - look-ahead bias prevented`);
|
|
2167
|
+
}
|
|
2168
|
+
const since = new Date(startTimestamp);
|
|
2169
|
+
let allData = [];
|
|
2170
|
+
// Fetch data in chunks if limit exceeds max per request
|
|
2171
|
+
if (candleLimit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
|
|
2172
|
+
let remaining = candleLimit;
|
|
2173
|
+
let currentSince = new Date(since.getTime());
|
|
2174
|
+
while (remaining > 0) {
|
|
2175
|
+
const chunkLimit = Math.min(remaining, GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST);
|
|
2176
|
+
const chunkData = await GET_CANDLES_FN({ symbol, interval, limit: chunkLimit }, currentSince, this);
|
|
2177
|
+
allData.push(...chunkData);
|
|
2178
|
+
remaining -= chunkLimit;
|
|
2179
|
+
if (remaining > 0) {
|
|
2180
|
+
currentSince = new Date(currentSince.getTime() + chunkLimit * stepMs);
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
else {
|
|
2185
|
+
allData = await GET_CANDLES_FN({ symbol, interval, limit: candleLimit }, since, this);
|
|
2186
|
+
}
|
|
2187
|
+
// Filter candles to strictly match the requested range
|
|
2188
|
+
const filteredData = allData.filter((candle) => candle.timestamp >= startTimestamp &&
|
|
2189
|
+
candle.timestamp < endTimestamp);
|
|
2190
|
+
// Apply distinct by timestamp to remove duplicates
|
|
2191
|
+
const uniqueData = Array.from(new Map(filteredData.map((candle) => [candle.timestamp, candle])).values());
|
|
2192
|
+
if (filteredData.length !== uniqueData.length) {
|
|
2193
|
+
const msg = `ClientExchange getRawCandles: Removed ${filteredData.length - uniqueData.length} duplicate candles by timestamp`;
|
|
2194
|
+
this.params.logger.warn(msg);
|
|
2195
|
+
console.warn(msg);
|
|
2196
|
+
}
|
|
2197
|
+
if (uniqueData.length < candleLimit) {
|
|
2198
|
+
const msg = `ClientExchange getRawCandles: Expected ${candleLimit} candles, got ${uniqueData.length}`;
|
|
2199
|
+
this.params.logger.warn(msg);
|
|
2200
|
+
console.warn(msg);
|
|
2201
|
+
}
|
|
2202
|
+
await CALL_CANDLE_DATA_CALLBACKS_FN(this, symbol, interval, since, candleLimit, uniqueData);
|
|
2203
|
+
return uniqueData;
|
|
1934
2204
|
}
|
|
1935
2205
|
/**
|
|
1936
|
-
*
|
|
1937
|
-
*
|
|
2206
|
+
* Fetches order book for a trading pair.
|
|
2207
|
+
*
|
|
2208
|
+
* Calculates time range based on execution context time (when) and
|
|
2209
|
+
* CC_ORDER_BOOK_TIME_OFFSET_MINUTES, then delegates to the exchange
|
|
2210
|
+
* schema implementation which may use or ignore the time range.
|
|
2211
|
+
*
|
|
2212
|
+
* @param symbol - Trading pair symbol
|
|
2213
|
+
* @param depth - Maximum depth levels (default: CC_ORDER_BOOK_MAX_DEPTH_LEVELS)
|
|
2214
|
+
* @returns Promise resolving to order book data
|
|
2215
|
+
* @throws Error if getOrderBook is not implemented
|
|
1938
2216
|
*/
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
2217
|
+
async getOrderBook(symbol, depth = GLOBAL_CONFIG.CC_ORDER_BOOK_MAX_DEPTH_LEVELS) {
|
|
2218
|
+
this.params.logger.debug("ClientExchange getOrderBook", {
|
|
2219
|
+
symbol,
|
|
2220
|
+
depth,
|
|
2221
|
+
});
|
|
2222
|
+
const to = new Date(this.params.execution.context.when.getTime());
|
|
2223
|
+
const from = new Date(to.getTime() -
|
|
2224
|
+
GLOBAL_CONFIG.CC_ORDER_BOOK_TIME_OFFSET_MINUTES * 60 * 1000);
|
|
2225
|
+
return await this.params.getOrderBook(symbol, depth, from, to, this.params.execution.context.backtest);
|
|
1942
2226
|
}
|
|
1943
2227
|
}
|
|
2228
|
+
|
|
1944
2229
|
/**
|
|
1945
|
-
*
|
|
1946
|
-
*
|
|
1947
|
-
*
|
|
1948
|
-
* @example
|
|
1949
|
-
* ```typescript
|
|
1950
|
-
* // Custom adapter
|
|
1951
|
-
* PersistScheduleAdapter.usePersistScheduleAdapter(RedisPersist);
|
|
1952
|
-
*
|
|
1953
|
-
* // Read scheduled signal
|
|
1954
|
-
* const scheduled = await PersistScheduleAdapter.readScheduleData("my-strategy", "BTCUSDT");
|
|
1955
|
-
*
|
|
1956
|
-
* // Write scheduled signal
|
|
1957
|
-
* await PersistScheduleAdapter.writeScheduleData(scheduled, "my-strategy", "BTCUSDT");
|
|
1958
|
-
* ```
|
|
2230
|
+
* Default implementation for getCandles.
|
|
2231
|
+
* Throws an error indicating the method is not implemented.
|
|
1959
2232
|
*/
|
|
1960
|
-
const
|
|
2233
|
+
const DEFAULT_GET_CANDLES_FN$1 = async (_symbol, _interval, _since, _limit, _backtest) => {
|
|
2234
|
+
throw new Error(`getCandles is not implemented for this exchange`);
|
|
2235
|
+
};
|
|
1961
2236
|
/**
|
|
1962
|
-
*
|
|
1963
|
-
*
|
|
1964
|
-
* Features:
|
|
1965
|
-
* - Memoized storage instances per symbol:strategyName
|
|
1966
|
-
* - Custom adapter support
|
|
1967
|
-
* - Atomic read/write operations for partial data
|
|
1968
|
-
* - Crash-safe partial state management
|
|
1969
|
-
*
|
|
1970
|
-
* Used by ClientPartial for live mode persistence of profit/loss levels.
|
|
2237
|
+
* Default implementation for formatQuantity.
|
|
2238
|
+
* Returns Bitcoin precision on Binance (8 decimal places).
|
|
1971
2239
|
*/
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
this.getPartialStorage = memoize(([symbol, strategyName, exchangeName]) => `${symbol}:${strategyName}:${exchangeName}`, (symbol, strategyName, exchangeName) => Reflect.construct(this.PersistPartialFactory, [
|
|
1976
|
-
`${symbol}_${strategyName}_${exchangeName}`,
|
|
1977
|
-
`./dump/data/partial/`,
|
|
1978
|
-
]));
|
|
1979
|
-
/**
|
|
1980
|
-
* Reads persisted partial data for a symbol and strategy.
|
|
1981
|
-
*
|
|
1982
|
-
* Called by ClientPartial.waitForInit() to restore state.
|
|
1983
|
-
* Returns empty object if no partial data exists.
|
|
1984
|
-
*
|
|
1985
|
-
* @param symbol - Trading pair symbol
|
|
1986
|
-
* @param strategyName - Strategy identifier
|
|
1987
|
-
* @param signalId - Signal identifier
|
|
1988
|
-
* @param exchangeName - Exchange identifier
|
|
1989
|
-
* @returns Promise resolving to partial data record
|
|
1990
|
-
*/
|
|
1991
|
-
this.readPartialData = async (symbol, strategyName, signalId, exchangeName) => {
|
|
1992
|
-
bt.loggerService.info(PERSIST_PARTIAL_UTILS_METHOD_NAME_READ_DATA);
|
|
1993
|
-
const key = `${symbol}:${strategyName}:${exchangeName}`;
|
|
1994
|
-
const isInitial = !this.getPartialStorage.has(key);
|
|
1995
|
-
const stateStorage = this.getPartialStorage(symbol, strategyName, exchangeName);
|
|
1996
|
-
await stateStorage.waitForInit(isInitial);
|
|
1997
|
-
if (await stateStorage.hasValue(signalId)) {
|
|
1998
|
-
return await stateStorage.readValue(signalId);
|
|
1999
|
-
}
|
|
2000
|
-
return {};
|
|
2001
|
-
};
|
|
2002
|
-
/**
|
|
2003
|
-
* Writes partial data to disk with atomic file writes.
|
|
2004
|
-
*
|
|
2005
|
-
* Called by ClientPartial after profit/loss level changes to persist state.
|
|
2006
|
-
* Uses atomic writes to prevent corruption on crashes.
|
|
2007
|
-
*
|
|
2008
|
-
* @param partialData - Record of signal IDs to partial data
|
|
2009
|
-
* @param symbol - Trading pair symbol
|
|
2010
|
-
* @param strategyName - Strategy identifier
|
|
2011
|
-
* @param signalId - Signal identifier
|
|
2012
|
-
* @param exchangeName - Exchange identifier
|
|
2013
|
-
* @returns Promise that resolves when write is complete
|
|
2014
|
-
*/
|
|
2015
|
-
this.writePartialData = async (partialData, symbol, strategyName, signalId, exchangeName) => {
|
|
2016
|
-
bt.loggerService.info(PERSIST_PARTIAL_UTILS_METHOD_NAME_WRITE_DATA);
|
|
2017
|
-
const key = `${symbol}:${strategyName}:${exchangeName}`;
|
|
2018
|
-
const isInitial = !this.getPartialStorage.has(key);
|
|
2019
|
-
const stateStorage = this.getPartialStorage(symbol, strategyName, exchangeName);
|
|
2020
|
-
await stateStorage.waitForInit(isInitial);
|
|
2021
|
-
await stateStorage.writeValue(signalId, partialData);
|
|
2022
|
-
};
|
|
2023
|
-
}
|
|
2024
|
-
/**
|
|
2025
|
-
* Registers a custom persistence adapter.
|
|
2026
|
-
*
|
|
2027
|
-
* @param Ctor - Custom PersistBase constructor
|
|
2028
|
-
*
|
|
2029
|
-
* @example
|
|
2030
|
-
* ```typescript
|
|
2031
|
-
* class RedisPersist extends PersistBase {
|
|
2032
|
-
* async readValue(id) { return JSON.parse(await redis.get(id)); }
|
|
2033
|
-
* async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
|
|
2034
|
-
* }
|
|
2035
|
-
* PersistPartialAdapter.usePersistPartialAdapter(RedisPersist);
|
|
2036
|
-
* ```
|
|
2037
|
-
*/
|
|
2038
|
-
usePersistPartialAdapter(Ctor) {
|
|
2039
|
-
bt.loggerService.info(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_PERSIST_PARTIAL_ADAPTER);
|
|
2040
|
-
this.PersistPartialFactory = Ctor;
|
|
2041
|
-
}
|
|
2042
|
-
/**
|
|
2043
|
-
* Switches to the default JSON persist adapter.
|
|
2044
|
-
* All future persistence writes will use JSON storage.
|
|
2045
|
-
*/
|
|
2046
|
-
useJson() {
|
|
2047
|
-
bt.loggerService.log(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_JSON);
|
|
2048
|
-
this.usePersistPartialAdapter(PersistBase);
|
|
2049
|
-
}
|
|
2050
|
-
/**
|
|
2051
|
-
* Switches to a dummy persist adapter that discards all writes.
|
|
2052
|
-
* All future persistence writes will be no-ops.
|
|
2053
|
-
*/
|
|
2054
|
-
useDummy() {
|
|
2055
|
-
bt.loggerService.log(PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_DUMMY);
|
|
2056
|
-
this.usePersistPartialAdapter(PersistDummy);
|
|
2057
|
-
}
|
|
2058
|
-
}
|
|
2240
|
+
const DEFAULT_FORMAT_QUANTITY_FN$1 = async (_symbol, quantity, _backtest) => {
|
|
2241
|
+
return quantity.toFixed(8);
|
|
2242
|
+
};
|
|
2059
2243
|
/**
|
|
2060
|
-
*
|
|
2061
|
-
*
|
|
2062
|
-
*
|
|
2063
|
-
* @example
|
|
2064
|
-
* ```typescript
|
|
2065
|
-
* // Custom adapter
|
|
2066
|
-
* PersistPartialAdapter.usePersistPartialAdapter(RedisPersist);
|
|
2067
|
-
*
|
|
2068
|
-
* // Read partial data
|
|
2069
|
-
* const partialData = await PersistPartialAdapter.readPartialData("BTCUSDT", "my-strategy");
|
|
2070
|
-
*
|
|
2071
|
-
* // Write partial data
|
|
2072
|
-
* await PersistPartialAdapter.writePartialData(partialData, "BTCUSDT", "my-strategy");
|
|
2073
|
-
* ```
|
|
2244
|
+
* Default implementation for formatPrice.
|
|
2245
|
+
* Returns Bitcoin precision on Binance (2 decimal places).
|
|
2074
2246
|
*/
|
|
2075
|
-
const
|
|
2247
|
+
const DEFAULT_FORMAT_PRICE_FN$1 = async (_symbol, price, _backtest) => {
|
|
2248
|
+
return price.toFixed(2);
|
|
2249
|
+
};
|
|
2076
2250
|
/**
|
|
2077
|
-
*
|
|
2251
|
+
* Default implementation for getOrderBook.
|
|
2252
|
+
* Throws an error indicating the method is not implemented.
|
|
2078
2253
|
*
|
|
2079
|
-
*
|
|
2080
|
-
*
|
|
2254
|
+
* @param _symbol - Trading pair symbol (unused)
|
|
2255
|
+
* @param _depth - Maximum depth levels (unused)
|
|
2256
|
+
* @param _from - Start of time range (unused - can be ignored in live implementations)
|
|
2257
|
+
* @param _to - End of time range (unused - can be ignored in live implementations)
|
|
2258
|
+
* @param _backtest - Whether running in backtest mode (unused)
|
|
2259
|
+
*/
|
|
2260
|
+
const DEFAULT_GET_ORDER_BOOK_FN$1 = async (_symbol, _depth, _from, _to, _backtest) => {
|
|
2261
|
+
throw new Error(`getOrderBook is not implemented for this exchange`);
|
|
2262
|
+
};
|
|
2263
|
+
/**
|
|
2264
|
+
* Connection service routing exchange operations to correct ClientExchange instance.
|
|
2081
2265
|
*
|
|
2082
|
-
*
|
|
2083
|
-
*
|
|
2084
|
-
*
|
|
2085
|
-
* - Singleton pattern for global access
|
|
2086
|
-
* - Custom adapter support via usePersistBreakevenAdapter()
|
|
2266
|
+
* Routes all IExchange method calls to the appropriate exchange implementation
|
|
2267
|
+
* based on methodContextService.context.exchangeName. Uses memoization to cache
|
|
2268
|
+
* ClientExchange instances for performance.
|
|
2087
2269
|
*
|
|
2088
|
-
*
|
|
2089
|
-
*
|
|
2090
|
-
*
|
|
2091
|
-
*
|
|
2092
|
-
*
|
|
2093
|
-
* └── ETHUSDT_other-strategy/
|
|
2094
|
-
* └── state.json
|
|
2095
|
-
* ```
|
|
2270
|
+
* Key features:
|
|
2271
|
+
* - Automatic exchange routing via method context
|
|
2272
|
+
* - Memoized ClientExchange instances by exchangeName
|
|
2273
|
+
* - Implements full IExchange interface
|
|
2274
|
+
* - Logging for all operations
|
|
2096
2275
|
*
|
|
2097
2276
|
* @example
|
|
2098
2277
|
* ```typescript
|
|
2099
|
-
* //
|
|
2100
|
-
* const
|
|
2101
|
-
*
|
|
2102
|
-
*
|
|
2103
|
-
* //
|
|
2104
|
-
* await PersistBreakevenAdapter.writeBreakevenData(breakevenData, "BTCUSDT", "my-strategy");
|
|
2278
|
+
* // Used internally by framework
|
|
2279
|
+
* const candles = await exchangeConnectionService.getCandles(
|
|
2280
|
+
* "BTCUSDT", "1h", 100
|
|
2281
|
+
* );
|
|
2282
|
+
* // Automatically routes to correct exchange based on methodContext
|
|
2105
2283
|
* ```
|
|
2106
2284
|
*/
|
|
2107
|
-
class
|
|
2285
|
+
class ExchangeConnectionService {
|
|
2108
2286
|
constructor() {
|
|
2287
|
+
this.loggerService = inject(TYPES.loggerService);
|
|
2288
|
+
this.executionContextService = inject(TYPES.executionContextService);
|
|
2289
|
+
this.exchangeSchemaService = inject(TYPES.exchangeSchemaService);
|
|
2290
|
+
this.methodContextService = inject(TYPES.methodContextService);
|
|
2291
|
+
/**
|
|
2292
|
+
* Retrieves memoized ClientExchange instance for given exchange name.
|
|
2293
|
+
*
|
|
2294
|
+
* Creates ClientExchange on first call, returns cached instance on subsequent calls.
|
|
2295
|
+
* Cache key is exchangeName string.
|
|
2296
|
+
*
|
|
2297
|
+
* @param exchangeName - Name of registered exchange schema
|
|
2298
|
+
* @returns Configured ClientExchange instance
|
|
2299
|
+
*/
|
|
2300
|
+
this.getExchange = memoize(([exchangeName]) => `${exchangeName}`, (exchangeName) => {
|
|
2301
|
+
const { getCandles = DEFAULT_GET_CANDLES_FN$1, formatPrice = DEFAULT_FORMAT_PRICE_FN$1, formatQuantity = DEFAULT_FORMAT_QUANTITY_FN$1, getOrderBook = DEFAULT_GET_ORDER_BOOK_FN$1, callbacks } = this.exchangeSchemaService.get(exchangeName);
|
|
2302
|
+
return new ClientExchange({
|
|
2303
|
+
execution: this.executionContextService,
|
|
2304
|
+
logger: this.loggerService,
|
|
2305
|
+
exchangeName,
|
|
2306
|
+
getCandles,
|
|
2307
|
+
formatPrice,
|
|
2308
|
+
formatQuantity,
|
|
2309
|
+
getOrderBook,
|
|
2310
|
+
callbacks,
|
|
2311
|
+
});
|
|
2312
|
+
});
|
|
2313
|
+
/**
|
|
2314
|
+
* Fetches historical candles for symbol using configured exchange.
|
|
2315
|
+
*
|
|
2316
|
+
* Routes to exchange determined by methodContextService.context.exchangeName.
|
|
2317
|
+
*
|
|
2318
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
2319
|
+
* @param interval - Candle interval (e.g., "1h", "1d")
|
|
2320
|
+
* @param limit - Maximum number of candles to fetch
|
|
2321
|
+
* @returns Promise resolving to array of candle data
|
|
2322
|
+
*/
|
|
2323
|
+
this.getCandles = async (symbol, interval, limit) => {
|
|
2324
|
+
this.loggerService.log("exchangeConnectionService getCandles", {
|
|
2325
|
+
symbol,
|
|
2326
|
+
interval,
|
|
2327
|
+
limit,
|
|
2328
|
+
});
|
|
2329
|
+
return await this.getExchange(this.methodContextService.context.exchangeName).getCandles(symbol, interval, limit);
|
|
2330
|
+
};
|
|
2109
2331
|
/**
|
|
2110
|
-
*
|
|
2111
|
-
*
|
|
2332
|
+
* Fetches next batch of candles relative to executionContext.when.
|
|
2333
|
+
*
|
|
2334
|
+
* Returns candles that come after the current execution timestamp.
|
|
2335
|
+
* Used for backtest progression and live trading updates.
|
|
2336
|
+
*
|
|
2337
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
2338
|
+
* @param interval - Candle interval (e.g., "1h", "1d")
|
|
2339
|
+
* @param limit - Maximum number of candles to fetch
|
|
2340
|
+
* @returns Promise resolving to array of candle data
|
|
2112
2341
|
*/
|
|
2113
|
-
this.
|
|
2342
|
+
this.getNextCandles = async (symbol, interval, limit) => {
|
|
2343
|
+
this.loggerService.log("exchangeConnectionService getNextCandles", {
|
|
2344
|
+
symbol,
|
|
2345
|
+
interval,
|
|
2346
|
+
limit,
|
|
2347
|
+
});
|
|
2348
|
+
return await this.getExchange(this.methodContextService.context.exchangeName).getNextCandles(symbol, interval, limit);
|
|
2349
|
+
};
|
|
2114
2350
|
/**
|
|
2115
|
-
*
|
|
2116
|
-
* Creates one PersistBase instance per symbol-strategy-exchange combination.
|
|
2117
|
-
* Key format: "symbol:strategyName:exchangeName"
|
|
2351
|
+
* Retrieves current average price for symbol.
|
|
2118
2352
|
*
|
|
2119
|
-
*
|
|
2120
|
-
*
|
|
2121
|
-
*
|
|
2122
|
-
* @
|
|
2353
|
+
* In live mode: fetches real-time average price from exchange API.
|
|
2354
|
+
* In backtest mode: calculates VWAP from candles in current timeframe.
|
|
2355
|
+
*
|
|
2356
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
2357
|
+
* @returns Promise resolving to average price
|
|
2123
2358
|
*/
|
|
2124
|
-
this.
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2359
|
+
this.getAveragePrice = async (symbol) => {
|
|
2360
|
+
this.loggerService.log("exchangeConnectionService getAveragePrice", {
|
|
2361
|
+
symbol,
|
|
2362
|
+
});
|
|
2363
|
+
return await this.getExchange(this.methodContextService.context.exchangeName).getAveragePrice(symbol);
|
|
2364
|
+
};
|
|
2128
2365
|
/**
|
|
2129
|
-
*
|
|
2366
|
+
* Formats price according to exchange-specific precision rules.
|
|
2130
2367
|
*
|
|
2131
|
-
*
|
|
2132
|
-
* Returns empty object if no breakeven data exists.
|
|
2368
|
+
* Ensures price meets exchange requirements for decimal places and tick size.
|
|
2133
2369
|
*
|
|
2134
|
-
* @param symbol - Trading pair symbol
|
|
2135
|
-
* @param
|
|
2136
|
-
* @
|
|
2137
|
-
* @param exchangeName - Exchange identifier
|
|
2138
|
-
* @returns Promise resolving to breakeven data record
|
|
2370
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
2371
|
+
* @param price - Raw price value to format
|
|
2372
|
+
* @returns Promise resolving to formatted price string
|
|
2139
2373
|
*/
|
|
2140
|
-
this.
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
await
|
|
2146
|
-
if (await stateStorage.hasValue(signalId)) {
|
|
2147
|
-
return await stateStorage.readValue(signalId);
|
|
2148
|
-
}
|
|
2149
|
-
return {};
|
|
2374
|
+
this.formatPrice = async (symbol, price) => {
|
|
2375
|
+
this.loggerService.log("exchangeConnectionService getAveragePrice", {
|
|
2376
|
+
symbol,
|
|
2377
|
+
price,
|
|
2378
|
+
});
|
|
2379
|
+
return await this.getExchange(this.methodContextService.context.exchangeName).formatPrice(symbol, price);
|
|
2150
2380
|
};
|
|
2151
2381
|
/**
|
|
2152
|
-
*
|
|
2382
|
+
* Formats quantity according to exchange-specific precision rules.
|
|
2153
2383
|
*
|
|
2154
|
-
*
|
|
2155
|
-
* Creates directory and file if they don't exist.
|
|
2156
|
-
* Uses atomic writes to prevent data corruption.
|
|
2384
|
+
* Ensures quantity meets exchange requirements for decimal places and lot size.
|
|
2157
2385
|
*
|
|
2158
|
-
* @param
|
|
2159
|
-
* @param
|
|
2160
|
-
* @
|
|
2161
|
-
* @param signalId - Signal identifier
|
|
2162
|
-
* @param exchangeName - Exchange identifier
|
|
2163
|
-
* @returns Promise that resolves when write is complete
|
|
2386
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
2387
|
+
* @param quantity - Raw quantity value to format
|
|
2388
|
+
* @returns Promise resolving to formatted quantity string
|
|
2164
2389
|
*/
|
|
2165
|
-
this.
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
await
|
|
2171
|
-
|
|
2390
|
+
this.formatQuantity = async (symbol, quantity) => {
|
|
2391
|
+
this.loggerService.log("exchangeConnectionService getAveragePrice", {
|
|
2392
|
+
symbol,
|
|
2393
|
+
quantity,
|
|
2394
|
+
});
|
|
2395
|
+
return await this.getExchange(this.methodContextService.context.exchangeName).formatQuantity(symbol, quantity);
|
|
2396
|
+
};
|
|
2397
|
+
/**
|
|
2398
|
+
* Fetches order book for a trading pair using configured exchange.
|
|
2399
|
+
*
|
|
2400
|
+
* Routes to exchange determined by methodContextService.context.exchangeName.
|
|
2401
|
+
* The ClientExchange will calculate time range and pass it to the schema
|
|
2402
|
+
* implementation, which may use (backtest) or ignore (live) the parameters.
|
|
2403
|
+
*
|
|
2404
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
2405
|
+
* @param depth - Maximum depth levels (default: CC_ORDER_BOOK_MAX_DEPTH_LEVELS)
|
|
2406
|
+
* @returns Promise resolving to order book data
|
|
2407
|
+
*/
|
|
2408
|
+
this.getOrderBook = async (symbol, depth) => {
|
|
2409
|
+
this.loggerService.log("exchangeConnectionService getOrderBook", {
|
|
2410
|
+
symbol,
|
|
2411
|
+
depth,
|
|
2412
|
+
});
|
|
2413
|
+
return await this.getExchange(this.methodContextService.context.exchangeName).getOrderBook(symbol, depth);
|
|
2172
2414
|
};
|
|
2173
|
-
}
|
|
2174
|
-
/**
|
|
2175
|
-
* Registers a custom persistence adapter.
|
|
2176
|
-
*
|
|
2177
|
-
* @param Ctor - Custom PersistBase constructor
|
|
2178
|
-
*
|
|
2179
|
-
* @example
|
|
2180
|
-
* ```typescript
|
|
2181
|
-
* class RedisPersist extends PersistBase {
|
|
2182
|
-
* async readValue(id) { return JSON.parse(await redis.get(id)); }
|
|
2183
|
-
* async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
|
|
2184
|
-
* }
|
|
2185
|
-
* PersistBreakevenAdapter.usePersistBreakevenAdapter(RedisPersist);
|
|
2186
|
-
* ```
|
|
2187
|
-
*/
|
|
2188
|
-
usePersistBreakevenAdapter(Ctor) {
|
|
2189
|
-
bt.loggerService.info(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_PERSIST_BREAKEVEN_ADAPTER);
|
|
2190
|
-
this.PersistBreakevenFactory = Ctor;
|
|
2191
|
-
}
|
|
2192
|
-
/**
|
|
2193
|
-
* Switches to the default JSON persist adapter.
|
|
2194
|
-
* All future persistence writes will use JSON storage.
|
|
2195
|
-
*/
|
|
2196
|
-
useJson() {
|
|
2197
|
-
bt.loggerService.log(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_JSON);
|
|
2198
|
-
this.usePersistBreakevenAdapter(PersistBase);
|
|
2199
|
-
}
|
|
2200
|
-
/**
|
|
2201
|
-
* Switches to a dummy persist adapter that discards all writes.
|
|
2202
|
-
* All future persistence writes will be no-ops.
|
|
2203
|
-
*/
|
|
2204
|
-
useDummy() {
|
|
2205
|
-
bt.loggerService.log(PERSIST_BREAKEVEN_UTILS_METHOD_NAME_USE_DUMMY);
|
|
2206
|
-
this.usePersistBreakevenAdapter(PersistDummy);
|
|
2207
2415
|
}
|
|
2208
2416
|
}
|
|
2417
|
+
|
|
2209
2418
|
/**
|
|
2210
|
-
*
|
|
2211
|
-
*
|
|
2419
|
+
* Calculates profit/loss for a closed signal with slippage and fees.
|
|
2420
|
+
*
|
|
2421
|
+
* For signals with partial closes:
|
|
2422
|
+
* - Calculates weighted PNL: Σ(percent_i × pnl_i) for each partial + (remaining% × final_pnl)
|
|
2423
|
+
* - Each partial close has its own fees and slippage
|
|
2424
|
+
* - Total fees = 2 × (number of partial closes + 1 final close) × CC_PERCENT_FEE
|
|
2425
|
+
*
|
|
2426
|
+
* Formula breakdown:
|
|
2427
|
+
* 1. Apply slippage to open/close prices (worse execution)
|
|
2428
|
+
* - LONG: buy higher (+slippage), sell lower (-slippage)
|
|
2429
|
+
* - SHORT: sell lower (-slippage), buy higher (+slippage)
|
|
2430
|
+
* 2. Calculate raw PNL percentage
|
|
2431
|
+
* - LONG: ((closePrice - openPrice) / openPrice) * 100
|
|
2432
|
+
* - SHORT: ((openPrice - closePrice) / openPrice) * 100
|
|
2433
|
+
* 3. Subtract total fees (0.1% * 2 = 0.2% per transaction)
|
|
2434
|
+
*
|
|
2435
|
+
* @param signal - Closed signal with position details and optional partial history
|
|
2436
|
+
* @param priceClose - Actual close price at final exit
|
|
2437
|
+
* @returns PNL data with percentage and prices
|
|
2212
2438
|
*
|
|
2213
2439
|
* @example
|
|
2214
2440
|
* ```typescript
|
|
2215
|
-
* //
|
|
2216
|
-
*
|
|
2217
|
-
*
|
|
2218
|
-
*
|
|
2219
|
-
*
|
|
2441
|
+
* // Signal without partial closes
|
|
2442
|
+
* const pnl = toProfitLossDto(
|
|
2443
|
+
* {
|
|
2444
|
+
* position: "long",
|
|
2445
|
+
* priceOpen: 100,
|
|
2446
|
+
* },
|
|
2447
|
+
* 110 // close at +10%
|
|
2448
|
+
* );
|
|
2449
|
+
* console.log(pnl.pnlPercentage); // ~9.6% (after slippage and fees)
|
|
2220
2450
|
*
|
|
2221
|
-
* //
|
|
2222
|
-
*
|
|
2451
|
+
* // Signal with partial closes
|
|
2452
|
+
* const pnlPartial = toProfitLossDto(
|
|
2453
|
+
* {
|
|
2454
|
+
* position: "long",
|
|
2455
|
+
* priceOpen: 100,
|
|
2456
|
+
* _partial: [
|
|
2457
|
+
* { type: "profit", percent: 30, price: 120 }, // +20% on 30%
|
|
2458
|
+
* { type: "profit", percent: 40, price: 115 }, // +15% on 40%
|
|
2459
|
+
* ],
|
|
2460
|
+
* },
|
|
2461
|
+
* 105 // final close at +5% for remaining 30%
|
|
2462
|
+
* );
|
|
2463
|
+
* // Weighted PNL = 30% × 20% + 40% × 15% + 30% × 5% = 6% + 6% + 1.5% = 13.5% (before fees)
|
|
2223
2464
|
* ```
|
|
2224
2465
|
*/
|
|
2225
|
-
const
|
|
2466
|
+
const toProfitLossDto = (signal, priceClose) => {
|
|
2467
|
+
const priceOpen = signal.priceOpen;
|
|
2468
|
+
// Calculate weighted PNL with partial closes
|
|
2469
|
+
if (signal._partial && signal._partial.length > 0) {
|
|
2470
|
+
let totalWeightedPnl = 0;
|
|
2471
|
+
let totalFees = 0;
|
|
2472
|
+
// Calculate PNL for each partial close
|
|
2473
|
+
for (const partial of signal._partial) {
|
|
2474
|
+
const partialPercent = partial.percent;
|
|
2475
|
+
const partialPrice = partial.price;
|
|
2476
|
+
// Apply slippage to prices
|
|
2477
|
+
let priceOpenWithSlippage;
|
|
2478
|
+
let priceCloseWithSlippage;
|
|
2479
|
+
if (signal.position === "long") {
|
|
2480
|
+
priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2481
|
+
priceCloseWithSlippage = partialPrice * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2482
|
+
}
|
|
2483
|
+
else {
|
|
2484
|
+
priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2485
|
+
priceCloseWithSlippage = partialPrice * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2486
|
+
}
|
|
2487
|
+
// Calculate PNL for this partial
|
|
2488
|
+
let partialPnl;
|
|
2489
|
+
if (signal.position === "long") {
|
|
2490
|
+
partialPnl = ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100;
|
|
2491
|
+
}
|
|
2492
|
+
else {
|
|
2493
|
+
partialPnl = ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
|
|
2494
|
+
}
|
|
2495
|
+
// Weight by percentage of position closed
|
|
2496
|
+
const weightedPnl = (partialPercent / 100) * partialPnl;
|
|
2497
|
+
totalWeightedPnl += weightedPnl;
|
|
2498
|
+
// Each partial has fees for open + close (2 transactions)
|
|
2499
|
+
totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
|
|
2500
|
+
}
|
|
2501
|
+
// Calculate PNL for remaining position (if any)
|
|
2502
|
+
// Compute totalClosed from _partial array
|
|
2503
|
+
const totalClosed = signal._partial.reduce((sum, p) => sum + p.percent, 0);
|
|
2504
|
+
const remainingPercent = 100 - totalClosed;
|
|
2505
|
+
if (remainingPercent > 0) {
|
|
2506
|
+
// Apply slippage
|
|
2507
|
+
let priceOpenWithSlippage;
|
|
2508
|
+
let priceCloseWithSlippage;
|
|
2509
|
+
if (signal.position === "long") {
|
|
2510
|
+
priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2511
|
+
priceCloseWithSlippage = priceClose * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2512
|
+
}
|
|
2513
|
+
else {
|
|
2514
|
+
priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2515
|
+
priceCloseWithSlippage = priceClose * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2516
|
+
}
|
|
2517
|
+
// Calculate PNL for remaining
|
|
2518
|
+
let remainingPnl;
|
|
2519
|
+
if (signal.position === "long") {
|
|
2520
|
+
remainingPnl = ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100;
|
|
2521
|
+
}
|
|
2522
|
+
else {
|
|
2523
|
+
remainingPnl = ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
|
|
2524
|
+
}
|
|
2525
|
+
// Weight by remaining percentage
|
|
2526
|
+
const weightedRemainingPnl = (remainingPercent / 100) * remainingPnl;
|
|
2527
|
+
totalWeightedPnl += weightedRemainingPnl;
|
|
2528
|
+
// Final close also has fees
|
|
2529
|
+
totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
|
|
2530
|
+
}
|
|
2531
|
+
// Subtract total fees from weighted PNL
|
|
2532
|
+
const pnlPercentage = totalWeightedPnl - totalFees;
|
|
2533
|
+
return {
|
|
2534
|
+
pnlPercentage,
|
|
2535
|
+
priceOpen,
|
|
2536
|
+
priceClose,
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
// Original logic for signals without partial closes
|
|
2540
|
+
let priceOpenWithSlippage;
|
|
2541
|
+
let priceCloseWithSlippage;
|
|
2542
|
+
if (signal.position === "long") {
|
|
2543
|
+
// LONG: покупаем дороже, продаем дешевле
|
|
2544
|
+
priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2545
|
+
priceCloseWithSlippage = priceClose * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2546
|
+
}
|
|
2547
|
+
else {
|
|
2548
|
+
// SHORT: продаем дешевле, покупаем дороже
|
|
2549
|
+
priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2550
|
+
priceCloseWithSlippage = priceClose * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
|
|
2551
|
+
}
|
|
2552
|
+
// Применяем комиссию дважды (при открытии и закрытии)
|
|
2553
|
+
const totalFee = GLOBAL_CONFIG.CC_PERCENT_FEE * 2;
|
|
2554
|
+
let pnlPercentage;
|
|
2555
|
+
if (signal.position === "long") {
|
|
2556
|
+
// LONG: прибыль при росте цены
|
|
2557
|
+
pnlPercentage =
|
|
2558
|
+
((priceCloseWithSlippage - priceOpenWithSlippage) /
|
|
2559
|
+
priceOpenWithSlippage) *
|
|
2560
|
+
100;
|
|
2561
|
+
}
|
|
2562
|
+
else {
|
|
2563
|
+
// SHORT: прибыль при падении цены
|
|
2564
|
+
pnlPercentage =
|
|
2565
|
+
((priceOpenWithSlippage - priceCloseWithSlippage) /
|
|
2566
|
+
priceOpenWithSlippage) *
|
|
2567
|
+
100;
|
|
2568
|
+
}
|
|
2569
|
+
// Вычитаем комиссии
|
|
2570
|
+
pnlPercentage -= totalFee;
|
|
2571
|
+
return {
|
|
2572
|
+
pnlPercentage,
|
|
2573
|
+
priceOpen,
|
|
2574
|
+
priceClose,
|
|
2575
|
+
};
|
|
2576
|
+
};
|
|
2226
2577
|
|
|
2227
2578
|
/**
|
|
2228
2579
|
* Converts markdown content to plain text with minimal formatting
|
|
@@ -4389,6 +4740,7 @@ class ClientStrategy {
|
|
|
4389
4740
|
this._lastSignalTimestamp = null;
|
|
4390
4741
|
this._scheduledSignal = null;
|
|
4391
4742
|
this._cancelledSignal = null;
|
|
4743
|
+
this._closedSignal = null;
|
|
4392
4744
|
/**
|
|
4393
4745
|
* Initializes strategy state by loading persisted signal from disk.
|
|
4394
4746
|
*
|
|
@@ -4665,6 +5017,41 @@ class ClientStrategy {
|
|
|
4665
5017
|
reason: "user",
|
|
4666
5018
|
cancelId: cancelledSignal.cancelId,
|
|
4667
5019
|
};
|
|
5020
|
+
await CALL_TICK_CALLBACKS_FN(this, this.params.execution.context.symbol, result, currentTime, this.params.execution.context.backtest);
|
|
5021
|
+
return result;
|
|
5022
|
+
}
|
|
5023
|
+
// Check if pending signal was closed - emit closed event once
|
|
5024
|
+
if (this._closedSignal) {
|
|
5025
|
+
const currentPrice = await this.params.exchange.getAveragePrice(this.params.execution.context.symbol);
|
|
5026
|
+
const closedSignal = this._closedSignal;
|
|
5027
|
+
this._closedSignal = null; // Clear after emitting
|
|
5028
|
+
this.params.logger.info("ClientStrategy tick: pending signal was closed", {
|
|
5029
|
+
symbol: this.params.execution.context.symbol,
|
|
5030
|
+
signalId: closedSignal.id,
|
|
5031
|
+
});
|
|
5032
|
+
// Call onClose callback
|
|
5033
|
+
await CALL_CLOSE_CALLBACKS_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, currentTime, this.params.execution.context.backtest);
|
|
5034
|
+
// КРИТИЧНО: Очищаем состояние ClientPartial при закрытии позиции
|
|
5035
|
+
await CALL_PARTIAL_CLEAR_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, currentTime, this.params.execution.context.backtest);
|
|
5036
|
+
// КРИТИЧНО: Очищаем состояние ClientBreakeven при закрытии позиции
|
|
5037
|
+
await CALL_BREAKEVEN_CLEAR_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, currentTime, this.params.execution.context.backtest);
|
|
5038
|
+
await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, currentTime, this.params.execution.context.backtest);
|
|
5039
|
+
const pnl = toProfitLossDto(closedSignal, currentPrice);
|
|
5040
|
+
const result = {
|
|
5041
|
+
action: "closed",
|
|
5042
|
+
signal: TO_PUBLIC_SIGNAL(closedSignal),
|
|
5043
|
+
currentPrice,
|
|
5044
|
+
closeReason: "closed",
|
|
5045
|
+
closeTimestamp: currentTime,
|
|
5046
|
+
pnl,
|
|
5047
|
+
strategyName: this.params.method.context.strategyName,
|
|
5048
|
+
exchangeName: this.params.method.context.exchangeName,
|
|
5049
|
+
frameName: this.params.method.context.frameName,
|
|
5050
|
+
symbol: this.params.execution.context.symbol,
|
|
5051
|
+
backtest: this.params.execution.context.backtest,
|
|
5052
|
+
closeId: closedSignal.closeId,
|
|
5053
|
+
};
|
|
5054
|
+
await CALL_TICK_CALLBACKS_FN(this, this.params.execution.context.symbol, result, currentTime, this.params.execution.context.backtest);
|
|
4668
5055
|
return result;
|
|
4669
5056
|
}
|
|
4670
5057
|
// Monitor scheduled signal
|
|
@@ -4778,8 +5165,40 @@ class ClientStrategy {
|
|
|
4778
5165
|
reason: "user",
|
|
4779
5166
|
cancelId: cancelledSignal.cancelId,
|
|
4780
5167
|
};
|
|
5168
|
+
await CALL_TICK_CALLBACKS_FN(this, this.params.execution.context.symbol, cancelledResult, closeTimestamp, this.params.execution.context.backtest);
|
|
4781
5169
|
return cancelledResult;
|
|
4782
5170
|
}
|
|
5171
|
+
// If signal was closed - return closed
|
|
5172
|
+
if (this._closedSignal) {
|
|
5173
|
+
this.params.logger.debug("ClientStrategy backtest: pending signal was closed");
|
|
5174
|
+
const currentPrice = await this.params.exchange.getAveragePrice(symbol);
|
|
5175
|
+
const closedSignal = this._closedSignal;
|
|
5176
|
+
this._closedSignal = null; // Clear after using
|
|
5177
|
+
const closeTimestamp = this.params.execution.context.when.getTime();
|
|
5178
|
+
await CALL_CLOSE_CALLBACKS_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, closeTimestamp, this.params.execution.context.backtest);
|
|
5179
|
+
// КРИТИЧНО: Очищаем состояние ClientPartial при закрытии позиции
|
|
5180
|
+
await CALL_PARTIAL_CLEAR_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, closeTimestamp, this.params.execution.context.backtest);
|
|
5181
|
+
// КРИТИЧНО: Очищаем состояние ClientBreakeven при закрытии позиции
|
|
5182
|
+
await CALL_BREAKEVEN_CLEAR_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, closeTimestamp, this.params.execution.context.backtest);
|
|
5183
|
+
await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, closeTimestamp, this.params.execution.context.backtest);
|
|
5184
|
+
const pnl = toProfitLossDto(closedSignal, currentPrice);
|
|
5185
|
+
const closedResult = {
|
|
5186
|
+
action: "closed",
|
|
5187
|
+
signal: TO_PUBLIC_SIGNAL(closedSignal),
|
|
5188
|
+
currentPrice,
|
|
5189
|
+
closeReason: "closed",
|
|
5190
|
+
closeTimestamp: closeTimestamp,
|
|
5191
|
+
pnl,
|
|
5192
|
+
strategyName: this.params.method.context.strategyName,
|
|
5193
|
+
exchangeName: this.params.method.context.exchangeName,
|
|
5194
|
+
frameName: this.params.method.context.frameName,
|
|
5195
|
+
symbol: this.params.execution.context.symbol,
|
|
5196
|
+
backtest: true,
|
|
5197
|
+
closeId: closedSignal.closeId,
|
|
5198
|
+
};
|
|
5199
|
+
await CALL_TICK_CALLBACKS_FN(this, this.params.execution.context.symbol, closedResult, closeTimestamp, this.params.execution.context.backtest);
|
|
5200
|
+
return closedResult;
|
|
5201
|
+
}
|
|
4783
5202
|
if (!this._pendingSignal && !this._scheduledSignal) {
|
|
4784
5203
|
throw new Error("ClientStrategy backtest: no pending or scheduled signal");
|
|
4785
5204
|
}
|
|
@@ -4906,12 +5325,12 @@ class ClientStrategy {
|
|
|
4906
5325
|
* @example
|
|
4907
5326
|
* ```typescript
|
|
4908
5327
|
* // In Live.background() cancellation
|
|
4909
|
-
* await strategy.
|
|
5328
|
+
* await strategy.stopStrategy();
|
|
4910
5329
|
* // Existing signal will continue until natural close
|
|
4911
5330
|
* ```
|
|
4912
5331
|
*/
|
|
4913
|
-
async
|
|
4914
|
-
this.params.logger.debug("ClientStrategy
|
|
5332
|
+
async stopStrategy(symbol, backtest) {
|
|
5333
|
+
this.params.logger.debug("ClientStrategy stopStrategy", {
|
|
4915
5334
|
symbol,
|
|
4916
5335
|
hasPendingSignal: this._pendingSignal !== null,
|
|
4917
5336
|
hasScheduledSignal: this._scheduledSignal !== null,
|
|
@@ -4944,12 +5363,12 @@ class ClientStrategy {
|
|
|
4944
5363
|
* @example
|
|
4945
5364
|
* ```typescript
|
|
4946
5365
|
* // Cancel scheduled signal without stopping strategy
|
|
4947
|
-
* await strategy.
|
|
5366
|
+
* await strategy.cancelScheduled("BTCUSDT", "my-strategy", false);
|
|
4948
5367
|
* // Strategy continues, can generate new signals
|
|
4949
5368
|
* ```
|
|
4950
5369
|
*/
|
|
4951
|
-
async
|
|
4952
|
-
this.params.logger.debug("ClientStrategy
|
|
5370
|
+
async cancelScheduled(symbol, backtest, cancelId) {
|
|
5371
|
+
this.params.logger.debug("ClientStrategy cancelScheduled", {
|
|
4953
5372
|
symbol,
|
|
4954
5373
|
hasScheduledSignal: this._scheduledSignal !== null,
|
|
4955
5374
|
cancelId,
|
|
@@ -4966,6 +5385,45 @@ class ClientStrategy {
|
|
|
4966
5385
|
}
|
|
4967
5386
|
await PersistScheduleAdapter.writeScheduleData(this._scheduledSignal, symbol, this.params.method.context.strategyName, this.params.method.context.exchangeName);
|
|
4968
5387
|
}
|
|
5388
|
+
/**
|
|
5389
|
+
* Closes the pending signal without stopping the strategy.
|
|
5390
|
+
*
|
|
5391
|
+
* Clears the pending signal (active position).
|
|
5392
|
+
* Does NOT affect scheduled signals or strategy operation.
|
|
5393
|
+
* Does NOT set stop flag - strategy can continue generating new signals.
|
|
5394
|
+
*
|
|
5395
|
+
* Use case: Close an active position that is no longer desired without stopping the entire strategy.
|
|
5396
|
+
*
|
|
5397
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
5398
|
+
* @param backtest - Whether running in backtest mode
|
|
5399
|
+
* @param closeId - Optional identifier for this close operation
|
|
5400
|
+
* @returns Promise that resolves when pending signal is cleared
|
|
5401
|
+
*
|
|
5402
|
+
* @example
|
|
5403
|
+
* ```typescript
|
|
5404
|
+
* // Close pending signal without stopping strategy
|
|
5405
|
+
* await strategy.closePending("BTCUSDT", false, "user-close-123");
|
|
5406
|
+
* // Strategy continues, can generate new signals
|
|
5407
|
+
* ```
|
|
5408
|
+
*/
|
|
5409
|
+
async closePending(symbol, backtest, closeId) {
|
|
5410
|
+
this.params.logger.debug("ClientStrategy closePending", {
|
|
5411
|
+
symbol,
|
|
5412
|
+
hasPendingSignal: this._pendingSignal !== null,
|
|
5413
|
+
closeId,
|
|
5414
|
+
});
|
|
5415
|
+
// Save closed signal for next tick to emit closed event
|
|
5416
|
+
if (this._pendingSignal) {
|
|
5417
|
+
this._closedSignal = Object.assign({}, this._pendingSignal, {
|
|
5418
|
+
closeId,
|
|
5419
|
+
});
|
|
5420
|
+
this._pendingSignal = null;
|
|
5421
|
+
}
|
|
5422
|
+
if (backtest) {
|
|
5423
|
+
return;
|
|
5424
|
+
}
|
|
5425
|
+
await PersistSignalAdapter.writeSignalData(this._pendingSignal, symbol, this.params.strategyName, this.params.exchangeName);
|
|
5426
|
+
}
|
|
4969
5427
|
/**
|
|
4970
5428
|
* Executes partial close at profit level (moving toward TP).
|
|
4971
5429
|
*
|
|
@@ -6418,7 +6876,7 @@ class StrategyConnectionService {
|
|
|
6418
6876
|
/**
|
|
6419
6877
|
* Stops the specified strategy from generating new signals.
|
|
6420
6878
|
*
|
|
6421
|
-
* Delegates to ClientStrategy.
|
|
6879
|
+
* Delegates to ClientStrategy.stopStrategy() which sets internal flag to prevent
|
|
6422
6880
|
* getSignal from being called on subsequent ticks.
|
|
6423
6881
|
*
|
|
6424
6882
|
* @param backtest - Whether running in backtest mode
|
|
@@ -6426,13 +6884,13 @@ class StrategyConnectionService {
|
|
|
6426
6884
|
* @param ctx - Context with strategyName, exchangeName, frameName
|
|
6427
6885
|
* @returns Promise that resolves when stop flag is set
|
|
6428
6886
|
*/
|
|
6429
|
-
this.
|
|
6430
|
-
this.loggerService.log("strategyConnectionService
|
|
6887
|
+
this.stopStrategy = async (backtest, symbol, context) => {
|
|
6888
|
+
this.loggerService.log("strategyConnectionService stopStrategy", {
|
|
6431
6889
|
symbol,
|
|
6432
6890
|
context,
|
|
6433
6891
|
});
|
|
6434
6892
|
const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
|
|
6435
|
-
await strategy.
|
|
6893
|
+
await strategy.stopStrategy(symbol, backtest);
|
|
6436
6894
|
};
|
|
6437
6895
|
/**
|
|
6438
6896
|
* Disposes the ClientStrategy instance for the given context.
|
|
@@ -6484,7 +6942,7 @@ class StrategyConnectionService {
|
|
|
6484
6942
|
/**
|
|
6485
6943
|
* Cancels the scheduled signal for the specified strategy.
|
|
6486
6944
|
*
|
|
6487
|
-
* Delegates to ClientStrategy.
|
|
6945
|
+
* Delegates to ClientStrategy.cancelScheduled() which clears the scheduled signal
|
|
6488
6946
|
* without stopping the strategy or affecting pending signals.
|
|
6489
6947
|
*
|
|
6490
6948
|
* Note: Cancelled event will be emitted on next tick() call when strategy
|
|
@@ -6496,14 +6954,39 @@ class StrategyConnectionService {
|
|
|
6496
6954
|
* @param cancelId - Optional cancellation ID for user-initiated cancellations
|
|
6497
6955
|
* @returns Promise that resolves when scheduled signal is cancelled
|
|
6498
6956
|
*/
|
|
6499
|
-
this.
|
|
6500
|
-
this.loggerService.log("strategyConnectionService
|
|
6957
|
+
this.cancelScheduled = async (backtest, symbol, context, cancelId) => {
|
|
6958
|
+
this.loggerService.log("strategyConnectionService cancelScheduled", {
|
|
6501
6959
|
symbol,
|
|
6502
6960
|
context,
|
|
6503
6961
|
cancelId,
|
|
6504
6962
|
});
|
|
6505
6963
|
const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
|
|
6506
|
-
await strategy.
|
|
6964
|
+
await strategy.cancelScheduled(symbol, backtest, cancelId);
|
|
6965
|
+
};
|
|
6966
|
+
/**
|
|
6967
|
+
* Closes the pending signal without stopping the strategy.
|
|
6968
|
+
*
|
|
6969
|
+
* Clears the pending signal (active position).
|
|
6970
|
+
* Does NOT affect scheduled signals or strategy operation.
|
|
6971
|
+
* Does NOT set stop flag - strategy can continue generating new signals.
|
|
6972
|
+
*
|
|
6973
|
+
* Note: Closed event will be emitted on next tick() call when strategy
|
|
6974
|
+
* detects the pending signal was closed.
|
|
6975
|
+
*
|
|
6976
|
+
* @param backtest - Whether running in backtest mode
|
|
6977
|
+
* @param symbol - Trading pair symbol
|
|
6978
|
+
* @param context - Context with strategyName, exchangeName, frameName
|
|
6979
|
+
* @param closeId - Optional close ID for user-initiated closes
|
|
6980
|
+
* @returns Promise that resolves when pending signal is closed
|
|
6981
|
+
*/
|
|
6982
|
+
this.closePending = async (backtest, symbol, context, closeId) => {
|
|
6983
|
+
this.loggerService.log("strategyConnectionService closePending", {
|
|
6984
|
+
symbol,
|
|
6985
|
+
context,
|
|
6986
|
+
closeId,
|
|
6987
|
+
});
|
|
6988
|
+
const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
|
|
6989
|
+
await strategy.closePending(symbol, backtest, closeId);
|
|
6507
6990
|
};
|
|
6508
6991
|
/**
|
|
6509
6992
|
* Executes partial close at profit level (moving toward TP).
|
|
@@ -9690,19 +10173,19 @@ class StrategyCoreService {
|
|
|
9690
10173
|
* @param ctx - Context with strategyName, exchangeName, frameName
|
|
9691
10174
|
* @returns Promise that resolves when stop flag is set
|
|
9692
10175
|
*/
|
|
9693
|
-
this.
|
|
9694
|
-
this.loggerService.log("strategyCoreService
|
|
10176
|
+
this.stopStrategy = async (backtest, symbol, context) => {
|
|
10177
|
+
this.loggerService.log("strategyCoreService stopStrategy", {
|
|
9695
10178
|
symbol,
|
|
9696
10179
|
context,
|
|
9697
10180
|
backtest,
|
|
9698
10181
|
});
|
|
9699
10182
|
await this.validate(context);
|
|
9700
|
-
return await this.strategyConnectionService.
|
|
10183
|
+
return await this.strategyConnectionService.stopStrategy(backtest, symbol, context);
|
|
9701
10184
|
};
|
|
9702
10185
|
/**
|
|
9703
10186
|
* Cancels the scheduled signal without stopping the strategy.
|
|
9704
10187
|
*
|
|
9705
|
-
* Delegates to StrategyConnectionService.
|
|
10188
|
+
* Delegates to StrategyConnectionService.cancelScheduled() to clear scheduled signal
|
|
9706
10189
|
* and emit cancelled event through emitters.
|
|
9707
10190
|
* Does not require execution context.
|
|
9708
10191
|
*
|
|
@@ -9712,15 +10195,42 @@ class StrategyCoreService {
|
|
|
9712
10195
|
* @param cancelId - Optional cancellation ID for user-initiated cancellations
|
|
9713
10196
|
* @returns Promise that resolves when scheduled signal is cancelled
|
|
9714
10197
|
*/
|
|
9715
|
-
this.
|
|
9716
|
-
this.loggerService.log("strategyCoreService
|
|
10198
|
+
this.cancelScheduled = async (backtest, symbol, context, cancelId) => {
|
|
10199
|
+
this.loggerService.log("strategyCoreService cancelScheduled", {
|
|
9717
10200
|
symbol,
|
|
9718
10201
|
context,
|
|
9719
10202
|
backtest,
|
|
9720
10203
|
cancelId,
|
|
9721
10204
|
});
|
|
9722
10205
|
await this.validate(context);
|
|
9723
|
-
return await this.strategyConnectionService.
|
|
10206
|
+
return await this.strategyConnectionService.cancelScheduled(backtest, symbol, context, cancelId);
|
|
10207
|
+
};
|
|
10208
|
+
/**
|
|
10209
|
+
* Closes the pending signal without stopping the strategy.
|
|
10210
|
+
*
|
|
10211
|
+
* Clears the pending signal (active position).
|
|
10212
|
+
* Does NOT affect scheduled signals or strategy operation.
|
|
10213
|
+
* Does NOT set stop flag - strategy can continue generating new signals.
|
|
10214
|
+
*
|
|
10215
|
+
* Delegates to StrategyConnectionService.closePending() to clear pending signal
|
|
10216
|
+
* and emit closed event through emitters.
|
|
10217
|
+
* Does not require execution context.
|
|
10218
|
+
*
|
|
10219
|
+
* @param backtest - Whether running in backtest mode
|
|
10220
|
+
* @param symbol - Trading pair symbol
|
|
10221
|
+
* @param context - Context with strategyName, exchangeName, frameName
|
|
10222
|
+
* @param closeId - Optional close ID for user-initiated closes
|
|
10223
|
+
* @returns Promise that resolves when pending signal is closed
|
|
10224
|
+
*/
|
|
10225
|
+
this.closePending = async (backtest, symbol, context, closeId) => {
|
|
10226
|
+
this.loggerService.log("strategyCoreService closePending", {
|
|
10227
|
+
symbol,
|
|
10228
|
+
context,
|
|
10229
|
+
backtest,
|
|
10230
|
+
closeId,
|
|
10231
|
+
});
|
|
10232
|
+
await this.validate(context);
|
|
10233
|
+
return await this.strategyConnectionService.closePending(backtest, symbol, context, closeId);
|
|
9724
10234
|
};
|
|
9725
10235
|
/**
|
|
9726
10236
|
* Disposes the ClientStrategy instance for the given context.
|
|
@@ -25006,7 +25516,8 @@ async function getOrderBook(symbol, depth) {
|
|
|
25006
25516
|
return await bt.exchangeConnectionService.getOrderBook(symbol, depth);
|
|
25007
25517
|
}
|
|
25008
25518
|
|
|
25009
|
-
const
|
|
25519
|
+
const CANCEL_SCHEDULED_METHOD_NAME = "strategy.commitCancelScheduled";
|
|
25520
|
+
const CLOSE_PENDING_METHOD_NAME = "strategy.commitClosePending";
|
|
25010
25521
|
const PARTIAL_PROFIT_METHOD_NAME = "strategy.commitPartialProfit";
|
|
25011
25522
|
const PARTIAL_LOSS_METHOD_NAME = "strategy.commitPartialLoss";
|
|
25012
25523
|
const TRAILING_STOP_METHOD_NAME = "strategy.commitTrailingStop";
|
|
@@ -25028,26 +25539,62 @@ const BREAKEVEN_METHOD_NAME = "strategy.commitBreakeven";
|
|
|
25028
25539
|
*
|
|
25029
25540
|
* @example
|
|
25030
25541
|
* ```typescript
|
|
25031
|
-
* import {
|
|
25542
|
+
* import { commitCancelScheduled } from "backtest-kit";
|
|
25032
25543
|
*
|
|
25033
25544
|
* // Cancel scheduled signal with custom ID
|
|
25034
|
-
* await
|
|
25545
|
+
* await commitCancelScheduled("BTCUSDT", "manual-cancel-001");
|
|
25035
25546
|
* ```
|
|
25036
25547
|
*/
|
|
25037
|
-
async function
|
|
25038
|
-
bt.loggerService.info(
|
|
25548
|
+
async function commitCancelScheduled(symbol, cancelId) {
|
|
25549
|
+
bt.loggerService.info(CANCEL_SCHEDULED_METHOD_NAME, {
|
|
25039
25550
|
symbol,
|
|
25040
25551
|
cancelId,
|
|
25041
25552
|
});
|
|
25042
25553
|
if (!ExecutionContextService.hasContext()) {
|
|
25043
|
-
throw new Error("
|
|
25554
|
+
throw new Error("commitCancelScheduled requires an execution context");
|
|
25555
|
+
}
|
|
25556
|
+
if (!MethodContextService.hasContext()) {
|
|
25557
|
+
throw new Error("commitCancelScheduled requires a method context");
|
|
25558
|
+
}
|
|
25559
|
+
const { backtest: isBacktest } = bt.executionContextService.context;
|
|
25560
|
+
const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
|
|
25561
|
+
await bt.strategyCoreService.cancelScheduled(isBacktest, symbol, { exchangeName, frameName, strategyName }, cancelId);
|
|
25562
|
+
}
|
|
25563
|
+
/**
|
|
25564
|
+
* Closes the pending signal without stopping the strategy.
|
|
25565
|
+
*
|
|
25566
|
+
* Clears the pending signal (active position).
|
|
25567
|
+
* Does NOT affect scheduled signals or strategy operation.
|
|
25568
|
+
* Does NOT set stop flag - strategy can continue generating new signals.
|
|
25569
|
+
*
|
|
25570
|
+
* Automatically detects backtest/live mode from execution context.
|
|
25571
|
+
*
|
|
25572
|
+
* @param symbol - Trading pair symbol
|
|
25573
|
+
* @param closeId - Optional close ID for tracking user-initiated closes
|
|
25574
|
+
* @returns Promise that resolves when pending signal is closed
|
|
25575
|
+
*
|
|
25576
|
+
* @example
|
|
25577
|
+
* ```typescript
|
|
25578
|
+
* import { commitClosePending } from "backtest-kit";
|
|
25579
|
+
*
|
|
25580
|
+
* // Close pending signal with custom ID
|
|
25581
|
+
* await commitClosePending("BTCUSDT", "manual-close-001");
|
|
25582
|
+
* ```
|
|
25583
|
+
*/
|
|
25584
|
+
async function commitClosePending(symbol, closeId) {
|
|
25585
|
+
bt.loggerService.info(CLOSE_PENDING_METHOD_NAME, {
|
|
25586
|
+
symbol,
|
|
25587
|
+
closeId,
|
|
25588
|
+
});
|
|
25589
|
+
if (!ExecutionContextService.hasContext()) {
|
|
25590
|
+
throw new Error("commitClosePending requires an execution context");
|
|
25044
25591
|
}
|
|
25045
25592
|
if (!MethodContextService.hasContext()) {
|
|
25046
|
-
throw new Error("
|
|
25593
|
+
throw new Error("commitClosePending requires a method context");
|
|
25047
25594
|
}
|
|
25048
25595
|
const { backtest: isBacktest } = bt.executionContextService.context;
|
|
25049
25596
|
const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
|
|
25050
|
-
await bt.strategyCoreService.
|
|
25597
|
+
await bt.strategyCoreService.closePending(isBacktest, symbol, { exchangeName, frameName, strategyName }, closeId);
|
|
25051
25598
|
}
|
|
25052
25599
|
/**
|
|
25053
25600
|
* Executes partial close at profit level (moving toward TP).
|
|
@@ -25294,7 +25841,7 @@ async function commitBreakeven(symbol) {
|
|
|
25294
25841
|
return await bt.strategyCoreService.breakeven(isBacktest, symbol, currentPrice, { exchangeName, frameName, strategyName });
|
|
25295
25842
|
}
|
|
25296
25843
|
|
|
25297
|
-
const
|
|
25844
|
+
const STOP_STRATEGY_METHOD_NAME = "control.stopStrategy";
|
|
25298
25845
|
/**
|
|
25299
25846
|
* Stops the strategy from generating new signals.
|
|
25300
25847
|
*
|
|
@@ -25316,8 +25863,8 @@ const STOP_METHOD_NAME = "control.stop";
|
|
|
25316
25863
|
* await stop("BTCUSDT", "my-strategy");
|
|
25317
25864
|
* ```
|
|
25318
25865
|
*/
|
|
25319
|
-
async function
|
|
25320
|
-
bt.loggerService.info(
|
|
25866
|
+
async function stopStrategy(symbol) {
|
|
25867
|
+
bt.loggerService.info(STOP_STRATEGY_METHOD_NAME, {
|
|
25321
25868
|
symbol,
|
|
25322
25869
|
});
|
|
25323
25870
|
if (!ExecutionContextService.hasContext()) {
|
|
@@ -25328,7 +25875,7 @@ async function stop(symbol) {
|
|
|
25328
25875
|
}
|
|
25329
25876
|
const { backtest: isBacktest } = bt.executionContextService.context;
|
|
25330
25877
|
const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
|
|
25331
|
-
await bt.strategyCoreService.
|
|
25878
|
+
await bt.strategyCoreService.stopStrategy(isBacktest, symbol, {
|
|
25332
25879
|
exchangeName,
|
|
25333
25880
|
frameName,
|
|
25334
25881
|
strategyName,
|
|
@@ -27715,7 +28262,8 @@ const BACKTEST_METHOD_NAME_GET_PENDING_SIGNAL = "BacktestUtils.getPendingSignal"
|
|
|
27715
28262
|
const BACKTEST_METHOD_NAME_GET_SCHEDULED_SIGNAL = "BacktestUtils.getScheduledSignal";
|
|
27716
28263
|
const BACKTEST_METHOD_NAME_GET_BREAKEVEN = "BacktestUtils.getBreakeven";
|
|
27717
28264
|
const BACKTEST_METHOD_NAME_BREAKEVEN = "Backtest.commitBreakeven";
|
|
27718
|
-
const
|
|
28265
|
+
const BACKTEST_METHOD_NAME_CANCEL_SCHEDULED = "Backtest.commitCancelScheduled";
|
|
28266
|
+
const BACKTEST_METHOD_NAME_CLOSE_PENDING = "Backtest.commitClosePending";
|
|
27719
28267
|
const BACKTEST_METHOD_NAME_PARTIAL_PROFIT = "BacktestUtils.commitPartialProfit";
|
|
27720
28268
|
const BACKTEST_METHOD_NAME_PARTIAL_LOSS = "BacktestUtils.commitPartialLoss";
|
|
27721
28269
|
const BACKTEST_METHOD_NAME_TRAILING_STOP = "BacktestUtils.commitTrailingStop";
|
|
@@ -27957,7 +28505,7 @@ class BacktestInstance {
|
|
|
27957
28505
|
}
|
|
27958
28506
|
this.task(symbol, context).catch((error) => exitEmitter.next(new Error(getErrorMessage(error))));
|
|
27959
28507
|
return () => {
|
|
27960
|
-
bt.strategyCoreService.
|
|
28508
|
+
bt.strategyCoreService.stopStrategy(true, symbol, {
|
|
27961
28509
|
strategyName: context.strategyName,
|
|
27962
28510
|
exchangeName: context.exchangeName,
|
|
27963
28511
|
frameName: context.frameName,
|
|
@@ -28225,7 +28773,7 @@ class BacktestUtils {
|
|
|
28225
28773
|
actions &&
|
|
28226
28774
|
actions.forEach((actionName) => bt.actionValidationService.validate(actionName, BACKTEST_METHOD_NAME_STOP));
|
|
28227
28775
|
}
|
|
28228
|
-
await bt.strategyCoreService.
|
|
28776
|
+
await bt.strategyCoreService.stopStrategy(true, symbol, context);
|
|
28229
28777
|
};
|
|
28230
28778
|
/**
|
|
28231
28779
|
* Cancels the scheduled signal without stopping the strategy.
|
|
@@ -28250,24 +28798,65 @@ class BacktestUtils {
|
|
|
28250
28798
|
* }, "manual-cancel-001");
|
|
28251
28799
|
* ```
|
|
28252
28800
|
*/
|
|
28253
|
-
this.
|
|
28254
|
-
bt.loggerService.info(
|
|
28801
|
+
this.commitCancelScheduled = async (symbol, context, cancelId) => {
|
|
28802
|
+
bt.loggerService.info(BACKTEST_METHOD_NAME_CANCEL_SCHEDULED, {
|
|
28255
28803
|
symbol,
|
|
28256
28804
|
context,
|
|
28257
28805
|
cancelId,
|
|
28258
28806
|
});
|
|
28259
|
-
bt.strategyValidationService.validate(context.strategyName,
|
|
28260
|
-
bt.exchangeValidationService.validate(context.exchangeName,
|
|
28807
|
+
bt.strategyValidationService.validate(context.strategyName, BACKTEST_METHOD_NAME_CANCEL_SCHEDULED);
|
|
28808
|
+
bt.exchangeValidationService.validate(context.exchangeName, BACKTEST_METHOD_NAME_CANCEL_SCHEDULED);
|
|
28809
|
+
{
|
|
28810
|
+
const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
|
|
28811
|
+
riskName &&
|
|
28812
|
+
bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_CANCEL_SCHEDULED);
|
|
28813
|
+
riskList &&
|
|
28814
|
+
riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_CANCEL_SCHEDULED));
|
|
28815
|
+
actions &&
|
|
28816
|
+
actions.forEach((actionName) => bt.actionValidationService.validate(actionName, BACKTEST_METHOD_NAME_CANCEL_SCHEDULED));
|
|
28817
|
+
}
|
|
28818
|
+
await bt.strategyCoreService.cancelScheduled(true, symbol, context, cancelId);
|
|
28819
|
+
};
|
|
28820
|
+
/**
|
|
28821
|
+
* Closes the pending signal without stopping the strategy.
|
|
28822
|
+
*
|
|
28823
|
+
* Clears the pending signal (active position).
|
|
28824
|
+
* Does NOT affect scheduled signals or strategy operation.
|
|
28825
|
+
* Does NOT set stop flag - strategy can continue generating new signals.
|
|
28826
|
+
*
|
|
28827
|
+
* @param symbol - Trading pair symbol
|
|
28828
|
+
* @param context - Execution context with strategyName, exchangeName, and frameName
|
|
28829
|
+
* @param closeId - Optional close ID for user-initiated closes
|
|
28830
|
+
* @returns Promise that resolves when pending signal is closed
|
|
28831
|
+
*
|
|
28832
|
+
* @example
|
|
28833
|
+
* ```typescript
|
|
28834
|
+
* // Close pending signal with custom ID
|
|
28835
|
+
* await Backtest.commitClose("BTCUSDT", {
|
|
28836
|
+
* exchangeName: "binance",
|
|
28837
|
+
* strategyName: "my-strategy",
|
|
28838
|
+
* frameName: "1m"
|
|
28839
|
+
* }, "manual-close-001");
|
|
28840
|
+
* ```
|
|
28841
|
+
*/
|
|
28842
|
+
this.commitClosePending = async (symbol, context, closeId) => {
|
|
28843
|
+
bt.loggerService.info(BACKTEST_METHOD_NAME_CLOSE_PENDING, {
|
|
28844
|
+
symbol,
|
|
28845
|
+
context,
|
|
28846
|
+
closeId,
|
|
28847
|
+
});
|
|
28848
|
+
bt.strategyValidationService.validate(context.strategyName, BACKTEST_METHOD_NAME_CLOSE_PENDING);
|
|
28849
|
+
bt.exchangeValidationService.validate(context.exchangeName, BACKTEST_METHOD_NAME_CLOSE_PENDING);
|
|
28261
28850
|
{
|
|
28262
28851
|
const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
|
|
28263
28852
|
riskName &&
|
|
28264
|
-
bt.riskValidationService.validate(riskName,
|
|
28853
|
+
bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_CLOSE_PENDING);
|
|
28265
28854
|
riskList &&
|
|
28266
|
-
riskList.forEach((riskName) => bt.riskValidationService.validate(riskName,
|
|
28855
|
+
riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_CLOSE_PENDING));
|
|
28267
28856
|
actions &&
|
|
28268
|
-
actions.forEach((actionName) => bt.actionValidationService.validate(actionName,
|
|
28857
|
+
actions.forEach((actionName) => bt.actionValidationService.validate(actionName, BACKTEST_METHOD_NAME_CLOSE_PENDING));
|
|
28269
28858
|
}
|
|
28270
|
-
await bt.strategyCoreService.
|
|
28859
|
+
await bt.strategyCoreService.closePending(true, symbol, context, closeId);
|
|
28271
28860
|
};
|
|
28272
28861
|
/**
|
|
28273
28862
|
* Executes partial close at profit level (moving toward TP).
|
|
@@ -28704,7 +29293,8 @@ const LIVE_METHOD_NAME_GET_PENDING_SIGNAL = "LiveUtils.getPendingSignal";
|
|
|
28704
29293
|
const LIVE_METHOD_NAME_GET_SCHEDULED_SIGNAL = "LiveUtils.getScheduledSignal";
|
|
28705
29294
|
const LIVE_METHOD_NAME_GET_BREAKEVEN = "LiveUtils.getBreakeven";
|
|
28706
29295
|
const LIVE_METHOD_NAME_BREAKEVEN = "Live.commitBreakeven";
|
|
28707
|
-
const
|
|
29296
|
+
const LIVE_METHOD_NAME_CANCEL_SCHEDULED = "Live.cancelScheduled";
|
|
29297
|
+
const LIVE_METHOD_NAME_CLOSE_PENDING = "Live.closePending";
|
|
28708
29298
|
const LIVE_METHOD_NAME_PARTIAL_PROFIT = "LiveUtils.commitPartialProfit";
|
|
28709
29299
|
const LIVE_METHOD_NAME_PARTIAL_LOSS = "LiveUtils.commitPartialLoss";
|
|
28710
29300
|
const LIVE_METHOD_NAME_TRAILING_STOP = "LiveUtils.commitTrailingStop";
|
|
@@ -28909,7 +29499,7 @@ class LiveInstance {
|
|
|
28909
29499
|
}
|
|
28910
29500
|
this.task(symbol, context).catch((error) => exitEmitter.next(new Error(getErrorMessage(error))));
|
|
28911
29501
|
return () => {
|
|
28912
|
-
bt.strategyCoreService.
|
|
29502
|
+
bt.strategyCoreService.stopStrategy(false, symbol, {
|
|
28913
29503
|
strategyName: context.strategyName,
|
|
28914
29504
|
exchangeName: context.exchangeName,
|
|
28915
29505
|
frameName: ""
|
|
@@ -29176,7 +29766,7 @@ class LiveUtils {
|
|
|
29176
29766
|
riskList && riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_STOP));
|
|
29177
29767
|
actions && actions.forEach((actionName) => bt.actionValidationService.validate(actionName, LIVE_METHOD_NAME_STOP));
|
|
29178
29768
|
}
|
|
29179
|
-
await bt.strategyCoreService.
|
|
29769
|
+
await bt.strategyCoreService.stopStrategy(false, symbol, {
|
|
29180
29770
|
strategyName: context.strategyName,
|
|
29181
29771
|
exchangeName: context.exchangeName,
|
|
29182
29772
|
frameName: "",
|
|
@@ -29205,26 +29795,67 @@ class LiveUtils {
|
|
|
29205
29795
|
* }, "manual-cancel-001");
|
|
29206
29796
|
* ```
|
|
29207
29797
|
*/
|
|
29208
|
-
this.
|
|
29209
|
-
bt.loggerService.info(
|
|
29798
|
+
this.commitCancelScheduled = async (symbol, context, cancelId) => {
|
|
29799
|
+
bt.loggerService.info(LIVE_METHOD_NAME_CANCEL_SCHEDULED, {
|
|
29210
29800
|
symbol,
|
|
29211
29801
|
context,
|
|
29212
29802
|
cancelId,
|
|
29213
29803
|
});
|
|
29214
|
-
bt.strategyValidationService.validate(context.strategyName,
|
|
29215
|
-
bt.exchangeValidationService.validate(context.exchangeName,
|
|
29804
|
+
bt.strategyValidationService.validate(context.strategyName, LIVE_METHOD_NAME_CANCEL_SCHEDULED);
|
|
29805
|
+
bt.exchangeValidationService.validate(context.exchangeName, LIVE_METHOD_NAME_CANCEL_SCHEDULED);
|
|
29216
29806
|
{
|
|
29217
29807
|
const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
|
|
29218
|
-
riskName && bt.riskValidationService.validate(riskName,
|
|
29219
|
-
riskList && riskList.forEach((riskName) => bt.riskValidationService.validate(riskName,
|
|
29220
|
-
actions && actions.forEach((actionName) => bt.actionValidationService.validate(actionName,
|
|
29808
|
+
riskName && bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_CANCEL_SCHEDULED);
|
|
29809
|
+
riskList && riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_CANCEL_SCHEDULED));
|
|
29810
|
+
actions && actions.forEach((actionName) => bt.actionValidationService.validate(actionName, LIVE_METHOD_NAME_CANCEL_SCHEDULED));
|
|
29221
29811
|
}
|
|
29222
|
-
await bt.strategyCoreService.
|
|
29812
|
+
await bt.strategyCoreService.cancelScheduled(false, symbol, {
|
|
29223
29813
|
strategyName: context.strategyName,
|
|
29224
29814
|
exchangeName: context.exchangeName,
|
|
29225
29815
|
frameName: "",
|
|
29226
29816
|
}, cancelId);
|
|
29227
29817
|
};
|
|
29818
|
+
/**
|
|
29819
|
+
* Closes the pending signal without stopping the strategy.
|
|
29820
|
+
*
|
|
29821
|
+
* Clears the pending signal (active position).
|
|
29822
|
+
* Does NOT affect scheduled signals or strategy operation.
|
|
29823
|
+
* Does NOT set stop flag - strategy can continue generating new signals.
|
|
29824
|
+
*
|
|
29825
|
+
* @param symbol - Trading pair symbol
|
|
29826
|
+
* @param context - Execution context with strategyName and exchangeName
|
|
29827
|
+
* @param closeId - Optional close ID for user-initiated closes
|
|
29828
|
+
* @returns Promise that resolves when pending signal is closed
|
|
29829
|
+
*
|
|
29830
|
+
* @example
|
|
29831
|
+
* ```typescript
|
|
29832
|
+
* // Close pending signal with custom ID
|
|
29833
|
+
* await Live.commitClose("BTCUSDT", {
|
|
29834
|
+
* exchangeName: "binance",
|
|
29835
|
+
* strategyName: "my-strategy"
|
|
29836
|
+
* }, "manual-close-001");
|
|
29837
|
+
* ```
|
|
29838
|
+
*/
|
|
29839
|
+
this.commitClosePending = async (symbol, context, closeId) => {
|
|
29840
|
+
bt.loggerService.info(LIVE_METHOD_NAME_CLOSE_PENDING, {
|
|
29841
|
+
symbol,
|
|
29842
|
+
context,
|
|
29843
|
+
closeId,
|
|
29844
|
+
});
|
|
29845
|
+
bt.strategyValidationService.validate(context.strategyName, LIVE_METHOD_NAME_CLOSE_PENDING);
|
|
29846
|
+
bt.exchangeValidationService.validate(context.exchangeName, LIVE_METHOD_NAME_CLOSE_PENDING);
|
|
29847
|
+
{
|
|
29848
|
+
const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
|
|
29849
|
+
riskName && bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_CLOSE_PENDING);
|
|
29850
|
+
riskList && riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_CLOSE_PENDING));
|
|
29851
|
+
actions && actions.forEach((actionName) => bt.actionValidationService.validate(actionName, LIVE_METHOD_NAME_CLOSE_PENDING));
|
|
29852
|
+
}
|
|
29853
|
+
await bt.strategyCoreService.closePending(false, symbol, {
|
|
29854
|
+
strategyName: context.strategyName,
|
|
29855
|
+
exchangeName: context.exchangeName,
|
|
29856
|
+
frameName: "",
|
|
29857
|
+
}, closeId);
|
|
29858
|
+
};
|
|
29228
29859
|
/**
|
|
29229
29860
|
* Executes partial close at profit level (moving toward TP).
|
|
29230
29861
|
*
|
|
@@ -30141,7 +30772,7 @@ class WalkerInstance {
|
|
|
30141
30772
|
this.task(symbol, context).catch((error) => exitEmitter.next(new Error(getErrorMessage(error))));
|
|
30142
30773
|
return () => {
|
|
30143
30774
|
for (const strategyName of walkerSchema.strategies) {
|
|
30144
|
-
bt.strategyCoreService.
|
|
30775
|
+
bt.strategyCoreService.stopStrategy(true, symbol, {
|
|
30145
30776
|
strategyName,
|
|
30146
30777
|
exchangeName: walkerSchema.exchangeName,
|
|
30147
30778
|
frameName: walkerSchema.frameName
|
|
@@ -30297,7 +30928,7 @@ class WalkerUtils {
|
|
|
30297
30928
|
}
|
|
30298
30929
|
for (const strategyName of walkerSchema.strategies) {
|
|
30299
30930
|
await walkerStopSubject.next({ symbol, strategyName, walkerName: context.walkerName });
|
|
30300
|
-
await bt.strategyCoreService.
|
|
30931
|
+
await bt.strategyCoreService.stopStrategy(true, symbol, {
|
|
30301
30932
|
strategyName,
|
|
30302
30933
|
exchangeName: walkerSchema.exchangeName,
|
|
30303
30934
|
frameName: walkerSchema.frameName
|
|
@@ -31230,6 +31861,61 @@ const CREATE_EXCHANGE_INSTANCE_FN = (schema) => {
|
|
|
31230
31861
|
getOrderBook,
|
|
31231
31862
|
};
|
|
31232
31863
|
};
|
|
31864
|
+
/**
|
|
31865
|
+
* Attempts to read candles from cache.
|
|
31866
|
+
* Validates cache consistency (no gaps in timestamps) before returning.
|
|
31867
|
+
*
|
|
31868
|
+
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
31869
|
+
* @param sinceTimestamp - Start timestamp in milliseconds
|
|
31870
|
+
* @param untilTimestamp - End timestamp in milliseconds
|
|
31871
|
+
* @param exchangeName - Exchange name
|
|
31872
|
+
* @returns Cached candles array or null if cache miss or inconsistent
|
|
31873
|
+
*/
|
|
31874
|
+
const READ_CANDLES_CACHE_FN = trycatch(async (dto, sinceTimestamp, untilTimestamp, exchangeName) => {
|
|
31875
|
+
const cachedCandles = await PersistCandleAdapter.readCandlesData(dto.symbol, dto.interval, exchangeName, dto.limit, sinceTimestamp, untilTimestamp);
|
|
31876
|
+
// Return cached data only if we have exactly the requested limit
|
|
31877
|
+
if (cachedCandles.length === dto.limit) {
|
|
31878
|
+
bt.loggerService.debug(`ExchangeInstance READ_CANDLES_CACHE_FN: cache hit for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, limit=${dto.limit}`);
|
|
31879
|
+
return cachedCandles;
|
|
31880
|
+
}
|
|
31881
|
+
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}`);
|
|
31882
|
+
return null;
|
|
31883
|
+
}, {
|
|
31884
|
+
fallback: async (error) => {
|
|
31885
|
+
const message = `ExchangeInstance READ_CANDLES_CACHE_FN: cache read failed`;
|
|
31886
|
+
const payload = {
|
|
31887
|
+
error: errorData(error),
|
|
31888
|
+
message: getErrorMessage(error),
|
|
31889
|
+
};
|
|
31890
|
+
bt.loggerService.warn(message, payload);
|
|
31891
|
+
console.warn(message, payload);
|
|
31892
|
+
errorEmitter.next(error);
|
|
31893
|
+
},
|
|
31894
|
+
defaultValue: null,
|
|
31895
|
+
});
|
|
31896
|
+
/**
|
|
31897
|
+
* Writes candles to cache with error handling.
|
|
31898
|
+
*
|
|
31899
|
+
* @param candles - Array of candle data to cache
|
|
31900
|
+
* @param dto - Data transfer object containing symbol, interval, and limit
|
|
31901
|
+
* @param exchangeName - Exchange name
|
|
31902
|
+
*/
|
|
31903
|
+
const WRITE_CANDLES_CACHE_FN = trycatch(queued(async (candles, dto, exchangeName) => {
|
|
31904
|
+
await PersistCandleAdapter.writeCandlesData(candles, dto.symbol, dto.interval, exchangeName);
|
|
31905
|
+
bt.loggerService.debug(`ExchangeInstance WRITE_CANDLES_CACHE_FN: cache updated for exchangeName=${exchangeName}, symbol=${dto.symbol}, interval=${dto.interval}, count=${candles.length}`);
|
|
31906
|
+
}), {
|
|
31907
|
+
fallback: async (error) => {
|
|
31908
|
+
const message = `ExchangeInstance WRITE_CANDLES_CACHE_FN: cache write failed`;
|
|
31909
|
+
const payload = {
|
|
31910
|
+
error: errorData(error),
|
|
31911
|
+
message: getErrorMessage(error),
|
|
31912
|
+
};
|
|
31913
|
+
bt.loggerService.warn(message, payload);
|
|
31914
|
+
console.warn(message, payload);
|
|
31915
|
+
errorEmitter.next(error);
|
|
31916
|
+
},
|
|
31917
|
+
defaultValue: null,
|
|
31918
|
+
});
|
|
31233
31919
|
/**
|
|
31234
31920
|
* Instance class for exchange operations on a specific exchange.
|
|
31235
31921
|
*
|
|
@@ -31287,6 +31973,13 @@ class ExchangeInstance {
|
|
|
31287
31973
|
}
|
|
31288
31974
|
const when = new Date(Date.now());
|
|
31289
31975
|
const since = new Date(when.getTime() - adjust * 60 * 1000);
|
|
31976
|
+
const sinceTimestamp = since.getTime();
|
|
31977
|
+
const untilTimestamp = sinceTimestamp + limit * step * 60 * 1000;
|
|
31978
|
+
// Try to read from cache first
|
|
31979
|
+
const cachedCandles = await READ_CANDLES_CACHE_FN({ symbol, interval, limit }, sinceTimestamp, untilTimestamp, this.exchangeName);
|
|
31980
|
+
if (cachedCandles !== null) {
|
|
31981
|
+
return cachedCandles;
|
|
31982
|
+
}
|
|
31290
31983
|
let allData = [];
|
|
31291
31984
|
// If limit exceeds CC_MAX_CANDLES_PER_REQUEST, fetch data in chunks
|
|
31292
31985
|
if (limit > GLOBAL_CONFIG.CC_MAX_CANDLES_PER_REQUEST) {
|
|
@@ -31309,7 +32002,6 @@ class ExchangeInstance {
|
|
|
31309
32002
|
allData = await getCandles(symbol, interval, since, limit, isBacktest);
|
|
31310
32003
|
}
|
|
31311
32004
|
// Filter candles to strictly match the requested range
|
|
31312
|
-
const sinceTimestamp = since.getTime();
|
|
31313
32005
|
const whenTimestamp = when.getTime();
|
|
31314
32006
|
const stepMs = step * 60 * 1000;
|
|
31315
32007
|
const filteredData = allData.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp < whenTimestamp + stepMs);
|
|
@@ -31321,6 +32013,8 @@ class ExchangeInstance {
|
|
|
31321
32013
|
if (uniqueData.length < limit) {
|
|
31322
32014
|
bt.loggerService.warn(`ExchangeInstance Expected ${limit} candles, got ${uniqueData.length}`);
|
|
31323
32015
|
}
|
|
32016
|
+
// Write to cache after successful fetch
|
|
32017
|
+
await WRITE_CANDLES_CACHE_FN(uniqueData, { symbol, interval, limit }, this.exchangeName);
|
|
31324
32018
|
return uniqueData;
|
|
31325
32019
|
};
|
|
31326
32020
|
/**
|
|
@@ -32680,4 +33374,4 @@ const set = (object, path, value) => {
|
|
|
32680
33374
|
}
|
|
32681
33375
|
};
|
|
32682
33376
|
|
|
32683
|
-
export { ActionBase, Backtest, Breakeven, Cache, Constant, Exchange, ExecutionContextService, Heat, Live, Markdown, MarkdownFileBase, MarkdownFolderBase, MethodContextService, Notification, Optimizer, Partial, Performance, PersistBase, PersistBreakevenAdapter, PersistPartialAdapter, PersistRiskAdapter, PersistScheduleAdapter, PersistSignalAdapter, PositionSize, Report, ReportBase, Risk, Schedule, Walker, addActionSchema, addExchangeSchema, addFrameSchema, addOptimizerSchema, addRiskSchema, addSizingSchema, addStrategySchema, addWalkerSchema, commitBreakeven,
|
|
33377
|
+
export { ActionBase, Backtest, Breakeven, Cache, Constant, Exchange, ExecutionContextService, Heat, Live, Markdown, MarkdownFileBase, MarkdownFolderBase, MethodContextService, Notification, Optimizer, Partial, Performance, PersistBase, PersistBreakevenAdapter, PersistCandleAdapter, PersistPartialAdapter, PersistRiskAdapter, PersistScheduleAdapter, PersistSignalAdapter, PositionSize, Report, ReportBase, Risk, Schedule, Walker, addActionSchema, addExchangeSchema, addFrameSchema, addOptimizerSchema, addRiskSchema, addSizingSchema, addStrategySchema, addWalkerSchema, commitBreakeven, commitCancelScheduled, commitClosePending, commitPartialLoss, commitPartialProfit, commitSignalPromptHistory, commitTrailingStop, commitTrailingTake, dumpSignalData, emitters, formatPrice, formatQuantity, get, getActionSchema, getAveragePrice, getBacktestTimeframe, getCandles, getColumns, getConfig, getContext, getDate, getDefaultColumns, getDefaultConfig, getExchangeSchema, getFrameSchema, getMode, getOptimizerSchema, getOrderBook, getRiskSchema, getSizingSchema, getStrategySchema, getSymbol, getWalkerSchema, hasTradeContext, backtest as lib, listExchangeSchema, listFrameSchema, listOptimizerSchema, listRiskSchema, listSizingSchema, listStrategySchema, listWalkerSchema, listenActivePing, listenActivePingOnce, listenBacktestProgress, listenBreakevenAvailable, listenBreakevenAvailableOnce, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenExit, listenOptimizerProgress, listenPartialLossAvailable, listenPartialLossAvailableOnce, listenPartialProfitAvailable, listenPartialProfitAvailableOnce, listenPerformance, listenRisk, listenRiskOnce, listenSchedulePing, listenSchedulePingOnce, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, listenWalkerProgress, overrideActionSchema, overrideExchangeSchema, overrideFrameSchema, overrideOptimizerSchema, overrideRiskSchema, overrideSizingSchema, overrideStrategySchema, overrideWalkerSchema, parseArgs, roundTicks, set, setColumns, setConfig, setLogger, stopStrategy, validate };
|