Initial commit
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user