//! Integration Test - Dynamic Stop-Loss with Regime Detection //! //! End-to-end integration test for dynamic stop-loss with regime-aware multipliers. //! Validates that stop-loss distances adjust correctly based on market regimes. //! //! AGENT IMPL-23: Integration Test - Dynamic Stop-Loss with Regime //! //! Test Coverage: //! 1. Stop-loss widens in volatile regime (1.5x → 3.0x ATR) //! 2. Stop-loss tightens in ranging regime (1.5x ATR) //! 3. Stop-loss maximizes in crisis regime (4.0x ATR) //! 4. Sell orders have stop-loss above entry price //! 5. Stop-loss prevents immediate trigger (>2% minimum distance) //! 6. ATR calculation uses 14-period default //! 7. Stop-loss persisted to database with metadata //! 8. Real-world validation with historical data use anyhow::Result; use common::{Order, OrderSide, OrderType, Price, Quantity, Symbol}; use rust_decimal::prelude::*; use rust_decimal::Decimal; use serial_test::serial; use serde_json::json; use sqlx::PgPool; use std::time::Instant; use trading_agent_service::dynamic_stop_loss::{ apply_dynamic_stop_loss, calculate_atr, get_regime_multiplier, OHLCBar, }; // ============================================================================ // 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"); // Note: Assumes migrations (including 045 for regime_states and 011 for market_data) // have already been applied to the database pool } /// Insert regime state into database async fn insert_regime_state( pool: &PgPool, symbol: &str, regime: &str, confidence: f64, ) -> Result<()> { 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?; // 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(()) } /// Clean up market data for testing async fn cleanup_market_data(pool: &PgPool, symbol: &str) -> Result<()> { sqlx::query("DELETE FROM prices WHERE symbol = $1") .bind(symbol) .execute(pool) .await?; Ok(()) } /// Insert market data bars into database async fn insert_market_data_bars(pool: &PgPool, symbol: &str, bars: &[OHLCBar]) -> Result<()> { for (i, bar) in bars.iter().enumerate() { // Convert f64 prices to BIGINT fixed-point (cents) let high_cents = (bar.high * 100.0) as i64; let low_cents = (bar.low * 100.0) as i64; let close_cents = (bar.close * 100.0) as i64; // Use sequential timestamps (1 minute apart) let timestamp = chrono::Utc::now() - chrono::Duration::minutes((bars.len() - i) as i64); sqlx::query( r#" INSERT INTO prices (symbol, timestamp, high, low, close, open, volume) VALUES ($1, $2, $3, $4, $5, $6, $7) "#, ) .bind(symbol) .bind(timestamp) .bind(high_cents) .bind(low_cents) .bind(close_cents) .bind(close_cents) // Use close as open for simplicity .bind(1000_i64) // Dummy volume .execute(pool) .await?; } Ok(()) } /// Generate test OHLC bars with specified ATR fn generate_test_bars_with_atr(atr: f64, num_bars: usize, base_price: f64) -> Vec { let mut bars = Vec::new(); let mut price = base_price; for _ in 0..num_bars { let high = price + atr * 0.5; let low = price - atr * 0.5; let close = price; bars.push(OHLCBar { high, low, close }); // Vary price slightly for next bar price += (rand::random::() - 0.5) * atr * 0.2; } bars } /// Create a test order for stop-loss application fn create_test_order(symbol: &str, side: OrderSide, entry_price: f64) -> Order { let symbol_obj: Symbol = symbol.into(); let quantity = Quantity::from_decimal(Decimal::from(10)).expect("Valid quantity"); let price = Price::from_f64(entry_price).ok(); let mut order = Order::new(symbol_obj, side, quantity, price, OrderType::Limit); // Add estimated price to metadata for market orders order.metadata = json!({ "estimated_price": entry_price, }); order } // ============================================================================ // TEST CATEGORY 1: Stop-Loss Widens in Volatile Regime // ============================================================================ #[tokio::test] #[serial] async fn test_stop_loss_widens_in_volatile_regime() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); cleanup_market_data(&pool, "ES.FUT").await.unwrap(); // 1. Setup: Ranging regime (1.5x ATR) insert_regime_state(&pool, "ES.FUT", "Ranging", 0.88) .await .unwrap(); // Use ATR = 60 points to meet >2% minimum (60 * 1.5 = 90 points = 2.25%) let atr = 60.0; // ATR = 60 points let bars = generate_test_bars_with_atr(atr, 20, 4000.0); insert_market_data_bars(&pool, "ES.FUT", &bars) .await .unwrap(); // 2. Generate order let order = create_test_order("ES.FUT", OrderSide::Buy, 4000.0); let order_with_stop = apply_dynamic_stop_loss(order.clone(), "ES.FUT", &pool) .await .unwrap(); // 3. Verify stop-loss in Ranging regime (1.5x ATR = 90 points = 2.25%) assert!(order_with_stop.stop_loss.is_some()); let stop_price: Decimal = order_with_stop.stop_loss.unwrap().into(); let stop_distance = 4000.0 - stop_price.to_f64().unwrap(); assert!( (stop_distance - 90.0).abs() < 5.0, "Ranging regime stop should be ~90 points (1.5 * 60), got {}", stop_distance ); // 4. Change to Volatile regime (3.0x ATR) cleanup_market_data(&pool, "ES.FUT").await.unwrap(); let bars2 = generate_test_bars_with_atr(atr, 20, 4000.0); insert_market_data_bars(&pool, "ES.FUT", &bars2) .await .unwrap(); update_regime_state(&pool, "ES.FUT", "Volatile", 0.93) .await .unwrap(); // 5. Generate new order let order2 = create_test_order("ES.FUT", OrderSide::Buy, 4000.0); let order2_with_stop = apply_dynamic_stop_loss(order2, "ES.FUT", &pool) .await .unwrap(); // 6. Verify stop-loss widened (3.0x ATR = 180 points = 4.5%) assert!(order2_with_stop.stop_loss.is_some()); let stop_price2: Decimal = order2_with_stop.stop_loss.unwrap().into(); let stop_distance2 = 4000.0 - stop_price2.to_f64().unwrap(); assert!( (stop_distance2 - 180.0).abs() < 5.0, "Volatile regime stop should be ~180 points (3.0 * 60), got {}", stop_distance2 ); // 7. Verify Crisis regime uses 4.0x (240 points = 6%) cleanup_market_data(&pool, "ES.FUT").await.unwrap(); let bars3 = generate_test_bars_with_atr(atr, 20, 4000.0); insert_market_data_bars(&pool, "ES.FUT", &bars3) .await .unwrap(); update_regime_state(&pool, "ES.FUT", "Crisis", 0.95) .await .unwrap(); let order3 = create_test_order("ES.FUT", OrderSide::Buy, 4000.0); let order3_with_stop = apply_dynamic_stop_loss(order3, "ES.FUT", &pool) .await .unwrap(); assert!(order3_with_stop.stop_loss.is_some()); let stop_price3: Decimal = order3_with_stop.stop_loss.unwrap().into(); let stop_distance3 = 4000.0 - stop_price3.to_f64().unwrap(); assert!( (stop_distance3 - 240.0).abs() < 5.0, "Crisis regime stop should be ~240 points (4.0 * 60), got {}", stop_distance3 ); println!("✓ Stop-loss widens correctly with regime changes"); println!( " Ranging (1.5x): ${:.2} ({:.1} points)", stop_price.to_f64().unwrap(), stop_distance ); println!( " Volatile (3.0x): ${:.2} ({:.1} points)", stop_price2.to_f64().unwrap(), stop_distance2 ); println!( " Crisis (4.0x): ${:.2} ({:.1} points)", stop_price3.to_f64().unwrap(), stop_distance3 ); cleanup_regime_states(&pool).await.unwrap(); cleanup_market_data(&pool, "ES.FUT").await.unwrap(); } // ============================================================================ // TEST CATEGORY 2: Sell Order Stop-Loss Above Entry // ============================================================================ #[tokio::test] #[serial] async fn test_sell_order_stop_loss_above_entry() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); cleanup_market_data(&pool, "NQ.FUT").await.unwrap(); // Setup: Normal regime (2.0x ATR) insert_regime_state(&pool, "NQ.FUT", "Normal", 0.85) .await .unwrap(); // Use ATR = 250 points to meet >2% minimum (250 * 2.0 = 500 points = 2.5%) let atr = 250.0; // ATR = 250 points let bars = generate_test_bars_with_atr(atr, 20, 20000.0); insert_market_data_bars(&pool, "NQ.FUT", &bars) .await .unwrap(); // Create SELL order let order = create_test_order("NQ.FUT", OrderSide::Sell, 20000.0); let order_with_stop = apply_dynamic_stop_loss(order, "NQ.FUT", &pool) .await .unwrap(); // Verify stop-loss is ABOVE entry price for sell orders assert!(order_with_stop.stop_loss.is_some()); let stop_price: Decimal = order_with_stop.stop_loss.unwrap().into(); let stop_price_f64 = stop_price.to_f64().unwrap(); assert!( stop_price_f64 > 20000.0, "Sell order stop should be above entry (20000), got {}", stop_price_f64 ); // Verify distance is 2.0x ATR = 500 points (2.5% of entry) let stop_distance = stop_price_f64 - 20000.0; assert!( (stop_distance - 500.0).abs() < 10.0, "Normal regime stop should be ~500 points (2.0 * 250), got {}", stop_distance ); println!("✓ Sell order stop-loss correctly placed above entry"); println!(" Entry: ${:.2}", 20000.0); println!(" Stop: ${:.2} (+{:.1} points)", stop_price_f64, stop_distance); cleanup_regime_states(&pool).await.unwrap(); cleanup_market_data(&pool, "NQ.FUT").await.unwrap(); } // ============================================================================ // TEST CATEGORY 3: Stop-Loss Prevents Immediate Trigger (>2% Rule) // ============================================================================ #[tokio::test] #[serial] async fn test_stop_loss_prevents_immediate_trigger() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); cleanup_market_data(&pool, "6E.FUT").await.unwrap(); // Setup: Ranging regime with VERY LOW ATR (would result in <2% stop) insert_regime_state(&pool, "6E.FUT", "Ranging", 0.90) .await .unwrap(); let atr = 0.005; // ATR = 0.005 (very low for 6E.FUT ~1.10) let bars = generate_test_bars_with_atr(atr, 20, 1.10); insert_market_data_bars(&pool, "6E.FUT", &bars) .await .unwrap(); // Create order let order = create_test_order("6E.FUT", OrderSide::Buy, 1.10); let order_with_stop = apply_dynamic_stop_loss(order, "6E.FUT", &pool) .await .unwrap(); // Verify stop-loss is NOT applied (would be <2%) // 1.5x * 0.005 = 0.0075 = 0.68% of 1.10 (< 2% threshold) assert!( order_with_stop.stop_loss.is_none(), "Stop-loss should not be applied when <2% from entry" ); println!("✓ Stop-loss correctly rejected when <2% from entry"); println!(" Entry: ${:.4}", 1.10); println!(" ATR: {:.4} (too small)", atr); println!(" Stop: None (would be {:.2}% < 2%)", 0.68); cleanup_regime_states(&pool).await.unwrap(); cleanup_market_data(&pool, "6E.FUT").await.unwrap(); } // ============================================================================ // TEST CATEGORY 4: ATR Calculation (14-Period) // ============================================================================ #[tokio::test] #[serial] async fn test_atr_calculation_14_period() { // Create 15 bars with known True Range values let bars = vec![ OHLCBar { high: 5010.0, low: 4990.0, close: 5000.0, }, // TR = 20 OHLCBar { high: 5020.0, low: 5000.0, close: 5015.0, }, // TR = 20 OHLCBar { high: 5025.0, low: 5005.0, close: 5020.0, }, // TR = 20 OHLCBar { high: 5030.0, low: 5010.0, close: 5025.0, }, // TR = 20 OHLCBar { high: 5035.0, low: 5015.0, close: 5030.0, }, // TR = 20 OHLCBar { high: 5040.0, low: 5020.0, close: 5035.0, }, // TR = 20 OHLCBar { high: 5045.0, low: 5025.0, close: 5040.0, }, // TR = 20 OHLCBar { high: 5050.0, low: 5030.0, close: 5045.0, }, // TR = 20 OHLCBar { high: 5055.0, low: 5035.0, close: 5050.0, }, // TR = 20 OHLCBar { high: 5060.0, low: 5040.0, close: 5055.0, }, // TR = 20 OHLCBar { high: 5065.0, low: 5045.0, close: 5060.0, }, // TR = 20 OHLCBar { high: 5070.0, low: 5050.0, close: 5065.0, }, // TR = 20 OHLCBar { high: 5075.0, low: 5055.0, close: 5070.0, }, // TR = 20 OHLCBar { high: 5080.0, low: 5060.0, close: 5075.0, }, // TR = 20 OHLCBar { high: 5085.0, low: 5065.0, close: 5080.0, }, // TR = 20 ]; let atr = calculate_atr(&bars, 14).expect("Should calculate ATR"); // ATR should be ~20 (all bars have TR = 20) assert!( (atr - 20.0).abs() < 1.0, "ATR should be ~20 for consistent 20-point ranges, got {}", atr ); println!("✓ ATR calculation (14-period) validated"); println!(" Bars: {}", bars.len()); println!(" ATR: {:.2}", atr); } // ============================================================================ // TEST CATEGORY 5: Stop-Loss Persisted to Database // ============================================================================ #[tokio::test] #[serial] async fn test_stop_loss_persisted_to_database() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); cleanup_market_data(&pool, "ZN.FUT").await.unwrap(); // Setup: Trending regime (2.0x ATR) insert_regime_state(&pool, "ZN.FUT", "Trending", 0.82) .await .unwrap(); let atr = 2.0; // ATR = 2.0 points (typical for ZN) let bars = generate_test_bars_with_atr(atr, 20, 110.0); insert_market_data_bars(&pool, "ZN.FUT", &bars) .await .unwrap(); // Create order and apply stop-loss let order = create_test_order("ZN.FUT", OrderSide::Buy, 110.0); let order_with_stop = apply_dynamic_stop_loss(order, "ZN.FUT", &pool) .await .unwrap(); // Verify metadata contains regime information assert!(order_with_stop.metadata.get("regime").is_some()); assert!(order_with_stop.metadata.get("atr").is_some()); assert!(order_with_stop.metadata.get("stop_multiplier").is_some()); assert!(order_with_stop.metadata.get("stop_distance").is_some()); let regime = order_with_stop .metadata .get("regime") .and_then(|v| v.as_str()) .unwrap(); let metadata_atr = order_with_stop .metadata .get("atr") .and_then(|v| v.as_f64()) .unwrap(); let stop_mult = order_with_stop .metadata .get("stop_multiplier") .and_then(|v| v.as_f64()) .unwrap(); assert_eq!(regime, "Trending"); assert!((metadata_atr - 2.0).abs() < 0.5); assert_eq!(stop_mult, 2.0); println!("✓ Stop-loss metadata persisted to order"); println!(" Regime: {}", regime); println!(" ATR: {:.2}", metadata_atr); println!(" Multiplier: {:.1}x", stop_mult); cleanup_regime_states(&pool).await.unwrap(); cleanup_market_data(&pool, "ZN.FUT").await.unwrap(); } // ============================================================================ // TEST CATEGORY 6: Real-World Validation with Historical Data // ============================================================================ #[tokio::test] #[serial] async fn test_real_world_volatility_spike() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); cleanup_market_data(&pool, "ES.FUT").await.unwrap(); // Simulate March 2023 banking crisis volatility spike // Normal period: ATR ~50 points (2.0x * 50 = 100 points = 2.5%) // Crisis period: ATR ~200 points (4.0x * 200 = 800 points = 20%) // Crisis / Normal ratio: 800/100 = 8x (well above 3x requirement) // 1. Normal period insert_regime_state(&pool, "ES.FUT", "Normal", 0.85) .await .unwrap(); let normal_bars = generate_test_bars_with_atr(50.0, 20, 4000.0); insert_market_data_bars(&pool, "ES.FUT", &normal_bars) .await .unwrap(); let order_normal = create_test_order("ES.FUT", OrderSide::Buy, 4000.0); let order_normal_stop = apply_dynamic_stop_loss(order_normal, "ES.FUT", &pool) .await .unwrap(); let normal_stop: Decimal = order_normal_stop.stop_loss.unwrap().into(); let normal_distance = 4000.0 - normal_stop.to_f64().unwrap(); // 2. Crisis period (simulate volatility spike) cleanup_market_data(&pool, "ES.FUT").await.unwrap(); update_regime_state(&pool, "ES.FUT", "Crisis", 0.92) .await .unwrap(); let crisis_bars = generate_test_bars_with_atr(200.0, 20, 4000.0); insert_market_data_bars(&pool, "ES.FUT", &crisis_bars) .await .unwrap(); let order_crisis = create_test_order("ES.FUT", OrderSide::Buy, 4000.0); let order_crisis_stop = apply_dynamic_stop_loss(order_crisis, "ES.FUT", &pool) .await .unwrap(); let crisis_stop: Decimal = order_crisis_stop.stop_loss.unwrap().into(); let crisis_distance = 4000.0 - crisis_stop.to_f64().unwrap(); // Verify stop widened significantly during crisis assert!( crisis_distance > normal_distance * 3.0, "Crisis stop ({:.1}) should be >3x normal stop ({:.1})", crisis_distance, normal_distance ); println!("✓ Real-world volatility spike handling validated"); println!(" Normal (2.0x * 45): ${:.2} ({:.1} points)", normal_stop.to_f64().unwrap(), normal_distance); println!(" Crisis (4.0x * 100): ${:.2} ({:.1} points)", crisis_stop.to_f64().unwrap(), crisis_distance); println!(" Widening ratio: {:.1}x", crisis_distance / normal_distance); cleanup_regime_states(&pool).await.unwrap(); cleanup_market_data(&pool, "ES.FUT").await.unwrap(); } // ============================================================================ // TEST CATEGORY 7: Multiple Symbols with Different Regimes // ============================================================================ #[tokio::test] #[serial] async fn test_multi_symbol_different_regimes() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); // Setup different regimes for different symbols // ATR values chosen to meet >2% minimum after multiplier: // ES.FUT: 60 * 1.5 = 90 points = 2.25% // NQ.FUT: 150 * 3.0 = 450 points = 2.25% // ZN.FUT: 0.6 * 4.0 = 2.4 points = 2.18% let symbols = vec![ ("ES.FUT", "Ranging", 0.88, 60.0, 4000.0), ("NQ.FUT", "Volatile", 0.90, 150.0, 20000.0), ("ZN.FUT", "Crisis", 0.95, 0.6, 110.0), ]; for (symbol, regime, confidence, atr, price) in &symbols { insert_regime_state(&pool, symbol, regime, *confidence) .await .unwrap(); cleanup_market_data(&pool, symbol).await.unwrap(); let bars = generate_test_bars_with_atr(*atr, 20, *price); insert_market_data_bars(&pool, symbol, &bars) .await .unwrap(); } // Generate orders with stops let mut results = Vec::new(); for (symbol, regime, _, atr, price) in &symbols { let order = create_test_order(symbol, OrderSide::Buy, *price); let order_with_stop = apply_dynamic_stop_loss(order, symbol, &pool) .await .unwrap(); let stop_price: Decimal = order_with_stop.stop_loss.unwrap().into(); let stop_distance = price - stop_price.to_f64().unwrap(); let multiplier = get_regime_multiplier(regime); let expected_distance = atr * multiplier; assert!( (stop_distance - expected_distance).abs() < 5.0, "{} stop distance {:.1} should be ~{:.1} ({:.1}x * {:.1})", symbol, stop_distance, expected_distance, multiplier, atr ); results.push((symbol, regime, stop_distance, multiplier)); } println!("✓ Multi-symbol regime-adaptive stop-loss validated"); for (symbol, regime, distance, mult) in results { println!(" {}: {} ({:.1}x) = {:.1} points", symbol, regime, mult, distance); } cleanup_regime_states(&pool).await.unwrap(); for (symbol, _, _, _, _) in &symbols { cleanup_market_data(&pool, symbol).await.unwrap(); } } // ============================================================================ // TEST CATEGORY 8: Performance Benchmarks // ============================================================================ #[tokio::test] #[serial] async fn test_stop_loss_application_performance() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); cleanup_market_data(&pool, "ES.FUT").await.unwrap(); // Setup insert_regime_state(&pool, "ES.FUT", "Normal", 0.85) .await .unwrap(); let bars = generate_test_bars_with_atr(20.0, 20, 4000.0); insert_market_data_bars(&pool, "ES.FUT", &bars) .await .unwrap(); // Benchmark 100 stop-loss applications let start = Instant::now(); for _ in 0..100 { let order = create_test_order("ES.FUT", OrderSide::Buy, 4000.0); let _order_with_stop = apply_dynamic_stop_loss(order, "ES.FUT", &pool) .await .unwrap(); } let duration = start.elapsed(); let avg_per_order = duration.as_micros() / 100; // Performance target: <5ms per order assert!( avg_per_order < 5000, "Average stop-loss application took {}μs (target: <5000μs)", avg_per_order ); println!("✓ Stop-loss application performance validated"); println!(" 100 orders: {:?}", duration); println!(" Avg per order: {}μs", avg_per_order); cleanup_regime_states(&pool).await.unwrap(); cleanup_market_data(&pool, "ES.FUT").await.unwrap(); } // ============================================================================ // TEST CATEGORY 9: Regime Multiplier Validation // ============================================================================ #[test] fn test_regime_multipliers_comprehensive() { let regimes = vec![ ("Ranging", 1.5), ("Sideways", 1.5), ("Trending", 2.0), ("Normal", 2.0), ("Volatile", 3.0), ("Crisis", 4.0), ("Breakdown", 4.0), ("Unknown", 2.0), // Default ]; for (regime, expected_mult) in regimes { let mult = get_regime_multiplier(regime); assert_eq!( mult, expected_mult, "Regime {} should have multiplier {}, got {}", regime, expected_mult, mult ); } println!("✓ All regime multipliers validated"); println!(" Ranging/Sideways: 1.5x (tight stops)"); println!(" Trending/Normal: 2.0x (normal stops)"); println!(" Volatile: 3.0x (wide stops)"); println!(" Crisis/Breakdown: 4.0x (very wide stops)"); } // ============================================================================ // TEST CATEGORY 10: Validation - Dynamic Stop Uses Actual Regime from DB // ============================================================================ #[tokio::test] #[serial] async fn test_dynamic_stop_uses_actual_regime() { let pool = setup_test_db().await; cleanup_regime_states(&pool).await.unwrap(); cleanup_market_data(&pool, "ES.FUT").await.unwrap(); // Insert Crisis regime (4.0x multiplier) into regime_states table sqlx::query!( "INSERT INTO regime_states (symbol, regime, confidence, event_timestamp) VALUES ('ES.FUT', 'Crisis', 0.95, NOW())" ) .execute(&pool) .await .unwrap(); // Generate bars with ATR = 60 (60 * 4.0 = 240 points = 6% for Crisis) let atr = 60.0; let bars = generate_test_bars_with_atr(atr, 20, 4000.0); insert_market_data_bars(&pool, "ES.FUT", &bars) .await .unwrap(); // Create buy order at $4000 let order = create_test_order("ES.FUT", OrderSide::Buy, 4000.0); let order = apply_dynamic_stop_loss(order, "ES.FUT", &pool) .await .unwrap(); // Verify stop-loss distance is ~4x ATR (Crisis regime) assert!(order.stop_loss.is_some(), "Stop-loss should be applied"); let stop_price: Decimal = order.stop_loss.unwrap().into(); let stop_price_f64 = stop_price.to_f64().unwrap(); let stop_distance = (4000.0 - stop_price_f64).abs(); // Crisis regime should use 4.0x multiplier: 60 * 4.0 = 240 points let expected_distance = 240.0; let expected_min = expected_distance - 10.0; // Allow 10-point tolerance assert!( stop_distance >= expected_min, "Crisis regime should use 4.0x ATR (~240 points), got {:.1} points", stop_distance ); // Verify metadata confirms Crisis regime let regime_metadata = order .metadata .get("regime") .and_then(|v| v.as_str()) .unwrap(); assert_eq!(regime_metadata, "Crisis", "Metadata should confirm Crisis regime"); let stop_mult_metadata = order .metadata .get("stop_multiplier") .and_then(|v| v.as_f64()) .unwrap(); assert_eq!(stop_mult_metadata, 4.0, "Metadata should show 4.0x multiplier"); println!("✓ Dynamic stop-loss correctly reads from regime_states table"); println!(" Symbol: ES.FUT"); println!(" Regime: Crisis (from DB)"); println!(" ATR: {:.1}", atr); println!(" Multiplier: 4.0x"); println!(" Entry: $4000.00"); println!(" Stop: ${:.2} ({:.1} points)", stop_price_f64, stop_distance); println!(" Expected: ~240 points (4.0x * 60)"); cleanup_regime_states(&pool).await.unwrap(); cleanup_market_data(&pool, "ES.FUT").await.unwrap(); }