work on backtest

This commit is contained in:
Boki 2025-07-03 09:55:13 -04:00
parent 5a3a23a2ba
commit 143e2e1678
9 changed files with 613 additions and 46 deletions

View file

@ -167,7 +167,21 @@ export class BacktestEngine extends EventEmitter {
this.container.logger.info(`Loaded ${marketData.length} market data points`);
// Initialize strategies
await this.strategyManager.initializeStrategies(validatedConfig.strategies || []);
const strategies = validatedConfig.strategies || [{
id: `${validatedConfig.strategy}-${Date.now()}`,
name: validatedConfig.strategy,
enabled: true,
parameters: {
symbols: validatedConfig.symbols,
initialCapital: validatedConfig.initialCapital,
// Add any default strategy parameters here
},
symbols: validatedConfig.symbols,
allocation: 1.0 // Use 100% of capital
}];
await this.strategyManager.initializeStrategies(strategies);
this.container.logger.info(`Initialized ${strategies.length} strategies`);
// Convert market data to events
this.populateEventQueue(marketData);
@ -421,6 +435,12 @@ export class BacktestEngine extends EventEmitter {
this.currentTime = event.timestamp;
if (tradingEngine) {
await tradingEngine.advanceTime(this.currentTime);
// Check for any fills
const fills = tradingEngine.getFills ? tradingEngine.getFills() : [];
for (const fill of fills) {
await this.processFill(fill);
}
}
// Process event based on type
@ -523,6 +543,9 @@ export class BacktestEngine extends EventEmitter {
// Let strategies process the data
await this.strategyManager.onMarketData(data);
// Check for any pending orders that should be filled
await this.checkAndFillOrders(data);
// Track performance
this.performanceAnalyzer.addEquityPoint(
new Date(this.currentTime),
@ -536,11 +559,55 @@ export class BacktestEngine extends EventEmitter {
}
private async processFill(fill: any): Promise<void> {
// Process fill in trading engine for position tracking
const tradingEngine = this.strategyManager.getTradingEngine();
if (tradingEngine) {
const fillResult = await tradingEngine.processFill(
fill.symbol,
fill.price,
fill.quantity,
fill.side,
fill.commission || 0
);
this.container.logger.debug('Fill processed:', fillResult);
}
// Record trade
this.trades.push({
...fill,
const trade = {
symbol: fill.symbol,
side: fill.side,
quantity: fill.quantity,
entryPrice: fill.price,
entryTime: fill.timestamp || this.currentTime,
exitPrice: null,
exitTime: null,
pnl: 0,
returnPct: 0,
commission: fill.commission || 0,
currentPrice: fill.price,
holdingPeriod: 0,
backtestTime: this.currentTime
});
};
this.trades.push(trade);
this.container.logger.info(`💵 Trade recorded: ${fill.side} ${fill.quantity} ${fill.symbol} @ ${fill.price}`);
// Update existing trades if this is a closing trade
if (fill.side === 'sell') {
this.updateClosedTrades(fill);
}
// Notify strategies of fill
const strategy = fill.strategyId ? this.strategyManager.getStrategy(fill.strategyId) : null;
if (strategy) {
await strategy.onOrderUpdate({
orderId: fill.orderId,
symbol: fill.symbol,
side: fill.side,
status: 'filled',
fills: [fill]
});
}
// Store in database
await this.storageService.storeFill(fill);
@ -934,4 +1001,76 @@ export class BacktestEngine extends EventEmitter {
return ohlcData;
}
private pendingOrders: Map<string, any> = new Map();
private ordersListenerSetup = false;
private async checkAndFillOrders(data: MarketData): Promise<void> {
if (data.type !== 'bar') return;
const symbol = data.data.symbol;
const currentPrice = data.data.close;
// Listen for orders from strategy manager
if (!this.ordersListenerSetup) {
this.strategyManager.on('order', async (orderEvent: any) => {
this.container.logger.info('New order received:', orderEvent);
this.pendingOrders.set(orderEvent.orderId, {
...orderEvent,
timestamp: this.currentTime
});
});
this.ordersListenerSetup = true;
}
// Check pending orders for this symbol
for (const [orderId, orderEvent] of this.pendingOrders) {
if (orderEvent.order.symbol === symbol) {
// For market orders, fill immediately at current price
if (orderEvent.order.orderType === 'market') {
const fillPrice = orderEvent.order.side === 'buy' ?
currentPrice * (1 + (this.marketSimulator.config.slippage || 0.0001)) :
currentPrice * (1 - (this.marketSimulator.config.slippage || 0.0001));
const fill = {
orderId,
symbol: orderEvent.order.symbol,
side: orderEvent.order.side,
quantity: orderEvent.order.quantity,
price: fillPrice,
timestamp: this.currentTime,
commission: orderEvent.order.quantity * fillPrice * 0.001, // 0.1% commission
strategyId: orderEvent.strategyId
};
this.container.logger.info(`✅ Filling ${orderEvent.order.side} order for ${symbol} @ ${fillPrice}`);
await this.processFill(fill);
this.pendingOrders.delete(orderId);
}
// TODO: Handle limit orders
}
}
}
private updateClosedTrades(fill: any): void {
// Find open trades for this symbol
const openTrades = this.trades.filter(t =>
t.symbol === fill.symbol &&
t.side === 'buy' &&
!t.exitTime
);
if (openTrades.length > 0) {
// FIFO - close oldest trade first
const tradeToClose = openTrades[0];
tradeToClose.exitPrice = fill.price;
tradeToClose.exitTime = fill.timestamp || this.currentTime;
tradeToClose.pnl = (tradeToClose.exitPrice - tradeToClose.entryPrice) * tradeToClose.quantity - tradeToClose.commission - fill.commission;
tradeToClose.returnPct = ((tradeToClose.exitPrice - tradeToClose.entryPrice) / tradeToClose.entryPrice) * 100;
tradeToClose.holdingPeriod = tradeToClose.exitTime - tradeToClose.entryTime;
this.container.logger.info(`💰 Trade closed: P&L ${tradeToClose.pnl.toFixed(2)}, Return ${tradeToClose.returnPct.toFixed(2)}%`);
}
}
}

View file

@ -72,19 +72,61 @@ export class StrategyManager extends EventEmitter {
}
private async createStrategy(config: StrategyConfig): Promise<BaseStrategy> {
// In a real system, this would dynamically load strategy classes
// For now, create a base strategy instance
const strategy = new BaseStrategy(
config,
this.container.custom?.ModeManager,
this.container.custom?.ExecutionService
);
// Load strategy based on name
let strategy: BaseStrategy;
switch (config.name.toLowerCase()) {
case 'meanreversion':
case 'mean-reversion': {
const { MeanReversionStrategy } = await import('./examples/MeanReversionStrategy');
strategy = new MeanReversionStrategy(
config,
this.container.custom?.ModeManager,
this.container.custom?.ExecutionService
);
break;
}
case 'smacrossover':
case 'sma-crossover':
case 'moving-average': {
const { SimpleMovingAverageCrossover } = await import('./examples/SimpleMovingAverageCrossover');
strategy = new SimpleMovingAverageCrossover(
config,
this.container.custom?.ModeManager,
this.container.custom?.ExecutionService
);
break;
}
case 'mlenhanced':
case 'ml-enhanced': {
const { MLEnhancedStrategy } = await import('./examples/MLEnhancedStrategy');
strategy = new MLEnhancedStrategy(
config,
this.container.custom?.ModeManager,
this.container.custom?.ExecutionService
);
break;
}
default:
// Default to base strategy
this.container.logger.warn(`Unknown strategy: ${config.name}, using base strategy`);
strategy = new BaseStrategy(
config,
this.container.custom?.ModeManager,
this.container.custom?.ExecutionService
);
break;
}
// Set up strategy event handlers
strategy.on('signal', (signal: any) => {
this.handleStrategySignal(config.id, signal);
});
strategy.on('order', (order: any) => {
this.handleStrategyOrder(config.id, order);
});
strategy.on('error', (error: Error) => {
this.container.logger.error(`Strategy ${config.id} error:`, error);
});
@ -99,6 +141,7 @@ export class StrategyManager extends EventEmitter {
}
await strategy.initialize();
await strategy.start(); // Start the strategy to make it active
this.activeStrategies.add(strategyId);
this.container.logger.info(`Enabled strategy: ${strategyId}`);
}
@ -114,6 +157,10 @@ export class StrategyManager extends EventEmitter {
this.container.logger.info(`Disabled strategy: ${strategyId}`);
}
async onMarketData(data: MarketData): Promise<void> {
return this.handleMarketData(data);
}
private async handleMarketData(data: MarketData): Promise<void> {
// Forward to all active strategies
for (const strategyId of this.activeStrategies) {
@ -158,25 +205,49 @@ export class StrategyManager extends EventEmitter {
private async handleStrategySignal(strategyId: string, signal: any): Promise<void> {
this.container.logger.info(`Strategy ${strategyId} generated signal:`, signal);
// Signals are informational - strategies will convert strong signals to orders
}
private async handleStrategyOrder(strategyId: string, order: OrderRequest): Promise<void> {
this.container.logger.info(`Strategy ${strategyId} generated order:`, order);
// Convert signal to order request
const orderRequest: OrderRequest = {
symbol: signal.symbol,
quantity: signal.quantity,
side: signal.side,
type: signal.orderType || 'market',
timeInForce: signal.timeInForce || 'day',
strategyId
};
// Submit order through execution service
const executionService = this.container.custom?.ExecutionService;
if (executionService) {
// Submit order through trading engine (for backtesting)
if (this.tradingEngine) {
try {
const result = await executionService.submitOrder(orderRequest);
this.container.logger.info(`Order submitted for strategy ${strategyId}:`, result);
// Create order object for Rust API
const orderObj = {
id: `${strategyId}-${Date.now()}`,
symbol: order.symbol,
side: order.side,
quantity: order.quantity,
orderType: order.orderType,
limitPrice: order.limitPrice,
timeInForce: order.timeInForce || 'DAY'
};
const orderResult = await this.tradingEngine.submitOrder(orderObj);
const result = JSON.parse(orderResult);
this.container.logger.info(`Order placed for strategy ${strategyId}: ${result.order_id}`);
// Emit order event
this.emit('order', {
strategyId,
orderId: result.order_id,
order
});
} catch (error) {
this.container.logger.error(`Failed to submit order for strategy ${strategyId}:`, error);
this.container.logger.error(`Failed to place order for strategy ${strategyId}:`, error);
}
} else {
// Use execution service for paper/live trading
const executionService = this.container.custom?.ExecutionService;
if (executionService) {
try {
const result = await executionService.submitOrder(order);
this.container.logger.info(`Order submitted for strategy ${strategyId}:`, result);
} catch (error) {
this.container.logger.error(`Failed to submit order for strategy ${strategyId}:`, error);
}
}
}
}

View file

@ -0,0 +1,254 @@
import { BaseStrategy, Signal } from '../BaseStrategy';
import { MarketData } from '../../types';
import { getLogger } from '@stock-bot/logger';
const logger = getLogger('SimpleMovingAverageCrossover');
export class SimpleMovingAverageCrossover extends BaseStrategy {
private priceHistory = new Map<string, number[]>();
private positions = new Map<string, number>();
private lastSignalTime = new Map<string, number>();
private totalSignals = 0;
// Strategy parameters
private readonly FAST_PERIOD = 10;
private readonly SLOW_PERIOD = 20;
private readonly POSITION_SIZE = 0.1; // 10% of capital per position
private readonly MIN_SIGNAL_INTERVAL = 24 * 60 * 60 * 1000; // 1 day minimum between signals
constructor(config: any, modeManager?: any, executionService?: any) {
super(config, modeManager, executionService);
logger.info(`SimpleMovingAverageCrossover initialized with Fast=${this.FAST_PERIOD}, Slow=${this.SLOW_PERIOD}`);
}
protected updateIndicators(data: MarketData): void {
if (data.type !== 'bar') return;
const symbol = data.data.symbol;
const price = data.data.close;
// Update price history
if (!this.priceHistory.has(symbol)) {
this.priceHistory.set(symbol, []);
logger.info(`📊 Starting to track ${symbol} @ ${price}`);
}
const history = this.priceHistory.get(symbol)!;
history.push(price);
// Keep only needed history
if (history.length > this.SLOW_PERIOD * 2) {
history.shift();
}
// Log when we have enough data to start trading
if (history.length === this.SLOW_PERIOD) {
logger.info(`${symbol} now has enough history (${history.length} bars) to start trading`);
}
}
protected async generateSignal(data: MarketData): Promise<Signal | null> {
if (data.type !== 'bar') return null;
const symbol = data.data.symbol;
const history = this.priceHistory.get(symbol);
if (!history || history.length < this.SLOW_PERIOD) {
if (history && history.length % 5 === 0) {
logger.debug(`${symbol} - Not enough history: ${history.length}/${this.SLOW_PERIOD} bars`);
}
return null;
}
// Calculate moving averages
const fastMA = this.calculateSMA(history, this.FAST_PERIOD);
const slowMA = this.calculateSMA(history, this.SLOW_PERIOD);
// Get previous MAs for crossover detection
const prevHistory = history.slice(0, -1);
const prevFastMA = this.calculateSMA(prevHistory, this.FAST_PERIOD);
const prevSlowMA = this.calculateSMA(prevHistory, this.SLOW_PERIOD);
const currentPosition = this.positions.get(symbol) || 0;
const currentPrice = data.data.close;
const timestamp = new Date(data.data.timestamp).toISOString().split('T')[0];
// Log every 50 bars to track MA values and crossover conditions
if (history.length % 50 === 0) {
logger.info(`${symbol} @ ${timestamp} - Price: ${currentPrice.toFixed(2)}, Fast MA: ${fastMA.toFixed(2)}, Slow MA: ${slowMA.toFixed(2)}, Position: ${currentPosition}`);
logger.debug(`${symbol} - Prev Fast MA: ${prevFastMA.toFixed(2)}, Prev Slow MA: ${prevSlowMA.toFixed(2)}`);
logger.debug(`${symbol} - Fast > Slow: ${fastMA > slowMA}, Prev Fast <= Prev Slow: ${prevFastMA <= prevSlowMA}`);
}
// Detect crossovers with detailed logging
const goldenCross = prevFastMA <= prevSlowMA && fastMA > slowMA;
const deathCross = prevFastMA >= prevSlowMA && fastMA < slowMA;
if (goldenCross && currentPosition === 0) {
// Golden cross - buy signal
logger.info(`🟢 Golden cross detected for ${symbol} @ ${timestamp}`);
logger.info(` Price: ${currentPrice.toFixed(2)}, Fast MA: ${fastMA.toFixed(2)} > Slow MA: ${slowMA.toFixed(2)}`);
logger.info(` Prev Fast MA: ${prevFastMA.toFixed(2)} <= Prev Slow MA: ${prevSlowMA.toFixed(2)}`);
// Calculate position size
const positionSize = this.calculatePositionSize(currentPrice);
logger.info(` Position size: ${positionSize} shares`);
const signal: Signal = {
type: 'buy',
symbol,
strength: 0.8,
reason: 'Golden cross - Fast MA crossed above Slow MA',
metadata: {
fastMA,
slowMA,
prevFastMA,
prevSlowMA,
crossoverType: 'golden',
price: currentPrice,
quantity: positionSize
}
};
// Track signal time
this.lastSignalTime.set(symbol, Date.now());
this.totalSignals++;
logger.info(`👉 Total signals generated: ${this.totalSignals}`);
return signal;
} else if (deathCross && currentPosition > 0) {
// Death cross - sell signal
logger.info(`🔴 Death cross detected for ${symbol} @ ${timestamp}`);
logger.info(` Price: ${currentPrice.toFixed(2)}, Fast MA: ${fastMA.toFixed(2)} < Slow MA: ${slowMA.toFixed(2)}`);
logger.info(` Prev Fast MA: ${prevFastMA.toFixed(2)} >= Prev Slow MA: ${prevSlowMA.toFixed(2)}`);
logger.info(` Current position: ${currentPosition} shares`);
const signal: Signal = {
type: 'sell',
symbol,
strength: 0.8,
reason: 'Death cross - Fast MA crossed below Slow MA',
metadata: {
fastMA,
slowMA,
prevFastMA,
prevSlowMA,
crossoverType: 'death',
price: currentPrice,
quantity: currentPosition
}
};
// Track signal time
this.lastSignalTime.set(symbol, Date.now());
this.totalSignals++;
logger.info(`👉 Total signals generated: ${this.totalSignals}`);
return signal;
}
// Log near-crossover conditions
const fastApproachingSlow = Math.abs(fastMA - slowMA) / slowMA < 0.01; // Within 1%
if (fastApproachingSlow && history.length % 20 === 0) {
logger.debug(`${symbol} - MAs converging: Fast MA ${fastMA.toFixed(2)} ~ Slow MA ${slowMA.toFixed(2)} (${((Math.abs(fastMA - slowMA) / slowMA) * 100).toFixed(2)}% diff)`);
}
return null;
}
private calculateSMA(prices: number[], period: number): number {
if (prices.length < period) {
logger.warn(`Not enough data for SMA calculation: ${prices.length} < ${period}`);
return 0;
}
const slice = prices.slice(-period);
const sum = slice.reduce((a, b) => a + b, 0);
const sma = sum / period;
// Sanity check
if (isNaN(sma) || !isFinite(sma)) {
logger.error(`Invalid SMA calculation: sum=${sum}, period=${period}, prices=${slice.length}`);
return 0;
}
return sma;
}
private calculatePositionSize(price: number): number {
// Get account balance from trading engine
const tradingEngine = this.modeManager?.getTradingEngine();
if (!tradingEngine) {
logger.warn('No trading engine available, using default position size');
return 100;
}
// Try to get account balance from trading engine
let accountBalance = 100000; // Default
try {
if (tradingEngine.getAccountBalance) {
accountBalance = tradingEngine.getAccountBalance();
} else if (tradingEngine.getTotalPnl) {
const [realized, unrealized] = tradingEngine.getTotalPnl();
accountBalance = 100000 + realized + unrealized; // Assuming 100k initial
}
} catch (error) {
logger.warn('Could not get account balance:', error);
}
const positionValue = accountBalance * this.POSITION_SIZE;
const shares = Math.floor(positionValue / price);
logger.debug(`Position sizing: Balance=$${accountBalance}, Position Size=${this.POSITION_SIZE}, Price=$${price}, Shares=${shares}`);
return Math.max(1, shares); // At least 1 share
}
protected onOrderFilled(fill: any): void {
const { symbol, side, quantity, price } = fill;
const currentPosition = this.positions.get(symbol) || 0;
if (side === 'buy') {
this.positions.set(symbol, currentPosition + quantity);
logger.info(`✅ BUY filled: ${symbol} - ${quantity} shares @ ${price}`);
} else {
this.positions.set(symbol, Math.max(0, currentPosition - quantity));
logger.info(`✅ SELL filled: ${symbol} - ${quantity} shares @ ${price}`);
}
logger.info(`Position updated for ${symbol}: ${this.positions.get(symbol)} shares`);
}
// Override to provide custom order generation
protected async signalToOrder(signal: Signal): Promise<OrderRequest | null> {
logger.info(`🔄 Converting signal to order:`, signal);
// Get position sizing from metadata or calculate
const quantity = signal.metadata?.quantity || 100;
if (signal.type === 'buy') {
const order: OrderRequest = {
symbol: signal.symbol,
side: 'buy',
quantity,
orderType: 'market',
timeInForce: 'DAY'
};
logger.info(`📈 Generated BUY order:`, order);
return order;
} else if (signal.type === 'sell') {
const order: OrderRequest = {
symbol: signal.symbol,
side: 'sell',
quantity,
orderType: 'market',
timeInForce: 'DAY'
};
logger.info(`📉 Generated SELL order:`, order);
return order;
}
return null;
}
}

View file

@ -12,6 +12,14 @@ export const BacktestConfigSchema = z.object({
symbols: z.array(z.string()),
initialCapital: z.number().positive(),
dataFrequency: z.enum(['1m', '5m', '15m', '1h', '1d']),
strategy: z.string(),
strategies: z.array(z.object({
name: z.string(),
enabled: z.boolean().default(true),
config: z.record(z.any()).optional()
})).optional(),
commission: z.number().optional(),
slippage: z.number().optional(),
fillModel: z.object({
slippage: z.enum(['zero', 'conservative', 'realistic', 'aggressive']),
marketImpact: z.boolean(),