// ml-backtesting/src/barrier_backtest.rs // Barrier parameter optimization backtesting framework use crate::MLError; use anyhow::Result; /// Barrier parameters for triple barrier labeling #[derive(Debug, Clone, Copy)] pub struct BarrierParams { pub profit_target: f64, pub stop_loss: f64, pub max_holding_periods: usize, } impl BarrierParams { /// Validate barrier parameters pub fn validate(&self) -> Result<(), MLError> { if self.profit_target <= 0.0 { return Err(MLError::ValidationError { message: "Profit target must be positive".to_owned(), }); } if self.stop_loss <= 0.0 { return Err(MLError::ValidationError { message: "Stop loss must be positive".to_owned(), }); } if self.max_holding_periods == 0 { return Err(MLError::ValidationError { message: "Max holding periods must be greater than zero".to_owned(), }); } Ok(()) } } /// Results from barrier backtesting #[derive(Debug, Clone)] pub struct BacktestResults { pub sharpe_ratio: f64, pub win_rate: f64, pub max_drawdown: f64, pub label_distribution: (usize, usize, usize), // (buy, sell, hold) pub stability_score: f64, } /// Barrier backtester with walk-forward validation #[derive(Debug)] pub struct BarrierBacktester { walk_forward_windows: usize, train_test_split: f64, } impl BarrierBacktester { /// Create new barrier backtester pub const fn new(walk_forward_windows: usize, train_test_split: f64) -> Self { Self { walk_forward_windows, train_test_split, } } /// Get walk-forward windows configuration pub const fn walk_forward_windows(&self) -> usize { self.walk_forward_windows } /// Get train/test split ratio pub const fn train_test_split(&self) -> f64 { self.train_test_split } /// Run backtesting with walk-forward validation pub fn run(&self, prices: &[f64], params: BarrierParams) -> Result { // Validate inputs if prices.is_empty() { return Err(MLError::ValidationError { message: "Empty price series".to_owned(), } .into()); } params.validate()?; // Check if we have enough data for walk-forward windows let min_samples_per_window = 20; // Minimum samples needed per window let min_total_samples = min_samples_per_window * self.walk_forward_windows; if prices.len() < min_total_samples { return Err(MLError::InsufficientData(format!( "Need at least {} samples for {} windows, got {}", min_total_samples, self.walk_forward_windows, prices.len() )) .into()); } // Run walk-forward validation let window_results = self.walk_forward_backtest(prices, params)?; // Aggregate results self.aggregate_results(&window_results, prices) } /// Walk-forward backtesting across multiple windows fn walk_forward_backtest( &self, prices: &[f64], params: BarrierParams, ) -> Result> { let window_size = prices.len() / self.walk_forward_windows; let mut window_results = Vec::new(); for window_idx in 0..self.walk_forward_windows { let start_idx = window_idx * window_size; let end_idx = if window_idx == self.walk_forward_windows - 1 { prices.len() } else { (window_idx + 1) * window_size }; let window_prices = &prices[start_idx..end_idx]; // Split into train/test let train_size = (window_prices.len() as f64 * self.train_test_split) as usize; let test_prices = &window_prices[train_size..]; if test_prices.is_empty() { continue; } // Run labeling on test set let labels = self.label_bars(test_prices, params)?; // Calculate window metrics let window_result = self.calculate_window_metrics(test_prices, &labels)?; window_results.push(window_result); } Ok(window_results) } /// Label bars using triple barrier method #[allow(clippy::unnecessary_wraps)] fn label_bars(&self, prices: &[f64], params: BarrierParams) -> Result> { let mut labels = Vec::with_capacity(prices.len()); for (i, ¤t_price) in prices.iter().enumerate() { if i + params.max_holding_periods >= prices.len() { // Not enough future data for labeling labels.push(0); // Hold continue; } let future_prices = &prices[i + 1..=i + params.max_holding_periods]; let label = self.apply_triple_barrier(current_price, future_prices, params); labels.push(label); } Ok(labels) } /// Apply triple barrier method to determine label fn apply_triple_barrier( &self, entry_price: f64, future_prices: &[f64], params: BarrierParams, ) -> i8 { let upper_barrier = entry_price * (1.0 + params.profit_target); let lower_barrier = entry_price * (1.0 - params.stop_loss); for &price in future_prices { if price >= upper_barrier { return 1; // Profit target hit (Buy signal) } if price <= lower_barrier { return -1; // Stop loss hit (Sell signal) } } // Timeout - determine label based on final price let final_price = future_prices.last().copied().unwrap_or(entry_price); if final_price > entry_price { 1 // Positive return } else if final_price < entry_price { -1 // Negative return } else { 0 // No change } } /// Calculate metrics for a single window #[allow(clippy::unnecessary_wraps)] fn calculate_window_metrics(&self, prices: &[f64], labels: &[i8]) -> Result { let mut returns = Vec::new(); let mut equity_curve = Vec::new(); let mut current_equity = 1.0; let mut wins = 0; let mut total_trades = 0; for (i, &label) in labels.iter().enumerate() { if i + 1 >= prices.len() { break; } let price_return = (prices[i + 1] / prices[i]) - 1.0; // Simulate strategy return based on label let strategy_return = match label { 1 => price_return, // Buy signal -1 => -price_return, // Sell signal _ => 0.0, // Hold }; if label != 0 { total_trades += 1; if strategy_return > 0.0 { wins += 1; } } returns.push(strategy_return); current_equity *= 1.0 + strategy_return; equity_curve.push(current_equity); } // Calculate Sharpe ratio let sharpe = if !returns.is_empty() { calculate_sharpe_ratio(&returns) } else { 0.0 }; // Calculate max drawdown let max_dd = calculate_max_drawdown(&equity_curve); // Calculate win rate let win_rate = if total_trades > 0 { wins as f64 / total_trades as f64 } else { 0.0 }; // Count label distribution let buys = labels.iter().filter(|&&l| l == 1).count(); let sells = labels.iter().filter(|&&l| l == -1).count(); let holds = labels.iter().filter(|&&l| l == 0).count(); Ok(WindowResult { sharpe_ratio: sharpe, win_rate, max_drawdown: max_dd, label_distribution: (buys, sells, holds), }) } /// Aggregate results across all windows fn aggregate_results( &self, window_results: &[WindowResult], prices: &[f64], ) -> Result { if window_results.is_empty() { return Err( MLError::InsufficientData("No window results available".to_owned()).into(), ); } // Average Sharpe ratio let avg_sharpe = window_results.iter().map(|w| w.sharpe_ratio).sum::() / window_results.len() as f64; // Average win rate let avg_win_rate = window_results.iter().map(|w| w.win_rate).sum::() / window_results.len() as f64; // Worst max drawdown let worst_dd = window_results .iter() .map(|w| w.max_drawdown) .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .unwrap_or(0.0); // Aggregate label distribution let total_buys: usize = window_results.iter().map(|w| w.label_distribution.0).sum(); let total_sells: usize = window_results.iter().map(|w| w.label_distribution.1).sum(); let total_holds: usize = window_results.iter().map(|w| w.label_distribution.2).sum(); // Calculate stability score (variance of Sharpe ratios across windows) let stability_score = if window_results.len() > 1 { let sharpe_variance = calculate_variance( &window_results .iter() .map(|w| w.sharpe_ratio) .collect::>(), ); sharpe_variance } else { 0.0 }; // Ensure total labels match price series length let total_labels = total_buys + total_sells + total_holds; if total_labels != prices.len() { // Adjust for any discrepancies let holds_adjustment = prices.len() - total_labels; return Ok(BacktestResults { sharpe_ratio: avg_sharpe, win_rate: avg_win_rate, max_drawdown: worst_dd, label_distribution: (total_buys, total_sells, total_holds + holds_adjustment), stability_score, }); } Ok(BacktestResults { sharpe_ratio: avg_sharpe, win_rate: avg_win_rate, max_drawdown: worst_dd, label_distribution: (total_buys, total_sells, total_holds), stability_score, }) } } /// Results from a single walk-forward window #[derive(Debug, Clone)] struct WindowResult { sharpe_ratio: f64, win_rate: f64, max_drawdown: f64, label_distribution: (usize, usize, usize), } /// Calculate Sharpe ratio from returns fn calculate_sharpe_ratio(returns: &[f64]) -> f64 { if returns.is_empty() { return 0.0; } let mean_return = returns.iter().sum::() / returns.len() as f64; let std_dev = calculate_std_dev(returns, mean_return); if std_dev == 0.0 { return 0.0; } // Annualized Sharpe ratio (assuming daily returns) let sharpe = mean_return / std_dev; sharpe * (252.0_f64).sqrt() // 252 trading days } /// Calculate standard deviation fn calculate_std_dev(values: &[f64], mean: f64) -> f64 { if values.is_empty() { return 0.0; } let variance = values .iter() .map(|&v| { let diff = v - mean; diff * diff }) .sum::() / values.len() as f64; variance.sqrt() } /// Calculate variance fn calculate_variance(values: &[f64]) -> f64 { if values.is_empty() { return 0.0; } let mean = values.iter().sum::() / values.len() as f64; calculate_std_dev(values, mean).powi(2) } /// Calculate maximum drawdown fn calculate_max_drawdown(equity_curve: &[f64]) -> f64 { if equity_curve.is_empty() { return 0.0; } let mut max_equity = equity_curve[0]; let mut max_dd = 0.0; for &equity in equity_curve { if equity > max_equity { max_equity = equity; } let drawdown = (equity - max_equity) / max_equity; if drawdown < max_dd { max_dd = drawdown; } } max_dd } #[cfg(test)] #[allow(clippy::assertions_on_result_states)] mod tests { use super::*; #[test] fn test_sharpe_ratio_calculation() { let returns = vec![0.01, -0.005, 0.015, 0.02, -0.01]; let sharpe = calculate_sharpe_ratio(&returns); assert!(sharpe.is_finite()); } #[test] fn test_max_drawdown_calculation() { let equity = vec![1.0, 1.1, 1.05, 0.95, 1.15]; let max_dd = calculate_max_drawdown(&equity); assert!(max_dd <= 0.0); assert!(max_dd.is_finite()); } #[test] fn test_variance_calculation() { let values = vec![1.0, 2.0, 3.0, 4.0, 5.0]; let variance = calculate_variance(&values); assert!(variance > 0.0); assert!(variance.is_finite()); } #[test] fn test_barrier_params_validation() { let valid_params = BarrierParams { profit_target: 0.02, stop_loss: 0.01, max_holding_periods: 10, }; assert!(valid_params.validate().is_ok()); let invalid_params = BarrierParams { profit_target: -0.02, stop_loss: 0.01, max_holding_periods: 10, }; assert!(invalid_params.validate().is_err()); } }