UNPKG

backtest-kit

Version:

A TypeScript library for trading system backtest

1,273 lines (1,262 loc) 562 kB
import { createActivator } from 'di-kit'; import { scoped } from 'di-scoped'; import { errorData, getErrorMessage, sleep, memoize, makeExtendable, singleshot, not, trycatch, retry, Subject, randomString, ToolRegistry, isObject, resolveDocuments, str, iterateDocuments, distinctDocuments, queued } from 'functools-kit'; import fs, { mkdir, writeFile } from 'fs/promises'; import path, { join } from 'path'; import crypto from 'crypto'; import os from 'os'; const GLOBAL_CONFIG = { /** * Time to wait for scheduled signal to activate (in minutes) * If signal does not activate within this time, it will be cancelled. */ CC_SCHEDULE_AWAIT_MINUTES: 120, /** * Number of candles to use for average price calculation (VWAP) * Default: 5 candles (last 5 minutes when using 1m interval) */ CC_AVG_PRICE_CANDLES_COUNT: 5, /** * Minimum TakeProfit distance from priceOpen (percentage) * Must be greater than trading fees to ensure profitable trades * Default: 0.3% (covers 2×0.1% fees + minimum profit margin) */ CC_MIN_TAKEPROFIT_DISTANCE_PERCENT: 0.1, /** * Maximum StopLoss distance from priceOpen (percentage) * Prevents catastrophic losses from extreme StopLoss values * Default: 20% (one signal cannot lose more than 20% of position) */ CC_MAX_STOPLOSS_DISTANCE_PERCENT: 20, /** * Maximum signal lifetime in minutes * Prevents eternal signals that block risk limits for weeks/months * Default: 1440 minutes (1 day) */ CC_MAX_SIGNAL_LIFETIME_MINUTES: 1440, /** * Number of retries for getCandles function * Default: 3 retries */ CC_GET_CANDLES_RETRY_COUNT: 3, /** * Delay between retries for getCandles function (in milliseconds) * Default: 5000 ms (5 seconds) */ CC_GET_CANDLES_RETRY_DELAY_MS: 5000, /** * Maximum allowed deviation factor for price anomaly detection. * Price should not be more than this factor lower than reference price. * * Reasoning: * - Incomplete candles from Binance API typically have prices near 0 (e.g., $0.01-1) * - Normal BTC price ranges: $20,000-100,000 * - Factor 1000 catches prices below $20-100 when median is $20,000-100,000 * - Factor 100 would be too permissive (allows $200 when median is $20,000) * - Factor 10000 might be too strict for low-cap altcoins * * Example: BTC at $50,000 median → threshold $50 (catches $0.01-1 anomalies) */ CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR: 1000, /** * Minimum number of candles required for reliable median calculation. * Below this threshold, use simple average instead of median. * * Reasoning: * - Each candle provides 4 price points (OHLC) * - 5 candles = 20 price points, sufficient for robust median calculation * - Below 5 candles, single anomaly can heavily skew median * - Statistical rule of thumb: minimum 7-10 data points for median stability * - Average is more stable than median for small datasets (n < 20) * * Example: 3 candles = 12 points (use average), 5 candles = 20 points (use median) */ CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN: 5, }; const { init, inject, provide } = createActivator("backtest"); /** * Scoped service for method context propagation. * * Uses di-scoped for implicit context passing without explicit parameters. * Context includes strategyName, exchangeName, and frameName. * * Used by PublicServices to inject schema names into ConnectionServices. * * @example * ```typescript * MethodContextService.runAsyncIterator( * backtestGenerator, * { * strategyName: "my-strategy", * exchangeName: "my-exchange", * frameName: "1d-backtest" * } * ); * ``` */ const MethodContextService = scoped(class { constructor(context) { this.context = context; } }); const baseServices$1 = { loggerService: Symbol('loggerService'), }; const contextServices$1 = { executionContextService: Symbol('executionContextService'), methodContextService: Symbol('methodContextService'), }; const connectionServices$1 = { exchangeConnectionService: Symbol('exchangeConnectionService'), strategyConnectionService: Symbol('strategyConnectionService'), frameConnectionService: Symbol('frameConnectionService'), sizingConnectionService: Symbol('sizingConnectionService'), riskConnectionService: Symbol('riskConnectionService'), optimizerConnectionService: Symbol('optimizerConnectionService'), partialConnectionService: Symbol('partialConnectionService'), }; const schemaServices$1 = { exchangeSchemaService: Symbol('exchangeSchemaService'), strategySchemaService: Symbol('strategySchemaService'), frameSchemaService: Symbol('frameSchemaService'), walkerSchemaService: Symbol('walkerSchemaService'), sizingSchemaService: Symbol('sizingSchemaService'), riskSchemaService: Symbol('riskSchemaService'), optimizerSchemaService: Symbol('optimizerSchemaService'), }; const globalServices$1 = { exchangeGlobalService: Symbol('exchangeGlobalService'), strategyGlobalService: Symbol('strategyGlobalService'), frameGlobalService: Symbol('frameGlobalService'), sizingGlobalService: Symbol('sizingGlobalService'), riskGlobalService: Symbol('riskGlobalService'), optimizerGlobalService: Symbol('optimizerGlobalService'), partialGlobalService: Symbol('partialGlobalService'), }; const commandServices$1 = { liveCommandService: Symbol('liveCommandService'), backtestCommandService: Symbol('backtestCommandService'), walkerCommandService: Symbol('walkerCommandService'), }; const logicPrivateServices$1 = { backtestLogicPrivateService: Symbol('backtestLogicPrivateService'), liveLogicPrivateService: Symbol('liveLogicPrivateService'), walkerLogicPrivateService: Symbol('walkerLogicPrivateService'), }; const logicPublicServices$1 = { backtestLogicPublicService: Symbol('backtestLogicPublicService'), liveLogicPublicService: Symbol('liveLogicPublicService'), walkerLogicPublicService: Symbol('walkerLogicPublicService'), }; const markdownServices$1 = { backtestMarkdownService: Symbol('backtestMarkdownService'), liveMarkdownService: Symbol('liveMarkdownService'), scheduleMarkdownService: Symbol('scheduleMarkdownService'), performanceMarkdownService: Symbol('performanceMarkdownService'), walkerMarkdownService: Symbol('walkerMarkdownService'), heatMarkdownService: Symbol('heatMarkdownService'), partialMarkdownService: Symbol('partialMarkdownService'), }; const validationServices$1 = { exchangeValidationService: Symbol('exchangeValidationService'), strategyValidationService: Symbol('strategyValidationService'), frameValidationService: Symbol('frameValidationService'), walkerValidationService: Symbol('walkerValidationService'), sizingValidationService: Symbol('sizingValidationService'), riskValidationService: Symbol('riskValidationService'), optimizerValidationService: Symbol('optimizerValidationService'), }; const templateServices$1 = { optimizerTemplateService: Symbol('optimizerTemplateService'), }; const TYPES = { ...baseServices$1, ...contextServices$1, ...connectionServices$1, ...schemaServices$1, ...globalServices$1, ...commandServices$1, ...logicPrivateServices$1, ...logicPublicServices$1, ...markdownServices$1, ...validationServices$1, ...templateServices$1, }; /** * Scoped service for execution context propagation. * * Uses di-scoped for implicit context passing without explicit parameters. * Context includes symbol, when (timestamp), and backtest flag. * * Used by GlobalServices to inject context into operations. * * @example * ```typescript * ExecutionContextService.runInContext( * async () => { * // Inside this callback, context is automatically available * return await someOperation(); * }, * { symbol: "BTCUSDT", when: new Date(), backtest: true } * ); * ``` */ const ExecutionContextService = scoped(class { constructor(context) { this.context = context; } }); /** * No-op logger implementation used as default. * Silently discards all log messages. */ const NOOP_LOGGER = { log() { }, debug() { }, info() { }, warn() { }, }; /** * Logger service with automatic context injection. * * Features: * - Delegates to user-provided logger via setLogger() * - Automatically appends method context (strategyName, exchangeName, frameName) * - Automatically appends execution context (symbol, when, backtest) * - Defaults to NOOP_LOGGER if no logger configured * * Used throughout the framework for consistent logging with context. */ class LoggerService { constructor() { this.methodContextService = inject(TYPES.methodContextService); this.executionContextService = inject(TYPES.executionContextService); this._commonLogger = NOOP_LOGGER; /** * Logs general-purpose message with automatic context injection. * * @param topic - Log topic/category * @param args - Additional log arguments */ this.log = async (topic, ...args) => { await this._commonLogger.log(topic, ...args, this.methodContext, this.executionContext); }; /** * Logs debug-level message with automatic context injection. * * @param topic - Log topic/category * @param args - Additional log arguments */ this.debug = async (topic, ...args) => { await this._commonLogger.debug(topic, ...args, this.methodContext, this.executionContext); }; /** * Logs info-level message with automatic context injection. * * @param topic - Log topic/category * @param args - Additional log arguments */ this.info = async (topic, ...args) => { await this._commonLogger.info(topic, ...args, this.methodContext, this.executionContext); }; /** * Logs warning-level message with automatic context injection. * * @param topic - Log topic/category * @param args - Additional log arguments */ this.warn = async (topic, ...args) => { await this._commonLogger.warn(topic, ...args, this.methodContext, this.executionContext); }; /** * Sets custom logger implementation. * * @param logger - Custom logger implementing ILogger interface */ this.setLogger = (logger) => { this._commonLogger = logger; }; } /** * Gets current method context if available. * Contains strategyName, exchangeName, frameName from MethodContextService. */ get methodContext() { if (MethodContextService.hasContext()) { return this.methodContextService.context; } return {}; } /** * Gets current execution context if available. * Contains symbol, when, backtest from ExecutionContextService. */ get executionContext() { if (ExecutionContextService.hasContext()) { return this.executionContextService.context; } return {}; } } const INTERVAL_MINUTES$2 = { "1m": 1, "3m": 3, "5m": 5, "15m": 15, "30m": 30, "1h": 60, "2h": 120, "4h": 240, "6h": 360, "8h": 480, }; /** * Validates that all candles have valid OHLCV data without anomalies. * Detects incomplete candles from Binance API by checking for abnormally low prices or volumes. * Incomplete candles often have prices like 0.1 instead of normal 100,000 or zero volume. * * @param candles - Array of candle data to validate * @throws Error if any candles have anomalous OHLCV values */ const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => { if (candles.length === 0) { return; } // Calculate reference price (median or average depending on candle count) const allPrices = candles.flatMap((c) => [c.open, c.high, c.low, c.close]); const validPrices = allPrices.filter(p => p > 0); let referencePrice; if (candles.length >= GLOBAL_CONFIG.CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN) { // Use median for reliable statistics with enough data const sortedPrices = [...validPrices].sort((a, b) => a - b); referencePrice = sortedPrices[Math.floor(sortedPrices.length / 2)] || 0; } else { // Use average for small datasets (more stable than median) const sum = validPrices.reduce((acc, p) => acc + p, 0); referencePrice = validPrices.length > 0 ? sum / validPrices.length : 0; } if (referencePrice === 0) { throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: cannot calculate reference price (all prices are zero)`); } const minValidPrice = referencePrice / GLOBAL_CONFIG.CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR; for (let i = 0; i < candles.length; i++) { const candle = candles[i]; // Check for invalid numeric values if (!Number.isFinite(candle.open) || !Number.isFinite(candle.high) || !Number.isFinite(candle.low) || !Number.isFinite(candle.close) || !Number.isFinite(candle.volume) || !Number.isFinite(candle.timestamp)) { throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has invalid numeric values (NaN or Infinity)`); } // Check for negative values if (candle.open <= 0 || candle.high <= 0 || candle.low <= 0 || candle.close <= 0 || candle.volume < 0) { throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has zero or negative values`); } // Check for anomalously low prices (incomplete candle indicator) if (candle.open < minValidPrice || candle.high < minValidPrice || candle.low < minValidPrice || candle.close < minValidPrice) { throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has anomalously low price. ` + `OHLC: [${candle.open}, ${candle.high}, ${candle.low}, ${candle.close}], ` + `reference: ${referencePrice}, threshold: ${minValidPrice}`); } } }; /** * Retries the getCandles function with specified retry count and delay. * @param dto - Data transfer object containing symbol, interval, and limit * @param since - Date object representing the start time for fetching candles * @param self - Instance of ClientExchange * @returns Promise resolving to array of candle data */ const GET_CANDLES_FN = async (dto, since, self) => { let lastError; for (let i = 0; i !== GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT; i++) { try { const result = await self.params.getCandles(dto.symbol, dto.interval, since, dto.limit); VALIDATE_NO_INCOMPLETE_CANDLES_FN(result); return result; } catch (err) { const message = `ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}}`; self.params.logger.warn(message, { error: errorData(err), message: getErrorMessage(err), }); console.warn(message); lastError = err; await sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS); } } throw lastError; }; /** * Client implementation for exchange data access. * * Features: * - Historical candle fetching (backwards from execution context) * - Future candle fetching (forwards for backtest) * - VWAP calculation from last 5 1m candles * - Price/quantity formatting for exchange * * All methods use prototype functions for memory efficiency. * * @example * ```typescript * const exchange = new ClientExchange({ * exchangeName: "binance", * getCandles: async (symbol, interval, since, limit) => [...], * formatPrice: async (symbol, price) => price.toFixed(2), * formatQuantity: async (symbol, quantity) => quantity.toFixed(8), * execution: executionService, * logger: loggerService, * }); * * const candles = await exchange.getCandles("BTCUSDT", "1m", 100); * const vwap = await exchange.getAveragePrice("BTCUSDT"); * ``` */ class ClientExchange { constructor(params) { this.params = params; } /** * Fetches historical candles backwards from execution context time. * * @param symbol - Trading pair symbol * @param interval - Candle interval * @param limit - Number of candles to fetch * @returns Promise resolving to array of candles */ async getCandles(symbol, interval, limit) { this.params.logger.debug(`ClientExchange getCandles`, { symbol, interval, limit, }); const step = INTERVAL_MINUTES$2[interval]; const adjust = step * limit - step; if (!adjust) { throw new Error(`ClientExchange unknown time adjust for interval=${interval}`); } const since = new Date(this.params.execution.context.when.getTime() - adjust * 60 * 1000); const data = await GET_CANDLES_FN({ symbol, interval, limit }, since, this); // Filter candles to strictly match the requested range const whenTimestamp = this.params.execution.context.when.getTime(); const sinceTimestamp = since.getTime(); const filteredData = data.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp <= whenTimestamp); if (filteredData.length < limit) { this.params.logger.warn(`ClientExchange Expected ${limit} candles, got ${filteredData.length}`); } if (this.params.callbacks?.onCandleData) { this.params.callbacks.onCandleData(symbol, interval, since, limit, filteredData); } return filteredData; } /** * Fetches future candles forwards from execution context time. * Used in backtest mode to get candles for signal duration. * * @param symbol - Trading pair symbol * @param interval - Candle interval * @param limit - Number of candles to fetch * @returns Promise resolving to array of candles * @throws Error if trying to fetch future candles in live mode */ async getNextCandles(symbol, interval, limit) { this.params.logger.debug(`ClientExchange getNextCandles`, { symbol, interval, limit, }); const since = new Date(this.params.execution.context.when.getTime()); const now = Date.now(); // Вычисляем конечное время запроса const step = INTERVAL_MINUTES$2[interval]; const endTime = since.getTime() + limit * step * 60 * 1000; // Проверяем что запрошенный период не заходит за Date.now() if (endTime > now) { return []; } const data = await GET_CANDLES_FN({ symbol, interval, limit }, since, this); // Filter candles to strictly match the requested range const sinceTimestamp = since.getTime(); const filteredData = data.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp <= endTime); if (filteredData.length < limit) { this.params.logger.warn(`ClientExchange getNextCandles: Expected ${limit} candles, got ${filteredData.length}`); } if (this.params.callbacks?.onCandleData) { this.params.callbacks.onCandleData(symbol, interval, since, limit, filteredData); } return filteredData; } /** * Calculates VWAP (Volume Weighted Average Price) from last N 1m candles. * The number of candles is configurable via GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT. * * Formula: * - Typical Price = (high + low + close) / 3 * - VWAP = sum(typical_price * volume) / sum(volume) * * If volume is zero, returns simple average of close prices. * * @param symbol - Trading pair symbol * @returns Promise resolving to VWAP price * @throws Error if no candles available */ async getAveragePrice(symbol) { this.params.logger.debug(`ClientExchange getAveragePrice`, { symbol, }); const candles = await this.getCandles(symbol, "1m", GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT); if (candles.length === 0) { throw new Error(`ClientExchange getAveragePrice: no candles data for symbol=${symbol}`); } // VWAP (Volume Weighted Average Price) // Используем типичную цену (typical price) = (high + low + close) / 3 const sumPriceVolume = candles.reduce((acc, candle) => { const typicalPrice = (candle.high + candle.low + candle.close) / 3; return acc + typicalPrice * candle.volume; }, 0); const totalVolume = candles.reduce((acc, candle) => acc + candle.volume, 0); if (totalVolume === 0) { // Если объем нулевой, возвращаем простое среднее close цен const sum = candles.reduce((acc, candle) => acc + candle.close, 0); return sum / candles.length; } const vwap = sumPriceVolume / totalVolume; return vwap; } async formatQuantity(symbol, quantity) { this.params.logger.debug("binanceService formatQuantity", { symbol, quantity, }); return await this.params.formatQuantity(symbol, quantity); } async formatPrice(symbol, price) { this.params.logger.debug("binanceService formatPrice", { symbol, price, }); return await this.params.formatPrice(symbol, price); } } /** * Connection service routing exchange operations to correct ClientExchange instance. * * Routes all IExchange method calls to the appropriate exchange implementation * based on methodContextService.context.exchangeName. Uses memoization to cache * ClientExchange instances for performance. * * Key features: * - Automatic exchange routing via method context * - Memoized ClientExchange instances by exchangeName * - Implements full IExchange interface * - Logging for all operations * * @example * ```typescript * // Used internally by framework * const candles = await exchangeConnectionService.getCandles( * "BTCUSDT", "1h", 100 * ); * // Automatically routes to correct exchange based on methodContext * ``` */ class ExchangeConnectionService { constructor() { this.loggerService = inject(TYPES.loggerService); this.executionContextService = inject(TYPES.executionContextService); this.exchangeSchemaService = inject(TYPES.exchangeSchemaService); this.methodContextService = inject(TYPES.methodContextService); /** * Retrieves memoized ClientExchange instance for given exchange name. * * Creates ClientExchange on first call, returns cached instance on subsequent calls. * Cache key is exchangeName string. * * @param exchangeName - Name of registered exchange schema * @returns Configured ClientExchange instance */ this.getExchange = memoize(([exchangeName]) => `${exchangeName}`, (exchangeName) => { const { getCandles, formatPrice, formatQuantity, callbacks } = this.exchangeSchemaService.get(exchangeName); return new ClientExchange({ execution: this.executionContextService, logger: this.loggerService, exchangeName, getCandles, formatPrice, formatQuantity, callbacks, }); }); /** * Fetches historical candles for symbol using configured exchange. * * Routes to exchange determined by methodContextService.context.exchangeName. * * @param symbol - Trading pair symbol (e.g., "BTCUSDT") * @param interval - Candle interval (e.g., "1h", "1d") * @param limit - Maximum number of candles to fetch * @returns Promise resolving to array of candle data */ this.getCandles = async (symbol, interval, limit) => { this.loggerService.log("exchangeConnectionService getCandles", { symbol, interval, limit, }); return await this.getExchange(this.methodContextService.context.exchangeName).getCandles(symbol, interval, limit); }; /** * Fetches next batch of candles relative to executionContext.when. * * Returns candles that come after the current execution timestamp. * Used for backtest progression and live trading updates. * * @param symbol - Trading pair symbol (e.g., "BTCUSDT") * @param interval - Candle interval (e.g., "1h", "1d") * @param limit - Maximum number of candles to fetch * @returns Promise resolving to array of candle data */ this.getNextCandles = async (symbol, interval, limit) => { this.loggerService.log("exchangeConnectionService getNextCandles", { symbol, interval, limit, }); return await this.getExchange(this.methodContextService.context.exchangeName).getNextCandles(symbol, interval, limit); }; /** * Retrieves current average price for symbol. * * In live mode: fetches real-time average price from exchange API. * In backtest mode: calculates VWAP from candles in current timeframe. * * @param symbol - Trading pair symbol (e.g., "BTCUSDT") * @returns Promise resolving to average price */ this.getAveragePrice = async (symbol) => { this.loggerService.log("exchangeConnectionService getAveragePrice", { symbol, }); return await this.getExchange(this.methodContextService.context.exchangeName).getAveragePrice(symbol); }; /** * Formats price according to exchange-specific precision rules. * * Ensures price meets exchange requirements for decimal places and tick size. * * @param symbol - Trading pair symbol (e.g., "BTCUSDT") * @param price - Raw price value to format * @returns Promise resolving to formatted price string */ this.formatPrice = async (symbol, price) => { this.loggerService.log("exchangeConnectionService getAveragePrice", { symbol, price, }); return await this.getExchange(this.methodContextService.context.exchangeName).formatPrice(symbol, price); }; /** * Formats quantity according to exchange-specific precision rules. * * Ensures quantity meets exchange requirements for decimal places and lot size. * * @param symbol - Trading pair symbol (e.g., "BTCUSDT") * @param quantity - Raw quantity value to format * @returns Promise resolving to formatted quantity string */ this.formatQuantity = async (symbol, quantity) => { this.loggerService.log("exchangeConnectionService getAveragePrice", { symbol, quantity, }); return await this.getExchange(this.methodContextService.context.exchangeName).formatQuantity(symbol, quantity); }; } } /** * Slippage percentage applied to entry and exit prices. * Simulates market impact and order book depth. */ const PERCENT_SLIPPAGE = 0.1; /** * Fee percentage charged per transaction. * Applied twice (entry and exit) for total fee calculation. */ const PERCENT_FEE = 0.1; /** * Calculates profit/loss for a closed signal with slippage and fees. * * Formula breakdown: * 1. Apply slippage to open/close prices (worse execution) * - LONG: buy higher (+slippage), sell lower (-slippage) * - SHORT: sell lower (-slippage), buy higher (+slippage) * 2. Calculate raw PNL percentage * - LONG: ((closePrice - openPrice) / openPrice) * 100 * - SHORT: ((openPrice - closePrice) / openPrice) * 100 * 3. Subtract total fees (0.1% * 2 = 0.2%) * * @param signal - Closed signal with position details * @param priceClose - Actual close price at exit * @returns PNL data with percentage and prices * * @example * ```typescript * const pnl = toProfitLossDto( * { * position: "long", * priceOpen: 50000, * // ... other signal fields * }, * 51000 // close price * ); * console.log(pnl.pnlPercentage); // e.g., 1.8% (after slippage and fees) * ``` */ const toProfitLossDto = (signal, priceClose) => { const priceOpen = signal.priceOpen; let priceOpenWithSlippage; let priceCloseWithSlippage; if (signal.position === "long") { // LONG: покупаем дороже, продаем дешевле priceOpenWithSlippage = priceOpen * (1 + PERCENT_SLIPPAGE / 100); priceCloseWithSlippage = priceClose * (1 - PERCENT_SLIPPAGE / 100); } else { // SHORT: продаем дешевле, покупаем дороже priceOpenWithSlippage = priceOpen * (1 - PERCENT_SLIPPAGE / 100); priceCloseWithSlippage = priceClose * (1 + PERCENT_SLIPPAGE / 100); } // Применяем комиссию дважды (при открытии и закрытии) const totalFee = PERCENT_FEE * 2; let pnlPercentage; if (signal.position === "long") { // LONG: прибыль при росте цены pnlPercentage = ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100; } else { // SHORT: прибыль при падении цены pnlPercentage = ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100; } // Вычитаем комиссии pnlPercentage -= totalFee; return { pnlPercentage, priceOpen, priceClose, }; }; const IS_WINDOWS = os.platform() === "win32"; /** * Atomically writes data to a file, ensuring the operation either fully completes or leaves the original file unchanged. * 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). * * * @param {string} file - The file parameter. * @param {string | Buffer} data - The data to be processed or validated. * @param {Options | BufferEncoding} options - The options parameter (optional). * @throws {Error} Throws an error if the write, sync, or rename operation fails, after attempting cleanup of temporary files. * * @example * // Basic usage with default options * await writeFileAtomic("output.txt", "Hello, world!"); * // Writes "Hello, world!" to "output.txt" atomically * * @example * // Custom options and Buffer data * const buffer = Buffer.from("Binary data"); * await writeFileAtomic("data.bin", buffer, { encoding: "binary", mode: 0o644, tmpPrefix: "temp-" }); * // Writes binary data to "data.bin" with custom permissions and temp prefix * * @example * // Using encoding shorthand * await writeFileAtomic("log.txt", "Log entry", "utf16le"); * // Writes "Log entry" to "log.txt" in UTF-16LE encoding * * @remarks * This function ensures atomicity to prevent partial writes: * - On POSIX systems (non-Windows, unless `GLOBAL_CONFIG.CC_SKIP_POSIX_RENAME` is true): * - Writes data to a temporary file (e.g., `.tmp-<random>-filename`) in the same directory. * - Uses `crypto.randomBytes` to generate a unique temporary name, reducing collision risk. * - Syncs the data to disk and renames the temporary file to the target file atomically with `fs.rename`. * - Cleans up the temporary file on failure, swallowing cleanup errors to prioritize throwing the original error. * - On Windows (or when POSIX rename is skipped): * - Writes directly to the target file, syncing data to disk to minimize corruption risk (though not fully atomic). * - Closes the file handle on failure without additional cleanup. * - Accepts `options` as an object or a string (interpreted as `encoding`), defaulting to `{ encoding: "utf8", mode: 0o666, tmpPrefix: ".tmp-" }`. * Useful in the agent swarm system for safely writing configuration files, logs, or state data where partial writes could cause corruption. * * @see {@link https://nodejs.org/api/fs.html#fspromiseswritefilefile-data-options|fs.promises.writeFile} for file writing details. * @see {@link https://nodejs.org/api/crypto.html#cryptorandombytessize-callback|crypto.randomBytes} for temporary file naming. * @see {@link ../config/params|GLOBAL_CONFIG} for configuration impacting POSIX behavior. */ async function writeFileAtomic(file, data, options = {}) { if (typeof options === "string") { options = { encoding: options }; } else if (!options) { options = {}; } const { encoding = "utf8", mode = 0o666, tmpPrefix = ".tmp-" } = options; let fileHandle = null; if (IS_WINDOWS) { try { // Create and write to temporary file fileHandle = await fs.open(file, "w", mode); // Write data to the temp file await fileHandle.writeFile(data, { encoding }); // Ensure data is flushed to disk await fileHandle.sync(); // Close the file before rename await fileHandle.close(); } catch (error) { // Clean up if something went wrong if (fileHandle) { await fileHandle.close().catch(() => { }); } throw error; // Re-throw the original error } return; } // Create a temporary filename in the same directory const dir = path.dirname(file); const filename = path.basename(file); const tmpFile = path.join(dir, `${tmpPrefix}${crypto.randomBytes(6).toString("hex")}-${filename}`); try { // Create and write to temporary file fileHandle = await fs.open(tmpFile, "w", mode); // Write data to the temp file await fileHandle.writeFile(data, { encoding }); // Ensure data is flushed to disk await fileHandle.sync(); // Close the file before rename await fileHandle.close(); fileHandle = null; // Atomically replace the target file with our temp file await fs.rename(tmpFile, file); } catch (error) { // Clean up if something went wrong if (fileHandle) { await fileHandle.close().catch(() => { }); } // Try to remove the temporary file try { await fs.unlink(tmpFile).catch(() => { }); } catch (_) { // Ignore errors during cleanup } throw error; } } var _a; const BASE_WAIT_FOR_INIT_SYMBOL = Symbol("wait-for-init"); const PERSIST_SIGNAL_UTILS_METHOD_NAME_USE_PERSIST_SIGNAL_ADAPTER = "PersistSignalUtils.usePersistSignalAdapter"; const PERSIST_SIGNAL_UTILS_METHOD_NAME_READ_DATA = "PersistSignalUtils.readSignalData"; const PERSIST_SIGNAL_UTILS_METHOD_NAME_WRITE_DATA = "PersistSignalUtils.writeSignalData"; const PERSIST_SCHEDULE_UTILS_METHOD_NAME_USE_PERSIST_SCHEDULE_ADAPTER = "PersistScheduleUtils.usePersistScheduleAdapter"; const PERSIST_SCHEDULE_UTILS_METHOD_NAME_READ_DATA = "PersistScheduleUtils.readScheduleData"; const PERSIST_SCHEDULE_UTILS_METHOD_NAME_WRITE_DATA = "PersistScheduleUtils.writeScheduleData"; const PERSIST_PARTIAL_UTILS_METHOD_NAME_USE_PERSIST_PARTIAL_ADAPTER = "PersistPartialUtils.usePersistPartialAdapter"; const PERSIST_PARTIAL_UTILS_METHOD_NAME_READ_DATA = "PersistPartialUtils.readPartialData"; const PERSIST_PARTIAL_UTILS_METHOD_NAME_WRITE_DATA = "PersistPartialUtils.writePartialData"; const PERSIST_BASE_METHOD_NAME_CTOR = "PersistBase.CTOR"; const PERSIST_BASE_METHOD_NAME_WAIT_FOR_INIT = "PersistBase.waitForInit"; const PERSIST_BASE_METHOD_NAME_READ_VALUE = "PersistBase.readValue"; const PERSIST_BASE_METHOD_NAME_WRITE_VALUE = "PersistBase.writeValue"; const PERSIST_BASE_METHOD_NAME_HAS_VALUE = "PersistBase.hasValue"; const PERSIST_BASE_METHOD_NAME_REMOVE_VALUE = "PersistBase.removeValue"; const PERSIST_BASE_METHOD_NAME_REMOVE_ALL = "PersistBase.removeAll"; const PERSIST_BASE_METHOD_NAME_VALUES = "PersistBase.values"; const PERSIST_BASE_METHOD_NAME_KEYS = "PersistBase.keys"; const BASE_WAIT_FOR_INIT_FN_METHOD_NAME = "PersistBase.waitForInitFn"; const BASE_UNLINK_RETRY_COUNT = 5; const BASE_UNLINK_RETRY_DELAY = 1000; const BASE_WAIT_FOR_INIT_FN = async (self) => { backtest$1.loggerService.debug(BASE_WAIT_FOR_INIT_FN_METHOD_NAME, { entityName: self.entityName, directory: self._directory, }); await fs.mkdir(self._directory, { recursive: true }); for await (const key of self.keys()) { try { await self.readValue(key); } catch { const filePath = self._getFilePath(key); console.error(`backtest-kit PersistBase found invalid document for filePath=${filePath} entityName=${self.entityName}`); if (await not(BASE_WAIT_FOR_INIT_UNLINK_FN(filePath))) { console.error(`backtest-kit PersistBase failed to remove invalid document for filePath=${filePath} entityName=${self.entityName}`); } } } }; const BASE_WAIT_FOR_INIT_UNLINK_FN = async (filePath) => trycatch(retry(async () => { try { await fs.unlink(filePath); return true; } catch (error) { console.error(`backtest-kit PersistBase unlink failed for filePath=${filePath} error=${getErrorMessage(error)}`); throw error; } }, BASE_UNLINK_RETRY_COUNT, BASE_UNLINK_RETRY_DELAY), { defaultValue: false, }); /** * Base class for file-based persistence with atomic writes. * * Features: * - Atomic file writes using writeFileAtomic * - Auto-validation and cleanup of corrupted files * - Async generator support for iteration * - Retry logic for file deletion * * @example * ```typescript * const persist = new PersistBase("my-entity", "./data"); * await persist.waitForInit(true); * await persist.writeValue("key1", { data: "value" }); * const value = await persist.readValue("key1"); * ``` */ const PersistBase = makeExtendable(class { /** * Creates new persistence instance. * * @param entityName - Unique entity type identifier * @param baseDir - Base directory for all entities (default: ./dump/data) */ constructor(entityName, baseDir = join(process.cwd(), "logs/data")) { this.entityName = entityName; this.baseDir = baseDir; this[_a] = singleshot(async () => await BASE_WAIT_FOR_INIT_FN(this)); backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_CTOR, { entityName: this.entityName, baseDir, }); this._directory = join(this.baseDir, this.entityName); } /** * Computes file path for entity ID. * * @param entityId - Entity identifier * @returns Full file path to entity JSON file */ _getFilePath(entityId) { return join(this.baseDir, this.entityName, `${entityId}.json`); } async waitForInit(initial) { backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_WAIT_FOR_INIT, { entityName: this.entityName, initial, }); await this[BASE_WAIT_FOR_INIT_SYMBOL](); } /** * Returns count of persisted entities. * * @returns Promise resolving to number of .json files in directory */ async getCount() { const files = await fs.readdir(this._directory); const { length } = files.filter((file) => file.endsWith(".json")); return length; } async readValue(entityId) { backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_READ_VALUE, { entityName: this.entityName, entityId, }); try { const filePath = this._getFilePath(entityId); const fileContent = await fs.readFile(filePath, "utf-8"); return JSON.parse(fileContent); } catch (error) { if (error?.code === "ENOENT") { throw new Error(`Entity ${this.entityName}:${entityId} not found`); } throw new Error(`Failed to read entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`); } } async hasValue(entityId) { backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_HAS_VALUE, { entityName: this.entityName, entityId, }); try { const filePath = this._getFilePath(entityId); await fs.access(filePath); return true; } catch (error) { if (error?.code === "ENOENT") { return false; } throw new Error(`Failed to check existence of entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`); } } async writeValue(entityId, entity) { backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_WRITE_VALUE, { entityName: this.entityName, entityId, }); try { const filePath = this._getFilePath(entityId); const serializedData = JSON.stringify(entity); await writeFileAtomic(filePath, serializedData, "utf-8"); } catch (error) { throw new Error(`Failed to write entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`); } } /** * Removes entity from storage. * * @param entityId - Entity identifier to remove * @returns Promise that resolves when entity is deleted * @throws Error if entity not found or deletion fails */ async removeValue(entityId) { backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_REMOVE_VALUE, { entityName: this.entityName, entityId, }); try { const filePath = this._getFilePath(entityId); await fs.unlink(filePath); } catch (error) { if (error?.code === "ENOENT") { throw new Error(`Entity ${this.entityName}:${entityId} not found for deletion`); } throw new Error(`Failed to remove entity ${this.entityName}:${entityId}: ${getErrorMessage(error)}`); } } /** * Removes all entities from storage. * * @returns Promise that resolves when all entities are deleted * @throws Error if deletion fails */ async removeAll() { backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_REMOVE_ALL, { entityName: this.entityName, }); try { const files = await fs.readdir(this._directory); const entityFiles = files.filter((file) => file.endsWith(".json")); for (const file of entityFiles) { await fs.unlink(join(this._directory, file)); } } catch (error) { throw new Error(`Failed to remove values for ${this.entityName}: ${getErrorMessage(error)}`); } } /** * Async generator yielding all entity values. * Sorted alphanumerically by entity ID. * * @returns AsyncGenerator yielding entities * @throws Error if reading fails */ async *values() { backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_VALUES, { entityName: this.entityName, }); try { const files = await fs.readdir(this._directory); const entityIds = files .filter((file) => file.endsWith(".json")) .map((file) => file.slice(0, -5)) .sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base", })); for (const entityId of entityIds) { const entity = await this.readValue(entityId); yield entity; } } catch (error) { throw new Error(`Failed to read values for ${this.entityName}: ${getErrorMessage(error)}`); } } /** * Async generator yielding all entity IDs. * Sorted alphanumerically. * * @returns AsyncGenerator yielding entity IDs * @throws Error if reading fails */ async *keys() { backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_KEYS, { entityName: this.entityName, }); try { const files = await fs.readdir(this._directory); const entityIds = files .filter((file) => file.endsWith(".json")) .map((file) => file.slice(0, -5)) .sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base", })); for (const entityId of entityIds) { yield entityId; } } catch (error) { throw new Error(`Failed to read keys for ${this.entityName}: ${getErrorMessage(error)}`); } } /** * Async iterator implementation. * Delegates to values() generator. * * @returns AsyncIterableIterator yielding entities */ async *[(_a = BASE_WAIT_FOR_INIT_SYMBOL, Symbol.asyncIterator)]() { for await (const entity of this.values()) { yield entity; } } /** * Filters entities by predicate function. * * @param predicate - Filter function * @returns AsyncGenerator yielding filtered entities */ async *filter(predicate) { for await (const entity of this.values()) { if (predicate(entity)) { yield entity; } } } /** * Takes first N entities, optionally filtered. * * @param total - Maximum number of entities to yield * @param predicate - Optional filter function * @returns AsyncGenerator yielding up to total entities */ async *take(total, predicate) { let count = 0; if (predicate) { for await (const entity of this.values()) { if (!predicate(entity)) { continue; } count += 1; yield entity; if (count >= total) { break; } } } else { for await (const entity of this.values()) { count += 1; yield entity; if (count >= total) { break; } } } } }); /** * Utility class for managing signal persistence. * * Features: * - Memoized storage instances per strategy * - Custom adapter support * - Atomic read/write operations * - Crash-safe signal state management * * Used by ClientStrategy for live mode persistence. */ class PersistSignalUtils { constructor() { this.PersistSignalFactory = PersistBase; this.getSignalStorage = memoize(([symbol, strategyName]) => `${symbol}:${strategyName}`, (symbol, strategyName) => Reflect.construct(this.PersistSignalFactory, [ `${symbol}_${strategyName}`, `./dump/data/signal/`, ])); /** * Reads persisted signal data for a symbol and strategy. * * Called by ClientStrategy.waitForInit() to restore state. * Returns null if no signal exists. * * @param symbol - Trading pair symbol * @param strategyName - Strategy identifier * @returns Promise resolving to signal or null */ this.readSignalData = async (symbol, strategyName) => { backtest$1.loggerService.info(PERSIST_SIGNAL_UTILS_METHOD_NAME_READ_DATA); const key = `${symbol}:${strategyName}`; const isInitial = !this.getSignalStorage.has(key); const stateStorage = this.getSignalStorage(symbol, strategyName); await stateStorage.waitForInit(isInitial); if (await stateStorage.hasValue(symbol)) { return await stateStorage.readValue(symbol); } return null; }; /** * Writes signal data to disk with atomic file writes. * * Called by ClientStrategy.setPendingSignal() to persist state. * Uses atomic writes to prevent corruption on crashes. * * @param signalRow - Signal data (null to clear) * @param symbol - Trading pair symbol * @param strategyName - Strategy identifier * @returns Promise that resolves when write is complete */ this.writeSignalData = async (signalRow, symbol, strategyName) => { backtest$1.loggerService.info(PERSIST_SIGNAL_UTILS_METHOD_NAME_WRITE_DATA); const key = `${symbol}:${strategyName}`; const isInitial = !this.getSignalStorage.has(key); const stateStorage = this.getSignalStorage(symbol, strategyName); await stateStorage.waitForInit(isInitial); await stateStorage.writeValue(symbol, signalRow); }; } /** * Registers a custom persistence adapter. * * @param Ctor - Custom PersistBase constructor * * @example * ```typescript * class RedisPersist extends PersistBase { * async readValue(id) { return JSON.parse(await redis.get(id)); } * async writeValue(i