//! Comprehensive unit tests for DQNReplayStrategy //! //! Tests cover: //! - CSV loading and validation //! - Action-to-signal conversion logic //! - Position state machine transitions //! - Error handling (out of bounds, invalid data) //! - Integration with Strategy trait use backtesting::{ DQNReplayStrategy, PositionState, SignalType, Strategy, StrategyConfig, StrategyContext, TradingActionType, }; use chrono::Utc; use common::{Position, Price, Quantity, Symbol}; use rust_decimal::Decimal; use rust_decimal_macros::dec; use std::collections::HashMap; use std::io::Write; use tempfile::NamedTempFile; use trading_engine::types::events::MarketEvent; use uuid::Uuid; /// Helper to create a test CSV file fn create_test_csv(actions: &[(usize, u8, [f64; 3])]) -> anyhow::Result { let mut file = NamedTempFile::new()?; writeln!(file, "bar_index,action,q_buy,q_sell,q_hold")?; for (bar_index, action, q_values) in actions { writeln!( file, "{},{},{},{},{}", bar_index, action, q_values[0], q_values[1], q_values[2] )?; } file.flush()?; Ok(file) } /// Helper to create a test strategy context fn create_test_context() -> StrategyContext { StrategyContext { current_time: Utc::now(), account_balance: dec!(100000.0), buying_power: dec!(100000.0), positions: HashMap::new(), open_orders: HashMap::new(), market_prices: HashMap::new(), performance: backtesting::strategy_tester::PerformanceMetrics::default(), } } /// Helper to create a test bar event fn create_bar_event(symbol: &str, close: f64) -> MarketEvent { MarketEvent::Bar { symbol: Symbol::from(symbol), open: Price::from_f64(close).unwrap(), high: Price::from_f64(close + 1.0).unwrap(), low: Price::from_f64(close - 1.0).unwrap(), close: Price::from_f64(close).unwrap(), volume: Quantity::from_f64(1000.0).unwrap(), timestamp: Utc::now(), interval: "1m".to_string(), venue: Some("TEST".to_string()), } } #[test] fn test_csv_loading_valid() { let csv_file = create_test_csv(&[ (0, 2, [0.1, 0.2, 0.9]), // Hold (1, 0, [0.8, 0.1, 0.3]), // Buy (2, 1, [0.2, 0.7, 0.4]), // Sell ]) .unwrap(); let strategy = DQNReplayStrategy::from_csv( "test_strategy".to_string(), csv_file.path(), Symbol::from("ES"), ) .unwrap(); assert_eq!(strategy.name(), "test_strategy"); } #[test] fn test_csv_loading_invalid_action() { let csv_file = create_test_csv(&[(0, 3, [0.1, 0.2, 0.3])]).unwrap(); let result = DQNReplayStrategy::from_csv( "test_strategy".to_string(), csv_file.path(), Symbol::from("ES"), ); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("Invalid action value")); } #[test] fn test_csv_loading_gap_in_indices() { let csv_file = create_test_csv(&[ (0, 2, [0.1, 0.2, 0.9]), (2, 0, [0.8, 0.1, 0.3]), // Gap: missing index 1 ]) .unwrap(); let result = DQNReplayStrategy::from_csv( "test_strategy".to_string(), csv_file.path(), Symbol::from("ES"), ); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("Bar index mismatch")); } #[test] fn test_position_state_from_quantity() { assert_eq!( PositionState::from_quantity(dec!(10.0)), PositionState::Long ); assert_eq!( PositionState::from_quantity(dec!(-5.0)), PositionState::Short ); assert_eq!( PositionState::from_quantity(Decimal::ZERO), PositionState::Flat ); } #[test] fn test_trading_action_type_from_int() { assert_eq!( TradingActionType::from_int(0).unwrap(), TradingActionType::Buy ); assert_eq!( TradingActionType::from_int(1).unwrap(), TradingActionType::Sell ); assert_eq!( TradingActionType::from_int(2).unwrap(), TradingActionType::Hold ); assert!(TradingActionType::from_int(3).is_err()); } #[tokio::test] async fn test_action_conversion_flat_to_long() { let csv_file = create_test_csv(&[(0, 0, [0.8, 0.1, 0.3])]).unwrap(); // Buy let mut strategy = DQNReplayStrategy::from_csv( "test_strategy".to_string(), csv_file.path(), Symbol::from("ES"), ) .unwrap(); let context = create_test_context(); let bar_event = create_bar_event("ES", 100.0); let signals = strategy .on_market_event(&bar_event, &context) .await .unwrap(); assert_eq!(signals.len(), 1); assert!(matches!(signals[0].signal_type, SignalType::Buy)); assert_eq!(signals[0].symbol, Symbol::from("ES")); } #[tokio::test] async fn test_action_conversion_long_to_flat_via_sell() { let csv_file = create_test_csv(&[ (0, 0, [0.8, 0.1, 0.3]), // Buy (Flat -> Long) (1, 1, [0.2, 0.7, 0.4]), // Sell (Long -> Close) ]) .unwrap(); let mut strategy = DQNReplayStrategy::from_csv( "test_strategy".to_string(), csv_file.path(), Symbol::from("ES"), ) .unwrap(); strategy .initialize(dec!(100000.0), StrategyConfig::default()) .await .unwrap(); let context = create_test_context(); // First bar: Buy signal let bar_event_1 = create_bar_event("ES", 100.0); let signals_1 = strategy .on_market_event(&bar_event_1, &context) .await .unwrap(); assert_eq!(signals_1.len(), 1); assert!(matches!(signals_1[0].signal_type, SignalType::Buy)); // Second bar: Sell signal should close long let bar_event_2 = create_bar_event("ES", 101.0); let signals_2 = strategy .on_market_event(&bar_event_2, &context) .await .unwrap(); assert_eq!(signals_2.len(), 1); assert!(matches!(signals_2[0].signal_type, SignalType::CloseLong)); } #[tokio::test] async fn test_action_conversion_hold_generates_no_signal() { let csv_file = create_test_csv(&[(0, 2, [0.1, 0.2, 0.9])]).unwrap(); // Hold let mut strategy = DQNReplayStrategy::from_csv( "test_strategy".to_string(), csv_file.path(), Symbol::from("ES"), ) .unwrap(); let context = create_test_context(); let bar_event = create_bar_event("ES", 100.0); let signals = strategy .on_market_event(&bar_event, &context) .await .unwrap(); assert_eq!(signals.len(), 0); } #[tokio::test] async fn test_out_of_bounds_error() { let csv_file = create_test_csv(&[(0, 0, [0.8, 0.1, 0.3])]).unwrap(); // Only 1 action let mut strategy = DQNReplayStrategy::from_csv( "test_strategy".to_string(), csv_file.path(), Symbol::from("ES"), ) .unwrap(); let context = create_test_context(); // First bar: OK let bar_event_1 = create_bar_event("ES", 100.0); let result_1 = strategy.on_market_event(&bar_event_1, &context).await; assert!(result_1.is_ok()); // Second bar: Out of bounds let bar_event_2 = create_bar_event("ES", 101.0); let result_2 = strategy.on_market_event(&bar_event_2, &context).await; assert!(result_2.is_err()); let err = result_2.unwrap_err().to_string(); assert!(err.contains("Action index out of bounds")); } #[tokio::test] async fn test_ignore_other_symbols() { let csv_file = create_test_csv(&[(0, 0, [0.8, 0.1, 0.3])]).unwrap(); let mut strategy = DQNReplayStrategy::from_csv( "test_strategy".to_string(), csv_file.path(), Symbol::from("ES"), ) .unwrap(); let context = create_test_context(); let bar_event = create_bar_event("NQ", 100.0); // Different symbol let signals = strategy .on_market_event(&bar_event, &context) .await .unwrap(); assert_eq!(signals.len(), 0); } #[tokio::test] async fn test_strategy_finalize() { let csv_file = create_test_csv(&[ (0, 0, [0.8, 0.1, 0.3]), // Buy (1, 2, [0.5, 0.3, 0.7]), // Hold (2, 1, [0.2, 0.8, 0.4]), // Sell ]) .unwrap(); let mut strategy = DQNReplayStrategy::from_csv( "test_strategy".to_string(), csv_file.path(), Symbol::from("ES"), ) .unwrap(); strategy .initialize(dec!(100000.0), StrategyConfig::default()) .await .unwrap(); let mut context = create_test_context(); // Process all bars for i in 0..3 { let bar_event = create_bar_event("ES", 100.0 + i as f64); let _ = strategy.on_market_event(&bar_event, &context).await; } // Update final balance context.account_balance = dec!(105000.0); let result = strategy.finalize(&context).await.unwrap(); assert_eq!(result.strategy_name, "test_strategy"); assert_eq!(result.final_value, dec!(105000.0)); assert_eq!(result.total_return, dec!(0.05)); // 5% return } #[tokio::test] async fn test_position_update_callback() { let csv_file = create_test_csv(&[(0, 0, [0.8, 0.1, 0.3])]).unwrap(); let mut strategy = DQNReplayStrategy::from_csv( "test_strategy".to_string(), csv_file.path(), Symbol::from("ES"), ) .unwrap(); let context = create_test_context(); // Simulate position update with full Position struct let position = Position { id: Uuid::new_v4(), symbol: "ES".to_string(), quantity: dec!(10.0), avg_price: dec!(100.0), avg_cost: dec!(100.0), basis: dec!(1000.0), average_price: dec!(100.0), market_value: dec!(1000.0), unrealized_pnl: Decimal::ZERO, realized_pnl: Decimal::ZERO, created_at: Utc::now(), updated_at: Utc::now(), last_updated: Utc::now(), current_price: Some(dec!(100.0)), notional_value: dec!(1000.0), margin_requirement: dec!(200.0), }; let result = strategy.on_position_update(&position, &context).await; assert!(result.is_ok()); } #[tokio::test] async fn test_get_state() { let csv_file = create_test_csv(&[ (0, 0, [0.8, 0.1, 0.3]), (1, 1, [0.2, 0.7, 0.4]), (2, 2, [0.5, 0.3, 0.7]), ]) .unwrap(); let strategy = DQNReplayStrategy::from_csv( "test_strategy".to_string(), csv_file.path(), Symbol::from("ES"), ) .unwrap(); let state = strategy.get_state().await.unwrap(); assert_eq!(state["name"], "test_strategy"); assert_eq!(state["current_index"], 0); assert_eq!(state["total_actions"], 3); assert!(state["metrics"].is_object()); } #[tokio::test] async fn test_flat_to_short_transition() { let csv_file = create_test_csv(&[(0, 1, [0.2, 0.8, 0.3])]).unwrap(); // Sell from flat let mut strategy = DQNReplayStrategy::from_csv( "test_strategy".to_string(), csv_file.path(), Symbol::from("ES"), ) .unwrap(); let context = create_test_context(); let bar_event = create_bar_event("ES", 100.0); let signals = strategy .on_market_event(&bar_event, &context) .await .unwrap(); assert_eq!(signals.len(), 1); assert!(matches!(signals[0].signal_type, SignalType::Short)); } #[tokio::test] async fn test_short_to_long_flip() { let csv_file = create_test_csv(&[ (0, 1, [0.2, 0.8, 0.3]), // Sell (Flat -> Short) (1, 0, [0.8, 0.1, 0.3]), // Buy (Short -> Cover) ]) .unwrap(); let mut strategy = DQNReplayStrategy::from_csv( "test_strategy".to_string(), csv_file.path(), Symbol::from("ES"), ) .unwrap(); strategy .initialize(dec!(100000.0), StrategyConfig::default()) .await .unwrap(); let context = create_test_context(); // First bar: Sell to open short let bar_event_1 = create_bar_event("ES", 100.0); let signals_1 = strategy .on_market_event(&bar_event_1, &context) .await .unwrap(); assert_eq!(signals_1.len(), 1); assert!(matches!(signals_1[0].signal_type, SignalType::Short)); // Second bar: Buy should cover short let bar_event_2 = create_bar_event("ES", 99.0); let signals_2 = strategy .on_market_event(&bar_event_2, &context) .await .unwrap(); assert_eq!(signals_2.len(), 1); assert!(matches!(signals_2[0].signal_type, SignalType::Cover)); }