255 lines
7.7 KiB
Rust
255 lines
7.7 KiB
Rust
//! 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<Asset> {
|
|
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);
|
|
}
|
|
}
|