backtest-kit
Version:
A TypeScript library for trading system backtest
1,274 lines (1,262 loc) • 566 kB
JavaScript
'use strict';
var diKit = require('di-kit');
var diScoped = require('di-scoped');
var functoolsKit = require('functools-kit');
var fs = require('fs/promises');
var path = require('path');
var crypto = require('crypto');
var os = require('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 } = diKit.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 = diScoped.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 = diScoped.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: functoolsKit.errorData(err),
message: functoolsKit.getErrorMessage(err),
});
console.warn(message);
lastError = err;
await functoolsKit.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 = functoolsKit.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 functoolsKit.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) => functoolsKit.trycatch(functoolsKit.retry(async () => {
try {
await fs.unlink(filePath);
return true;
}
catch (error) {
console.error(`backtest-kit PersistBase unlink failed for filePath=${filePath} error=${functoolsKit.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 = functoolsKit.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 = path.join(process.cwd(), "logs/data")) {
this.entityName = entityName;
this.baseDir = baseDir;
this[_a] = functoolsKit.singleshot(async () => await BASE_WAIT_FOR_INIT_FN(this));
backtest$1.loggerService.debug(PERSIST_BASE_METHOD_NAME_CTOR, {
entityName: this.entityName,
baseDir,
});
this._directory = path.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 path.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}: ${functoolsKit.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}: ${functoolsKit.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}: ${functoolsKit.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}: ${functoolsKit.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(path.join(this._directory, file));
}
}
catch (error) {
throw new Error(`Failed to remove values for ${this.entityName}: ${functoolsKit.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}: ${functoolsKit.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}: ${functoolsKit.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 = functoolsKit.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) { re