//! Integration Test - Kelly Criterion + Regime Detection //! //! End-to-end integration test for Kelly allocation with regime multipliers. //! Validates that regime-adaptive position sizing works correctly through //! the full allocation pipeline. //! //! AGENT IMPL-20: Integration Test - Kelly Criterion + Regime Detection //! //! Test Coverage: //! 1. Kelly allocation adapts to regime multipliers //! 2. Regime change triggers reallocation //! 3. Kelly falls back on missing regime data //! 4. Crisis regime limits position sizes //! 5. Allocation respects max 20% cap per asset //! 6. Database persistence and retrieval //! 7. Performance targets (<500ms allocation) use anyhow::Result; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use serial_test::serial; use sqlx::PgPool; use std::collections::HashMap; use std::time::Instant; use trading_agent_service::allocation::{AllocationMethod, AssetInfo, PortfolioAllocator}; use trading_agent_service::regime::{ get_regime_for_symbol, get_regimes_for_symbols, regime_to_position_multiplier, regime_to_stoploss_multiplier, }; // ============================================================================ // Test Setup Helpers // ============================================================================ /// Setup test database with migrations async fn setup_test_db() -> PgPool { let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { "postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string() }); let pool = PgPool::connect(&database_url) .await .expect("Failed to connect to database"); // Run migrations (includes migration 045 for regime_states) sqlx::migrate!("../../migrations") .run(&pool) .await .expect("Failed to run migrations"); pool } /// Insert regime state into database async fn insert_regime_state( pool: &PgPool, symbol: &str, regime: &str, confidence: f64, ) -> Result<()> { // Add small delay to ensure unique timestamps tokio::time::sleep(tokio::time::Duration::from_millis(2)).await; sqlx::query( r#" INSERT INTO regime_states (symbol, event_timestamp, regime, confidence) VALUES ($1, NOW(), $2, $3) ON CONFLICT (symbol, event_timestamp) DO UPDATE SET regime = EXCLUDED.regime, confidence = EXCLUDED.confidence "#, ) .bind(symbol) .bind(regime) .bind(confidence) .execute(pool) .await?; Ok(()) } /// Update regime state in database async fn update_regime_state( pool: &PgPool, symbol: &str, regime: &str, confidence: f64, ) -> Result<()> { // Delete old regime state sqlx::query("DELETE FROM regime_states WHERE symbol = $1") .bind(symbol) .execute(pool) .await?; // Add 1 millisecond delay to ensure different timestamp tokio::time::sleep(tokio::time::Duration::from_millis(1)).await; // Insert new regime state insert_regime_state(pool, symbol, regime, confidence).await } /// Clean up regime states for testing async fn cleanup_regime_states(pool: &PgPool) -> Result<()> { sqlx::query("DELETE FROM regime_states") .execute(pool) .await?; Ok(()) } /// Create test asset info with Kelly parameters fn create_test_asset( symbol: &str, expected_return: f64, volatility: f64, win_rate: f64, avg_win: f64, avg_loss: f64, ) -> AssetInfo { AssetInfo { symbol: symbol.to_string(), expected_return, volatility, ml_score: 0.65, // Placeholder win_rate, avg_win, avg_loss, } } // ============================================================================ // TEST CATEGORY 1: Kelly Allocation with Regime Multipliers // ============================================================================ #[tokio::test] #[serial] async fn test_kelly_allocation_adapts_to_regime() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); // Setup: Insert regime states // ES.FUT: Trending (1.5x position multiplier) // NQ.FUT: Crisis (0.2x position multiplier) insert_regime_state(&pool, "ES.FUT", "Trending", 0.85) .await .unwrap(); insert_regime_state(&pool, "NQ.FUT", "Crisis", 0.92) .await .unwrap(); // Create test assets with similar Kelly fractions let assets = vec![ create_test_asset( "ES.FUT", 0.10, // 10% expected return 0.15, // 15% volatility 0.55, // 55% win rate 150.0, // $150 avg win 100.0, // $100 avg loss ), create_test_asset( "NQ.FUT", 0.12, // 12% expected return 0.20, // 20% volatility 0.55, // 55% win rate (same as ES) 150.0, // $150 avg win (same as ES) 100.0, // $100 avg loss (same as ES) ), ]; // Allocate capital using Kelly Criterion (quarter Kelly = 0.25) let allocator = PortfolioAllocator::new(AllocationMethod::KellyCriterion { fraction: 0.25 }); let total_capital = Decimal::from(100_000); let start = Instant::now(); let base_allocation = allocator.allocate(&assets, total_capital).unwrap(); let allocation_duration = start.elapsed(); // Retrieve regime states let es_regime = get_regime_for_symbol(&pool, "ES.FUT").await.unwrap(); let nq_regime = get_regime_for_symbol(&pool, "NQ.FUT").await.unwrap(); // Apply regime multipliers to base allocation let mut regime_adjusted_allocation = HashMap::new(); for (symbol, capital) in &base_allocation { let regime = if symbol == "ES.FUT" { &es_regime } else if symbol == "NQ.FUT" { &nq_regime } else { panic!("Unexpected symbol: {}", symbol); }; let multiplier = regime_to_position_multiplier(®ime.regime); let adjusted_capital = *capital * Decimal::from_f64_retain(multiplier).unwrap(); regime_adjusted_allocation.insert(symbol.clone(), adjusted_capital); } // Normalize to ensure total doesn't exceed 100% let total_adjusted: Decimal = regime_adjusted_allocation.values().sum(); if total_adjusted > total_capital { for capital in regime_adjusted_allocation.values_mut() { *capital = (*capital / total_adjusted) * total_capital; } } // Verify ES (Trending, 1.5x) gets MORE capital than NQ (Crisis, 0.2x) let es_alloc = regime_adjusted_allocation.get("ES.FUT").unwrap(); let nq_alloc = regime_adjusted_allocation.get("NQ.FUT").unwrap(); assert!( *es_alloc > *nq_alloc * Decimal::from(5), "ES (Trending 1.5x) should get >5x capital of NQ (Crisis 0.2x). ES: {}, NQ: {}", es_alloc, nq_alloc ); // Verify total capital allocated is LESS than total capital when regime multipliers reduce positions // (ES: 1.5x Trending, NQ: 0.2x Crisis means overall reduction) let total: Decimal = regime_adjusted_allocation.values().sum(); assert!( total <= total_capital, "Total allocation {} should not exceed total capital {}", total, total_capital ); // Verify total is significantly reduced due to Crisis regime (should be < 20% of capital) assert!( total < total_capital * Decimal::from_f64_retain(0.20).unwrap(), "Total allocation {} should be <20% of capital {} due to Crisis regime (0.2x multiplier)", total, total_capital ); // Verify regime multipliers assert_eq!( regime_to_position_multiplier(&es_regime.regime), 1.5, "Trending regime should have 1.5x multiplier" ); assert_eq!( regime_to_position_multiplier(&nq_regime.regime), 0.2, "Crisis regime should have 0.2x multiplier" ); // Verify performance target (<500ms) assert!( allocation_duration.as_millis() < 500, "Allocation took {}ms (target: <500ms)", allocation_duration.as_millis() ); println!( "✓ Kelly allocation with regime multipliers completed in {}ms", allocation_duration.as_millis() ); println!(" ES.FUT (Trending 1.5x): ${}", es_alloc); println!(" NQ.FUT (Crisis 0.2x): ${}", nq_alloc); cleanup_regime_states(&pool).await.unwrap(); } // ============================================================================ // TEST CATEGORY 2: Regime Change Triggers Reallocation // ============================================================================ #[tokio::test] #[serial] async fn test_regime_change_triggers_reallocation() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); // Initial state: ES.FUT in Normal regime (1.0x) insert_regime_state(&pool, "ES.FUT", "Normal", 0.80) .await .unwrap(); let asset = create_test_asset("ES.FUT", 0.10, 0.15, 0.55, 150.0, 100.0); let allocator = PortfolioAllocator::new(AllocationMethod::KellyCriterion { fraction: 0.25 }); let total_capital = Decimal::from(100_000); // Initial allocation let initial_allocation = allocator.allocate(&[asset.clone()], total_capital).unwrap(); let initial_capital = initial_allocation.get("ES.FUT").unwrap(); let initial_regime = get_regime_for_symbol(&pool, "ES.FUT").await.unwrap(); let initial_multiplier = regime_to_position_multiplier(&initial_regime.regime); // Change regime to Trending (1.5x) update_regime_state(&pool, "ES.FUT", "Trending", 0.85) .await .unwrap(); // Reallocation after regime change let new_allocation = allocator.allocate(&[asset], total_capital).unwrap(); let new_capital_base = new_allocation.get("ES.FUT").unwrap(); let new_regime = get_regime_for_symbol(&pool, "ES.FUT").await.unwrap(); let new_multiplier = regime_to_position_multiplier(&new_regime.regime); // Apply regime multipliers let initial_adjusted = *initial_capital * Decimal::from_f64_retain(initial_multiplier).unwrap(); let new_adjusted = *new_capital_base * Decimal::from_f64_retain(new_multiplier).unwrap(); // Verify allocation increased due to regime change (Normal 1.0x → Trending 1.5x) assert!( new_adjusted > initial_adjusted, "Allocation should increase when regime changes from Normal (1.0x) to Trending (1.5x)" ); // Verify multiplier change assert_eq!(initial_multiplier, 1.0); assert_eq!(new_multiplier, 1.5); println!("✓ Regime change from Normal to Trending triggered reallocation"); println!( " Initial (Normal 1.0x): ${}", initial_adjusted.round_dp(2) ); println!(" New (Trending 1.5x): ${}", new_adjusted.round_dp(2)); println!( " Increase: {:.1}%", ((new_adjusted - initial_adjusted) / initial_adjusted * Decimal::from(100)) .to_f64() .unwrap() ); cleanup_regime_states(&pool).await.unwrap(); } // ============================================================================ // TEST CATEGORY 3: Fallback on Missing Regime // ============================================================================ #[tokio::test] #[serial] async fn test_kelly_falls_back_on_missing_regime() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); // Do NOT insert regime state for ZN.FUT (missing regime) let asset = create_test_asset("ZN.FUT", 0.08, 0.12, 0.53, 100.0, 90.0); let allocator = PortfolioAllocator::new(AllocationMethod::KellyCriterion { fraction: 0.25 }); let total_capital = Decimal::from(100_000); // Allocation should still work (fallback to Normal regime) let allocation = allocator.allocate(&[asset], total_capital).unwrap(); let allocated_capital = allocation.get("ZN.FUT").unwrap(); // Attempt to get regime (should fail) let regime_result = get_regime_for_symbol(&pool, "ZN.FUT").await; assert!( regime_result.is_err(), "Should not have regime data for ZN.FUT" ); // Fallback to Normal regime (1.0x multiplier) let fallback_multiplier = regime_to_position_multiplier("Normal"); assert_eq!(fallback_multiplier, 1.0); // Verify allocation succeeded with fallback assert!( *allocated_capital > Decimal::ZERO, "Should allocate capital even without regime data" ); assert!( *allocated_capital <= total_capital, "Should not exceed total capital" ); println!("✓ Kelly allocation succeeded with missing regime (fallback to Normal 1.0x)"); println!(" ZN.FUT (fallback): ${}", allocated_capital); cleanup_regime_states(&pool).await.unwrap(); } // ============================================================================ // TEST CATEGORY 4: Crisis Regime Limits Position Sizes // ============================================================================ #[tokio::test] #[serial] async fn test_crisis_regime_limits_position_sizes() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); // Setup: 3 assets, all in Crisis regime (0.2x) let symbols = vec!["ES.FUT", "NQ.FUT", "6E.FUT"]; for symbol in &symbols { insert_regime_state(&pool, symbol, "Crisis", 0.90) .await .unwrap(); } let assets = vec![ create_test_asset("ES.FUT", 0.10, 0.15, 0.55, 150.0, 100.0), create_test_asset("NQ.FUT", 0.12, 0.20, 0.52, 200.0, 120.0), create_test_asset("6E.FUT", 0.08, 0.12, 0.53, 80.0, 70.0), ]; let allocator = PortfolioAllocator::new(AllocationMethod::KellyCriterion { fraction: 0.25 }); let total_capital = Decimal::from(100_000); // Base allocation let base_allocation = allocator.allocate(&assets, total_capital).unwrap(); // Apply Crisis regime multiplier (0.2x) let crisis_multiplier = regime_to_position_multiplier("Crisis"); assert_eq!(crisis_multiplier, 0.2); let mut total_crisis_capital = Decimal::ZERO; for (symbol, capital) in &base_allocation { let adjusted = *capital * Decimal::from_f64_retain(crisis_multiplier).unwrap(); total_crisis_capital += adjusted; println!(" {} (Crisis 0.2x): ${}", symbol, adjusted); } // Verify total allocation is severely reduced (should be ~20% of normal) let max_expected = total_capital * Decimal::from_f64_retain(0.3).unwrap(); // 30% max assert!( total_crisis_capital < max_expected, "Crisis regime should severely limit total allocation. Total: {}, Max: {}", total_crisis_capital, max_expected ); println!("✓ Crisis regime limits position sizes to 20%"); println!( " Total crisis allocation: ${} ({:.1}% of capital)", total_crisis_capital, (total_crisis_capital / total_capital * Decimal::from(100)) .to_f64() .unwrap() ); cleanup_regime_states(&pool).await.unwrap(); } // ============================================================================ // TEST CATEGORY 5: Allocation Respects Max 20% Cap // ============================================================================ #[tokio::test] #[serial] async fn test_allocation_respects_max_20_percent_cap() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); // Setup: Single asset with very high win rate (would exceed 20% without cap) insert_regime_state(&pool, "ES.FUT", "Trending", 0.90) .await .unwrap(); let asset = create_test_asset( "ES.FUT", 0.25, // 25% expected return (very high) 0.15, // 15% volatility 0.75, // 75% win rate (very high) 500.0, // $500 avg win 100.0, // $100 avg loss ); let allocator = PortfolioAllocator::new(AllocationMethod::KellyCriterion { fraction: 1.0 }); // Use full Kelly (fraction=1.0) to test cap let total_capital = Decimal::from(100_000); let allocation = allocator.allocate(&[asset], total_capital).unwrap(); let allocated_capital = allocation.get("ES.FUT").unwrap(); // Calculate weight let weight = *allocated_capital / total_capital; // Verify weight does NOT exceed 20% (even with very favorable Kelly parameters) assert!( weight <= Decimal::from_f64_retain(0.20).unwrap(), "Weight {} exceeds max 20% cap", weight ); println!("✓ Kelly allocation respects max 20% position size cap"); println!(" ES.FUT weight: {:.1}%", (weight * Decimal::from(100)).to_f64().unwrap()); println!(" Allocated: ${}", allocated_capital); cleanup_regime_states(&pool).await.unwrap(); } // ============================================================================ // TEST CATEGORY 6: Multi-Symbol Regime Retrieval // ============================================================================ #[tokio::test] #[serial] async fn test_multi_symbol_regime_retrieval() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); // Setup: Multiple assets with different regimes insert_regime_state(&pool, "ES.FUT", "Trending", 0.85) .await .unwrap(); insert_regime_state(&pool, "NQ.FUT", "Volatile", 0.78) .await .unwrap(); insert_regime_state(&pool, "ZN.FUT", "Normal", 0.90) .await .unwrap(); // Batch retrieve regimes let symbols = vec!["ES.FUT", "NQ.FUT", "ZN.FUT"]; let start = Instant::now(); let regimes = get_regimes_for_symbols(&pool, &symbols).await.unwrap(); let retrieval_duration = start.elapsed(); // Verify all regimes retrieved assert_eq!(regimes.len(), 3); let es_regime = regimes.iter().find(|r| r.symbol == "ES.FUT").unwrap(); let nq_regime = regimes.iter().find(|r| r.symbol == "NQ.FUT").unwrap(); let zn_regime = regimes.iter().find(|r| r.symbol == "ZN.FUT").unwrap(); assert_eq!(es_regime.regime, "Trending"); assert_eq!(nq_regime.regime, "Volatile"); assert_eq!(zn_regime.regime, "Normal"); // Verify confidence values assert_eq!(es_regime.confidence, 0.85); assert_eq!(nq_regime.confidence, 0.78); assert_eq!(zn_regime.confidence, 0.90); // Verify multipliers assert_eq!(regime_to_position_multiplier(&es_regime.regime), 1.5); assert_eq!(regime_to_position_multiplier(&nq_regime.regime), 0.5); assert_eq!(regime_to_position_multiplier(&zn_regime.regime), 1.0); // Performance target: Batch retrieval <100ms assert!( retrieval_duration.as_millis() < 100, "Batch regime retrieval took {}ms (target: <100ms)", retrieval_duration.as_millis() ); println!( "✓ Multi-symbol regime retrieval completed in {}ms", retrieval_duration.as_millis() ); println!(" ES.FUT: {} (conf: {:.2})", es_regime.regime, es_regime.confidence); println!(" NQ.FUT: {} (conf: {:.2})", nq_regime.regime, nq_regime.confidence); println!(" ZN.FUT: {} (conf: {:.2})", zn_regime.regime, zn_regime.confidence); cleanup_regime_states(&pool).await.unwrap(); } // ============================================================================ // TEST CATEGORY 7: Stop-Loss Multipliers // ============================================================================ #[tokio::test] #[serial] async fn test_regime_stoploss_multipliers() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); // Setup: Different regimes for stop-loss validation insert_regime_state(&pool, "ES.FUT", "Ranging", 0.85) .await .unwrap(); insert_regime_state(&pool, "NQ.FUT", "Crisis", 0.90) .await .unwrap(); let es_regime = get_regime_for_symbol(&pool, "ES.FUT").await.unwrap(); let nq_regime = get_regime_for_symbol(&pool, "NQ.FUT").await.unwrap(); let es_stop_mult = regime_to_stoploss_multiplier(&es_regime.regime); let nq_stop_mult = regime_to_stoploss_multiplier(&nq_regime.regime); // Verify stop-loss multipliers assert_eq!( es_stop_mult, 1.5, "Ranging regime should have 1.5x stop-loss (tight stops)" ); assert_eq!( nq_stop_mult, 4.0, "Crisis regime should have 4.0x stop-loss (wide stops)" ); // Crisis should have wider stops than Ranging assert!( nq_stop_mult > es_stop_mult, "Crisis stops ({}) should be wider than Ranging stops ({})", nq_stop_mult, es_stop_mult ); println!("✓ Regime-specific stop-loss multipliers validated"); println!(" ES.FUT (Ranging): {:.1}x ATR", es_stop_mult); println!(" NQ.FUT (Crisis): {:.1}x ATR", nq_stop_mult); cleanup_regime_states(&pool).await.unwrap(); } // ============================================================================ // TEST CATEGORY 8: Performance Benchmarks // ============================================================================ #[tokio::test] #[serial] async fn test_allocation_performance_50_assets() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); // Setup: 50 assets with various regimes let mut assets = Vec::new(); for i in 0..50 { let symbol = format!("ASSET_{}", i); let regime = match i % 5 { 0 => "Trending", 1 => "Normal", 2 => "Volatile", 3 => "Ranging", _ => "Crisis", }; insert_regime_state(&pool, &symbol, regime, 0.80 + (i as f64 * 0.002)) .await .unwrap(); assets.push(create_test_asset( &symbol, 0.08 + (i as f64 * 0.001), 0.12 + (i as f64 * 0.002), 0.50 + (i as f64 * 0.005), 100.0 + (i as f64 * 2.0), 80.0 + (i as f64 * 1.5), )); } let allocator = PortfolioAllocator::new(AllocationMethod::KellyCriterion { fraction: 0.25 }); let total_capital = Decimal::from(1_000_000); // $1M portfolio // Benchmark allocation let start = Instant::now(); let allocation = allocator.allocate(&assets, total_capital).unwrap(); let allocation_duration = start.elapsed(); // Verify allocation succeeded assert_eq!(allocation.len(), 50); // Performance target: <500ms for 50 assets assert!( allocation_duration.as_millis() < 500, "50-asset allocation took {}ms (target: <500ms)", allocation_duration.as_millis() ); // Verify total allocation let total: Decimal = allocation.values().sum(); assert!( total <= total_capital, "Total allocation {} exceeds capital {}", total, total_capital ); println!( "✓ 50-asset Kelly allocation completed in {}ms", allocation_duration.as_millis() ); println!( " Total allocated: ${} ({:.1}%)", total, (total / total_capital * Decimal::from(100)) .to_f64() .unwrap() ); cleanup_regime_states(&pool).await.unwrap(); } // ============================================================================ // TEST CATEGORY 9: Regime State Validation // ============================================================================ #[tokio::test] #[serial] async fn test_regime_state_persistence() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); // Insert regime with full metadata sqlx::query( r#" INSERT INTO regime_states ( symbol, event_timestamp, regime, confidence, cusum_s_plus, cusum_s_minus, cusum_alert_count, adx, plus_di, minus_di, stability, entropy ) VALUES ($1, NOW(), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) "#, ) .bind("ES.FUT") .bind("Trending") .bind(0.85) .bind(2.5) // cusum_s_plus .bind(-0.5) // cusum_s_minus .bind(3_i32) // cusum_alert_count .bind(35.0) // adx .bind(28.0) // plus_di .bind(15.0) // minus_di .bind(0.92) // stability .bind(0.15) // entropy .execute(&pool) .await .unwrap(); // Retrieve and validate let regime = get_regime_for_symbol(&pool, "ES.FUT").await.unwrap(); assert_eq!(regime.symbol, "ES.FUT"); assert_eq!(regime.regime, "Trending"); assert_eq!(regime.confidence, 0.85); assert_eq!(regime.adx, Some(35.0)); assert_eq!(regime.plus_di, Some(28.0)); assert_eq!(regime.minus_di, Some(15.0)); println!("✓ Regime state persistence validated"); println!(" Symbol: {}", regime.symbol); println!(" Regime: {}", regime.regime); println!(" Confidence: {:.2}", regime.confidence); println!(" ADX: {:.1}", regime.adx.unwrap()); cleanup_regime_states(&pool).await.unwrap(); }