//! Financial Calculation Precision Test Suite //! //! Property-based testing for financial calculations, ML prediction consistency, //! and risk metrics. Ensures mathematical invariants hold under all conditions. use proptest::prelude::*; use std::collections::HashMap; #[cfg(test)] mod property_based_financial_tests { use super::*; /// Property-based test for `price` arithmetic precision proptest! { #[test] fn test_price_arithmetic_invariants( price_a in 0.0001f64..10000.0f64, price_b in 0.0001f64..10000.0f64, quantity in 1i64..1_000_000i64 ) { // Test addition commutative property let p1 = TestPrice::from_f64(price_a).expect("Valid price"); let p2 = TestPrice::from_f64(price_b).expect("Valid price"); prop_assert_eq!(p1.clone() + p2.clone(), p2.clone() + p1.clone(), "Price addition must be commutative"); // Test multiplication with quantity let total_value_1 = p1.clone() * TestQuantity::from_i64(quantity); let total_value_2 = TestQuantity::from_i64(quantity) * p1.clone(); prop_assert!((total_value_1.to_f64() - total_value_2.to_f64()).abs() < 1e-10, "Price-quantity multiplication must be commutative"); // Test precision preservation let original_precision = count_decimal_places(price_a); let reconstructed = TestPrice::from_f64(price_a).expect("Valid price").to_f64(); let precision_loss = (price_a - reconstructed).abs() / price_a; prop_assert!(precision_loss < 1e-8, "Price precision loss {} exceeds tolerance for original {}", precision_loss, price_a); // Test zero properties let zero = TestPrice::zero(); prop_assert_eq!(p1.clone() + zero.clone(), p1.clone(), "Adding zero must be identity"); prop_assert_eq!(p1.clone() - p1.clone(), zero, "Self subtraction must equal zero"); } } /// Property-based test for PnL calculation accuracy proptest! { #[test] fn test_pnl_calculation_invariants( entry_price in 1.0f64..2.0f64, exit_price in 1.0f64..2.0f64, quantity in 1i64..1_000_000i64, is_long in prop::bool::ANY ) { let pnl_calculator = TestPnLCalculator::new(); let entry = TestPrice::from_f64(entry_price).expect("Valid price"); let exit = TestPrice::from_f64(exit_price).expect("Valid price"); let qty = if is_long { TestQuantity::from_i64(quantity) } else { TestQuantity::from_i64(-quantity) }; let pnl = pnl_calculator.calculate_unrealized_pnl(entry.clone(), exit.clone(), qty.clone()); // Test PnL symmetry property let opposite_qty = TestQuantity::from_i64(-qty.to_i64()); let opposite_pnl = pnl_calculator.calculate_unrealized_pnl(entry.clone(), exit.clone(), opposite_qty); prop_assert!((pnl.to_f64() + opposite_pnl.to_f64()).abs() < 1e-10, "Opposite positions should have opposite PnL"); // Test price reversal property let reversed_pnl = pnl_calculator.calculate_unrealized_pnl(exit.clone(), entry.clone(), qty.clone()); prop_assert!((pnl.to_f64() + reversed_pnl.to_f64()).abs() < 1e-10, "Reversing entry/exit prices should reverse PnL sign"); // Test zero quantity property let zero_qty = TestQuantity::from_i64(0); let zero_pnl = pnl_calculator.calculate_unrealized_pnl(entry.clone(), exit.clone(), zero_qty); prop_assert_eq!(zero_pnl.to_f64(), 0.0, "Zero quantity should result in zero PnL"); // Test linearity property let double_qty = TestQuantity::from_i64(qty.to_i64() * 2); let double_pnl = pnl_calculator.calculate_unrealized_pnl(entry.clone(), exit.clone(), double_qty); prop_assert!((double_pnl.to_f64() - 2.0 * pnl.to_f64()).abs() < 1e-8, "PnL should scale linearly with quantity"); } } /// Property-based test for risk metrics consistency proptest! { #[test] fn test_risk_metrics_invariants( returns in prop::collection::vec(-0.1f64..0.1f64, 100..1000), confidence_level in 0.90f64..0.99f64, time_horizon in 1u32..30u32 ) { let risk_calculator = TestRiskCalculator::new(); // Calculate Value at Risk let var = risk_calculator.calculate_var(&returns, confidence_level, time_horizon); // VaR should always be negative (loss) prop_assert!(var <= 0.0, "VaR should represent a loss (non-positive value)"); // Test VaR monotonicity with confidence level if confidence_level < 0.98 { let higher_confidence_var = risk_calculator.calculate_var(&returns, confidence_level + 0.01, time_horizon); prop_assert!(higher_confidence_var <= var, "Higher confidence level should result in higher (more negative) VaR"); } // Test time scaling property if time_horizon < 20 { let longer_horizon_var = risk_calculator.calculate_var(&returns, confidence_level, time_horizon * 2); let scaling_factor = (2.0f64).sqrt(); // Square root of time scaling let expected_var = var * scaling_factor; let scaling_error = ((longer_horizon_var / expected_var) - 1.0).abs(); prop_assert!(scaling_error < 0.2, // Allow 20% deviation due to estimation methods "VaR should approximately scale with square root of time"); } // Calculate Expected Shortfall let es = risk_calculator.calculate_expected_shortfall(&returns, confidence_level); // Expected Shortfall should be more extreme than VaR prop_assert!(es <= var, "Expected Shortfall should be greater than or equal to VaR in magnitude"); // Test coherent risk measure properties let scaled_returns: Vec = returns.iter().map(|&r| r * 2.0).collect(); let scaled_var = risk_calculator.calculate_var(&scaled_returns, confidence_level, time_horizon); prop_assert!((scaled_var / (var * 2.0) - 1.0).abs() < 0.1, "VaR should approximately scale linearly with position size"); } } /// Property-based test for portfolio allocation invariants proptest! { #[test] fn test_portfolio_allocation_invariants( weights in prop::collection::vec(0.0f64..1.0f64, 3..10), returns in prop::collection::vec(-0.05f64..0.05f64, 3..10), volatilities in prop::collection::vec(0.001f64..0.5f64, 3..10) ) { prop_assume!(weights.len() == returns.len() && returns.len() == volatilities.len()); prop_assume!(weights.iter().sum::() > 0.1); // Ensure meaningful weights let portfolio_optimizer = TestPortfolioOptimizer::new(); // Normalize weights to sum to 1 let weight_sum: f64 = weights.iter().sum(); let normalized_weights: Vec = weights.iter().map(|&w| w / weight_sum).collect(); let portfolio_return = portfolio_optimizer.calculate_portfolio_return(&normalized_weights, &returns); let portfolio_risk = portfolio_optimizer.calculate_portfolio_risk(&normalized_weights, &volatilities); // Test weight normalization property let weight_sum_normalized: f64 = normalized_weights.iter().sum(); prop_assert!((weight_sum_normalized - 1.0).abs() < 1e-10, "Normalized weights must sum to 1.0"); // Test portfolio return linearity let manual_return: f64 = normalized_weights.iter() .zip(returns.iter()) .map(|(&w, &r)| w * r) .sum(); prop_assert!((portfolio_return - manual_return).abs() < 1e-10, "Portfolio return calculation must match weighted average"); // Test risk bounds let min_individual_risk = volatilities.iter().cloned().fold(f64::INFINITY, f64::min); let max_individual_risk = volatilities.iter().cloned().fold(f64::NEG_INFINITY, f64::max); prop_assert!(portfolio_risk >= 0.0, "Portfolio risk must be non-negative"); prop_assert!(portfolio_risk <= max_individual_risk, "Portfolio risk should not exceed maximum individual asset risk"); // Test concentration risk let max_weight = normalized_weights.iter().cloned().fold(f64::NEG_INFINITY, f64::max); if max_weight > 0.8 { // High concentration should result in risk close to that asset's risk let dominant_asset_risk = volatilities[normalized_weights.iter() .position(|&w| w == max_weight).unwrap()]; let risk_difference = (portfolio_risk - dominant_asset_risk).abs(); prop_assert!(risk_difference < dominant_asset_risk * 0.3, "Concentrated portfolio risk should approximate dominant asset risk"); } } } /// Property-based test for `ML` prediction consistency proptest! { #[test] fn test_ml_prediction_consistency( market_data in prop::collection::vec(0.5f64..2.0f64, 10..20), model_confidence in 0.0f64..1.0f64, prediction_horizon in 1u32..100u32 ) { let ml_predictor = TestMLPredictor::new(); let market_state = TestMarketState::from_prices(market_data.clone()); let prediction = ml_predictor.predict(&market_state, prediction_horizon); // Test prediction bounds prop_assert!(prediction.probability >= 0.0 && prediction.probability <= 1.0, "Prediction probability must be in [0,1]"); prop_assert!(prediction.confidence >= 0.0 && prediction.confidence <= 1.0, "Prediction confidence must be in [0,1]"); // Test deterministic consistency let prediction2 = ml_predictor.predict(&market_state, prediction_horizon); prop_assert!((prediction.probability - prediction2.probability).abs() < 1e-10, "Identical inputs should produce identical predictions"); // Test input sensitivity let mut perturbed_data = market_data.clone(); if let Some(last) = perturbed_data.last_mut() { *last *= 1.001; // 0.1% perturbation } let perturbed_state = TestMarketState::from_prices(perturbed_data); let perturbed_prediction = ml_predictor.predict(&perturbed_state, prediction_horizon); let prediction_sensitivity = (prediction.probability - perturbed_prediction.probability).abs(); prop_assert!(prediction_sensitivity < 0.1, "Small input changes should not cause large prediction changes"); // Test horizon scaling if prediction_horizon < 50 { let longer_prediction = ml_predictor.predict(&market_state, prediction_horizon * 2); // Longer horizons should generally have lower confidence prop_assert!(longer_prediction.confidence <= prediction.confidence + 0.1, "Longer prediction horizons should not increase confidence significantly"); } } } /// Property-based test for position sizing algorithms proptest! { #[test] fn test_position_sizing_invariants( account_balance in 10000.0f64..1_000_000.0f64, win_probability in 0.51f64..0.80f64, win_amount in 1.0f64..10.0f64, loss_amount in 1.0f64..10.0f64, risk_tolerance in 0.01f64..0.10f64 ) { let position_sizer = TestPositionSizer::new(); // Kelly Criterion position size let kelly_fraction = position_sizer.calculate_kelly_fraction( win_probability, win_amount, loss_amount ); // Kelly fraction should be positive for profitable opportunities prop_assert!(kelly_fraction >= 0.0, "Kelly fraction should be non-negative for profitable trades"); // Kelly fraction should not exceed 1 for reasonable parameters prop_assert!(kelly_fraction <= 1.0, "Kelly fraction should not exceed 100% allocation"); // Risk-adjusted position size let position_size = position_sizer.calculate_position_size( account_balance, kelly_fraction, risk_tolerance ); // Position size should respect risk tolerance let max_loss = position_size * loss_amount; let portfolio_risk = max_loss / account_balance; prop_assert!(portfolio_risk <= risk_tolerance * 1.1, // Small tolerance for rounding "Position size should respect risk tolerance"); // Test scaling properties let double_balance_size = position_sizer.calculate_position_size( account_balance * 2.0, kelly_fraction, risk_tolerance ); prop_assert!((double_balance_size / (position_size * 2.0) - 1.0).abs() < 0.01, "Position size should scale approximately linearly with account balance"); // Test edge cases if win_probability <= 0.5 { let unprofitable_kelly = position_sizer.calculate_kelly_fraction( win_probability, win_amount, loss_amount ); prop_assert!(unprofitable_kelly <= 0.0, "Kelly fraction should be non-positive for unprofitable trades"); } } } /// Property-based test for order book impact calculations proptest! { #[test] fn test_market_impact_invariants( order_size in 1000.0f64..100_000.0f64, daily_volume in 100_000.0f64..10_000_000.0f64, spread in 0.0001f64..0.01f64, volatility in 0.001f64..0.1f64 ) { let impact_calculator = TestMarketImpactCalculator::new(); let participation_rate = order_size / daily_volume; let impact = impact_calculator.calculate_linear_impact( order_size, daily_volume, spread, volatility ); // Market impact should be non-negative prop_assert!(impact >= 0.0, "Market impact should be non-negative"); // Impact should increase with order size let larger_order_impact = impact_calculator.calculate_linear_impact( order_size * 2.0, daily_volume, spread, volatility ); prop_assert!(larger_order_impact >= impact, "Larger orders should have greater or equal market impact"); // Impact should decrease with higher daily volume (more liquidity) let higher_volume_impact = impact_calculator.calculate_linear_impact( order_size, daily_volume * 2.0, spread, volatility ); prop_assert!(higher_volume_impact <= impact, "Higher daily volume should reduce market impact"); // Impact should be roughly proportional to participation rate for small orders if participation_rate < 0.1 { let double_participation = impact_calculator.calculate_linear_impact( order_size * 2.0, daily_volume, spread, volatility ); let scaling_ratio = double_participation / impact; prop_assert!(scaling_ratio >= 1.8 && scaling_ratio <= 2.2, "Market impact should scale approximately linearly for small participation rates"); } // Impact should have reasonable bounds let impact_in_spreads = impact / spread; prop_assert!(impact_in_spreads < 10.0, "Market impact should not exceed 10 spread widths for reasonable parameters"); } } // Helper functions fn count_decimal_places(value: f64) -> usize { let s = format!("{:.10}", value); if let Some(dot_pos) = s.find('.') { s.len() - dot_pos - 1 } else { 0 } } } // Test data structures and implementations #[derive(Debug, Clone, PartialEq)] struct TestPrice { value: i64, // Store as fixed-point integer for precision scale: u32, // Number of decimal places } impl TestPrice { fn from_f64(value: f64) -> Self { let scale = 8; // 8 decimal places let scaled_value = (value * 10_i64.pow(scale) as f64).round() as i64; Self { value: scaled_value, scale, } } fn to_f64(&self) -> f64 { self.value as f64 / 10_i64.pow(self.scale) as f64 } fn zero() -> Self { Self { value: 0, scale: 8 } } } impl std::ops::Add for TestPrice { type Output = Self; fn add(self, other: Self) -> Self { assert_eq!(self.scale, other.scale); Self { value: self.value + other.value, scale: self.scale, } } } impl std::ops::Sub for TestPrice { type Output = Self; fn sub(self, other: Self) -> Self { assert_eq!(self.scale, other.scale); Self { value: self.value - other.value, scale: self.scale, } } } impl std::ops::Mul for TestPrice { type Output = TestPrice; fn mul(self, quantity: TestQuantity) -> TestPrice { TestPrice { value: self.value * quantity.value, scale: self.scale, } } } #[derive(Debug, Clone, PartialEq)] struct TestQuantity { value: i64, } impl TestQuantity { fn from_i64(value: i64) -> Self { Self { value } } fn to_i64(&self) -> i64 { self.value } fn to_f64(&self) -> f64 { self.value as f64 } } impl std::ops::Mul for TestQuantity { type Output = TestPrice; fn mul(self, price: TestPrice) -> TestPrice { price * self } } #[derive(Debug)] struct TestPnLCalculator; impl TestPnLCalculator { fn new() -> Self { Self } fn calculate_unrealized_pnl(&self, entry_price: TestPrice, current_price: TestPrice, quantity: TestQuantity) -> TestPrice { let price_diff = current_price - entry_price; price_diff * quantity } } #[derive(Debug)] struct TestRiskCalculator; impl TestRiskCalculator { fn new() -> Self { Self } fn calculate_var(&self, returns: &[f64], confidence_level: f64, _time_horizon: u32) -> f64 { let mut sorted_returns = returns.to_vec(); sorted_returns.sort_by(|a, b| a.partial_cmp(b).unwrap()); let percentile_index = ((1.0 - confidence_level) * sorted_returns.len() as f64) as usize; sorted_returns.get(percentile_index).copied().unwrap_or(0.0) } fn calculate_expected_shortfall(&self, returns: &[f64], confidence_level: f64) -> f64 { let var = self.calculate_var(returns, confidence_level, 1); let tail_returns: Vec = returns.iter() .filter(|&&r| r <= var) .copied() .collect(); if tail_returns.is_empty() { var } else { tail_returns.iter().sum::() / tail_returns.len() as f64 } } } #[derive(Debug)] struct TestPortfolioOptimizer; impl TestPortfolioOptimizer { fn new() -> Self { Self } fn calculate_portfolio_return(&self, weights: &[f64], returns: &[f64]) -> f64 { weights.iter().zip(returns.iter()).map(|(&w, &r)| w * r).sum() } fn calculate_portfolio_risk(&self, weights: &[f64], volatilities: &[f64]) -> f64 { // Simplified calculation assuming zero correlation for testing let variance: f64 = weights.iter() .zip(volatilities.iter()) .map(|(&w, &v)| (w * v).powi(2)) .sum(); variance.sqrt() } } #[derive(Debug)] struct TestMarketState { prices: Vec, } impl TestMarketState { fn from_prices(prices: Vec) -> Self { Self { prices } } } #[derive(Debug)] struct TestMLPredictor; #[derive(Debug)] struct MLPrediction { probability: f64, confidence: f64, } impl TestMLPredictor { fn new() -> Self { Self } fn predict(&self, market_state: &TestMarketState, _horizon: u32) -> MLPrediction { // Simplified deterministic prediction for testing let last_price = market_state.prices.last().unwrap_or(&1.0); let price_hash = (last_price * 1000000.0) as u64; MLPrediction { probability: ((price_hash % 1000) as f64) / 1000.0, confidence: 0.75, // Fixed confidence for deterministic testing } } } #[derive(Debug)] struct TestPositionSizer; impl TestPositionSizer { fn new() -> Self { Self } fn calculate_kelly_fraction(&self, win_prob: f64, win_amount: f64, loss_amount: f64) -> f64 { let expected_return = win_prob * win_amount - (1.0 - win_prob) * loss_amount; if expected_return <= 0.0 { 0.0 } else { expected_return / win_amount } } fn calculate_position_size(&self, balance: f64, kelly_fraction: f64, risk_tolerance: f64) -> f64 { let kelly_size = balance * kelly_fraction; let risk_adjusted_size = balance * risk_tolerance; kelly_size.min(risk_adjusted_size) } } #[derive(Debug)] struct TestMarketImpactCalculator; impl TestMarketImpactCalculator { fn new() -> Self { Self } fn calculate_linear_impact(&self, order_size: f64, daily_volume: f64, spread: f64, volatility: f64) -> f64 { let participation_rate = order_size / daily_volume; let base_impact = spread * 0.5; // Half spread as base impact let volume_impact = volatility * participation_rate; base_impact + volume_impact } }