//! Financial calculation unit tests //! //! Tests mathematical accuracy, edge cases, and precision requirements //! for financial calculations in the trading system. // CANONICAL TYPE IMPORTS - Use types::prelude::Decimal use proptest::prelude::*; use num_traits::FromPrimitive; // For Decimal::from_f64 #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_pnl_calculations_precision() { // Test precise PnL calculations let entry_price = Price::new(Decimal::from_str("123.456789").unwrap()).map_err(|e| format!("Failed to create entry price: {}", e)).unwrap(); let current_price = Price::new(Decimal::from_str("125.987654").unwrap()).map_err(|e| format!("Failed to create current price: {}", e)).unwrap(); let quantity = Quantity::new(Decimal::from(1000)).map_err(|e| format!("Failed to create quantity: {}", e)).unwrap(); let unrealized_pnl = calculate_unrealized_pnl( &quantity, &entry_price, ¤t_price, Side::Buy ).expect("Should calculate unrealized PnL"); // Expected: (125.987654 - 123.456789) * 1000 = 2530.865 let expected = Decimal::from_str("2530.865000").unwrap(); assert_eq!(unrealized_pnl.value(), expected); } #[tokio::test] async fn test_pnl_short_position() { let entry_price = Price::new(Decimal::from_str("100.00").unwrap()).map_err(|e| format!("Failed to create entry price: {}", e)).unwrap(); let current_price = Price::new(Decimal::from_str("95.00").unwrap()).map_err(|e| format!("Failed to create current price: {}", e)).unwrap(); let quantity = Quantity::new(Decimal::from(100)).map_err(|e| format!("Failed to create quantity: {}", e)).unwrap(); // Short position: profit when price goes down let unrealized_pnl = calculate_unrealized_pnl( &quantity, &entry_price, ¤t_price, Side::Sell ).expect("Should calculate short PnL"); // Expected: (100.00 - 95.00) * 100 = 500.00 profit let expected = Decimal::from(500); assert_eq!(unrealized_pnl.value(), expected); } #[tokio::test] async fn test_commission_calculations() { let trade_value = Money::new(Decimal::from(10000), Currency::USD); let commission_rate = Decimal::from_str("0.001").unwrap(); // 0.1% let commission = calculate_commission(&trade_value, commission_rate) .expect("Should calculate commission"); assert_eq!(commission.amount, Decimal::from(10)); // $10 commission assert_eq!(commission.currency, Currency::USD); } #[tokio::test] async fn test_slippage_calculations() { let expected_price = Price::new(Decimal::from(100)).map_err(|e| format!("Failed to create expected price: {}", e)).unwrap(); let actual_price = Price::new(Decimal::from_str("100.05").unwrap()).map_err(|e| format!("Failed to create actual price: {}", e)).unwrap(); let quantity = Quantity::new(Decimal::from(1000)).map_err(|e| format!("Failed to create quantity: {}", e)).unwrap(); let slippage = calculate_slippage( &expected_price, &actual_price, &quantity, Side::Buy ).expect("Should calculate slippage"); // Expected slippage: (100.05 - 100.00) * 1000 = 50.00 assert_eq!(slippage.amount, Decimal::from_str("50.00").unwrap()); } #[tokio::test] async fn test_portfolio_value_calculation() { let mut portfolio = Portfolio::new(); // Add AAPL position let aapl_position = Position { symbol: Symbol::new("AAPL".to_string()).map_err(|e| format!("Failed to create AAPL symbol: {}", e)).unwrap(), quantity: Quantity::new(Decimal::from(100)).map_err(|e| format!("Failed to create quantity: {}", e)).unwrap(), average_price: Price::new(Decimal::from(150)).map_err(|e| format!("Failed to create average price: {}", e)).unwrap(), realized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create realized PnL: {}", e)).unwrap(), unrealized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create unrealized PnL: {}", e)).unwrap(), last_updated: chrono::Utc::now(), }; portfolio.add_position(aapl_position); // Add MSFT position let msft_position = Position { symbol: Symbol::new("MSFT".to_string()).map_err(|e| format!("Failed to create MSFT symbol: {}", e)).unwrap(), quantity: Quantity::new(Decimal::from(50)).map_err(|e| format!("Failed to create quantity: {}", e)).unwrap(), average_price: Price::new(Decimal::from(300)).map_err(|e| format!("Failed to create average price: {}", e)).unwrap(), realized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create realized PnL: {}", e)).unwrap(), unrealized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create unrealized PnL: {}", e)).unwrap(), last_updated: chrono::Utc::now(), }; portfolio.add_position(msft_position); // Mock current prices let mut current_prices = std::collections::HashMap::new(); current_prices.insert( Symbol::new("AAPL".to_string()).map_err(|e| format!("Failed to create AAPL symbol: {}", e)).unwrap(), Price::new(Decimal::from(155)).map_err(|e| format!("Failed to create AAPL price: {}", e)).unwrap() ); current_prices.insert( Symbol::new("MSFT".to_string()).map_err(|e| format!("Failed to create MSFT symbol: {}", e)).unwrap(), Price::new(Decimal::from(310)).map_err(|e| format!("Failed to create MSFT price: {}", e)).unwrap() ); let total_value = portfolio.calculate_total_value(¤t_prices) .expect("Should calculate portfolio value"); // AAPL: 100 * 155 = 15,500 // MSFT: 50 * 310 = 15,500 // Total: 31,000 assert_eq!(total_value.amount, Decimal::from(31000)); } #[tokio::test] async fn test_risk_metrics() { let positions = vec![ Position { symbol: Symbol::new("AAPL".to_string()).map_err(|e| format!("Failed to create AAPL symbol: {}", e)).unwrap(), quantity: Quantity::new(Decimal::from(1000)).map_err(|e| format!("Failed to create quantity: {}", e)).unwrap(), average_price: Price::new(Decimal::from(150)).map_err(|e| format!("Failed to create average price: {}", e)).unwrap(), realized_pnl: PnL::new(Decimal::from(500)).map_err(|e| format!("Failed to create realized PnL: {}", e)).unwrap(), unrealized_pnl: PnL::new(Decimal::from(-200)).map_err(|e| format!("Failed to create unrealized PnL: {}", e)).unwrap(), last_updated: chrono::Utc::now(), }, Position { symbol: Symbol::new("MSFT".to_string()).map_err(|e| format!("Failed to create MSFT symbol: {}", e)).unwrap(), quantity: Quantity::new(Decimal::from(-500)).map_err(|e| format!("Failed to create quantity: {}", e)).unwrap(), // Short position average_price: Price::new(Decimal::from(300)).map_err(|e| format!("Failed to create average price: {}", e)).unwrap(), realized_pnl: PnL::new(Decimal::from(1000)).map_err(|e| format!("Failed to create realized PnL: {}", e)).unwrap(), unrealized_pnl: PnL::new(Decimal::from(300)).map_err(|e| format!("Failed to create unrealized PnL: {}", e)).unwrap(), last_updated: chrono::Utc::now(), }, ]; let risk_metrics = calculate_portfolio_risk(&positions) .expect("Should calculate risk metrics"); // Test that risk metrics are computed assert!(risk_metrics.total_exposure.amount > Decimal::ZERO); assert!(risk_metrics.net_exposure.amount != Decimal::ZERO); assert!(risk_metrics.var_1_day.is_some()); } #[tokio::test] async fn test_compound_interest() { let principal = Decimal::from(10000); let rate = Decimal::from_str("0.05").unwrap(); // 5% annual let periods = 252; // Trading days in a year let time_years = Decimal::from(1); let final_amount = calculate_compound_interest(principal, rate, periods, time_years) .expect("Should calculate compound interest"); // Compound daily: 10000 * (1 + 0.05/252)^252 ≈ 10512.71 let expected_min = Decimal::from_str("10500").unwrap(); let expected_max = Decimal::from_str("10520").unwrap(); assert!(final_amount >= expected_min); assert!(final_amount <= expected_max); } #[tokio::test] async fn test_sharpe_ratio() { let returns = vec![ Decimal::from_str("0.02").unwrap(), // 2% Decimal::from_str("-0.01").unwrap(), // -1% Decimal::from_str("0.03").unwrap(), // 3% Decimal::from_str("0.01").unwrap(), // 1% Decimal::from_str("-0.02").unwrap(), // -2% ]; let risk_free_rate = Decimal::from_str("0.001").unwrap(); // 0.1% let sharpe = calculate_sharpe_ratio(&returns, risk_free_rate) .expect("Should calculate Sharpe ratio"); // Expected: (mean_return - risk_free_rate) / std_dev assert!(sharpe > Decimal::ZERO); assert!(sharpe < Decimal::from(10)); // Reasonable upper bound } #[tokio::test] async fn test_max_drawdown() { let portfolio_values = vec![ Decimal::from(100000), Decimal::from(105000), Decimal::from(102000), Decimal::from(98000), // Drawdown starts Decimal::from(95000), // Maximum drawdown Decimal::from(97000), Decimal::from(103000), // Recovery ]; let max_drawdown = calculate_max_drawdown(&portfolio_values) .expect("Should calculate max drawdown"); // Max drawdown: (105000 - 95000) / 105000 ≈ 9.52% let expected = Decimal::from_str("0.095238095238095238").unwrap(); let tolerance = Decimal::from_str("0.001").unwrap(); assert!((max_drawdown - expected).abs() < tolerance); } } // Helper functions for financial calculations fn calculate_unrealized_pnl( quantity: &Quantity, entry_price: &Price, current_price: &Price, side: Side, ) -> Result> { let price_diff = match side { Side::Buy => current_price.value() - entry_price.value(), Side::Sell => entry_price.value() - current_price.value(), }; let pnl_value = price_diff * quantity.value(); Ok(PnL::new(pnl_value)?) } fn calculate_commission( trade_value: &Money, rate: Decimal, ) -> Result> { let commission_amount = trade_value.amount * rate; Ok(Money::new(commission_amount, trade_value.currency)) } fn calculate_slippage( expected_price: &Price, actual_price: &Price, quantity: &Quantity, _side: Side, ) -> Result> { let price_diff = (actual_price.value() - expected_price.value()).abs(); let slippage_cost = price_diff * quantity.value(); Ok(Money::new(slippage_cost, Currency::USD)) } fn calculate_portfolio_risk( positions: &[Position], ) -> Result> { let mut total_long_exposure = Decimal::ZERO; let mut total_short_exposure = Decimal::ZERO; for position in positions { let position_value = position.quantity.value().abs() * position.average_price.value(); if position.quantity.value() > Decimal::ZERO { total_long_exposure += position_value; } else { total_short_exposure += position_value; } } Ok(PositionRiskMetrics { total_exposure: Money::new(total_long_exposure + total_short_exposure, Currency::USD), net_exposure: Money::new(total_long_exposure - total_short_exposure, Currency::USD), long_exposure: Money::new(total_long_exposure, Currency::USD), short_exposure: Money::new(total_short_exposure, Currency::USD), var_1_day: Some(Money::new(total_long_exposure * Decimal::from_str("0.02").unwrap(), Currency::USD)), var_5_day: Some(Money::new(total_long_exposure * Decimal::from_str("0.045").unwrap(), Currency::USD)), beta: Some(Decimal::from_str("1.0").unwrap()), correlation_to_market: Some(Decimal::from_str("0.8").unwrap()), }) } fn calculate_compound_interest( principal: Decimal, annual_rate: Decimal, compounding_periods: i32, years: Decimal, ) -> Result> { let periods_decimal = Decimal::from(compounding_periods); let rate_per_period = annual_rate / periods_decimal; let total_periods = periods_decimal * years; // A = P(1 + r/n)^(nt) let compound_factor = (Decimal::ONE + rate_per_period) .powd(total_periods) .ok_or("Compound calculation overflow")?; Ok(principal * compound_factor) } fn calculate_sharpe_ratio( returns: &[Decimal], risk_free_rate: Decimal, ) -> Result> { if returns.is_empty() { return Err("No returns provided".into()); } // Calculate mean return let sum: Decimal = returns.iter().sum(); let mean = sum / Decimal::from(returns.len()); // Calculate excess return let excess_return = mean - risk_free_rate; // Calculate standard deviation let variance: Decimal = returns.iter() .map(|r| (*r - mean).powi(2)) .sum::() / Decimal::from(returns.len()); let std_dev = variance.sqrt().ok_or("Cannot calculate square root")?; if std_dev == Decimal::ZERO { return Err("Zero standard deviation".into()); } Ok(excess_return / std_dev) } fn calculate_max_drawdown( portfolio_values: &[Decimal], ) -> Result> { if portfolio_values.is_empty() { return Err("No portfolio values provided".into()); } let mut peak = portfolio_values[0]; let mut max_drawdown = Decimal::ZERO; for &value in &portfolio_values { if value > peak { peak = value; } let drawdown = (peak - value) / peak; if drawdown > max_drawdown { max_drawdown = drawdown; } } Ok(max_drawdown) } #[cfg(test)] mod property_tests { use super::*; use proptest::prelude::*; proptest! { #[test] fn pnl_calculation_symmetry( entry_price in 1.0f64..1000.0, current_price in 1.0f64..1000.0, quantity in 1u32..10000 ) { let entry = Price::new(Decimal::from_f64_retain(entry_price).unwrap()).map_err(|e| format!("Failed to create entry price: {}", e)).unwrap(); let current = Price::new(Decimal::from_f64_retain(current_price).unwrap()).map_err(|e| format!("Failed to create current price: {}", e)).unwrap(); let qty = Quantity::new(Decimal::from(quantity)).map_err(|e| format!("Failed to create quantity: {}", e)).unwrap(); let long_pnl = calculate_unrealized_pnl(&qty, &entry, ¤t, Side::Buy).unwrap(); let short_pnl = calculate_unrealized_pnl(&qty, &entry, ¤t, Side::Sell).unwrap(); // Long and short PnL should be opposite prop_assert_eq!(long_pnl.value(), -short_pnl.value()); } #[test] fn commission_proportional( trade_value in 100.0f64..1_000_000.0, rate in 0.0001f64..0.01 // 0.01% to 1% ) { let value = Money::new( Decimal::from_f64_retain(trade_value).unwrap(), Currency::USD ); let commission_rate = Decimal::from_f64_retain(rate).unwrap(); let commission = calculate_commission(&value, commission_rate).unwrap(); // Commission should be proportional to trade value let expected = value.amount * commission_rate; prop_assert_eq!(commission.amount, expected); // Commission should never exceed trade value prop_assert!(commission.amount <= value.amount); } } }