//! Portfolio constraint validation and enforcement use crate::{Asset, PortfolioConstraints, PortfolioError, Result}; /// Validate portfolio weights against constraints pub fn validate_weights( weights: &[f64], assets: &[Asset], constraints: &PortfolioConstraints, ) -> Result<()> { if weights.len() != assets.len() { return Err(PortfolioError::InvalidConfig( "Number of weights must match number of assets".to_string(), )); } validate_sum(weights)?; validate_bounds(weights, constraints)?; validate_long_only(weights, constraints)?; validate_sector_limits(weights, assets, constraints)?; Ok(()) } /// Validate that weights sum to 1.0 fn validate_sum(weights: &[f64]) -> Result<()> { let sum: f64 = weights.iter().sum(); if (sum - 1.0).abs() > 1e-6 { return Err(PortfolioError::ConstraintViolation(format!( "Weights must sum to 1.0, got {sum}" ))); } Ok(()) } /// Validate weight bounds fn validate_bounds(weights: &[f64], constraints: &PortfolioConstraints) -> Result<()> { for (i, &weight) in weights.iter().enumerate() { if weight < constraints.min_weight - 1e-6 { return Err(PortfolioError::ConstraintViolation(format!( "Weight {i} ({weight}) below minimum ({})", constraints.min_weight ))); } if weight > constraints.max_weight + 1e-6 { return Err(PortfolioError::ConstraintViolation(format!( "Weight {i} ({weight}) above maximum ({})", constraints.max_weight ))); } } Ok(()) } /// Validate long-only constraint fn validate_long_only(weights: &[f64], constraints: &PortfolioConstraints) -> Result<()> { if constraints.long_only { for (i, &weight) in weights.iter().enumerate() { if weight < -1e-6 { return Err(PortfolioError::ConstraintViolation(format!( "Negative weight {i} ({weight}) violates long-only constraint" ))); } } } Ok(()) } /// Validate sector exposure limits fn validate_sector_limits( weights: &[f64], assets: &[Asset], constraints: &PortfolioConstraints, ) -> Result<()> { for sector_limit in &constraints.sector_limits { let mut sector_exposure = 0.0; for (i, asset) in assets.iter().enumerate() { if let Some(sector) = &asset.sector && sector == §or_limit.sector { sector_exposure += weights[i]; } } if sector_exposure > sector_limit.max_weight + 1e-6 { return Err(PortfolioError::ConstraintViolation(format!( "Sector '{}' exposure ({sector_exposure}) exceeds limit ({})", sector_limit.sector, sector_limit.max_weight ))); } } Ok(()) } /// Project weights to satisfy constraints (simple heuristic) pub fn project_to_constraints( weights: &mut [f64], _assets: &[Asset], constraints: &PortfolioConstraints, ) -> Result<()> { if constraints.long_only { for weight in weights.iter_mut() { *weight = weight.max(constraints.min_weight); } } for weight in weights.iter_mut() { *weight = weight.clamp(constraints.min_weight, constraints.max_weight); } let sum: f64 = weights.iter().sum(); if sum > 0.0 { for weight in weights.iter_mut() { *weight /= sum; } } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::SectorLimit; fn create_test_assets() -> Vec { vec![ Asset { ticker: "TECH1".to_string(), name: "Tech Stock 1".to_string(), asset_class: "equity".to_string(), sector: Some("Technology".to_string()), expected_return: 0.12, volatility: 0.20, }, Asset { ticker: "TECH2".to_string(), name: "Tech Stock 2".to_string(), asset_class: "equity".to_string(), sector: Some("Technology".to_string()), expected_return: 0.14, volatility: 0.25, }, Asset { ticker: "HEALTHCARE".to_string(), name: "Healthcare Stock".to_string(), asset_class: "equity".to_string(), sector: Some("Healthcare".to_string()), expected_return: 0.10, volatility: 0.18, }, ] } #[test] fn test_validate_sum() { let valid_weights = vec![0.5, 0.3, 0.2]; assert!(validate_sum(&valid_weights).is_ok()); let invalid_weights = vec![0.5, 0.3, 0.1]; assert!(validate_sum(&invalid_weights).is_err()); } #[test] fn test_validate_bounds() { let constraints = PortfolioConstraints { min_weight: 0.1, max_weight: 0.6, ..Default::default() }; let valid_weights = vec![0.4, 0.3, 0.3]; assert!(validate_bounds(&valid_weights, &constraints).is_ok()); let too_low = vec![0.05, 0.45, 0.5]; assert!(validate_bounds(&too_low, &constraints).is_err()); let too_high = vec![0.7, 0.2, 0.1]; assert!(validate_bounds(&too_high, &constraints).is_err()); } #[test] fn test_validate_long_only() { let long_only_constraints = PortfolioConstraints { long_only: true, ..Default::default() }; let valid_weights = vec![0.5, 0.3, 0.2]; assert!(validate_long_only(&valid_weights, &long_only_constraints).is_ok()); let negative_weights = vec![0.6, -0.1, 0.5]; assert!(validate_long_only(&negative_weights, &long_only_constraints).is_err()); let short_allowed_constraints = PortfolioConstraints { long_only: false, ..Default::default() }; assert!(validate_long_only(&negative_weights, &short_allowed_constraints).is_ok()); } #[test] fn test_validate_sector_limits() { let assets = create_test_assets(); let mut constraints = PortfolioConstraints::default(); constraints.sector_limits.push(SectorLimit { sector: "Technology".to_string(), max_weight: 0.5, }); let valid_weights = vec![0.3, 0.2, 0.5]; assert!(validate_sector_limits(&valid_weights, &assets, &constraints).is_ok()); let exceeds_limit = vec![0.4, 0.3, 0.3]; assert!(validate_sector_limits(&exceeds_limit, &assets, &constraints).is_err()); } #[test] fn test_validate_weights_success() { let assets = create_test_assets(); let constraints = PortfolioConstraints::default(); let weights = vec![0.4, 0.3, 0.3]; assert!(validate_weights(&weights, &assets, &constraints).is_ok()); } #[test] fn test_validate_weights_dimension_mismatch() { let assets = create_test_assets(); let constraints = PortfolioConstraints::default(); let weights = vec![0.5, 0.5]; assert!(validate_weights(&weights, &assets, &constraints).is_err()); } #[test] fn test_project_to_constraints() { let assets = create_test_assets(); let constraints = PortfolioConstraints { long_only: true, min_weight: 0.1, max_weight: 0.5, ..Default::default() }; let mut weights = vec![0.7, -0.1, 0.4]; project_to_constraints(&mut weights, &assets, &constraints).unwrap(); assert!(weights.iter().all(|&w| w >= 0.0)); assert!(weights.iter().all(|&w| w <= 0.5)); let sum: f64 = weights.iter().sum(); assert!((sum - 1.0).abs() < 1e-6); } }