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>
923 lines
31 KiB
Rust
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"));
|
|
}
|
|
}
|