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>
396 lines
16 KiB
Rust
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,
|
|
¤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<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, ¤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);
|
|
}
|
|
}
|
|
} |