Files
foxhunt/testing/integration/unit/unit-tests-src/financial.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

396 lines
16 KiB
Rust

//! 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,
&current_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,
&current_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(&current_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<PnL, Box<dyn std::error::Error>> {
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<Money, Box<dyn std::error::Error>> {
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<Money, Box<dyn std::error::Error>> {
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<PositionRiskMetrics, Box<dyn std::error::Error>> {
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<Decimal, Box<dyn std::error::Error>> {
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<Decimal, Box<dyn std::error::Error>> {
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>() / 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<Decimal, Box<dyn std::error::Error>> {
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, &current, Side::Buy).unwrap();
let short_pnl = calculate_unrealized_pnl(&qty, &entry, &current, 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);
}
}
}