Files
foxhunt/crates/backtesting/tests/dqn_replay_strategy_test.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

451 lines
12 KiB
Rust

//! 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<NamedTempFile> {
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));
}