skayn-trading-sdk
Version:
Professional Bitcoin trading strategy backtesting framework with institutional-grade architecture
493 lines (418 loc) ⢠18.3 kB
JavaScript
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;