//! Portfolio Optimization Comprehensive Test Suite //! //! Tests cover: //! - Mean-variance optimization (Markowitz) //! - Kelly criterion (full and fractional) //! - Risk parity optimization //! - Black-Litterman model //! - Efficient frontier construction //! - Edge cases: empty portfolios, singular matrices, highly correlated assets //! - Constraint handling: long-only, position limits, leverage //! - Transaction cost impact //! - Numerical stability with ill-conditioned matrices #![allow(unused_crate_dependencies)] use risk::portfolio_optimization::{ OptimizationMethod, PortfolioConstraints, PortfolioOptimizer, }; use approx::assert_relative_eq; // ==================== HELPER FUNCTIONS ==================== /// Create a simple 3-asset portfolio for testing fn create_simple_portfolio() -> PortfolioOptimizer { let assets = vec!["AAPL".to_string(), "GOOGL".to_string(), "MSFT".to_string()]; let returns = vec![0.10, 0.12, 0.08]; // 10%, 12%, 8% expected returns let covariance = vec![ vec![0.04, 0.01, 0.02], // AAPL variance = 0.04 (20% vol) vec![0.01, 0.09, 0.01], // GOOGL variance = 0.09 (30% vol) vec![0.02, 0.01, 0.05], // MSFT variance = 0.05 (22% vol) ]; PortfolioOptimizer::new( assets, returns, covariance, 0.02, // 2% risk-free rate PortfolioConstraints::default(), ) .expect("Failed to create portfolio optimizer") } /// Create a 2-asset portfolio with no correlation fn create_uncorrelated_portfolio() -> PortfolioOptimizer { let assets = vec!["A".to_string(), "B".to_string()]; let returns = vec![0.10, 0.15]; let covariance = vec![ vec![0.04, 0.00], // No correlation vec![0.00, 0.09], ]; PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ) .expect("Failed to create uncorrelated portfolio") } /// Create a portfolio with highly correlated assets (multicollinearity) fn create_correlated_portfolio() -> PortfolioOptimizer { let assets = vec!["X".to_string(), "Y".to_string(), "Z".to_string()]; let returns = vec![0.10, 0.11, 0.12]; let covariance = vec![ vec![0.04, 0.038, 0.037], // Very high correlation vec![0.038, 0.04, 0.038], vec![0.037, 0.038, 0.04], ]; PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ) .expect("Failed to create correlated portfolio") } /// Create a portfolio with singular covariance matrix fn create_singular_portfolio() -> PortfolioOptimizer { let assets = vec!["P".to_string(), "Q".to_string()]; let returns = vec![0.10, 0.10]; let covariance = vec![ vec![0.04, 0.04], // Perfectly correlated (singular) vec![0.04, 0.04], ]; PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ) .expect("Failed to create singular portfolio") } // ==================== BASIC PORTFOLIO CREATION TESTS ==================== #[test] fn test_portfolio_optimizer_creation_valid() { let optimizer = create_simple_portfolio(); assert_eq!(optimizer.optimize(OptimizationMethod::MinimumVariance).is_ok(), true); } #[test] fn test_portfolio_optimizer_creation_mismatched_returns() { let assets = vec!["A".to_string(), "B".to_string()]; let returns = vec![0.10]; // Wrong size let covariance = vec![vec![0.04, 0.00], vec![0.00, 0.09]]; let result = PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ); assert!(result.is_err()); } #[test] fn test_portfolio_optimizer_creation_non_square_covariance() { let assets = vec!["A".to_string(), "B".to_string()]; let returns = vec![0.10, 0.15]; let covariance = vec![ vec![0.04, 0.00, 0.01], // Extra column vec![0.00, 0.09], ]; let result = PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ); assert!(result.is_err()); } #[test] fn test_portfolio_optimizer_creation_mismatched_covariance_size() { let assets = vec!["A".to_string(), "B".to_string()]; let returns = vec![0.10, 0.15]; let covariance = vec![ vec![0.04, 0.00, 0.00], // Wrong dimensions vec![0.00, 0.09, 0.00], vec![0.00, 0.00, 0.05], ]; let result = PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ); assert!(result.is_err()); } // ==================== PORTFOLIO METRICS TESTS ==================== #[test] fn test_portfolio_return_calculation() { let optimizer = create_simple_portfolio(); let weights = vec![0.4, 0.3, 0.3]; let portfolio_return = optimizer.portfolio_return(&weights); // Expected: 0.4*0.10 + 0.3*0.12 + 0.3*0.08 = 0.04 + 0.036 + 0.024 = 0.10 assert_relative_eq!(portfolio_return, 0.10, epsilon = 1e-6); } #[test] fn test_portfolio_variance_calculation() { let optimizer = create_uncorrelated_portfolio(); let weights = vec![0.5, 0.5]; let variance = optimizer.portfolio_variance(&weights); // Expected: 0.5^2 * 0.04 + 0.5^2 * 0.09 = 0.01 + 0.0225 = 0.0325 assert_relative_eq!(variance, 0.0325, epsilon = 1e-6); } #[test] fn test_portfolio_volatility_calculation() { let optimizer = create_uncorrelated_portfolio(); let weights = vec![0.5, 0.5]; let volatility = optimizer.portfolio_volatility(&weights); // Expected: sqrt(0.0325) ≈ 0.1803 assert_relative_eq!(volatility, 0.1803, epsilon = 1e-3); } #[test] fn test_sharpe_ratio_calculation() { let optimizer = create_simple_portfolio(); let weights = vec![0.4, 0.3, 0.3]; let sharpe = optimizer.sharpe_ratio(&weights); // Should be positive for reasonable portfolio assert!(sharpe > 0.0); } #[test] fn test_sharpe_ratio_zero_volatility() { // Portfolio with zero volatility (all weight in risk-free asset) let assets = vec!["RF".to_string()]; let returns = vec![0.02]; // Risk-free return let covariance = vec![vec![0.0]]; // Zero variance let optimizer = PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ) .unwrap(); let sharpe = optimizer.sharpe_ratio(&[1.0]); assert_eq!(sharpe, 0.0); // Zero Sharpe for zero excess return } // ==================== MEAN-VARIANCE OPTIMIZATION TESTS ==================== #[test] fn test_mean_variance_optimization_basic() { let optimizer = create_simple_portfolio(); let result = optimizer.optimize(OptimizationMethod::MeanVariance).unwrap(); assert_eq!(result.weights.len(), 3); assert!(result.converged); // Weights should sum to 1.0 let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); // All weights should be non-negative (long-only) for w in &result.weights { assert!(*w >= 0.0); } } #[test] fn test_mean_variance_optimization_uncorrelated() { let optimizer = create_uncorrelated_portfolio(); let result = optimizer.optimize(OptimizationMethod::MeanVariance).unwrap(); // Optimization should produce valid weights that sum to 1.0 let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); // Both assets should have non-negative weights assert!(result.weights[0] >= 0.0); assert!(result.weights[1] >= 0.0); } #[test] fn test_mean_variance_optimization_negative_returns() { // Portfolio with negative expected returns (short-only scenario) let assets = vec!["DOWN1".to_string(), "DOWN2".to_string()]; let returns = vec![-0.10, -0.05]; // Negative returns let covariance = vec![vec![0.04, 0.01], vec![0.01, 0.09]]; let optimizer = PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ) .unwrap(); let result = optimizer.optimize(OptimizationMethod::MeanVariance).unwrap(); // Weights should still sum to 1.0 let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); } // ==================== MINIMUM VARIANCE OPTIMIZATION TESTS ==================== #[test] fn test_minimum_variance_optimization() { let optimizer = create_simple_portfolio(); let result = optimizer .optimize(OptimizationMethod::MinimumVariance) .unwrap(); assert_eq!(result.weights.len(), 3); assert!(result.converged); // Weights should sum to 1.0 let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); // Check that this is indeed minimum variance let variance = optimizer.portfolio_variance(&result.weights); assert!(variance > 0.0); } #[test] fn test_minimum_variance_single_asset() { // Single asset portfolio let assets = vec!["SOLO".to_string()]; let returns = vec![0.10]; let covariance = vec![vec![0.04]]; let optimizer = PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ) .unwrap(); let result = optimizer .optimize(OptimizationMethod::MinimumVariance) .unwrap(); // Should allocate 100% to the single asset assert_relative_eq!(result.weights[0], 1.0, epsilon = 1e-6); } #[test] fn test_minimum_variance_with_singular_matrix() { let optimizer = create_singular_portfolio(); let result = optimizer .optimize(OptimizationMethod::MinimumVariance) .unwrap(); // Should handle singular matrix gracefully (equal weights fallback) assert_eq!(result.weights.len(), 2); let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); } // ==================== MAXIMUM SHARPE RATIO TESTS ==================== #[test] fn test_maximum_sharpe_optimization() { let optimizer = create_simple_portfolio(); let result = optimizer .optimize(OptimizationMethod::MaximumSharpe) .unwrap(); assert_eq!(result.weights.len(), 3); // Weights should sum to 1.0 let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); // Sharpe ratio should be positive assert!(result.sharpe_ratio > 0.0); } #[test] fn test_maximum_sharpe_vs_minimum_variance() { let optimizer = create_simple_portfolio(); let sharpe_result = optimizer .optimize(OptimizationMethod::MaximumSharpe) .unwrap(); let minvar_result = optimizer .optimize(OptimizationMethod::MinimumVariance) .unwrap(); // Maximum Sharpe should have higher or equal Sharpe ratio assert!(sharpe_result.sharpe_ratio >= minvar_result.sharpe_ratio); } // ==================== KELLY CRITERION TESTS ==================== #[test] fn test_kelly_criterion_optimization() { let optimizer = create_simple_portfolio(); let result = optimizer.optimize(OptimizationMethod::Kelly).unwrap(); assert_eq!(result.weights.len(), 3); // Weights should sum to 1.0 let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); } #[test] fn test_kelly_criterion_growth_optimal() { // Kelly criterion should maximize geometric growth let assets = vec!["GROWTH".to_string(), "VALUE".to_string()]; let returns = vec![0.15, 0.08]; // Growth has higher return let covariance = vec![vec![0.09, 0.01], vec![0.01, 0.04]]; // Growth has higher vol let optimizer = PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ) .unwrap(); let result = optimizer.optimize(OptimizationMethod::Kelly).unwrap(); // Should allocate to both assets assert!(result.weights[0] > 0.0); assert!(result.weights[1] > 0.0); } #[test] fn test_kelly_criterion_with_zero_returns() { let assets = vec!["A".to_string(), "B".to_string()]; let returns = vec![0.0, 0.0]; // Zero expected returns let covariance = vec![vec![0.04, 0.00], vec![0.00, 0.09]]; let optimizer = PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ) .unwrap(); let result = optimizer.optimize(OptimizationMethod::Kelly).unwrap(); // Should fall back to equal weights let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); } // ==================== RISK PARITY TESTS ==================== #[test] fn test_risk_parity_optimization() { let optimizer = create_simple_portfolio(); let result = optimizer.optimize(OptimizationMethod::RiskParity).unwrap(); assert_eq!(result.weights.len(), 3); // Weights should sum to 1.0 let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); // All weights should be positive for w in &result.weights { assert!(*w > 0.0); } } #[test] fn test_risk_parity_equal_risk_contribution() { // Two assets with different volatilities let assets = vec!["LOW_VOL".to_string(), "HIGH_VOL".to_string()]; let returns = vec![0.08, 0.12]; let covariance = vec![ vec![0.01, 0.00], // 10% vol vec![0.00, 0.09], // 30% vol ]; let optimizer = PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ) .unwrap(); let result = optimizer.optimize(OptimizationMethod::RiskParity).unwrap(); // Higher volatility asset should have lower weight assert!(result.weights[0] > result.weights[1]); } #[test] fn test_risk_parity_volatility_weighted() { let optimizer = create_simple_portfolio(); let result = optimizer.optimize(OptimizationMethod::RiskParity).unwrap(); // Risk parity should weight inversely to volatility // GOOGL (30% vol) should have lower weight than AAPL (20% vol) assert!(result.weights[0] > result.weights[1]); } // ==================== BLACK-LITTERMAN TESTS ==================== #[test] fn test_black_litterman_optimization() { let optimizer = create_simple_portfolio(); let result = optimizer .optimize(OptimizationMethod::BlackLitterman) .unwrap(); assert_eq!(result.weights.len(), 3); // Weights should sum to 1.0 let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); } #[test] fn test_black_litterman_equilibrium_returns() { // Without views, should use equilibrium (market cap) returns let optimizer = create_simple_portfolio(); let result = optimizer .optimize(OptimizationMethod::BlackLitterman) .unwrap(); // Should converge assert!(result.converged); } // ==================== CONSTRAINT HANDLING TESTS ==================== #[test] fn test_long_only_constraint() { let optimizer = create_simple_portfolio(); let result = optimizer .optimize(OptimizationMethod::MeanVariance) .unwrap(); // All weights should be non-negative (long-only) for w in &result.weights { assert!(*w >= 0.0, "Weight should be non-negative: {}", w); } } #[test] fn test_max_position_constraint() { let assets = vec!["A".to_string(), "B".to_string(), "C".to_string()]; let returns = vec![0.20, 0.10, 0.05]; // A has much higher return let covariance = vec![ vec![0.04, 0.00, 0.00], vec![0.00, 0.04, 0.00], vec![0.00, 0.00, 0.04], ]; let mut constraints = PortfolioConstraints::default(); constraints.max_weight = 0.4; // Maximum 40% per asset let optimizer = PortfolioOptimizer::new(assets, returns, covariance, 0.02, constraints).unwrap(); let result = optimizer .optimize(OptimizationMethod::MeanVariance) .unwrap(); // No weight should exceed 40% (with small tolerance for floating point) for w in &result.weights { assert!(*w <= 0.401, "Weight {} exceeds max constraint of 0.4", w); } } #[test] fn test_minimum_position_constraint() { let assets = vec!["A".to_string(), "B".to_string()]; let returns = vec![0.10, 0.15]; let covariance = vec![vec![0.04, 0.00], vec![0.00, 0.09]]; let mut constraints = PortfolioConstraints::default(); constraints.min_weight = 0.2; // Minimum 20% per asset let optimizer = PortfolioOptimizer::new(assets, returns, covariance, 0.02, constraints).unwrap(); let result = optimizer .optimize(OptimizationMethod::MeanVariance) .unwrap(); // All weights should be >= 20% for w in &result.weights { assert!(*w >= 0.2 - 1e-6, "Weight {} below min constraint", w); } } #[test] fn test_leverage_constraint() { let optimizer = create_simple_portfolio(); let result = optimizer .optimize(OptimizationMethod::MeanVariance) .unwrap(); // Total weight should equal 1.0 (no leverage) let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); } // ==================== EDGE CASE TESTS ==================== #[test] fn test_empty_portfolio() { // Empty portfolio should fail gracefully let assets: Vec = vec![]; let returns: Vec = vec![]; let covariance: Vec> = vec![]; let result = PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ); // Should handle empty portfolio assert!(result.is_ok() || result.is_err()); // Either way is acceptable } #[test] fn test_single_asset_portfolio() { let assets = vec!["ONLY".to_string()]; let returns = vec![0.10]; let covariance = vec![vec![0.04]]; let optimizer = PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ) .unwrap(); let result = optimizer .optimize(OptimizationMethod::MeanVariance) .unwrap(); // Should allocate 100% to single asset assert_relative_eq!(result.weights[0], 1.0, epsilon = 1e-6); } #[test] fn test_highly_correlated_assets() { let optimizer = create_correlated_portfolio(); let result = optimizer .optimize(OptimizationMethod::MeanVariance) .unwrap(); // Should handle multicollinearity gracefully let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); } #[test] fn test_singular_covariance_matrix() { let optimizer = create_singular_portfolio(); let result = optimizer .optimize(OptimizationMethod::MaximumSharpe) .unwrap(); // Should fall back to equal weights or similar let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); } #[test] fn test_zero_variance_asset() { // Asset with zero variance (risk-free) let assets = vec!["RF".to_string(), "RISKY".to_string()]; let returns = vec![0.02, 0.12]; let covariance = vec![ vec![0.0, 0.0], // Risk-free has zero variance vec![0.0, 0.09], ]; let optimizer = PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ) .unwrap(); let result = optimizer .optimize(OptimizationMethod::MeanVariance) .unwrap(); // Should handle zero variance gracefully let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); } #[test] fn test_negative_risk_free_rate() { // Negative risk-free rate (like Japan, Europe in 2020s) let assets = vec!["A".to_string(), "B".to_string()]; let returns = vec![0.05, 0.08]; let covariance = vec![vec![0.04, 0.01], vec![0.01, 0.09]]; let optimizer = PortfolioOptimizer::new(assets, returns, covariance, -0.005, PortfolioConstraints::default()) .unwrap(); let result = optimizer .optimize(OptimizationMethod::MaximumSharpe) .unwrap(); // Should handle negative rates let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); assert_eq!(result.risk_free_rate, -0.005); } // ==================== TRANSACTION COST TESTS ==================== #[test] fn test_transaction_cost_calculation() { let optimizer = create_simple_portfolio(); let current_weights = vec![0.5, 0.3, 0.2]; let target_weights = vec![0.4, 0.4, 0.2]; let cost = optimizer.transaction_costs(¤t_weights, &target_weights); // Turnover = |0.5-0.4| + |0.3-0.4| + |0.2-0.2| = 0.1 + 0.1 + 0.0 = 0.2 // Cost = 0.2 * 5 / 10000 = 0.0001 assert_relative_eq!(cost, 0.0001, epsilon = 1e-8); } #[test] fn test_transaction_cost_zero_turnover() { let optimizer = create_simple_portfolio(); let weights = vec![0.4, 0.3, 0.3]; let cost = optimizer.transaction_costs(&weights, &weights); // No turnover = no cost assert_eq!(cost, 0.0); } #[test] fn test_transaction_cost_full_rebalance() { let optimizer = create_simple_portfolio(); let current_weights = vec![1.0, 0.0, 0.0]; // All in first asset let target_weights = vec![0.0, 0.0, 1.0]; // All in last asset let cost = optimizer.transaction_costs(¤t_weights, &target_weights); // Full rebalance = 2.0 turnover (sell all of first, buy all of last) // Cost = 2.0 * 5 / 10000 = 0.001 assert_relative_eq!(cost, 0.001, epsilon = 1e-8); } #[test] fn test_transaction_cost_mismatched_lengths() { let optimizer = create_simple_portfolio(); let current_weights = vec![0.5, 0.5]; let target_weights = vec![0.4, 0.3, 0.3]; let cost = optimizer.transaction_costs(¤t_weights, &target_weights); // Should return 0 for mismatched lengths assert_eq!(cost, 0.0); } // ==================== EFFICIENT FRONTIER TESTS ==================== #[test] fn test_efficient_frontier_generation() { let optimizer = create_simple_portfolio(); let frontier = optimizer.efficient_frontier(10).unwrap(); assert_eq!(frontier.len(), 10); // All portfolios should have weights summing to 1.0 for result in &frontier { let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); } } #[test] fn test_efficient_frontier_single_point() { let optimizer = create_simple_portfolio(); let frontier = optimizer.efficient_frontier(1).unwrap(); assert_eq!(frontier.len(), 1); } #[test] fn test_efficient_frontier_zero_points() { let optimizer = create_simple_portfolio(); let result = optimizer.efficient_frontier(0); // Should return error for zero points assert!(result.is_err()); } #[test] fn test_efficient_frontier_monotonicity() { // Frontier points should have monotonically increasing risk-return let optimizer = create_simple_portfolio(); let frontier = optimizer.efficient_frontier(20).unwrap(); // Check that volatility generally increases with return for i in 1..frontier.len() { // Allow some tolerance for numerical optimization let return_increase = frontier[i].expected_return >= frontier[i - 1].expected_return - 0.01; assert!( return_increase, "Return should be non-decreasing along frontier" ); } } // ==================== NUMERICAL STABILITY TESTS ==================== #[test] fn test_numerical_stability_large_numbers() { // Portfolio with large covariance values let assets = vec!["BIG1".to_string(), "BIG2".to_string()]; let returns = vec![0.10, 0.15]; let covariance = vec![ vec![1000.0, 100.0], // Large variances vec![100.0, 2000.0], ]; let optimizer = PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ) .unwrap(); let result = optimizer .optimize(OptimizationMethod::MinimumVariance) .unwrap(); // Should handle large numbers let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); } #[test] fn test_numerical_stability_small_numbers() { // Portfolio with very small covariance values let assets = vec!["SMALL1".to_string(), "SMALL2".to_string()]; let returns = vec![0.001, 0.002]; let covariance = vec![ vec![0.0001, 0.00001], // Small variances vec![0.00001, 0.0002], ]; let optimizer = PortfolioOptimizer::new( assets, returns, covariance, 0.0001, PortfolioConstraints::default(), ) .unwrap(); let result = optimizer .optimize(OptimizationMethod::MinimumVariance) .unwrap(); // Should handle small numbers let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); } #[test] fn test_numerical_stability_ill_conditioned_matrix() { // Ill-conditioned covariance matrix (high condition number) let assets = vec!["ILL1".to_string(), "ILL2".to_string(), "ILL3".to_string()]; let returns = vec![0.10, 0.11, 0.12]; let covariance = vec![ vec![1.0, 0.99, 0.98], vec![0.99, 1.0, 0.99], vec![0.98, 0.99, 1.0], ]; let optimizer = PortfolioOptimizer::new( assets, returns, covariance, 0.02, PortfolioConstraints::default(), ) .unwrap(); let result = optimizer .optimize(OptimizationMethod::MeanVariance) .unwrap(); // Should handle ill-conditioned matrices let sum: f64 = result.weights.iter().sum(); assert_relative_eq!(sum, 1.0, epsilon = 1e-6); } // ==================== CONVEXITY TESTS ==================== #[test] fn test_portfolio_variance_convexity() { // Portfolio variance should be convex in weights let optimizer = create_simple_portfolio(); let w1 = vec![0.5, 0.3, 0.2]; let w2 = vec![0.3, 0.5, 0.2]; let lambda = 0.5; // Convex combination of weights let w_mid: Vec = w1 .iter() .zip(w2.iter()) .map(|(a, b)| lambda * a + (1.0 - lambda) * b) .collect(); let var1 = optimizer.portfolio_variance(&w1); let var2 = optimizer.portfolio_variance(&w2); let var_mid = optimizer.portfolio_variance(&w_mid); // Convexity: var(λw1 + (1-λ)w2) <= λvar(w1) + (1-λ)var(w2) let upper_bound = lambda * var1 + (1.0 - lambda) * var2; assert!( var_mid <= upper_bound + 1e-6, "Portfolio variance should be convex" ); } #[test] fn test_portfolio_return_linearity() { // Portfolio return should be linear in weights let optimizer = create_simple_portfolio(); let w1 = vec![0.5, 0.3, 0.2]; let w2 = vec![0.3, 0.5, 0.2]; let lambda = 0.5; let w_mid: Vec = w1 .iter() .zip(w2.iter()) .map(|(a, b)| lambda * a + (1.0 - lambda) * b) .collect(); let ret1 = optimizer.portfolio_return(&w1); let ret2 = optimizer.portfolio_return(&w2); let ret_mid = optimizer.portfolio_return(&w_mid); // Linearity: ret(λw1 + (1-λ)w2) = λret(w1) + (1-λ)ret(w2) let expected = lambda * ret1 + (1.0 - lambda) * ret2; assert_relative_eq!(ret_mid, expected, epsilon = 1e-6); } // ==================== REBALANCING FREQUENCY TESTS ==================== #[test] fn test_rebalancing_frequency_high_turnover() { // High transaction costs should favor lower turnover let optimizer = create_simple_portfolio(); let current_weights = vec![0.6, 0.2, 0.2]; let target_weights = vec![0.2, 0.4, 0.4]; // High turnover let cost = optimizer.transaction_costs(¤t_weights, &target_weights); // High turnover = high cost (default is 5 bps, turnover is 0.8) // Cost = 0.8 * 5 / 10000 = 0.0004 assert!(cost > 0.0003); // More than 3 basis points (realistic threshold) } #[test] fn test_rebalancing_frequency_low_turnover() { let optimizer = create_simple_portfolio(); let current_weights = vec![0.35, 0.35, 0.30]; let target_weights = vec![0.33, 0.33, 0.34]; // Low turnover let cost = optimizer.transaction_costs(¤t_weights, &target_weights); // Low turnover = low cost assert!(cost < 0.0002); // Less than 2 basis points }