Files
foxhunt/services/trading_service/src/allocation.rs
jgrusewski 4363f779ab safety(trading-service): replace expect() with if-let in allocation
Replace two .expect() calls on HashMap lookups with safe if-let pattern
to comply with deny(clippy::expect_used) rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:03:10 +01:00

923 lines
31 KiB
Rust

//! Portfolio Allocation Module
//!
//! Implements portfolio allocation strategies for capital distribution across assets.
//! Supports multiple allocation methodologies including:
//! - Equal Weight: Simple 1/N allocation
//! - Risk Parity: Inverse volatility weighting
//! - Mean-Variance: Markowitz optimization
//! - ML-Optimized: ML-based allocation using predictions
//! - Kelly Criterion: Optimal bet sizing
use common::error::{CommonError, ErrorCategory};
use serde::{Deserialize, Serialize};
use sqlx::{types::Uuid, PgPool};
use std::collections::HashMap;
/// Allocation strategies for portfolio construction
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AllocationStrategy {
/// Equal weight allocation (1/N)
EqualWeight,
/// Risk parity (inverse volatility weighting)
RiskParity,
/// Mean-variance optimization (Markowitz)
MeanVariance,
/// ML-optimized allocation
MLOptimized,
/// Kelly Criterion optimal sizing
Kelly,
}
/// Portfolio allocation result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortfolioAllocation {
/// Unique allocation ID
pub allocation_id: String,
/// Asset weights (symbol -> weight 0.0-1.0)
pub assets: HashMap<String, f64>,
/// Total capital allocated
pub total_capital: f64,
/// Strategy used for allocation
pub strategy: AllocationStrategy,
/// Risk budget (maximum portfolio volatility)
pub risk_budget: f64,
/// Portfolio risk metrics
pub risk_metrics: RiskMetrics,
}
/// Risk metrics for portfolio
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RiskMetrics {
/// Portfolio volatility (annualized standard deviation)
pub volatility: f64,
/// Value at Risk (95% confidence)
pub var_95: f64,
/// Portfolio beta (market sensitivity)
pub beta: f64,
/// Expected Sharpe ratio
pub sharpe_ratio: f64,
/// Maximum drawdown
pub max_drawdown: f64,
}
/// Allocation constraints
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AllocationConstraints {
/// Maximum position size (e.g., 0.25 = 25%)
pub max_position_size: f64,
/// Minimum position size (e.g., 0.05 = 5%, 0.0 = allow zero)
pub min_position_size: f64,
/// Maximum sector/industry concentration
pub max_sector_concentration: Option<f64>,
/// Maximum leverage allowed (1.0 = no leverage)
pub max_leverage: f64,
/// Minimum diversification (number of assets)
pub min_diversification: usize,
}
impl Default for AllocationConstraints {
fn default() -> Self {
Self {
max_position_size: 0.25, // 25% max per asset
min_position_size: 0.05, // 5% min per asset
max_sector_concentration: Some(0.40), // 40% max per sector
max_leverage: 1.0, // No leverage
min_diversification: 4, // At least 4 assets
}
}
}
/// Allocation request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AllocationRequest {
/// Assets to allocate across
pub assets: Vec<String>,
/// Total capital to allocate
pub total_capital: f64,
/// Allocation strategy
pub strategy: AllocationStrategy,
/// Risk budget (max portfolio volatility)
pub risk_budget: f64,
/// Allocation constraints
pub constraints: AllocationConstraints,
/// Expected returns (required for MeanVariance)
pub expected_returns: Option<HashMap<String, f64>>,
/// Win rates (required for Kelly)
pub win_rates: Option<HashMap<String, f64>>,
}
/// Portfolio allocator
pub struct PortfolioAllocator {
pool: PgPool,
}
impl PortfolioAllocator {
/// Create new portfolio allocator
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
/// Allocate portfolio across assets
pub async fn allocate_portfolio(
&self,
request: AllocationRequest,
) -> Result<PortfolioAllocation, CommonError> {
// Validate request
self.validate_request(&request)?;
// Compute allocation weights based on strategy
let weights = match request.strategy {
AllocationStrategy::EqualWeight => self.equal_weight_allocation(&request.assets),
AllocationStrategy::RiskParity => self.risk_parity_allocation(&request.assets).await?,
AllocationStrategy::MeanVariance => {
let returns = request.expected_returns.ok_or_else(|| {
CommonError::validation("Expected returns required for MeanVariance strategy")
})?;
self.mean_variance_allocation(&request.assets, &returns)
.await?
},
AllocationStrategy::MLOptimized => {
self.ml_optimized_allocation(&request.assets).await?
},
AllocationStrategy::Kelly => {
let win_rates = request.win_rates.ok_or_else(|| {
CommonError::validation("Win rates required for Kelly strategy")
})?;
let returns = request.expected_returns.ok_or_else(|| {
CommonError::validation("Expected returns required for Kelly strategy")
})?;
self.kelly_allocation(&request.assets, &win_rates, &returns)?
},
};
// Apply constraints
let constrained_weights = self.apply_constraints(weights, &request.constraints)?;
// Calculate risk metrics
let risk_metrics = self
.calculate_risk_metrics(&request.assets, &constrained_weights)
.await?;
// Verify risk budget
if risk_metrics.volatility > request.risk_budget {
return Err(CommonError::validation(format!(
"Portfolio volatility {:.2}% exceeds risk budget {:.2}%",
risk_metrics.volatility * 100.0,
request.risk_budget * 100.0
)));
}
let allocation = PortfolioAllocation {
allocation_id: Uuid::new_v4().to_string(),
assets: constrained_weights,
total_capital: request.total_capital,
strategy: request.strategy,
risk_budget: request.risk_budget,
risk_metrics,
};
// Persist allocation
self.persist_allocation(&allocation).await?;
Ok(allocation)
}
/// Get existing allocation by ID
pub async fn get_allocation(
&self,
allocation_id: &str,
) -> Result<PortfolioAllocation, CommonError> {
// Parse allocation_id string to UUID
let uuid = Uuid::parse_str(allocation_id)
.map_err(|e| CommonError::validation(format!("Invalid allocation ID format: {}", e)))?;
let record = sqlx::query!(
r#"
SELECT allocation_data
FROM portfolio_allocations
WHERE allocation_id = $1
"#,
uuid
)
.fetch_optional(&self.pool)
.await
.map_err(|e| CommonError::service(ErrorCategory::Database, format!("Query failed: {}", e)))?
.ok_or_else(|| {
CommonError::validation(format!("Allocation {} not found", allocation_id))
})?;
let allocation: PortfolioAllocation = serde_json::from_value(record.allocation_data)
.map_err(|e| {
CommonError::serialization(format!("Failed to deserialize allocation: {}", e))
})?;
Ok(allocation)
}
/// Rebalance portfolio
pub async fn rebalance_portfolio(
&self,
allocation_id: &str,
) -> Result<PortfolioAllocation, CommonError> {
let current = self.get_allocation(allocation_id).await?;
// Create rebalance request with same parameters
let request = AllocationRequest {
assets: current.assets.keys().cloned().collect(),
total_capital: current.total_capital,
strategy: current.strategy,
risk_budget: current.risk_budget,
constraints: AllocationConstraints::default(),
expected_returns: None,
win_rates: None,
};
self.allocate_portfolio(request).await
}
/// Equal weight allocation (1/N)
fn equal_weight_allocation(&self, assets: &[String]) -> HashMap<String, f64> {
let weight = 1.0 / assets.len() as f64;
assets
.iter()
.map(|symbol| (symbol.clone(), weight))
.collect()
}
/// Risk parity allocation (inverse volatility weighting)
async fn risk_parity_allocation(
&self,
assets: &[String],
) -> Result<HashMap<String, f64>, CommonError> {
// Get historical volatilities
let volatilities = self.get_asset_volatilities(assets).await?;
// Inverse volatility weights
let mut weights = HashMap::new();
let mut total_inv_vol = 0.0;
for symbol in assets {
let vol = volatilities.get(symbol).ok_or_else(|| {
CommonError::validation(format!("No volatility data for {}", symbol))
})?;
if *vol <= 0.0 {
return Err(CommonError::validation(format!(
"Invalid volatility {} for {}",
vol, symbol
)));
}
let inv_vol = 1.0 / vol;
weights.insert(symbol.clone(), inv_vol);
total_inv_vol += inv_vol;
}
// Normalize to sum to 1.0
for weight in weights.values_mut() {
*weight /= total_inv_vol;
}
Ok(weights)
}
/// Mean-variance optimization (Markowitz)
async fn mean_variance_allocation(
&self,
assets: &[String],
expected_returns: &HashMap<String, f64>,
) -> Result<HashMap<String, f64>, CommonError> {
// Get covariance matrix
let _cov_matrix = self.get_covariance_matrix(assets).await?;
// Simplified Markowitz: maximize Sharpe ratio
// In production, would use quadratic programming solver
// For now, use risk-adjusted return weighting
let volatilities = self.get_asset_volatilities(assets).await?;
let mut weights = HashMap::new();
let mut total_score = 0.0;
for symbol in assets {
let ret = expected_returns.get(symbol).ok_or_else(|| {
CommonError::validation(format!("No expected return for {}", symbol))
})?;
let vol = volatilities
.get(symbol)
.ok_or_else(|| CommonError::validation(format!("No volatility for {}", symbol)))?;
// Sharpe ratio proxy (assuming risk-free rate = 0)
let score = if *vol > 0.0 { ret / vol } else { 0.0 };
if score > 0.0 {
weights.insert(symbol.clone(), score);
total_score += score;
}
}
// Normalize
if total_score > 0.0 {
for weight in weights.values_mut() {
*weight /= total_score;
}
} else {
// Fallback to equal weight
return Ok(self.equal_weight_allocation(assets));
}
Ok(weights)
}
/// ML-optimized allocation
async fn ml_optimized_allocation(
&self,
assets: &[String],
) -> Result<HashMap<String, f64>, CommonError> {
// Get ML predictions for each asset
let predictions = self.get_ml_predictions(assets).await?;
// Get covariance matrix
let _cov_matrix = self.get_covariance_matrix(assets).await?;
// Weight by prediction confidence and inverse correlation
let mut weights = HashMap::new();
let mut total_score = 0.0;
for symbol in assets {
let pred = predictions.get(symbol).ok_or_else(|| {
CommonError::validation(format!("No ML prediction for {}", symbol))
})?;
// Score based on prediction and diversification
let score = pred.abs();
if score > 0.0 {
weights.insert(symbol.clone(), score);
total_score += score;
}
}
// Normalize
if total_score > 0.0 {
for weight in weights.values_mut() {
*weight /= total_score;
}
} else {
return Ok(self.equal_weight_allocation(assets));
}
Ok(weights)
}
/// Kelly Criterion allocation
fn kelly_allocation(
&self,
assets: &[String],
win_rates: &HashMap<String, f64>,
expected_returns: &HashMap<String, f64>,
) -> Result<HashMap<String, f64>, CommonError> {
let mut weights = HashMap::new();
let mut total_kelly = 0.0;
for symbol in assets {
let win_rate = win_rates
.get(symbol)
.ok_or_else(|| CommonError::validation(format!("No win rate for {}", symbol)))?;
let expected_return = expected_returns.get(symbol).ok_or_else(|| {
CommonError::validation(format!("No expected return for {}", symbol))
})?;
// Kelly formula: f* = (p*b - q) / b
// where p = win probability, q = 1-p, b = odds (return ratio)
let p = win_rate;
let q = 1.0 - p;
let b = expected_return.abs();
if b > 0.0 {
let kelly = (p * b - q) / b;
// Use fractional Kelly (25%) for safety
let fractional_kelly = (kelly * 0.25).max(0.0);
if fractional_kelly > 0.0 {
weights.insert(symbol.clone(), fractional_kelly);
total_kelly += fractional_kelly;
}
}
}
// Normalize to sum to 1.0
if total_kelly > 0.0 {
for weight in weights.values_mut() {
*weight /= total_kelly;
}
} else {
// No positive Kelly fractions - equal weight
return Ok(self.equal_weight_allocation(assets));
}
Ok(weights)
}
/// Apply allocation constraints
fn apply_constraints(
&self,
mut weights: HashMap<String, f64>,
constraints: &AllocationConstraints,
) -> Result<HashMap<String, f64>, CommonError> {
// Apply position size limits
let mut total_weight = 0.0;
let mut removed = Vec::new();
for (symbol, weight) in &mut weights {
if *weight < constraints.min_position_size {
removed.push(symbol.clone());
continue;
}
if *weight > constraints.max_position_size {
*weight = constraints.max_position_size;
}
total_weight += *weight;
}
// Remove positions below minimum
for symbol in removed {
weights.remove(&symbol);
}
// Check minimum diversification
if weights.len() < constraints.min_diversification {
return Err(CommonError::validation(format!(
"Insufficient diversification: {} assets (min: {})",
weights.len(),
constraints.min_diversification
)));
}
// Check leverage BEFORE normalization
let leverage: f64 = weights.values().sum();
if leverage > constraints.max_leverage {
return Err(CommonError::validation(format!(
"Leverage {:.2} exceeds maximum {:.2}",
leverage, constraints.max_leverage
)));
}
// Renormalize to sum to 1.0
if total_weight > 0.0 {
for weight in weights.values_mut() {
*weight /= total_weight;
}
}
// Re-apply caps after normalization to ensure constraints are still met
// Use iterative algorithm: cap overweight positions, redistribute to uncapped ones
// Stop when either converged or no uncapped positions remain
const MAX_ITERATIONS: usize = 100;
for iteration in 0..MAX_ITERATIONS {
let mut capped_total = 0.0;
let mut uncapped_total = 0.0;
let mut capped_symbols = Vec::new();
let mut uncapped_symbols = Vec::new();
// Identify capped and uncapped positions
for (symbol, weight) in &weights {
if *weight > constraints.max_position_size + 1e-10 {
capped_symbols.push(symbol.clone());
capped_total += constraints.max_position_size;
} else {
uncapped_symbols.push(symbol.clone());
uncapped_total += *weight;
}
}
// If nothing is over the cap, we're done
if capped_symbols.is_empty() {
break;
}
// Cap the overweight positions
for symbol in &capped_symbols {
weights.insert(symbol.clone(), constraints.max_position_size);
}
// Redistribute remaining allocation across uncapped positions
let remaining = 1.0 - capped_total;
// If no uncapped positions or all positions are at cap, we can't redistribute
// This happens when max_position_size * num_assets < 1.0
if uncapped_symbols.is_empty() || remaining <= 0.0 || uncapped_total <= 0.0 {
break;
}
let scale = remaining / uncapped_total;
// Only redistribute if it won't cause new violations
// If scale would push any uncapped position over the limit, stop iterating
let max_uncapped_after_scale = uncapped_symbols.iter()
.map(|s| weights[s] * scale)
.fold(0.0f64, |a, b| a.max(b));
if max_uncapped_after_scale > constraints.max_position_size + 1e-10 {
// Would cause oscillation - stop here
// All positions get their proportional share of remaining space
for symbol in &uncapped_symbols {
if let Some(weight) = weights.get_mut(symbol) {
*weight = (*weight / uncapped_total) * remaining;
}
}
break;
}
for symbol in &uncapped_symbols {
if let Some(weight) = weights.get_mut(symbol) {
*weight *= scale;
}
}
// Safety: break if we've reached the last iteration
if iteration == MAX_ITERATIONS - 1 {
break;
}
}
Ok(weights)
}
/// Calculate portfolio risk metrics
async fn calculate_risk_metrics(
&self,
assets: &[String],
weights: &HashMap<String, f64>,
) -> Result<RiskMetrics, CommonError> {
// Get historical data
let _volatilities = self.get_asset_volatilities(assets).await?;
let cov_matrix = self.get_covariance_matrix(assets).await?;
// Portfolio volatility: sqrt(w' * Σ * w)
let mut portfolio_variance = 0.0;
for (i, symbol_i) in assets.iter().enumerate() {
for (j, symbol_j) in assets.iter().enumerate() {
let w_i = weights.get(symbol_i).unwrap_or(&0.0);
let w_j = weights.get(symbol_j).unwrap_or(&0.0);
let cov = cov_matrix[i][j];
portfolio_variance += w_i * w_j * cov;
}
}
let volatility = portfolio_variance.sqrt();
// VaR (95% confidence): -1.645 * volatility * sqrt(capital)
let var_95 = 1.645 * volatility;
// Portfolio beta (simplified: weighted average)
let beta = weights.values().sum::<f64>() / weights.len() as f64;
// Expected Sharpe ratio (simplified)
let sharpe_ratio = if volatility > 0.0 {
1.0 / volatility
} else {
0.0
};
// Max drawdown (estimated from volatility)
let max_drawdown = volatility * 2.0;
Ok(RiskMetrics {
volatility,
var_95,
beta,
sharpe_ratio,
max_drawdown,
})
}
/// Persist allocation to database
async fn persist_allocation(
&self,
allocation: &PortfolioAllocation,
) -> Result<(), CommonError> {
let allocation_json = serde_json::to_value(allocation)
.map_err(|e| CommonError::serialization(format!("Failed to serialize: {}", e)))?;
sqlx::query!(
r#"
INSERT INTO portfolio_allocations (allocation_id, allocation_data, created_at)
VALUES ($1, $2, NOW())
ON CONFLICT (allocation_id) DO UPDATE
SET allocation_data = $2, updated_at = NOW()
"#,
Uuid::parse_str(&allocation.allocation_id).map_err(|e| CommonError::validation(
format!("Invalid allocation_id UUID: {}", e)
))?,
allocation_json
)
.execute(&self.pool)
.await
.map_err(|e| {
CommonError::service(ErrorCategory::Database, format!("Insert failed: {}", e))
})?;
Ok(())
}
/// Validate allocation request
fn validate_request(&self, request: &AllocationRequest) -> Result<(), CommonError> {
if request.assets.is_empty() {
return Err(CommonError::validation("Assets list cannot be empty"));
}
if request.total_capital <= 0.0 {
return Err(CommonError::validation("Total capital must be positive"));
}
if request.risk_budget <= 0.0 || request.risk_budget > 1.0 {
return Err(CommonError::validation(
"Risk budget must be between 0 and 1",
));
}
if request.constraints.max_position_size <= 0.0
|| request.constraints.max_position_size > 1.0
{
return Err(CommonError::validation(
"Max position size must be between 0 and 1",
));
}
if request.constraints.min_position_size < 0.0
|| request.constraints.min_position_size > 1.0
{
return Err(CommonError::validation(
"Min position size must be between 0 and 1",
));
}
if request.constraints.max_leverage <= 0.0 {
return Err(CommonError::validation("Max leverage must be positive"));
}
Ok(())
}
/// Get asset volatilities from database
async fn get_asset_volatilities(
&self,
assets: &[String],
) -> Result<HashMap<String, f64>, CommonError> {
tracing::warn!(
"get_asset_volatilities: using default values (not yet wired to market data)"
);
let mut volatilities = HashMap::new();
for symbol in assets.iter() {
// 20% annual volatility default — replace with historical calculation
volatilities.insert(symbol.clone(), 0.20);
}
Ok(volatilities)
}
/// Get covariance matrix for assets
async fn get_covariance_matrix(&self, assets: &[String]) -> Result<Vec<Vec<f64>>, CommonError> {
tracing::warn!(
"get_covariance_matrix: using default values (not yet wired to market data)"
);
let n = assets.len();
let mut matrix = vec![vec![0.0; n]; n];
for i in 0..n {
for j in 0..n {
if i == j {
// Variance on diagonal: 0.20^2 = 0.04
matrix[i][j] = 0.04;
} else {
// Low correlation off-diagonal
matrix[i][j] = 0.01;
}
}
}
Ok(matrix)
}
/// Get ML predictions for assets
async fn get_ml_predictions(
&self,
assets: &[String],
) -> Result<HashMap<String, f64>, CommonError> {
tracing::warn!(
"get_ml_predictions: using default values (not yet wired to market data)"
);
let mut predictions = HashMap::new();
for symbol in assets.iter() {
// Neutral prediction — replace with actual ML inference
predictions.insert(symbol.clone(), 0.0);
}
Ok(predictions)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_request(strategy: AllocationStrategy) -> AllocationRequest {
let mut expected_returns = HashMap::new();
expected_returns.insert("AAPL".to_string(), 0.12);
expected_returns.insert("GOOGL".to_string(), 0.15);
expected_returns.insert("MSFT".to_string(), 0.10);
expected_returns.insert("AMZN".to_string(), 0.18);
let mut win_rates = HashMap::new();
win_rates.insert("AAPL".to_string(), 0.55);
win_rates.insert("GOOGL".to_string(), 0.60);
win_rates.insert("MSFT".to_string(), 0.52);
win_rates.insert("AMZN".to_string(), 0.58);
AllocationRequest {
assets: vec![
"AAPL".to_string(),
"GOOGL".to_string(),
"MSFT".to_string(),
"AMZN".to_string(),
],
total_capital: 100000.0,
strategy,
risk_budget: 0.20,
constraints: AllocationConstraints::default(),
expected_returns: Some(expected_returns),
win_rates: Some(win_rates),
}
}
#[tokio::test]
async fn test_equal_weight_allocation() {
let pool = PgPool::connect_lazy("postgresql://test").unwrap();
let allocator = PortfolioAllocator::new(pool);
let assets = vec![
"AAPL".to_string(),
"GOOGL".to_string(),
"MSFT".to_string(),
"AMZN".to_string(),
];
let weights = allocator.equal_weight_allocation(&assets);
assert_eq!(weights.len(), 4);
for weight in weights.values() {
assert!((weight - 0.25).abs() < 1e-10);
}
let total: f64 = weights.values().sum();
assert!((total - 1.0).abs() < 1e-10);
}
#[tokio::test]
async fn test_kelly_allocation() {
let pool = PgPool::connect_lazy("postgresql://test").unwrap();
let allocator = PortfolioAllocator::new(pool);
let assets = vec!["AAPL".to_string(), "GOOGL".to_string()];
let mut win_rates = HashMap::new();
win_rates.insert("AAPL".to_string(), 0.60);
win_rates.insert("GOOGL".to_string(), 0.55);
// Updated: Use higher expected returns to create positive edge for Kelly formula
// Kelly requires p*b > q (win_rate * return > loss_rate)
// AAPL: 0.60 * 0.80 = 0.48 > 0.40 (positive edge, kelly = 0.10 → fractional = 0.025)
// GOOGL: 0.55 * 0.85 = 0.4675 > 0.45 (positive edge, kelly = 0.0206 → fractional = 0.00515)
let mut expected_returns = HashMap::new();
expected_returns.insert("AAPL".to_string(), 0.80);
expected_returns.insert("GOOGL".to_string(), 0.85);
let weights = allocator
.kelly_allocation(&assets, &win_rates, &expected_returns)
.unwrap();
assert_eq!(weights.len(), 2);
let total: f64 = weights.values().sum();
assert!((total - 1.0).abs() < 1e-10);
// AAPL should have higher weight (better win rate and return)
assert!(weights["AAPL"] > weights["GOOGL"]);
}
#[tokio::test]
async fn test_apply_constraints() {
let pool = PgPool::connect_lazy("postgresql://test").unwrap();
let allocator = PortfolioAllocator::new(pool);
let mut weights = HashMap::new();
weights.insert("AAPL".to_string(), 0.60); // Exceeds max
weights.insert("GOOGL".to_string(), 0.30);
weights.insert("MSFT".to_string(), 0.03); // Below min
weights.insert("AMZN".to_string(), 0.07);
let constraints = AllocationConstraints {
max_position_size: 0.25,
min_position_size: 0.05,
max_sector_concentration: None,
max_leverage: 1.0,
min_diversification: 3,
};
let constrained = allocator.apply_constraints(weights, &constraints).unwrap();
// MSFT should be removed (below min)
assert!(!constrained.contains_key("MSFT"));
// AAPL should be capped at max
assert!(constrained["AAPL"] <= constraints.max_position_size);
// Should sum to 1.0
let total: f64 = constrained.values().sum();
assert!((total - 1.0).abs() < 1e-10);
// All remaining positions above min
for weight in constrained.values() {
assert!(*weight >= constraints.min_position_size);
}
}
#[tokio::test]
async fn test_validate_request() {
let pool = PgPool::connect_lazy("postgresql://test").unwrap();
let allocator = PortfolioAllocator::new(pool);
// Valid request
let request = create_test_request(AllocationStrategy::EqualWeight);
assert!(allocator.validate_request(&request).is_ok());
// Empty assets
let mut bad_request = request.clone();
bad_request.assets.clear();
assert!(allocator.validate_request(&bad_request).is_err());
// Negative capital
let mut bad_request = request.clone();
bad_request.total_capital = -1000.0;
assert!(allocator.validate_request(&bad_request).is_err());
// Invalid risk budget
let mut bad_request = request.clone();
bad_request.risk_budget = 1.5;
assert!(allocator.validate_request(&bad_request).is_err());
// Invalid max position size
let mut bad_request = request.clone();
bad_request.constraints.max_position_size = 1.5;
assert!(allocator.validate_request(&bad_request).is_err());
}
#[tokio::test]
async fn test_constraint_enforcement() {
let pool = PgPool::connect_lazy("postgresql://test").unwrap();
let allocator = PortfolioAllocator::new(pool);
// Test min diversification constraint
let mut weights = HashMap::new();
weights.insert("AAPL".to_string(), 0.50);
weights.insert("GOOGL".to_string(), 0.50);
let constraints = AllocationConstraints {
max_position_size: 0.50,
min_position_size: 0.0,
max_sector_concentration: None,
max_leverage: 1.0,
min_diversification: 4, // Require at least 4 assets
};
let result = allocator.apply_constraints(weights, &constraints);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Insufficient diversification"));
}
#[tokio::test]
async fn test_leverage_constraint() {
let pool = PgPool::connect_lazy("postgresql://test").unwrap();
let allocator = PortfolioAllocator::new(pool);
let mut weights = HashMap::new();
weights.insert("AAPL".to_string(), 0.50);
weights.insert("GOOGL".to_string(), 0.40);
weights.insert("MSFT".to_string(), 0.30);
weights.insert("AMZN".to_string(), 0.20);
let constraints = AllocationConstraints {
max_position_size: 0.50,
min_position_size: 0.05,
max_sector_concentration: None,
max_leverage: 1.0, // No leverage allowed
min_diversification: 2,
};
let result = allocator.apply_constraints(weights, &constraints);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Leverage"));
}
}