UNPKG

skayn-trading-sdk

Version:

Professional Bitcoin trading strategy backtesting framework with institutional-grade architecture

493 lines (418 loc) • 18.3 kB
const BinanceDataProvider = require('../providers/binance-data-provider'); const SimplePositionManager = require('../managers/simple-position-manager'); const logger = require('../../utils/logger'); /** * Simple Backtesting Engine * MVP implementation for strategy validation */ class SimpleBacktestEngine { constructor(config = {}) { this.config = { initialBalance: config.initialBalance || 10000000, // 10M sats default startDate: config.startDate || '2024-01-01', endDate: config.endDate || '2024-12-31', interval: config.interval || '1h', symbol: config.symbol || 'BTCUSDT', tradingFee: config.tradingFee || 0.001, // 0.1% per trade (typical exchange fee) ...config }; this.dataProvider = config.dataProvider || new BinanceDataProvider(); this.positionManager = new SimplePositionManager(config); this.strategy = null; // Backtest state this.currentBalance = this.config.initialBalance; this.currentBar = 0; this.historicalData = []; this.results = { trades: [], equity: [], drawdowns: [], metrics: {} }; } /** * Set strategy for backtesting */ setStrategy(strategy) { this.strategy = strategy; } /** * Set data provider (for live trading) */ setDataProvider(dataProvider) { this.dataProvider = dataProvider; } /** * Run backtest */ async runBacktest() { try { logger.info('šŸš€ Starting backtest', { strategy: this.strategy?.getStrategyMetadata?.()?.name || 'Unknown', period: `${this.config.startDate} to ${this.config.endDate}`, interval: this.config.interval, initialBalance: `${this.config.initialBalance.toLocaleString()} sats` }); // Load historical data await this.loadHistoricalData(); // Reset strategy and position manager this.strategy?.reset?.(); this.positionManager.reset(); // Run simulation await this.runSimulation(); // Close any remaining open positions this.closeRemainingPositions(); // Calculate final metrics this.calculateMetrics(); logger.info('āœ… Backtest completed', { totalTrades: this.results.trades.length, finalBalance: `${this.currentBalance.toLocaleString()} sats`, totalReturn: `${(((this.currentBalance / this.config.initialBalance) - 1) * 100).toFixed(2)}%` }); return this.results; } catch (error) { logger.error('āŒ Backtest failed', { error: error.message }); throw error; } } /** * Load historical data from provider */ async loadHistoricalData() { logger.info('šŸ“Š Loading historical data...'); // Calculate extended start date for regime detection (need 200+ bars) const originalStart = new Date(this.config.startDate); const extendedStart = new Date(originalStart); extendedStart.setDate(extendedStart.getDate() - 300); // Add 300 days for regime detection this.historicalData = await this.dataProvider.getData( extendedStart.toISOString().split('T')[0], this.config.endDate, this.config.interval, this.config.symbol ); if (this.historicalData.length === 0) { throw new Error('No historical data loaded'); } // Find the actual trading start index (after warmup period) this.tradingStartIndex = this.historicalData.findIndex(bar => new Date(bar.timestamp) >= originalStart ) || 220; // Fallback to 220 bars if not found logger.info('āœ… Historical data loaded', { totalBars: this.historicalData.length, warmupBars: this.tradingStartIndex, tradingBars: this.historicalData.length - this.tradingStartIndex, period: `${new Date(this.historicalData[0].timestamp).toISOString().split('T')[0]} to ${new Date(this.historicalData[this.historicalData.length - 1].timestamp).toISOString().split('T')[0]}` }); } /** * Run the simulation bar by bar */ async runSimulation() { // Use the trading start index calculated in loadHistoricalData (includes regime detection warmup) const startIndex = Math.max( this.tradingStartIndex || 220, this.strategy?.getRequiredWarmupPeriods?.() || 50 ); for (let i = startIndex; i < this.historicalData.length; i++) { this.currentBar = i; const currentPrice = this.historicalData[i].close; const historicalBars = this.historicalData.slice(0, i + 1); // Update equity tracking this.updateEquity(currentPrice); // Check for stop losses and take profits on open positions await this.processOpenPositions(currentPrice); // Get portfolio state const portfolioState = { balance: this.currentBalance, openPositions: this.positionManager.getOpenPositions().length, totalExposure: this.positionManager.totalExposure }; // Get strategy signal const signal = await this.strategy.analyze(historicalBars, portfolioState); // Apply smart defaults for zero-config experience if (signal && (!signal.strategy || signal.strategy === undefined)) { signal.strategy = 'conservative'; // Default to conservative tier for simplicity logger.debug('šŸ”§ Applied default strategy: conservative for zero-config experience'); } // Process signal if (signal && signal.action !== 'HOLD') { await this.processSignal(signal, currentPrice); } // Log progress periodically if (i % 100 === 0) { const progress = ((i / this.historicalData.length) * 100).toFixed(1); const date = new Date(this.historicalData[i].timestamp).toISOString().split('T')[0]; logger.debug(`šŸ“ˆ Backtest progress: ${progress}% (${date})`); } } } /** * Process trading signal */ async processSignal(signal, currentPrice) { try { // Convert sats balance to USD for position manager (which expects USD) const portfolioUSD = (this.currentBalance / 100000000) * currentPrice; // Check if we can open more positions (pass portfolio size for scaling) if (!this.positionManager.canOpenPosition(signal.strategy, portfolioUSD)) { return; } // Check if we have enough balance const positionData = this.positionManager.calculatePosition( signal, portfolioUSD, currentPrice ); if (positionData.marginRequired > this.currentBalance * 0.9) { logger.debug('āŒ Insufficient balance for position', { required: positionData.marginRequired, available: this.currentBalance }); return; } // Open position const position = this.positionManager.openPosition(positionData, signal); // Set correct historical timestamp for time stops position.openTime = this.historicalData[this.currentBar].timestamp; // Deduct margin from balance this.currentBalance -= positionData.marginRequired; // Record trade this.results.trades.push({ timestamp: this.historicalData[this.currentBar].timestamp, signal: signal, position: position, balanceAfter: this.currentBalance }); logger.debug('šŸ“ˆ Signal processed', { action: signal.action, strategy: signal.strategy, leverage: `${positionData.leverage}x`, reason: positionData.leverageReason, marginUsed: `${positionData.marginRequired.toLocaleString()} sats` }); } catch (error) { logger.error('āŒ Failed to process signal', { error: error.message, signal: signal.action, strategy: signal.strategy }); } } /** * Process open positions for stop loss and take profit */ async processOpenPositions(currentPrice) { const openPositions = this.positionManager.getOpenPositions(); for (const position of openPositions) { let shouldClose = false; let closeReason = ''; // Check stop loss if ((position.action === 'LONG' || position.action === 'BUY') && currentPrice <= position.stopLossPrice) { shouldClose = true; closeReason = 'Stop Loss'; } else if ((position.action === 'SHORT' || position.action === 'SELL') && currentPrice >= position.stopLossPrice) { shouldClose = true; closeReason = 'Stop Loss'; } // Check take profit if ((position.action === 'LONG' || position.action === 'BUY') && currentPrice >= position.takeProfitPrice) { shouldClose = true; closeReason = 'Take Profit'; } else if ((position.action === 'SHORT' || position.action === 'SELL') && currentPrice <= position.takeProfitPrice) { shouldClose = true; closeReason = 'Take Profit'; } // Check time stop (max hold time) if (position.maxHoldTime) { const currentTime = this.historicalData[this.currentBar].timestamp; const holdTime = currentTime - position.openTime; if (holdTime > position.maxHoldTime) { shouldClose = true; closeReason = 'Time Stop'; } } if (shouldClose) { const closedPosition = this.positionManager.closePosition(position.id, currentPrice, closeReason); // Return margin plus P&L to balance this.currentBalance += closedPosition.marginRequired + (closedPosition.realizedPnL || 0); // Record trade result with proper data structure const tradeIndex = this.results.trades.findIndex(t => t.position.id === position.id); if (tradeIndex >= 0) { this.results.trades[tradeIndex].closedPosition = closedPosition; this.results.trades[tradeIndex].closeTimestamp = this.historicalData[this.currentBar].timestamp; this.results.trades[tradeIndex].realizedPnL = closedPosition.realizedPnL; this.results.trades[tradeIndex].fees = closedPosition.tradingFees; this.results.trades[tradeIndex].closeReason = closeReason; this.results.trades[tradeIndex].holdTime = this.historicalData[this.currentBar].timestamp - position.openTime; // DEBUG: Log actual trade P&L console.log(`\nšŸ’° TRADE CLOSED: Reason: ${closeReason} Entry: $${position.entryPrice.toLocaleString()} Exit: $${currentPrice.toLocaleString()} Quantity: ${position.quantity.toFixed(8)} BTC Raw P&L: $${Math.round(closedPosition.rawPnL || 0)} Fees: $${Math.round(closedPosition.tradingFees || 0)} Net P&L: ${(closedPosition.realizedPnL || 0).toLocaleString()} sats Portfolio impact: ${((closedPosition.realizedPnL || 0) / this.config.initialBalance * 100).toFixed(4)}%`); } } } } /** * Update equity tracking */ updateEquity(currentPrice) { let totalEquity = this.currentBalance; // Add unrealized P&L from open positions const openPositions = this.positionManager.getOpenPositions(); for (const position of openPositions) { const priceDiff = (position.action === 'LONG' || position.action === 'BUY') ? currentPrice - position.entryPrice : position.entryPrice - currentPrice; const unrealizedPnL = Math.round(priceDiff * (position.quantity / 100000000) * 100000000); totalEquity += position.marginRequired + unrealizedPnL; } this.results.equity.push({ timestamp: this.historicalData[this.currentBar].timestamp, balance: this.currentBalance, totalEquity: totalEquity, unrealizedPnL: totalEquity - this.currentBalance }); } /** * Close any remaining open positions at end of backtest */ closeRemainingPositions() { const openPositions = this.positionManager.getOpenPositions(); if (openPositions.length === 0) return; const finalPrice = this.historicalData[this.historicalData.length - 1].close; logger.info('šŸ”’ Closing remaining open positions at backtest end', { openPositions: openPositions.length, finalPrice: `$${finalPrice.toLocaleString()}` }); for (const position of openPositions) { const closedPosition = this.positionManager.closePosition(position.id, finalPrice, 'Backtest End'); // Return margin plus P&L to balance this.currentBalance += closedPosition.marginRequired + (closedPosition.realizedPnL || 0); // Record trade result with proper data structure const tradeIndex = this.results.trades.findIndex(t => t.position.id === position.id); if (tradeIndex >= 0) { this.results.trades[tradeIndex].closedPosition = closedPosition; this.results.trades[tradeIndex].closeTimestamp = this.historicalData[this.historicalData.length - 1].timestamp; this.results.trades[tradeIndex].realizedPnL = closedPosition.realizedPnL; this.results.trades[tradeIndex].fees = closedPosition.tradingFees; this.results.trades[tradeIndex].closeReason = 'Backtest End'; this.results.trades[tradeIndex].holdTime = this.historicalData[this.historicalData.length - 1].timestamp - position.openTime; } logger.debug('šŸ”’ Position closed at backtest end', { id: position.id, action: position.action, entryPrice: position.entryPrice, exitPrice: finalPrice, realizedPnL: closedPosition.realizedPnL }); } } /** * Calculate backtest metrics */ calculateMetrics() { const closedTrades = this.results.trades.filter(t => t.closedPosition); const totalReturn = ((this.currentBalance / this.config.initialBalance) - 1) * 100; // Win rate calculation using both closedPosition and direct realizedPnL fields const winningTrades = closedTrades.filter(t => { const pnl = t.realizedPnL || t.closedPosition?.realizedPnL || 0; return pnl > 0; }); const winRate = closedTrades.length > 0 ? (winningTrades.length / closedTrades.length) * 100 : 0; // Average win/loss using corrected PnL access const avgWin = winningTrades.length > 0 ? winningTrades.reduce((sum, t) => sum + (t.realizedPnL || t.closedPosition?.realizedPnL || 0), 0) / winningTrades.length : 0; const losingTrades = closedTrades.filter(t => { const pnl = t.realizedPnL || t.closedPosition?.realizedPnL || 0; return pnl < 0; }); const avgLoss = losingTrades.length > 0 ? losingTrades.reduce((sum, t) => sum + (t.realizedPnL || t.closedPosition?.realizedPnL || 0), 0) / losingTrades.length : 0; // Profit factor using corrected PnL access const grossProfit = winningTrades.reduce((sum, t) => sum + (t.realizedPnL || t.closedPosition?.realizedPnL || 0), 0); const grossLoss = Math.abs(losingTrades.reduce((sum, t) => sum + (t.realizedPnL || t.closedPosition?.realizedPnL || 0), 0)); const profitFactor = grossLoss > 0 ? grossProfit / grossLoss : 0; // Max drawdown let maxEquity = this.config.initialBalance; let maxDrawdown = 0; for (const equity of this.results.equity) { maxEquity = Math.max(maxEquity, equity.totalEquity); const drawdown = ((maxEquity - equity.totalEquity) / maxEquity) * 100; maxDrawdown = Math.max(maxDrawdown, drawdown); } // Store detailed metrics in nested object this.results.metrics = { totalReturn: totalReturn, totalTrades: closedTrades.length, winRate: winRate, avgWin: avgWin, avgLoss: avgLoss, profitFactor: profitFactor, maxDrawdown: maxDrawdown, sharpeRatio: this.calculateSharpeRatio(), finalBalance: this.currentBalance, initialBalance: this.config.initialBalance, openTrades: this.results.trades.length - closedTrades.length }; // Also flatten key metrics to top level for backward compatibility this.results.totalReturn = totalReturn; this.results.totalTrades = closedTrades.length; this.results.winRate = winRate; this.results.finalBalance = this.currentBalance; this.results.initialBalance = this.config.initialBalance; this.results.maxDrawdown = maxDrawdown; this.results.profitFactor = profitFactor; this.results.sharpeRatio = this.calculateSharpeRatio(); } /** * Calculate Sharpe ratio (simplified) */ calculateSharpeRatio() { if (this.results.equity.length < 2) return 0; const returns = []; for (let i = 1; i < this.results.equity.length; i++) { const ret = (this.results.equity[i].totalEquity / this.results.equity[i-1].totalEquity) - 1; returns.push(ret); } const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length; const variance = returns.reduce((sum, ret) => sum + Math.pow(ret - avgReturn, 2), 0) / returns.length; const volatility = Math.sqrt(variance); return volatility > 0 ? (avgReturn / volatility) * Math.sqrt(252) : 0; // Annualized } /** * Generate backtest report */ generateReport() { const metrics = this.results.metrics; const strategyMeta = this.strategy?.getStrategyMetadata?.() || {}; return { summary: { strategy: strategyMeta.name || 'Unknown', period: `${this.config.startDate} to ${this.config.endDate}`, totalReturn: `${metrics.totalReturn.toFixed(2)}%`, winRate: `${metrics.winRate.toFixed(1)}%`, totalTrades: metrics.totalTrades, profitFactor: metrics.profitFactor.toFixed(2), maxDrawdown: `${metrics.maxDrawdown.toFixed(2)}%`, sharpeRatio: metrics.sharpeRatio.toFixed(2) }, details: { finalBalance: `${metrics.finalBalance.toLocaleString()} sats`, avgWin: `${Math.round(metrics.avgWin).toLocaleString()} sats`, avgLoss: `${Math.round(metrics.avgLoss).toLocaleString()} sats`, openPositions: this.positionManager.getOpenPositions().length }, trades: this.results.trades.slice(0, 10), // Last 10 trades equity: this.results.equity.slice(-100) // Last 100 equity points }; } } module.exports = SimpleBacktestEngine;