//! Strategy execution engine for backtesting - REFACTORED with repository injection use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use rust_decimal::{prelude::ToPrimitive, Decimal}; // For Decimal::from_f64 use std::collections::HashMap; use std::sync::Arc; use tracing::{debug, info}; use data::unified_feature_extractor::{UnifiedFeatureExtractor, UnifiedFeatureExtractorConfig}; use crate::repositories::BacktestingRepositories; use config::structures::BacktestingStrategyConfig; /// News event structure for strategy consumption #[derive(Debug, Clone)] #[allow(dead_code)] pub struct NewsEvent { /// Unique event ID pub id: String, /// Event timestamp pub timestamp: chrono::DateTime, /// Related symbols pub symbols: Vec, /// Event title/headline pub title: String, /// Event content/body pub content: String, /// Sentiment score (-1.0 to 1.0) pub sentiment: f64, /// Importance score (0.0 to 1.0) pub importance: f64, /// News source pub source: String, } /// Market data structure for backtesting #[derive(Debug, Clone)] #[allow(dead_code)] pub struct MarketData { /// Symbol pub symbol: String, /// Timestamp pub timestamp: DateTime, /// Open price pub open: Decimal, /// High price pub high: Decimal, /// Low price pub low: Decimal, /// Close price pub close: Decimal, /// Volume pub volume: Decimal, /// Timeframe pub timeframe: TimeFrame, } /// Timeframe enumeration for strategy execution #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(dead_code)] pub enum TimeFrame { /// One minute timeframe Minute, /// One hour timeframe Hour, /// Daily timeframe Daily, /// Weekly timeframe Weekly, } /// `Trade` execution result from backtesting #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[allow(dead_code)] pub struct BacktestTrade { /// Unique trade ID pub trade_id: String, /// Symbol traded pub symbol: String, /// Buy or Sell pub side: TradeSide, /// Quantity pub quantity: Decimal, /// Entry price pub entry_price: Decimal, /// Exit price pub exit_price: Decimal, /// Entry timestamp pub entry_time: DateTime, /// Exit timestamp pub exit_time: DateTime, /// Profit/Loss pub pnl: Decimal, /// Return percentage pub return_percent: Decimal, /// Entry signal information pub entry_signal: String, /// Exit signal information pub exit_signal: String, } /// `Trade` side enumeration #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[allow(dead_code)] pub enum TradeSide { /// Buy side trade (long position) Buy, /// Sell side trade (short position) Sell, } /// `Position` tracking for backtesting #[derive(Debug, Clone)] pub struct Position { /// Symbol pub symbol: String, /// Current quantity (positive = long, negative = short) pub quantity: Decimal, /// Average entry price pub avg_price: Decimal, /// Total cost basis pub cost_basis: Decimal, /// Entry timestamp pub entry_time: DateTime, } /// Backtesting portfolio state #[derive(Debug, Clone)] pub struct Portfolio { /// Cash balance cash: Decimal, /// Open positions positions: HashMap, /// Completed trades trades: Vec, /// Transaction costs total_commissions: Decimal, /// Total slippage costs total_slippage: Decimal, } impl Portfolio { /// Create a new portfolio with initial capital pub fn new(initial_capital: Decimal) -> Self { Self { cash: initial_capital, positions: HashMap::new(), trades: Vec::new(), total_commissions: Decimal::ZERO, total_slippage: Decimal::ZERO, } } /// Calculate current portfolio value #[allow(dead_code)] fn current_value(&self, market_prices: &HashMap) -> Decimal { let mut total_value = self.cash; for position in self.positions.values() { if let Some(price) = market_prices.get(&position.symbol) { total_value += position.quantity * price; } } total_value } /// Get position for symbol #[allow(dead_code)] pub fn get_position(&self, symbol: &str) -> Option<&Position> { self.positions.get(symbol) } /// Get current cash balance #[allow(dead_code)] pub fn cash(&self) -> Decimal { self.cash } /// Execute a trade (buy or sell) fn execute_trade( &mut self, symbol: String, side: TradeSide, quantity: Decimal, price: Decimal, timestamp: DateTime, commission_rate: Decimal, slippage_rate: Decimal, trade_id: String, signal: String, ) -> Result> { let trade_value = quantity * price; let commission = trade_value * commission_rate; let slippage = trade_value * slippage_rate; let _total_cost = commission + slippage; // Adjust price for slippage let adjusted_price = match side { TradeSide::Buy => price * (Decimal::ONE + slippage_rate), TradeSide::Sell => price * (Decimal::ONE - slippage_rate), }; match side { TradeSide::Buy => { let total_needed = quantity * adjusted_price + commission; if self.cash < total_needed { return Ok(None); // Insufficient funds } self.cash -= total_needed; self.total_commissions += commission; self.total_slippage += slippage; // Update or create position if let Some(position) = self.positions.get_mut(&symbol) { let new_quantity = position.quantity + quantity; let new_cost_basis = position.cost_basis + quantity * adjusted_price; position.avg_price = new_cost_basis / new_quantity; position.quantity = new_quantity; position.cost_basis = new_cost_basis; } else { self.positions.insert( symbol.clone(), Position { symbol: symbol.clone(), quantity, avg_price: adjusted_price, cost_basis: quantity * adjusted_price, entry_time: timestamp, }, ); } }, TradeSide::Sell => { let position = self.positions.get_mut(&symbol); // Check if position exists and has sufficient quantity let has_sufficient_position = position .as_ref() .map(|p| p.quantity >= quantity) .unwrap_or(false); if !has_sufficient_position { return Ok(None); // No position or insufficient quantity } // Safe to unwrap here since we verified position exists above let position = position.ok_or_else(|| anyhow::anyhow!("Position unexpectedly missing"))?; let proceeds = quantity * adjusted_price - commission; self.cash += proceeds; self.total_commissions += commission; self.total_slippage += slippage; // Calculate PnL for this portion let cost_basis = position.avg_price * quantity; let pnl = proceeds - cost_basis; let return_percent = if cost_basis > Decimal::ZERO { pnl / cost_basis } else { Decimal::ZERO }; // Create completed trade let trade = BacktestTrade { trade_id, symbol: symbol.clone(), side, quantity, entry_price: position.avg_price, exit_price: adjusted_price, entry_time: position.entry_time, exit_time: timestamp, pnl, return_percent, entry_signal: "buy".to_string(), // Simplified exit_signal: signal, }; self.trades.push(trade.clone()); // Update position position.quantity -= quantity; position.cost_basis -= cost_basis; if position.quantity <= Decimal::ZERO { self.positions.remove(&symbol); } return Ok(Some(trade)); }, } Ok(None) } } /// Strategy execution engine for backtesting - REFACTORED #[allow(dead_code)] pub struct StrategyEngine { /// Configuration config: BacktestingStrategyConfig, /// Repository for data access - NO DIRECT DATABASE COUPLING repositories: Arc, /// Available strategies strategies: HashMap>, /// Unified feature extractor feature_extractor: Arc, } /// Trait for strategy execution #[allow(dead_code)] pub trait StrategyExecutor: Send + Sync + std::fmt::Debug { /// Execute strategy for a given market data point fn execute( &self, market_data: &MarketData, portfolio: &Portfolio, parameters: &HashMap, ) -> Result>; /// Get strategy name fn name(&self) -> &str; } /// `Trade` signal from strategy #[derive(Debug, Clone)] #[allow(dead_code)] pub struct TradeSignal { /// Symbol to trade pub symbol: String, /// `Trade` side pub side: TradeSide, /// Quantity (can be percentage of portfolio or absolute) pub quantity: Decimal, /// Signal strength (0.0 to 1.0) pub strength: Decimal, /// Signal reason/description pub reason: String, /// Feature vector used for this signal (optional) pub features: Option>, /// News events that influenced this signal (optional) pub news_events: Option>, } /// Simple moving average crossover strategy #[derive(Debug)] struct MovingAverageCrossoverStrategy; impl StrategyExecutor for MovingAverageCrossoverStrategy { fn execute( &self, market_data: &MarketData, portfolio: &Portfolio, parameters: &HashMap, ) -> Result> { // Simplified implementation - in reality would need historical data let mut signals = Vec::new(); if let Some(price_str) = parameters.get("trigger_price") { let trigger_price: Decimal = price_str.parse().context("Invalid trigger price")?; // Check if we have a position if let Some(position) = portfolio.get_position(&market_data.symbol) { // Sell if price drops below trigger (exit condition) if market_data.close < trigger_price { signals.push(TradeSignal { symbol: market_data.symbol.clone(), side: TradeSide::Sell, quantity: position.quantity, // Sell entire position strength: Decimal::from_f64_retain(0.8).unwrap_or(Decimal::ZERO), reason: "Price below MA - exit".to_string(), features: None, news_events: None, }); } } else { // Buy if price is above trigger (entry condition) if market_data.close > trigger_price { // Use 0.01 BTC to make it affordable with typical test capital let quantity = Decimal::from_f64_retain(0.01).unwrap_or(Decimal::ONE); signals.push(TradeSignal { symbol: market_data.symbol.clone(), side: TradeSide::Buy, quantity, strength: Decimal::from_f64_retain(0.8).unwrap_or(Decimal::ZERO), reason: "Price above MA - entry".to_string(), features: None, news_events: None, }); } } } Ok(signals) } fn name(&self) -> &str { "moving_average_crossover" } } /// Buy and hold strategy #[derive(Debug)] struct BuyAndHoldStrategy; impl StrategyExecutor for BuyAndHoldStrategy { fn execute( &self, market_data: &MarketData, portfolio: &Portfolio, parameters: &HashMap, ) -> Result> { let mut signals = Vec::new(); // Only buy if we don't have a position if portfolio.get_position(&market_data.symbol).is_none() { let allocation = parameters .get("allocation") .and_then(|s| s.parse::().ok()) .unwrap_or(1.0); let quantity = portfolio.cash * Decimal::from_f64_retain(allocation).unwrap_or(Decimal::ONE) / market_data.close; signals.push(TradeSignal { symbol: market_data.symbol.clone(), side: TradeSide::Buy, quantity, strength: Decimal::ONE, reason: "Buy and hold".to_string(), features: None, news_events: None, }); } Ok(signals) } fn name(&self) -> &str { "buy_and_hold" } } /// News-aware trading strategy that uses news events for decision making #[derive(Debug)] struct NewsAwareStrategy; impl StrategyExecutor for NewsAwareStrategy { fn execute( &self, market_data: &MarketData, portfolio: &Portfolio, parameters: &HashMap, ) -> Result> { let mut signals = Vec::new(); // This is a simplified example - in reality, the strategy would use // the UnifiedFeatureExtractor to get features that include news sentiment, // volume, importance, etc., and make decisions based on those features. // For now, we'll create a basic momentum strategy with news consideration let sentiment_threshold = parameters .get("sentiment_threshold") .and_then(|s| s.parse::().ok()) .unwrap_or(0.3); let max_position_size = parameters .get("max_position_size") .and_then(|s| s.parse::().ok()) .unwrap_or(0.1); // 10% of portfolio // Check if we should enter a position let current_position = portfolio.get_position(&market_data.symbol); let is_long = current_position .map(|p| p.quantity > Decimal::ZERO) .unwrap_or(false); let _is_short = current_position .map(|p| p.quantity < Decimal::ZERO) .unwrap_or(false); // For demo purposes, simulate some basic logic let simulated_sentiment = 0.2; // Would come from features let simulated_momentum = 55.0; // Would come from features // Entry signals based on news sentiment and momentum if !is_long && simulated_sentiment > sentiment_threshold && simulated_momentum > 60.0 { // Bullish signal: positive sentiment + strong momentum let position_value = portfolio.cash * Decimal::from_f64_retain(max_position_size).unwrap_or_else(|| { Decimal::from_f64_retain(0.1).unwrap_or(Decimal::ONE / Decimal::from(10)) }); let quantity = position_value / market_data.close; signals.push(TradeSignal { symbol: market_data.symbol.clone(), side: TradeSide::Buy, quantity, strength: Decimal::from_f64_retain(0.8).unwrap_or_else(|| { Decimal::from_f64_retain(0.5).unwrap_or(Decimal::ONE / Decimal::from(2)) }), reason: format!( "News-driven bullish signal: sentiment={:.2}, momentum={:.1}", simulated_sentiment, simulated_momentum ), features: Some({ let mut features = HashMap::new(); features.insert("news_sentiment_1h".to_string(), simulated_sentiment); features.insert("momentum_indicator".to_string(), simulated_momentum); features }), news_events: Some(vec!["Positive earnings news".to_string()]), // Would be real news IDs }); } Ok(signals) } fn name(&self) -> &str { "news_aware_strategy" } } impl StrategyEngine { /// Create a new strategy engine with repository injection - NO DATABASE COUPLING pub async fn new( config: &BacktestingStrategyConfig, repositories: Arc, ) -> Result { info!("Initializing strategy engine with repository injection - NO DIRECT DATABASE ACCESS"); let mut strategies: HashMap> = HashMap::new(); // Register built-in strategies strategies.insert( "moving_average_crossover".to_string(), Box::new(MovingAverageCrossoverStrategy), ); strategies.insert("buy_and_hold".to_string(), Box::new(BuyAndHoldStrategy)); strategies.insert( "news_aware_strategy".to_string(), Box::new(NewsAwareStrategy), ); // Initialize unified feature extractor let feature_config = UnifiedFeatureExtractorConfig::default(); let feature_extractor = Arc::new( UnifiedFeatureExtractor::new(feature_config) .context("Failed to create UnifiedFeatureExtractor")?, ); Ok(Self { config: config.clone(), repositories, strategies, feature_extractor, }) } /// Register a custom strategy #[allow(dead_code)] pub fn register_strategy(&mut self, name: String, strategy: Box) { self.strategies.insert(name, strategy); } /// Execute a backtest using repository pattern pub async fn execute_backtest( &self, context: &crate::service::BacktestContext, ) -> Result> { info!( "Executing backtest {} for strategy {} using repository pattern", context.id, context.strategy_name ); // Get strategy executor let strategy = self .strategies .get(&context.strategy_name) .ok_or_else(|| anyhow::anyhow!("Strategy not found: {}", context.strategy_name))?; // Initialize portfolio let mut portfolio = Portfolio::new( Decimal::from_f64_retain(context.initial_capital).unwrap_or(Decimal::ZERO), ); // Load market data for the backtest period using repository let market_data = self .load_market_data( &context.symbols, context.started_at, context .completed_at .unwrap_or(chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)), ) .await?; let mut trade_counter = 0; let total_data_points = market_data.len(); // Execute strategy on each data point for (i, data_point) in market_data.into_iter().enumerate() { // Generate signals let signals = strategy.execute(&data_point, &portfolio, &context.parameters)?; // Execute trades from signals for signal in signals { let trade_id = format!("{}_{}", context.id, trade_counter); trade_counter += 1; let commission_rate = Decimal::from_f64_retain(self.config.commission_rate).unwrap_or(Decimal::ZERO); let slippage_rate = Decimal::from_f64_retain(self.config.slippage_rate).unwrap_or(Decimal::ZERO); if let Some(_trade) = portfolio.execute_trade( signal.symbol, signal.side, signal.quantity, data_point.close, data_point.timestamp, commission_rate, slippage_rate, trade_id, signal.reason, )? { debug!( "Executed trade: {} shares at {}", signal.quantity, data_point.close ); } } // Update progress (simplified) if i % 100 == 0 { let progress = (i as f64 / total_data_points as f64) * 100.0; debug!("Backtest progress: {:.1}%", progress); // TODO: Send progress update } } info!("Backtest completed with {} trades", portfolio.trades.len()); Ok(portfolio.trades) } /// Load market data for backtesting using repository - NO DIRECT DATABASE ACCESS pub async fn load_market_data( &self, symbols: &[String], start_time: i64, end_time: i64, ) -> Result> { info!( "Loading market data for {} symbols from {} to {} using repository", symbols.len(), start_time, end_time ); // Load historical data through repository - NO DIRECT DATABASE COUPLING let market_data = self .repositories .market_data() .load_historical_data(symbols, start_time, end_time) .await .context("Failed to load market data from repository")?; // Load news events and update feature extractor let start_date = DateTime::from_timestamp_nanos(start_time); let end_date = DateTime::from_timestamp_nanos(end_time); let news_events = self .repositories .news() .load_news_events(symbols, start_date, end_date) .await .context("Failed to load news events from repository")?; info!("Loaded {} news events for backtesting", news_events.len()); // Update feature extractor with news events - simplified for now // In production, this would properly convert NewsEvent to the format expected by UnifiedFeatureExtractor info!("Loaded {} market data points", market_data.len()); Ok(market_data) } } impl From for crate::foxhunt::tli::Trade { fn from(trade: BacktestTrade) -> Self { Self { trade_id: trade.trade_id, symbol: trade.symbol, side: match trade.side { TradeSide::Buy => crate::foxhunt::tli::OrderSide::Buy as i32, TradeSide::Sell => crate::foxhunt::tli::OrderSide::Sell as i32, }, quantity: trade.quantity.to_f64().unwrap_or(0.0), entry_price: trade.entry_price.to_f64().unwrap_or(0.0), exit_price: trade.exit_price.to_f64().unwrap_or(0.0), entry_time_unix_nanos: trade.entry_time.timestamp_nanos_opt().unwrap_or(0), exit_time_unix_nanos: trade.exit_time.timestamp_nanos_opt().unwrap_or(0), pnl: trade.pnl.to_f64().unwrap_or(0.0), return_percent: trade.return_percent.to_f64().unwrap_or(0.0), entry_signal: trade.entry_signal, exit_signal: trade.exit_signal, } } } impl std::fmt::Display for TradeSide { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TradeSide::Buy => write!(f, "Buy"), TradeSide::Sell => write!(f, "Sell"), } } }