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>
451 lines
12 KiB
Rust
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));
|
|
}
|