Files

653 lines
24 KiB
JavaScript

/**
* Backtesting Framework
*
* Historical simulation with comprehensive performance metrics:
* - Sharpe Ratio, Sortino Ratio
* - Maximum Drawdown, Calmar Ratio
* - Win Rate, Profit Factor
* - Value at Risk (VaR), Expected Shortfall
* - Rolling statistics and regime analysis
*/
import { TradingPipeline, createTradingPipeline } from './trading-pipeline.js';
// Backtesting Configuration
const backtestConfig = {
// Simulation settings
simulation: {
initialCapital: 100000,
startDate: null, // Use all available data if null
endDate: null,
rebalanceFrequency: 'daily', // daily, weekly, monthly
warmupPeriod: 50 // Days for indicator warmup
},
// Execution assumptions
execution: {
slippage: 0.001, // 0.1%
commission: 0.001, // 0.1%
marketImpact: 0.0005, // 0.05% for large orders
fillRate: 1.0 // 100% fill rate assumed
},
// Risk-free rate for Sharpe calculation
riskFreeRate: 0.05, // 5% annual
// Benchmark
benchmark: 'buyAndHold' // buyAndHold, equalWeight, or custom
};
/**
* Performance Metrics Calculator
*/
class PerformanceMetrics {
constructor(riskFreeRate = 0.05) {
this.riskFreeRate = riskFreeRate;
this.dailyRiskFreeRate = Math.pow(1 + riskFreeRate, 1/252) - 1;
}
// Optimized: Calculate all metrics with minimal passes over data
calculate(equityCurve, benchmark = null) {
if (equityCurve.length < 2) {
return this.emptyMetrics();
}
// Single pass: compute returns and statistics together
const n = equityCurve.length;
const returns = new Array(n - 1);
let sum = 0, sumSq = 0;
let positiveSum = 0, negativeSum = 0;
let positiveCount = 0, negativeCount = 0;
let compoundReturn = 1;
for (let i = 1; i < n; i++) {
const r = (equityCurve[i] - equityCurve[i-1]) / equityCurve[i-1];
returns[i-1] = r;
sum += r;
sumSq += r * r;
compoundReturn *= (1 + r);
if (r > 0) { positiveSum += r; positiveCount++; }
else if (r < 0) { negativeSum += r; negativeCount++; }
}
const mean = sum / returns.length;
const variance = sumSq / returns.length - mean * mean;
const volatility = Math.sqrt(variance);
const annualizedVol = volatility * Math.sqrt(252);
// Single pass: drawdown metrics
const ddMetrics = this.computeDrawdownMetrics(equityCurve);
// Pre-computed stats for Sharpe/Sortino
const excessMean = mean - this.dailyRiskFreeRate;
const sharpe = volatility > 0 ? (excessMean / volatility) * Math.sqrt(252) : 0;
// Downside deviation (single pass)
let downsideVariance = 0;
for (let i = 0; i < returns.length; i++) {
const excess = returns[i] - this.dailyRiskFreeRate;
if (excess < 0) downsideVariance += excess * excess;
}
const downsideDeviation = Math.sqrt(downsideVariance / returns.length);
const sortino = downsideDeviation > 0 ? (excessMean / downsideDeviation) * Math.sqrt(252) : 0;
// Annualized return
const years = returns.length / 252;
const annualizedReturn = Math.pow(compoundReturn, 1 / years) - 1;
// CAGR
const cagr = Math.pow(equityCurve[n-1] / equityCurve[0], 1 / years) - 1;
// Calmar
const calmar = ddMetrics.maxDrawdown > 0 ? annualizedReturn / ddMetrics.maxDrawdown : 0;
// Trade metrics (using pre-computed counts)
const winRate = returns.length > 0 ? positiveCount / returns.length : 0;
const avgWin = positiveCount > 0 ? positiveSum / positiveCount : 0;
const avgLoss = negativeCount > 0 ? negativeSum / negativeCount : 0;
const profitFactor = negativeSum !== 0 ? positiveSum / Math.abs(negativeSum) : Infinity;
const payoffRatio = avgLoss !== 0 ? avgWin / Math.abs(avgLoss) : Infinity;
const expectancy = winRate * avgWin - (1 - winRate) * Math.abs(avgLoss);
// VaR (requires sort - do lazily)
const sortedReturns = [...returns].sort((a, b) => a - b);
const var95 = -sortedReturns[Math.floor(0.05 * sortedReturns.length)];
const var99 = -sortedReturns[Math.floor(0.01 * sortedReturns.length)];
// CVaR
const tailIndex = Math.floor(0.05 * sortedReturns.length);
let cvarSum = 0;
for (let i = 0; i <= tailIndex; i++) cvarSum += sortedReturns[i];
const cvar95 = tailIndex > 0 ? -cvarSum / (tailIndex + 1) : 0;
// Skewness and Kurtosis (using pre-computed mean/variance)
let m3 = 0, m4 = 0;
for (let i = 0; i < returns.length; i++) {
const d = returns[i] - mean;
const d2 = d * d;
m3 += d * d2;
m4 += d2 * d2;
}
m3 /= returns.length;
m4 /= returns.length;
const std = volatility;
const skewness = std > 0 ? m3 / (std * std * std) : 0;
const kurtosis = std > 0 ? m4 / (std * std * std * std) - 3 : 0;
// Best/worst day
let bestDay = returns[0], worstDay = returns[0];
for (let i = 1; i < returns.length; i++) {
if (returns[i] > bestDay) bestDay = returns[i];
if (returns[i] < worstDay) worstDay = returns[i];
}
// Benchmark metrics
let informationRatio = null;
if (benchmark) {
informationRatio = this.informationRatioFast(returns, benchmark);
}
return {
totalReturn: compoundReturn - 1,
annualizedReturn,
cagr,
volatility,
annualizedVolatility: annualizedVol,
maxDrawdown: ddMetrics.maxDrawdown,
averageDrawdown: ddMetrics.averageDrawdown,
drawdownDuration: ddMetrics.maxDuration,
sharpeRatio: sharpe,
sortinoRatio: sortino,
calmarRatio: calmar,
informationRatio,
winRate,
profitFactor,
averageWin: avgWin,
averageLoss: avgLoss,
payoffRatio,
expectancy,
var95,
var99,
cvar95,
skewness,
kurtosis,
tradingDays: returns.length,
bestDay,
worstDay,
positiveMonths: this.positiveMonthsFast(returns),
returns,
equityCurve
};
}
// Optimized: Single pass drawdown computation
computeDrawdownMetrics(equityCurve) {
let maxDrawdown = 0;
let peak = equityCurve[0];
let ddSum = 0;
let maxDuration = 0;
let currentDuration = 0;
for (let i = 0; i < equityCurve.length; i++) {
const value = equityCurve[i];
if (value > peak) {
peak = value;
currentDuration = 0;
} else {
currentDuration++;
if (currentDuration > maxDuration) maxDuration = currentDuration;
}
const dd = (peak - value) / peak;
ddSum += dd;
if (dd > maxDrawdown) maxDrawdown = dd;
}
return {
maxDrawdown,
averageDrawdown: ddSum / equityCurve.length,
maxDuration
};
}
// Optimized information ratio
informationRatioFast(returns, benchmark) {
const benchmarkReturns = this.calculateReturns(benchmark);
const minLen = Math.min(returns.length, benchmarkReturns.length);
let sum = 0, sumSq = 0;
for (let i = 0; i < minLen; i++) {
const te = returns[i] - benchmarkReturns[i];
sum += te;
sumSq += te * te;
}
const mean = sum / minLen;
const variance = sumSq / minLen - mean * mean;
const vol = Math.sqrt(variance);
return vol > 0 ? (mean / vol) * Math.sqrt(252) : 0;
}
// Optimized positive months
positiveMonthsFast(returns) {
let positiveMonths = 0;
let totalMonths = 0;
let monthReturn = 1;
for (let i = 0; i < returns.length; i++) {
monthReturn *= (1 + returns[i]);
if ((i + 1) % 21 === 0 || i === returns.length - 1) {
if (monthReturn > 1) positiveMonths++;
totalMonths++;
monthReturn = 1;
}
}
return totalMonths > 0 ? positiveMonths / totalMonths : 0;
}
calculateReturns(equityCurve) {
const returns = new Array(equityCurve.length - 1);
for (let i = 1; i < equityCurve.length; i++) {
returns[i-1] = (equityCurve[i] - equityCurve[i-1]) / equityCurve[i-1];
}
return returns;
}
emptyMetrics() {
return {
totalReturn: 0, annualizedReturn: 0, cagr: 0,
volatility: 0, annualizedVolatility: 0, maxDrawdown: 0,
sharpeRatio: 0, sortinoRatio: 0, calmarRatio: 0,
winRate: 0, profitFactor: 0, expectancy: 0,
var95: 0, var99: 0, cvar95: 0,
tradingDays: 0, returns: [], equityCurve: []
};
}
}
/**
* Backtest Engine
*/
class BacktestEngine {
constructor(config = backtestConfig) {
this.config = config;
this.metricsCalculator = new PerformanceMetrics(config.riskFreeRate);
this.pipeline = createTradingPipeline();
}
// Run backtest on historical data
async run(historicalData, options = {}) {
const {
symbols = ['DEFAULT'],
newsData = [],
riskManager = null
} = options;
const results = {
equityCurve: [this.config.simulation.initialCapital],
benchmarkCurve: [this.config.simulation.initialCapital],
trades: [],
dailyReturns: [],
positions: [],
signals: []
};
// Initialize portfolio
let portfolio = {
equity: this.config.simulation.initialCapital,
cash: this.config.simulation.initialCapital,
positions: {},
assets: symbols
};
// Skip warmup period
const startIndex = this.config.simulation.warmupPeriod;
const prices = {};
// Process each day
for (let i = startIndex; i < historicalData.length; i++) {
const dayData = historicalData[i];
const currentPrice = dayData.close || dayData.price || 100;
// Update prices
for (const symbol of symbols) {
prices[symbol] = currentPrice;
}
// Get historical window for pipeline
const windowStart = Math.max(0, i - 100);
const marketWindow = historicalData.slice(windowStart, i + 1);
// Get news for this day (simplified - would filter by date in production)
const dayNews = newsData.filter((n, idx) => idx < 3);
// Execute pipeline
const context = {
marketData: marketWindow,
newsData: dayNews,
symbols,
portfolio,
prices,
riskManager
};
try {
const pipelineResult = await this.pipeline.execute(context);
// Store signals
if (pipelineResult.signals) {
results.signals.push({
day: i,
signals: pipelineResult.signals
});
}
// Execute orders
if (pipelineResult.orders && pipelineResult.orders.length > 0) {
for (const order of pipelineResult.orders) {
const trade = this.executeTrade(order, portfolio, prices);
if (trade) {
results.trades.push({ day: i, ...trade });
}
}
}
} catch (error) {
// Pipeline error - skip this day
console.warn(`Day ${i} pipeline error:`, error.message);
}
// Update portfolio value
portfolio.equity = portfolio.cash;
for (const [symbol, qty] of Object.entries(portfolio.positions)) {
portfolio.equity += qty * (prices[symbol] || 0);
}
results.equityCurve.push(portfolio.equity);
results.positions.push({ ...portfolio.positions });
// Update benchmark (buy and hold)
const benchmarkReturn = i > startIndex
? (currentPrice / historicalData[i - 1].close) - 1
: 0;
const lastBenchmark = results.benchmarkCurve[results.benchmarkCurve.length - 1];
results.benchmarkCurve.push(lastBenchmark * (1 + benchmarkReturn));
// Daily return
if (results.equityCurve.length >= 2) {
const prev = results.equityCurve[results.equityCurve.length - 2];
const curr = results.equityCurve[results.equityCurve.length - 1];
results.dailyReturns.push((curr - prev) / prev);
}
}
// Calculate performance metrics
results.metrics = this.metricsCalculator.calculate(
results.equityCurve,
results.benchmarkCurve
);
results.benchmarkMetrics = this.metricsCalculator.calculate(
results.benchmarkCurve
);
// Trade statistics
results.tradeStats = this.calculateTradeStats(results.trades);
return results;
}
// Execute a trade
executeTrade(order, portfolio, prices) {
const price = prices[order.symbol] || order.price;
const value = order.quantity * price;
const costs = value * (this.config.execution.slippage + this.config.execution.commission);
if (order.side === 'buy') {
if (portfolio.cash < value + costs) {
return null; // Insufficient funds
}
portfolio.cash -= value + costs;
portfolio.positions[order.symbol] = (portfolio.positions[order.symbol] || 0) + order.quantity;
} else {
const currentQty = portfolio.positions[order.symbol] || 0;
if (currentQty < order.quantity) {
return null; // Insufficient shares
}
portfolio.cash += value - costs;
portfolio.positions[order.symbol] = currentQty - order.quantity;
}
return {
symbol: order.symbol,
side: order.side,
quantity: order.quantity,
price,
value,
costs,
timestamp: Date.now()
};
}
// Calculate trade statistics
calculateTradeStats(trades) {
if (trades.length === 0) {
return { totalTrades: 0, buyTrades: 0, sellTrades: 0, totalVolume: 0, totalCosts: 0 };
}
return {
totalTrades: trades.length,
buyTrades: trades.filter(t => t.side === 'buy').length,
sellTrades: trades.filter(t => t.side === 'sell').length,
totalVolume: trades.reduce((a, t) => a + t.value, 0),
totalCosts: trades.reduce((a, t) => a + t.costs, 0),
avgTradeSize: trades.reduce((a, t) => a + t.value, 0) / trades.length
};
}
// Generate backtest report
generateReport(results) {
const m = results.metrics;
const b = results.benchmarkMetrics;
const t = results.tradeStats;
return `
══════════════════════════════════════════════════════════════════════
BACKTEST REPORT
══════════════════════════════════════════════════════════════════════
PERFORMANCE SUMMARY
──────────────────────────────────────────────────────────────────────
Strategy Benchmark Difference
Total Return: ${(m.totalReturn * 100).toFixed(2)}% ${(b.totalReturn * 100).toFixed(2)}% ${((m.totalReturn - b.totalReturn) * 100).toFixed(2)}%
Annualized Return: ${(m.annualizedReturn * 100).toFixed(2)}% ${(b.annualizedReturn * 100).toFixed(2)}% ${((m.annualizedReturn - b.annualizedReturn) * 100).toFixed(2)}%
CAGR: ${(m.cagr * 100).toFixed(2)}% ${(b.cagr * 100).toFixed(2)}% ${((m.cagr - b.cagr) * 100).toFixed(2)}%
RISK METRICS
──────────────────────────────────────────────────────────────────────
Volatility (Ann.): ${(m.annualizedVolatility * 100).toFixed(2)}% ${(b.annualizedVolatility * 100).toFixed(2)}%
Max Drawdown: ${(m.maxDrawdown * 100).toFixed(2)}% ${(b.maxDrawdown * 100).toFixed(2)}%
Avg Drawdown: ${(m.averageDrawdown * 100).toFixed(2)}%
DD Duration (days): ${m.drawdownDuration}
RISK-ADJUSTED RETURNS
──────────────────────────────────────────────────────────────────────
Sharpe Ratio: ${m.sharpeRatio.toFixed(2)} ${b.sharpeRatio.toFixed(2)}
Sortino Ratio: ${m.sortinoRatio.toFixed(2)} ${b.sortinoRatio.toFixed(2)}
Calmar Ratio: ${m.calmarRatio.toFixed(2)} ${b.calmarRatio.toFixed(2)}
Information Ratio: ${m.informationRatio?.toFixed(2) || 'N/A'}
TRADE STATISTICS
──────────────────────────────────────────────────────────────────────
Win Rate: ${(m.winRate * 100).toFixed(1)}%
Profit Factor: ${m.profitFactor.toFixed(2)}
Avg Win: ${(m.averageWin * 100).toFixed(2)}%
Avg Loss: ${(m.averageLoss * 100).toFixed(2)}%
Payoff Ratio: ${m.payoffRatio.toFixed(2)}
Expectancy: ${(m.expectancy * 100).toFixed(3)}%
TAIL RISK
──────────────────────────────────────────────────────────────────────
VaR (95%): ${(m.var95 * 100).toFixed(2)}%
VaR (99%): ${(m.var99 * 100).toFixed(2)}%
CVaR (95%): ${(m.cvar95 * 100).toFixed(2)}%
Skewness: ${m.skewness.toFixed(2)}
Kurtosis: ${m.kurtosis.toFixed(2)}
TRADING ACTIVITY
──────────────────────────────────────────────────────────────────────
Total Trades: ${t.totalTrades}
Buy Trades: ${t.buyTrades}
Sell Trades: ${t.sellTrades}
Total Volume: $${t.totalVolume.toFixed(2)}
Total Costs: $${t.totalCosts.toFixed(2)}
Avg Trade Size: $${(t.avgTradeSize || 0).toFixed(2)}
ADDITIONAL METRICS
──────────────────────────────────────────────────────────────────────
Trading Days: ${m.tradingDays}
Best Day: ${(m.bestDay * 100).toFixed(2)}%
Worst Day: ${(m.worstDay * 100).toFixed(2)}%
Positive Months: ${(m.positiveMonths * 100).toFixed(1)}%
══════════════════════════════════════════════════════════════════════
`;
}
}
/**
* Walk-Forward Analysis
*/
class WalkForwardAnalyzer {
constructor(config = {}) {
this.trainRatio = config.trainRatio || 0.7;
this.numFolds = config.numFolds || 5;
this.engine = new BacktestEngine();
}
async analyze(historicalData, options = {}) {
const foldSize = Math.floor(historicalData.length / this.numFolds);
const results = [];
for (let i = 0; i < this.numFolds; i++) {
const testStart = i * foldSize;
const testEnd = (i + 1) * foldSize;
const trainEnd = Math.floor(testStart * this.trainRatio);
// In-sample (training) period
const trainData = historicalData.slice(0, trainEnd);
// Out-of-sample (test) period
const testData = historicalData.slice(testStart, testEnd);
// Run backtest on test period
const foldResult = await this.engine.run(testData, options);
results.push({
fold: i + 1,
trainPeriod: { start: 0, end: trainEnd },
testPeriod: { start: testStart, end: testEnd },
metrics: foldResult.metrics
});
}
// Aggregate results
const avgSharpe = results.reduce((a, r) => a + r.metrics.sharpeRatio, 0) / results.length;
const avgReturn = results.reduce((a, r) => a + r.metrics.totalReturn, 0) / results.length;
return {
folds: results,
aggregate: {
avgSharpe,
avgReturn,
consistency: this.calculateConsistency(results)
}
};
}
calculateConsistency(results) {
const profitableFolds = results.filter(r => r.metrics.totalReturn > 0).length;
return profitableFolds / results.length;
}
}
// Exports
export {
BacktestEngine,
PerformanceMetrics,
WalkForwardAnalyzer,
backtestConfig
};
// Demo if run directly
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
console.log('══════════════════════════════════════════════════════════════════════');
console.log('BACKTESTING FRAMEWORK DEMO');
console.log('══════════════════════════════════════════════════════════════════════\n');
// Generate synthetic historical data
const generateHistoricalData = (days) => {
const data = [];
let price = 100;
for (let i = 0; i < days; i++) {
const trend = Math.sin(i / 50) * 0.001; // Cyclical trend
const noise = (Math.random() - 0.5) * 0.02; // Random noise
const change = trend + noise;
price *= (1 + change);
data.push({
date: new Date(Date.now() - (days - i) * 24 * 60 * 60 * 1000),
open: price * (1 - Math.random() * 0.005),
high: price * (1 + Math.random() * 0.01),
low: price * (1 - Math.random() * 0.01),
close: price,
volume: 1000000 * (0.5 + Math.random())
});
}
return data;
};
const historicalData = generateHistoricalData(500);
console.log('1. Data Summary:');
console.log('──────────────────────────────────────────────────────────────────────');
console.log(` Days: ${historicalData.length}`);
console.log(` Start: ${historicalData[0].date.toISOString().split('T')[0]}`);
console.log(` End: ${historicalData[historicalData.length-1].date.toISOString().split('T')[0]}`);
console.log(` Start Price: $${historicalData[0].close.toFixed(2)}`);
console.log(` End Price: $${historicalData[historicalData.length-1].close.toFixed(2)}`);
console.log();
const engine = new BacktestEngine();
console.log('2. Running Backtest...');
console.log('──────────────────────────────────────────────────────────────────────');
engine.run(historicalData, {
symbols: ['TEST'],
newsData: [
{ symbol: 'TEST', text: 'Strong growth reported in quarterly earnings', source: 'news' },
{ symbol: 'TEST', text: 'Analyst upgrades stock to buy rating', source: 'analyst' }
]
}).then(results => {
console.log(engine.generateReport(results));
console.log('3. Equity Curve Summary:');
console.log('──────────────────────────────────────────────────────────────────────');
console.log(` Initial: $${results.equityCurve[0].toFixed(2)}`);
console.log(` Final: $${results.equityCurve[results.equityCurve.length-1].toFixed(2)}`);
console.log(` Peak: $${Math.max(...results.equityCurve).toFixed(2)}`);
console.log(` Trough: $${Math.min(...results.equityCurve).toFixed(2)}`);
console.log();
console.log('══════════════════════════════════════════════════════════════════════');
console.log('Backtesting demo completed');
console.log('══════════════════════════════════════════════════════════════════════');
}).catch(err => {
console.error('Backtest error:', err);
});
}