Files
foxhunt/testing/integration/unit/financial_property_tests.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

710 lines
23 KiB
Rust

//! Comprehensive Property-Based Tests for Financial Calculations
//!
//! This module provides exhaustive property-based testing for all financial calculations
//! in the Foxhunt HFT system to ensure REAL MONEY safety through mathematical correctness.
//!
//! CRITICAL: These tests prevent financial losses through precision errors and edge cases.
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use std::f64::{INFINITY, NEG_INFINITY, NAN};
use chrono::{DateTime, Utc};
use common::Order;
use common::Position;
use common::Symbol;
use common::Price;
use common::Quantity;
use common::error::CommonError;
use common::error::CommonResult;
use common::OrderId;
use common::TradeId;
use common::ExecutionId;
use common::OrderType;
use common::OrderSide;
// Test Types - Simplified versions for property testing
#[derive(Debug, Clone, Copy, PartialEq)]
struct TestPrice(u64); // Fixed-point representation, 8 decimals
#[derive(Debug, Clone, Copy, PartialEq)]
struct Quantity(u64);
#[derive(Debug, Clone, Copy, PartialEq)]
struct TestVolume(u64);
#[derive(Debug, Clone, Copy, PartialEq)]
struct Amount(i64);
// Implementations
impl TestPrice {
pub fn new(value: f64) -> Self {
// Convert to fixed-point representation matching production types
let decimal_value = Decimal::try_from(value).unwrap_or(Decimal::ZERO);
let scaled = decimal_value * Decimal::from(100_000_000u64); // 8 decimal places
Self(scaled.to_u64().unwrap_or(0))
}
pub fn value(&self) -> f64 {
(self.0 as f64) / 100_000_000.0 // Convert back from fixed-point
}
pub fn from_f64(value: f64) -> Self {
TestTestPrice::new(value.max(0.0)) // Clamp to non-negative
}
pub fn to_f64(&self) -> f64 {
self.value()
}
pub fn add(&self, other: &TestPrice) -> TestPrice {
TestTestPrice::from_f64(self.value() + other.value())
}
pub fn multiply(&self, factor: f64) -> TestPrice {
TestTestPrice::from_f64(self.value() * factor)
}
pub fn percentage_change(&self, old_price: &TestPrice) -> f64 {
let old_val = old_price.value();
if old_val == 0.0 {
0.0
} else {
(self.value() - old_val) / old_val
}
}
}
impl Quantity {
pub fn new(value: i64) -> Self {
// Ensure non-negative values to match production u64 type
Quantity(value.max(0) as u64)
}
pub fn value(&self) -> i64 {
self.0 as i64
}
pub fn to_i64(&self) -> i64 {
self.0 as i64
}
pub fn abs(&self) -> Quantity {
Quantity(self.0)
}
pub fn add(&self, other: &Quantity) -> Quantity {
Quantity(self.0.saturating_add(other.0))
}
}
impl TestVolume {
pub fn new(value: u64) -> Self {
TestVolume(value)
}
pub fn to_u64(&self) -> u64 {
self.0
}
}
#[derive(Debug, Clone, PartialEq)]
/// Position component.
pub struct Position {
pub symbol: String,
pub side: Side,
pub quantity: Quantity,
pub average_price: TestPrice,
pub current_price: TestPrice,
pub unrealized_pnl: Amount,
}
impl Position {
pub fn new(symbol: String, side: Side, quantity: Quantity, average_price: TestPrice) -> Self {
Self {
symbol,
side,
quantity,
average_price,
current_price: average_price.clone(),
unrealized_pnl: Amount(0),
}
}
pub fn calculate_unrealized_pnl(&mut self, current_price: &TestPrice) -> f64 {
self.current_price = current_price.clone();
let price_diff = match self.side {
Side::Buy => current_price.0 as f64 - self.average_price.0 as f64,
Side::Sell => self.average_price.0 as f64 - current_price.0 as f64,
};
let pnl = price_diff * self.quantity.0 as f64;
self.unrealized_pnl = Amount(pnl as i64);
pnl
}
pub fn notional_value(&self) -> f64 {
(self.average_price.0 as f64) * (self.quantity.0 as f64)
}
}
// ============================================================================
// Property-Based Test Strategies
// ============================================================================
/// Strategy for generating valid prices (positive, finite)
fn valid_price_strategy() -> impl Strategy<Value = f64> {
(0.0001f64..1_000_000.0f64)
}
/// Strategy for generating extreme but valid prices
fn extreme_price_strategy() -> impl Strategy<Value = f64> {
prop_oneof![
Just(f64::MIN_POSITIVE),
Just(f64::MAX),
(0.0001f64..0.01f64), // Very small prices
(100_000.0f64..1_000_000.0f64), // Very large prices
]
}
/// Strategy for generating quantities with edge cases
fn quantity_strategy() -> impl Strategy<Value = i64> {
prop_oneof![
Just(i64::MIN),
Just(i64::MAX),
Just(0),
Just(1),
Just(-1),
(-1_000_000i64..1_000_000i64),
]
}
/// Strategy for generating large quantities for stress testing
fn large_quantity_strategy() -> impl Strategy<Value = i64> {
prop_oneof![
(1_000_000i64..i64::MAX / 2),
(i64::MIN / 2..-1_000_000i64),
]
}
// ============================================================================
// Price Arithmetic Properties
// ============================================================================
proptest! {
/// Test that `price` addition is commutative: a + b = b + a
#[test]
fn price_addition_commutative(
a in valid_price_strategy(),
b in valid_price_strategy()
) {
let price_a = TestPrice::from_f64(a).expect("Valid price");
let price_b = TestPrice::from_f64(b).expect("Valid price");
let sum_ab = price_a.add(&price_b);
let sum_ba = price_b.add(&price_a);
prop_assert_eq!(sum_ab, sum_ba);
}
/// Test that `price` addition is associative: (a + b) + c = a + (b + c)
#[test]
fn price_addition_associative(
a in valid_price_strategy(),
b in valid_price_strategy(),
c in valid_price_strategy()
) {
let price_a = TestPrice::from_f64(a).expect("Valid price");
let price_b = TestPrice::from_f64(b).expect("Valid price");
let price_c = TestPrice::from_f64(c).expect("Valid price");
let left = price_a.add(&price_b).add(&price_c);
let right = price_a.add(&price_b.add(&price_c));
// Allow small floating point differences
let diff = (left.to_f64() - right.to_f64()).abs();
prop_assert!(diff < 1e-10);
}
/// Test that `price` remains non-negative under all operations
#[test]
fn price_always_non_negative(
a in any::<f64>(),
b in any::<f64>()
) {
let price_a = TestPrice::from_f64(a).expect("Valid price");
let price_b = TestPrice::from_f64(b).expect("Valid price");
prop_assert!(price_a.to_f64() >= 0.0);
prop_assert!(price_b.to_f64() >= 0.0);
let sum = price_a.add(&price_b);
prop_assert!(sum.to_f64() >= 0.0);
let product = price_a.multiply(2.0);
prop_assert!(product.to_f64() >= 0.0);
}
/// Test `price` multiplication properties
#[test]
fn price_multiplication_properties(
price in valid_price_strategy(),
factor in -1000.0f64..1000.0f64
) {
let p = TestPrice::from_f64(price).expect("Valid price");
let result = p.multiply(factor);
// Result should always be non-negative due to clamping
prop_assert!(result.to_f64() >= 0.0);
// If factor is positive, result should be factor * price (or 0 if negative)
if factor >= 0.0 {
let expected = price * factor;
let diff = (result.to_f64() - expected).abs();
prop_assert!(diff < 1e-10 || expected < 0.0); // Account for clamping
}
// Multiplication by 0 should give 0
let zero_result = p.multiply(0.0);
prop_assert_eq!(zero_result.to_f64(), 0.0);
// Multiplication by 1 should give original price
let identity_result = p.multiply(1.0);
let diff = (identity_result.to_f64() - price).abs();
prop_assert!(diff < 1e-10);
}
/// Test percentage change calculations
#[test]
fn price_percentage_change_properties(
old_price in valid_price_strategy(),
new_price in valid_price_strategy()
) {
let old_p = TestPrice::from_f64(old_price).expect("Valid price");
let new_p = TestPrice::from_f64(new_price).expect("Valid price");
let pct_change = new_p.percentage_change(&old_p);
// Percentage change should be finite unless old_price is 0
if old_price > f64::EPSILON {
prop_assert!(pct_change.is_finite());
// Verify the calculation: new = old * (1 + pct_change)
let expected_new = old_price * (1.0 + pct_change);
let diff = (new_price - expected_new).abs();
prop_assert!(diff < 1e-10);
}
// When prices are equal, percentage change should be 0
let zero_change = old_p.percentage_change(&old_p);
prop_assert!(zero_change.abs() < 1e-10);
}
}
// ============================================================================
// Quantity Arithmetic Properties
// ============================================================================
proptest! {
/// Test `quantity` addition with overflow protection
#[test]
fn quantity_addition_overflow_safe(
a in quantity_strategy(),
b in quantity_strategy()
) {
let qty_a = Quantity::new(a);
let qty_b = Quantity::new(b);
let sum = qty_a.add(&qty_b);
// Addition should never panic (uses saturating_add)
prop_assert!(sum.to_i64() >= i64::MIN);
prop_assert!(sum.to_i64() <= i64::MAX);
// If no overflow would occur, result should be exact
if let Some(expected) = a.checked_add(b) {
prop_assert_eq!(sum.to_i64(), expected);
} else {
// Overflow occurred, result should be saturated
if (a > 0 && b > 0) || (a > 0 && b > 0) {
prop_assert_eq!(sum.to_i64(), i64::MAX);
} else if a < 0 && b < 0 {
prop_assert_eq!(sum.to_i64(), i64::MIN);
}
}
}
/// Test `quantity` absolute value properties
#[test]
fn quantity_absolute_value_properties(
value in quantity_strategy()
) {
let qty = Quantity::new(value);
let abs_qty = qty.abs();
// Absolute value should always be non-negative
prop_assert!(abs_qty.to_i64() >= 0);
// |x| = x if x >= 0, -x if x < 0
if value >= 0 {
prop_assert_eq!(abs_qty.to_i64(), value);
} else if value > i64::MIN { // Avoid overflow on MIN
prop_assert_eq!(abs_qty.to_i64(), -value);
}
// ||x|| = |x| (idempotent)
prop_assert_eq!(abs_qty.abs().to_i64(), abs_qty.to_i64());
}
/// Test `quantity` edge cases
#[test]
fn quantity_edge_cases(
value in prop_oneof![Just(i64::MIN), Just(i64::MAX), Just(0)]
) {
let qty = Quantity::new(value);
// Should handle extreme values without panicking
prop_assert_eq!(qty.to_i64(), value);
// Test operations don't panic
let _abs = qty.abs();
let _sum = qty.add(&Quantity::new(0));
}
}
// ============================================================================
// Position P&L Calculation Properties
// ============================================================================
proptest! {
/// Test `P`&`L` calculation properties for buy positions
#[test]
fn buy_position_pnl_properties(
entry_price in valid_price_strategy(),
current_price in valid_price_strategy(),
quantity in 1i64..1_000_000i64 // Positive quantities for buy positions
) {
let mut position = Position::new(
"EURUSD".to_string(),
Side::Buy,
Quantity::new(quantity),
TestPrice::from_f64(entry_price).expect("Valid price")
);
let pnl = position.calculate_unrealized_pnl(&TestPrice::from_f64(current_price).expect("Valid price"));
// P&L should equal (current_price - entry_price) * quantity
let expected_pnl = (current_price - entry_price) * quantity as f64;
let diff = (pnl - expected_pnl).abs();
prop_assert!(diff < 1e-10);
// If current price > entry price, P&L should be positive
if current_price > entry_price {
prop_assert!(pnl > 0.0);
}
// If current price < entry price, P&L should be negative
if current_price < entry_price {
prop_assert!(pnl < 0.0);
}
// If prices are equal, P&L should be zero
if (current_price - entry_price).abs() < f64::EPSILON {
prop_assert!(pnl.abs() < 1e-10);
}
}
/// Test `P`&`L` calculation properties for sell positions
#[test]
fn sell_position_pnl_properties(
entry_price in valid_price_strategy(),
current_price in valid_price_strategy(),
quantity in 1i64..1_000_000i64 // Positive quantities for sell positions
) {
let mut position = Position::new(
"EURUSD".to_string(),
Side::Sell,
Quantity::new(quantity),
TestPrice::from_f64(entry_price).expect("Valid price")
);
let pnl = position.calculate_unrealized_pnl(&TestPrice::from_f64(current_price).expect("Valid price"));
// P&L should equal (entry_price - current_price) * quantity
let expected_pnl = (entry_price - current_price) * quantity as f64;
let diff = (pnl - expected_pnl).abs();
prop_assert!(diff < 1e-10);
// If current price < entry price, P&L should be positive
if current_price < entry_price {
prop_assert!(pnl > 0.0);
}
// If current price > entry price, P&L should be negative
if current_price > entry_price {
prop_assert!(pnl < 0.0);
}
// If prices are equal, P&L should be zero
if (current_price - entry_price).abs() < f64::EPSILON {
prop_assert!(pnl.abs() < 1e-10);
}
}
/// Test position notional value calculation
#[test]
fn position_notional_value_properties(
price in valid_price_strategy(),
quantity in quantity_strategy()
) {
let position = Position::new(
"EURUSD".to_string(),
Side::Buy,
Quantity::new(quantity),
TestPrice::from_f64(price).expect("Valid price")
);
let notional = position.notional_value();
// Notional should equal price * |quantity|
let expected = price * quantity.abs() as f64;
let diff = (notional - expected).abs();
prop_assert!(diff < 1e-10);
// Notional should always be non-negative
prop_assert!(notional >= 0.0);
// Notional should be zero if price or quantity is zero
if price == 0.0 || quantity == 0 {
prop_assert_eq!(notional, 0.0);
}
}
/// Test large position `P`&`L` calculations don't overflow
#[test]
fn large_position_pnl_no_overflow(
entry_price in valid_price_strategy(),
current_price in valid_price_strategy(),
quantity in large_quantity_strategy()
) {
let mut position = Position::new(
"EURUSD".to_string(),
Side::Buy,
Quantity::new(quantity),
TestPrice::from_f64(entry_price).expect("Valid price")
);
let pnl = position.calculate_unrealized_pnl(&TestPrice::from_f64(current_price).expect("Valid price"));
// P&L calculation should not overflow to infinity
prop_assert!(pnl.is_finite());
// Large quantities should still produce mathematically correct results
// within floating point precision limits
if quantity.abs() < 1_000_000 {
let expected_pnl = (current_price - entry_price) * quantity as f64;
let relative_error = if expected_pnl != 0.0 {
((pnl - expected_pnl) / expected_pnl).abs()
} else {
pnl.abs()
};
prop_assert!(relative_error < 1e-10);
}
}
}
// ============================================================================
// Edge Case and Boundary Condition Tests
// ============================================================================
proptest! {
/// Test behavior with extreme `price` values
#[test]
fn extreme_price_handling(
price in extreme_price_strategy()
) {
let p = TestPrice::from_f64(price).expect("Valid price");
// Should handle extreme values gracefully
prop_assert!(p.to_f64().is_finite());
prop_assert!(p.to_f64() >= 0.0);
// Operations should remain stable
let doubled = p.multiply(2.0);
prop_assert!(doubled.to_f64().is_finite());
let added = p.add(&TestPrice::from_f64(1.0).expect("Valid price"));
prop_assert!(added.to_f64().is_finite());
}
/// Test precision preservation in financial calculations
#[test]
fn financial_precision_preservation(
base_price in 1.0f64..2.0f64,
pip_count in 1u32..10000u32
) {
let pip_value = 0.0001; // Standard pip for EUR/USD
let price_movement = pip_count as f64 * pip_value;
let entry_price = TestPrice::from_f64(base_price).expect("Valid price");
let exit_price = TestPrice::from_f64(base_price + price_movement).expect("Valid price");
// Calculate P&L for a standard lot (100,000 units)
let mut position = Position::new(
"EURUSD".to_string(),
Side::Buy,
Quantity::new(100_000),
entry_price
);
let pnl = position.calculate_unrealized_pnl(&exit_price);
// P&L should be close to pip_count * 10 USD (for EUR/USD)
let expected_pnl_usd = pip_count as f64 * 10.0;
let error = (pnl - expected_pnl_usd).abs();
// Allow small error due to floating point arithmetic
prop_assert!(error < 0.01, "P&L error too large: {} vs {}", pnl, expected_pnl_usd);
}
/// Test handling of special floating point values
#[test]
fn special_float_value_handling(
special_value in prop_oneof![
Just(NAN),
Just(INFINITY),
Just(NEG_INFINITY),
Just(f64::MIN),
Just(f64::MAX),
Just(f64::MIN_POSITIVE)
]
) {
let price = TestPrice::from_f64(special_value).expect("Valid price");
// Should convert special values to safe values
prop_assert!(price.to_f64().is_finite());
prop_assert!(price.to_f64() >= 0.0);
// Operations should not propagate special values
let result = price.multiply(1.5);
prop_assert!(result.to_f64().is_finite());
prop_assert!(result.to_f64() >= 0.0);
}
/// Test compound operations maintain precision
#[test]
fn compound_operations_precision(
prices in prop::collection::vec(valid_price_strategy(), 1..100)
) {
// Sum all prices
let mut total = TestPrice::from_f64(0.0).expect("Valid price");
let mut expected_sum = 0.0;
for price_val in &prices {
let price = TestPrice::from_f64(*price_val).expect("Valid price");
total = total.add(&price);
expected_sum += price_val;
}
// Precision should be maintained within reasonable bounds
let error = (total.to_f64() - expected_sum).abs();
let relative_error = if expected_sum > 0.0 {
error / expected_sum
} else {
error
};
// Allow small accumulation of floating point errors
prop_assert!(relative_error < 1e-12);
}
}
// ============================================================================
// Integration Tests for Financial Operations
// ============================================================================
#[tokio::test]
async fn test_realistic_trading_scenario_precision() {
// Test a realistic EUR/USD trading scenario
let entry_price = TestPrice::from_f64(1.1000).expect("Valid price");
let position_size = Quantity::new(100_000); // Standard lot
let mut position = Position::new(
"EURUSD".to_string(),
Side::Buy,
position_size,
entry_price.clone()
);
// Simulate market movements of 1, 5, 10, 20 pips
let pip_movements = vec![1, 5, 10, 20];
for pips in pip_movements {
let current_price = TestPrice::from_f64(1.1000 + pips as f64 * 0.0001).expect("Valid price");
let pnl = position.calculate_unrealized_pnl(&current_price);
// Each pip should be worth approximately $10 for EUR/USD standard lot
let expected_pnl = pips as f64 * 10.0;
let error = (pnl - expected_pnl).abs();
assert!(error < 0.01, "P&L error for {} pips: {} vs {}", pips, pnl, expected_pnl);
}
}
#[tokio::test]
async fn test_portfolio_level_precision() {
// Test precision when managing multiple positions
let symbols = vec!["EURUSD", "GBPUSD", "USDJPY", "USDCHF", "AUDUSD"];
let mut positions = Vec::new();
let mut total_pnl = 0.0;
for (i, symbol) in symbols.into_iter().enumerate() {
let entry_price = TestPrice::from_f64(1.0 + i as f64 * 0.1).expect("Valid price");
let current_price = TestPrice::from_f64(1.0 + i as f64 * 0.1 + 0.01).expect("Valid price"); // 100 pip profit each
let quantity = Quantity::new(10_000 * (i as i64 + 1)); // Different sizes
let mut position = Position::new(
symbol.to_string(),
Side::Buy,
quantity,
entry_price
);
let pnl = position.calculate_unrealized_pnl(&current_price);
total_pnl += pnl;
positions.push(position);
}
// Total P&L should be the sum of individual P&Ls
let manual_total = positions.iter()
.map(|p| p.unrealized_pnl.0 as f64)
.sum::<f64>();
let error = (total_pnl - manual_total).abs();
assert!(error < 1e-10, "Portfolio P&L calculation error: {}", error);
}
#[test]
fn test_stress_financial_calculations() {
// Stress test with many small operations
let base_price = TestPrice::from_f64(1.0).expect("Valid price");
let increment = TestPrice::from_f64(0.00001).expect("Valid price"); // Half pip
let mut accumulated = base_price.clone();
// Perform 100,000 small additions
for _ in 0..100_000 {
accumulated = accumulated.add(&increment);
}
// Final price should be approximately 1.0 + 100,000 * 0.00001 = 2.0
let expected = 2.0;
let error = (accumulated.to_f64() - expected).abs();
// Allow some accumulation of floating point errors
assert!(error < 1e-8, "Stress test precision error: {}", error);
}
} // end mod tests