424 lines
15 KiB
TypeScript
424 lines
15 KiB
TypeScript
import { DataFrame } from '@stock-bot/data-frame';
|
|
import { EventBus } from '@stock-bot/event-bus';
|
|
import { getLogger } from '@stock-bot/logger';
|
|
import { VectorEngine, VectorizedBacktestResult } from '@stock-bot/vector-engine';
|
|
import { BacktestContext, BacktestResult, ExecutionMode } from '../framework/execution-mode';
|
|
import { EventMode } from './event-mode';
|
|
import VectorizedMode from './vectorized-mode';
|
|
|
|
export interface HybridModeConfig {
|
|
vectorizedThreshold: number; // Switch to vectorized if data points > threshold
|
|
warmupPeriod: number; // Number of periods for initial vectorized calculation
|
|
eventDrivenRealtime: boolean; // Use event-driven for real-time portions
|
|
optimizeIndicators: boolean; // Pre-calculate indicators vectorized
|
|
batchSize: number; // Size of batches for hybrid processing
|
|
}
|
|
|
|
export class HybridMode extends ExecutionMode {
|
|
private vectorEngine: VectorEngine;
|
|
private eventMode: EventMode;
|
|
private vectorizedMode: VectorizedMode;
|
|
private config: HybridModeConfig;
|
|
private precomputedIndicators: Map<string, number[]> = new Map();
|
|
private currentIndex: number = 0;
|
|
|
|
constructor(context: BacktestContext, eventBus: EventBus, config: HybridModeConfig = {}) {
|
|
super(context, eventBus);
|
|
|
|
this.config = {
|
|
vectorizedThreshold: 50000,
|
|
warmupPeriod: 1000,
|
|
eventDrivenRealtime: true,
|
|
optimizeIndicators: true,
|
|
batchSize: 10000,
|
|
...config,
|
|
};
|
|
|
|
this.vectorEngine = new VectorEngine();
|
|
this.eventMode = new EventMode(context, eventBus);
|
|
this.vectorizedMode = new VectorizedMode(context, eventBus);
|
|
|
|
this.logger = getLogger('hybrid-mode');
|
|
}
|
|
|
|
async initialize(): Promise<void> {
|
|
await super.initialize();
|
|
|
|
// Initialize both modes
|
|
await this.eventMode.initialize();
|
|
await this.vectorizedMode.initialize();
|
|
|
|
this.logger.info('Hybrid mode initialized', {
|
|
backtestId: this.context.backtestId,
|
|
config: this.config,
|
|
});
|
|
}
|
|
|
|
async execute(): Promise<BacktestResult> {
|
|
const startTime = Date.now();
|
|
this.logger.info('Starting hybrid backtest execution');
|
|
|
|
try {
|
|
// Determine execution strategy based on data size
|
|
const dataSize = await this.estimateDataSize();
|
|
|
|
if (dataSize <= this.config.vectorizedThreshold) {
|
|
// Small dataset: use pure vectorized approach
|
|
this.logger.info('Using pure vectorized approach for small dataset', { dataSize });
|
|
return await this.vectorizedMode.execute();
|
|
}
|
|
|
|
// Large dataset: use hybrid approach
|
|
this.logger.info('Using hybrid approach for large dataset', { dataSize });
|
|
return await this.executeHybrid(startTime);
|
|
} catch (error) {
|
|
this.logger.error('Hybrid backtest failed', {
|
|
error,
|
|
backtestId: this.context.backtestId,
|
|
});
|
|
|
|
await this.eventBus.publishBacktestUpdate(this.context.backtestId, 0, {
|
|
status: 'failed',
|
|
error: error.message,
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async executeHybrid(startTime: number): Promise<BacktestResult> {
|
|
// Phase 1: Vectorized warmup and indicator pre-computation
|
|
const warmupResult = await this.executeWarmupPhase();
|
|
|
|
// Phase 2: Event-driven processing with pre-computed indicators
|
|
const eventResult = await this.executeEventPhase(warmupResult);
|
|
|
|
// Phase 3: Combine results
|
|
const combinedResult = this.combineResults(warmupResult, eventResult, startTime);
|
|
|
|
await this.eventBus.publishBacktestUpdate(this.context.backtestId, 100, {
|
|
status: 'completed',
|
|
result: combinedResult,
|
|
});
|
|
|
|
this.logger.info('Hybrid backtest completed', {
|
|
backtestId: this.context.backtestId,
|
|
duration: Date.now() - startTime,
|
|
totalTrades: combinedResult.trades.length,
|
|
warmupTrades: warmupResult.trades.length,
|
|
eventTrades: eventResult.trades.length,
|
|
});
|
|
|
|
return combinedResult;
|
|
}
|
|
|
|
private async executeWarmupPhase(): Promise<BacktestResult> {
|
|
this.logger.info('Executing vectorized warmup phase', {
|
|
warmupPeriod: this.config.warmupPeriod,
|
|
});
|
|
|
|
// Load warmup data
|
|
const warmupData = await this.loadWarmupData();
|
|
const dataFrame = this.createDataFrame(warmupData);
|
|
|
|
// Pre-compute indicators for entire dataset if optimization is enabled
|
|
if (this.config.optimizeIndicators) {
|
|
await this.precomputeIndicators(dataFrame);
|
|
}
|
|
|
|
// Run vectorized backtest on warmup period
|
|
const strategyCode = this.generateStrategyCode();
|
|
const vectorResult = await this.vectorEngine.executeVectorizedStrategy(
|
|
dataFrame.head(this.config.warmupPeriod),
|
|
strategyCode
|
|
);
|
|
|
|
// Convert to standard format
|
|
return this.convertVectorizedResult(vectorResult, Date.now());
|
|
}
|
|
|
|
private async executeEventPhase(warmupResult: BacktestResult): Promise<BacktestResult> {
|
|
this.logger.info('Executing event-driven phase');
|
|
|
|
// Set up event mode with warmup context
|
|
this.currentIndex = this.config.warmupPeriod;
|
|
|
|
// Create modified context for event phase
|
|
const eventContext: BacktestContext = {
|
|
...this.context,
|
|
initialPortfolio: this.extractFinalPortfolio(warmupResult),
|
|
};
|
|
|
|
// Execute event-driven backtest for remaining data
|
|
const eventMode = new EventMode(eventContext, this.eventBus);
|
|
await eventMode.initialize();
|
|
|
|
// Override indicator calculations to use pre-computed values
|
|
if (this.config.optimizeIndicators) {
|
|
this.overrideIndicatorCalculations(eventMode);
|
|
}
|
|
|
|
return await eventMode.execute();
|
|
}
|
|
|
|
private async precomputeIndicators(dataFrame: DataFrame): Promise<void> {
|
|
this.logger.info('Pre-computing indicators vectorized');
|
|
|
|
const close = dataFrame.getColumn('close');
|
|
const high = dataFrame.getColumn('high');
|
|
const low = dataFrame.getColumn('low');
|
|
|
|
// Import technical indicators from vector engine
|
|
const { TechnicalIndicators } = await import('@stock-bot/vector-engine');
|
|
|
|
// Pre-compute common indicators
|
|
this.precomputedIndicators.set('sma_20', TechnicalIndicators.sma(close, 20));
|
|
this.precomputedIndicators.set('sma_50', TechnicalIndicators.sma(close, 50));
|
|
this.precomputedIndicators.set('ema_12', TechnicalIndicators.ema(close, 12));
|
|
this.precomputedIndicators.set('ema_26', TechnicalIndicators.ema(close, 26));
|
|
this.precomputedIndicators.set('rsi', TechnicalIndicators.rsi(close));
|
|
this.precomputedIndicators.set('atr', TechnicalIndicators.atr(high, low, close));
|
|
|
|
const macd = TechnicalIndicators.macd(close);
|
|
this.precomputedIndicators.set('macd', macd.macd);
|
|
this.precomputedIndicators.set('macd_signal', macd.signal);
|
|
this.precomputedIndicators.set('macd_histogram', macd.histogram);
|
|
|
|
const bb = TechnicalIndicators.bollingerBands(close);
|
|
this.precomputedIndicators.set('bb_upper', bb.upper);
|
|
this.precomputedIndicators.set('bb_middle', bb.middle);
|
|
this.precomputedIndicators.set('bb_lower', bb.lower);
|
|
|
|
this.logger.info('Indicators pre-computed', {
|
|
indicators: Array.from(this.precomputedIndicators.keys()),
|
|
});
|
|
}
|
|
|
|
private overrideIndicatorCalculations(eventMode: EventMode): void {
|
|
// Override the event mode's indicator calculations to use pre-computed values
|
|
// This is a simplified approach - in production you'd want a more sophisticated interface
|
|
const _originalCalculateIndicators = (eventMode as any).calculateIndicators;
|
|
|
|
(eventMode as any).calculateIndicators = (symbol: string, index: number) => {
|
|
const indicators: Record<string, number> = {};
|
|
|
|
for (const [name, values] of this.precomputedIndicators.entries()) {
|
|
if (index < values.length) {
|
|
indicators[name] = values[index];
|
|
}
|
|
}
|
|
|
|
return indicators;
|
|
};
|
|
}
|
|
|
|
private async estimateDataSize(): Promise<number> {
|
|
// Estimate the number of data points for the backtest period
|
|
const startTime = new Date(this.context.startDate).getTime();
|
|
const endTime = new Date(this.context.endDate).getTime();
|
|
const timeRange = endTime - startTime;
|
|
|
|
// Assume 1-minute intervals (60000ms)
|
|
const estimatedPoints = Math.floor(timeRange / 60000);
|
|
|
|
this.logger.debug('Estimated data size', {
|
|
timeRange,
|
|
estimatedPoints,
|
|
threshold: this.config.vectorizedThreshold,
|
|
});
|
|
|
|
return estimatedPoints;
|
|
}
|
|
|
|
private async loadWarmupData(): Promise<any[]> {
|
|
// Load historical data for warmup phase
|
|
// This should load more data than just the warmup period for indicator calculations
|
|
const data = [];
|
|
const startTime = new Date(this.context.startDate).getTime();
|
|
const warmupEndTime = startTime + this.config.warmupPeriod * 60000;
|
|
|
|
// Add extra lookback for indicator calculations
|
|
const lookbackTime = startTime - 200 * 60000; // 200 periods lookback
|
|
|
|
for (let timestamp = lookbackTime; timestamp <= warmupEndTime; timestamp += 60000) {
|
|
const basePrice = 100 + Math.sin(timestamp / 1000000) * 10;
|
|
const volatility = 0.02;
|
|
|
|
const open = basePrice + (Math.random() - 0.5) * volatility * basePrice;
|
|
const close = open + (Math.random() - 0.5) * volatility * basePrice;
|
|
const high = Math.max(open, close) + Math.random() * volatility * basePrice;
|
|
const low = Math.min(open, close) - Math.random() * volatility * basePrice;
|
|
const volume = Math.floor(Math.random() * 10000) + 1000;
|
|
|
|
data.push({
|
|
timestamp,
|
|
symbol: this.context.symbol,
|
|
open,
|
|
high,
|
|
low,
|
|
close,
|
|
volume,
|
|
});
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
private createDataFrame(data: any[]): DataFrame {
|
|
return new DataFrame(data, {
|
|
columns: ['timestamp', 'symbol', 'open', 'high', 'low', 'close', 'volume'],
|
|
dtypes: {
|
|
timestamp: 'number',
|
|
symbol: 'string',
|
|
open: 'number',
|
|
high: 'number',
|
|
low: 'number',
|
|
close: 'number',
|
|
volume: 'number',
|
|
},
|
|
});
|
|
}
|
|
|
|
private generateStrategyCode(): string {
|
|
// Generate strategy code based on context
|
|
const strategy = this.context.strategy;
|
|
|
|
if (strategy.type === 'sma_crossover') {
|
|
return 'sma_crossover';
|
|
}
|
|
|
|
return strategy.code || 'sma_crossover';
|
|
}
|
|
|
|
private convertVectorizedResult(
|
|
vectorResult: VectorizedBacktestResult,
|
|
startTime: number
|
|
): BacktestResult {
|
|
return {
|
|
backtestId: this.context.backtestId,
|
|
strategy: this.context.strategy,
|
|
symbol: this.context.symbol,
|
|
startDate: this.context.startDate,
|
|
endDate: this.context.endDate,
|
|
mode: 'hybrid-vectorized',
|
|
duration: Date.now() - startTime,
|
|
trades: vectorResult.trades.map(trade => ({
|
|
id: `trade_${trade.entryIndex}_${trade.exitIndex}`,
|
|
symbol: this.context.symbol,
|
|
side: trade.side,
|
|
entryTime: vectorResult.timestamps[trade.entryIndex],
|
|
exitTime: vectorResult.timestamps[trade.exitIndex],
|
|
entryPrice: trade.entryPrice,
|
|
exitPrice: trade.exitPrice,
|
|
quantity: trade.quantity,
|
|
pnl: trade.pnl,
|
|
commission: 0,
|
|
slippage: 0,
|
|
})),
|
|
performance: {
|
|
totalReturn: vectorResult.metrics.totalReturns,
|
|
sharpeRatio: vectorResult.metrics.sharpeRatio,
|
|
maxDrawdown: vectorResult.metrics.maxDrawdown,
|
|
winRate: vectorResult.metrics.winRate,
|
|
profitFactor: vectorResult.metrics.profitFactor,
|
|
totalTrades: vectorResult.metrics.totalTrades,
|
|
winningTrades: vectorResult.trades.filter(t => t.pnl > 0).length,
|
|
losingTrades: vectorResult.trades.filter(t => t.pnl <= 0).length,
|
|
avgTrade: vectorResult.metrics.avgTrade,
|
|
avgWin:
|
|
vectorResult.trades.filter(t => t.pnl > 0).reduce((sum, t) => sum + t.pnl, 0) /
|
|
vectorResult.trades.filter(t => t.pnl > 0).length || 0,
|
|
avgLoss:
|
|
vectorResult.trades.filter(t => t.pnl <= 0).reduce((sum, t) => sum + t.pnl, 0) /
|
|
vectorResult.trades.filter(t => t.pnl <= 0).length || 0,
|
|
largestWin: Math.max(...vectorResult.trades.map(t => t.pnl), 0),
|
|
largestLoss: Math.min(...vectorResult.trades.map(t => t.pnl), 0),
|
|
},
|
|
equity: vectorResult.equity,
|
|
drawdown: vectorResult.metrics.drawdown,
|
|
metadata: {
|
|
mode: 'hybrid-vectorized',
|
|
dataPoints: vectorResult.timestamps.length,
|
|
signals: Object.keys(vectorResult.signals),
|
|
optimizations: ['vectorized_warmup', 'precomputed_indicators'],
|
|
},
|
|
};
|
|
}
|
|
|
|
private extractFinalPortfolio(warmupResult: BacktestResult): any {
|
|
// Extract the final portfolio state from warmup phase
|
|
const finalEquity = warmupResult.equity[warmupResult.equity.length - 1] || 10000;
|
|
|
|
return {
|
|
cash: finalEquity,
|
|
positions: [], // Simplified - in production would track actual positions
|
|
equity: finalEquity,
|
|
};
|
|
}
|
|
|
|
private combineResults(
|
|
warmupResult: BacktestResult,
|
|
eventResult: BacktestResult,
|
|
startTime: number
|
|
): BacktestResult {
|
|
// Combine results from both phases
|
|
const combinedTrades = [...warmupResult.trades, ...eventResult.trades];
|
|
const combinedEquity = [...warmupResult.equity, ...eventResult.equity];
|
|
const combinedDrawdown = [...(warmupResult.drawdown || []), ...(eventResult.drawdown || [])];
|
|
|
|
// Recalculate combined performance metrics
|
|
const totalPnL = combinedTrades.reduce((sum, trade) => sum + trade.pnl, 0);
|
|
const winningTrades = combinedTrades.filter(t => t.pnl > 0);
|
|
const losingTrades = combinedTrades.filter(t => t.pnl <= 0);
|
|
|
|
const grossProfit = winningTrades.reduce((sum, t) => sum + t.pnl, 0);
|
|
const grossLoss = Math.abs(losingTrades.reduce((sum, t) => sum + t.pnl, 0));
|
|
|
|
return {
|
|
backtestId: this.context.backtestId,
|
|
strategy: this.context.strategy,
|
|
symbol: this.context.symbol,
|
|
startDate: this.context.startDate,
|
|
endDate: this.context.endDate,
|
|
mode: 'hybrid',
|
|
duration: Date.now() - startTime,
|
|
trades: combinedTrades,
|
|
performance: {
|
|
totalReturn:
|
|
(combinedEquity[combinedEquity.length - 1] - combinedEquity[0]) / combinedEquity[0],
|
|
sharpeRatio: eventResult.performance.sharpeRatio, // Use event result for more accurate calculation
|
|
maxDrawdown: Math.max(...combinedDrawdown),
|
|
winRate: winningTrades.length / combinedTrades.length,
|
|
profitFactor: grossLoss !== 0 ? grossProfit / grossLoss : Infinity,
|
|
totalTrades: combinedTrades.length,
|
|
winningTrades: winningTrades.length,
|
|
losingTrades: losingTrades.length,
|
|
avgTrade: totalPnL / combinedTrades.length,
|
|
avgWin: grossProfit / winningTrades.length || 0,
|
|
avgLoss: grossLoss / losingTrades.length || 0,
|
|
largestWin: Math.max(...combinedTrades.map(t => t.pnl), 0),
|
|
largestLoss: Math.min(...combinedTrades.map(t => t.pnl), 0),
|
|
},
|
|
equity: combinedEquity,
|
|
drawdown: combinedDrawdown,
|
|
metadata: {
|
|
mode: 'hybrid',
|
|
phases: ['vectorized-warmup', 'event-driven'],
|
|
warmupPeriod: this.config.warmupPeriod,
|
|
optimizations: ['precomputed_indicators', 'hybrid_execution'],
|
|
warmupTrades: warmupResult.trades.length,
|
|
eventTrades: eventResult.trades.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
async cleanup(): Promise<void> {
|
|
await super.cleanup();
|
|
await this.eventMode.cleanup();
|
|
await this.vectorizedMode.cleanup();
|
|
this.precomputedIndicators.clear();
|
|
this.logger.info('Hybrid mode cleanup completed');
|
|
}
|
|
}
|
|
|
|
export default HybridMode;
|