Files
foxhunt/ml/tests/dqn_tensor_shape_validation.rs
jgrusewski 1934367bfa refactor(ml): consolidate 13 duplicate OHLCVBar definitions into single canonical type
Created ml/src/types/ohlcv.rs as the single source of truth for OHLCVBar
(DateTime<Utc> timestamp, f64 OHLCV fields). Replaced all 13 duplicate
definitions across features/, regime/, real_data_loader, and evaluation/
with imports from crate::types::OHLCVBar.

Key changes:
- New: ml/src/types/mod.rs + ohlcv.rs with canonical OHLCVBar
  (derives: Debug, Clone, Copy, PartialEq, Serialize, Deserialize + Default)
- Renamed: evaluation::metrics::OHLCVBar → OHLCVBarF32 (genuinely
  different type: f32 fields, i64 timestamp for compact backtesting)
- Eliminated all import aliases (ExtractionOHLCVBar, RegimeOHLCVBar,
  PriceOHLCVBar, VolumeOHLCVBar) in dbn_sequence_loader.rs and pipeline.rs
- Renamed regime::orchestrator::Bar → OHLCVBar (same fields, just aliased)
- Updated 39 files total (13 definitions removed, imports normalized)

1883 lib tests passing, compilation clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:14:42 +01:00

497 lines
16 KiB
Rust

//! DQN Tensor Shape Validation Tests
//!
//! Comprehensive regression prevention tests to catch shape mismatch bugs
//! in DQN evaluation and metrics calculation.
//!
//! ## Test Coverage
//! 1. Evaluation returns scalar metrics (rank-0 tensors)
//! 2. Batch size = 1 edge case (likely bug trigger)
//! 3. PerformanceMetrics::from_trades returns scalars
//! 4. All individual metric calculations return scalars
//! 5. Post-training evaluation shape correctness
//!
//! ## Bug Context (Wave 10)
//! Previous bug: PerformanceMetrics fields (sharpe_ratio, win_rate, max_drawdown_pct)
//! were returned as rank-1 tensors instead of scalars when batch_size=1.
//! This caused shape mismatch errors in hyperopt adapter.
#![allow(unused_crate_dependencies)]
use anyhow::Result;
use chrono::Utc;
use ml::evaluation::engine::{Action, EvaluationEngine, Trade};
use ml::evaluation::metrics::{OHLCVBarF32, PerformanceMetrics};
// ================================================================================================
// TEST UTILITIES
// ================================================================================================
/// Generate synthetic OHLCV bars for testing
fn create_synthetic_bars(count: usize) -> Vec<OHLCVBarF32> {
let mut bars = Vec::with_capacity(count);
let base_price = 100.0;
for i in 0..count {
let price = base_price + (i as f32) * 0.5;
bars.push(OHLCVBarF32 {
timestamp: Utc::now().timestamp(),
open: price - 0.1,
high: price + 0.2,
low: price - 0.2,
close: price,
volume: 1000.0,
});
}
bars
}
/// Create synthetic trades with varied P&L
fn create_synthetic_trades(count: usize) -> Vec<Trade> {
let mut trades = Vec::with_capacity(count);
for i in 0..count {
let entry_price = 100.0 + (i as f32) * 0.5;
let exit_price = entry_price + if i % 3 == 0 { -0.5 } else { 1.0 }; // 2/3 winners
trades.push(Trade {
entry_bar_idx: i,
exit_bar_idx: i + 1,
entry_price,
exit_price,
direction: if i % 2 == 0 {
"long".to_string()
} else {
"short".to_string()
},
pnl: if i % 2 == 0 {
exit_price - entry_price
} else {
entry_price - exit_price
},
});
}
trades
}
/// Assert that a value is a scalar (rank-0) - helper for primitive types
fn assert_scalar_value<T: std::fmt::Display>(value: T, metric_name: &str) {
// For primitive types (f64, f32, usize), they are inherently scalars
// This is a semantic check - just verify the value is finite for floats
let _ = (value, metric_name); // Use both parameters
}
/// Assert all PerformanceMetrics fields are scalars
fn assert_metrics_are_scalars(metrics: &PerformanceMetrics, context: &str) {
// All PerformanceMetrics fields are primitive types (f64, usize)
// which are inherently rank-0 scalars. Verify they are finite.
assert!(
metrics.sharpe_ratio.is_finite(),
"{}: sharpe_ratio is not finite: {}",
context,
metrics.sharpe_ratio
);
assert!(
metrics.win_rate.is_finite(),
"{}: win_rate is not finite: {}",
context,
metrics.win_rate
);
assert!(
metrics.max_drawdown_pct.is_finite(),
"{}: max_drawdown_pct is not finite: {}",
context,
metrics.max_drawdown_pct
);
assert!(
metrics.total_return_pct.is_finite(),
"{}: total_return_pct is not finite: {}",
context,
metrics.total_return_pct
);
assert!(
metrics.avg_trade_pnl.is_finite(),
"{}: avg_trade_pnl is not finite: {}",
context,
metrics.avg_trade_pnl
);
assert!(
metrics.final_equity.is_finite(),
"{}: final_equity is not finite: {}",
context,
metrics.final_equity
);
assert!(
metrics.max_equity.is_finite(),
"{}: max_equity is not finite: {}",
context,
metrics.max_equity
);
// Semantic check: total_trades is usize (inherently scalar)
assert!(
metrics.total_trades < 1_000_000,
"{}: total_trades is unreasonably large: {}",
context,
metrics.total_trades
);
}
// ================================================================================================
// TEST 1: BATCH SIZE = 1 EDGE CASE
// ================================================================================================
#[test]
fn test_batch_size_1_returns_scalar_metrics() -> Result<()> {
// This test specifically targets the batch_size=1 edge case that likely
// triggered the shape mismatch bug in hyperopt.
let bars = create_synthetic_bars(1);
let trades = create_synthetic_trades(1);
let initial_capital = 10000.0;
let metrics = PerformanceMetrics::from_trades(&trades, initial_capital, &bars);
// Verify all metrics are scalars (finite primitive types)
assert_metrics_are_scalars(&metrics, "batch_size=1");
// Specific checks for this edge case
assert!(
metrics.sharpe_ratio.is_finite(),
"sharpe_ratio should be finite for single trade"
);
assert!(
metrics.win_rate >= 0.0 && metrics.win_rate <= 100.0,
"win_rate should be percentage: {}",
metrics.win_rate
);
assert!(
metrics.max_drawdown_pct >= 0.0,
"max_drawdown_pct should be non-negative: {}",
metrics.max_drawdown_pct
);
Ok(())
}
// ================================================================================================
// TEST 2: PERFORMANCE METRICS FROM_TRADES SHAPE SAFETY
// ================================================================================================
#[test]
fn test_from_trades_various_batch_sizes() -> Result<()> {
// Test PerformanceMetrics::from_trades with various batch sizes
// to ensure it always returns scalars regardless of input size.
let test_cases = vec![1, 5, 10, 50, 100];
for batch_size in test_cases {
let bars = create_synthetic_bars(batch_size);
let trades = create_synthetic_trades(batch_size);
let initial_capital = 10000.0;
let metrics = PerformanceMetrics::from_trades(&trades, initial_capital, &bars);
let context = format!("batch_size={}", batch_size);
assert_metrics_are_scalars(&metrics, &context);
// Additional sanity checks
assert_eq!(
metrics.total_trades, batch_size,
"total_trades mismatch for {}",
context
);
assert!(
metrics.final_equity.is_finite(),
"final_equity should be finite for {}",
context
);
}
Ok(())
}
// ================================================================================================
// TEST 3: INDIVIDUAL METRIC CALCULATIONS
// ================================================================================================
#[test]
fn test_individual_metric_calculations_return_scalars() -> Result<()> {
// Test each metric calculation individually to ensure they return scalars.
// This provides more granular regression detection.
let trades = create_synthetic_trades(10);
let bars = create_synthetic_bars(10);
let initial_capital = 10000.0;
let metrics = PerformanceMetrics::from_trades(&trades, initial_capital, &bars);
// Sharpe ratio
assert_scalar_value(metrics.sharpe_ratio, "sharpe_ratio");
assert!(
metrics.sharpe_ratio.is_finite(),
"Sharpe ratio should be finite: {}",
metrics.sharpe_ratio
);
// Win rate
assert_scalar_value(metrics.win_rate, "win_rate");
assert!(
metrics.win_rate >= 0.0 && metrics.win_rate <= 100.0,
"Win rate should be percentage: {}",
metrics.win_rate
);
// Max drawdown
assert_scalar_value(metrics.max_drawdown_pct, "max_drawdown_pct");
assert!(
metrics.max_drawdown_pct >= 0.0,
"Max drawdown should be non-negative: {}",
metrics.max_drawdown_pct
);
// Total P&L
assert_scalar_value(metrics.total_return_pct, "total_return_pct");
assert!(
metrics.total_return_pct.is_finite(),
"Total return should be finite: {}",
metrics.total_return_pct
);
// Total trades
assert_scalar_value(metrics.total_trades, "total_trades");
assert_eq!(metrics.total_trades, 10, "Total trades should match input");
// Average trade P&L
assert_scalar_value(metrics.avg_trade_pnl, "avg_trade_pnl");
assert!(
metrics.avg_trade_pnl.is_finite(),
"Average trade P&L should be finite: {}",
metrics.avg_trade_pnl
);
// Final equity
assert_scalar_value(metrics.final_equity, "final_equity");
assert!(
metrics.final_equity > 0.0,
"Final equity should be positive: {}",
metrics.final_equity
);
// Max equity
assert_scalar_value(metrics.max_equity, "max_equity");
assert!(
metrics.max_equity >= metrics.final_equity,
"Max equity should be >= final equity"
);
Ok(())
}
// ================================================================================================
// TEST 4: EVALUATION ENGINE RETURNS SCALAR METRICS
// ================================================================================================
#[test]
fn test_evaluation_engine_scalar_metrics() -> Result<()> {
// Test that EvaluationEngine produces metrics that are scalars
// when processed through PerformanceMetrics.
let initial_capital = 10000.0;
let mut engine = EvaluationEngine::new(initial_capital);
let bars = create_synthetic_bars(20);
// Simulate trading actions
for (i, bar) in bars.iter().enumerate() {
let action = match i % 3 {
0 => Action::Buy,
1 => Action::Hold,
_ => Action::Sell,
};
engine.process_bar(i, bar, action);
}
// Get trades
let trades = &engine.trades;
// Calculate metrics
let metrics = PerformanceMetrics::from_trades(trades, initial_capital, &bars);
// Verify all metrics are scalars
assert_metrics_are_scalars(&metrics, "evaluation_engine");
Ok(())
}
// ================================================================================================
// TEST 5: EVALUATION ENGINE INTEGRATION WITH METRICS
// ================================================================================================
#[test]
fn test_evaluation_engine_to_metrics_pipeline() -> Result<()> {
// Integration test: EvaluationEngine → PerformanceMetrics
// This mirrors the hyperopt workflow without needing full training.
let initial_capital = 10000.0;
let mut eval_engine = EvaluationEngine::new(initial_capital);
// Create evaluation bars
let bars = create_synthetic_bars(50);
// Simulate action selection (alternating pattern)
for (i, bar) in bars.iter().enumerate() {
let action = match i % 3 {
0 => Action::Buy,
1 => Action::Hold,
_ => Action::Sell,
};
eval_engine.process_bar(i, bar, action);
}
// Calculate metrics (this is where the bug would manifest)
let eval_metrics = PerformanceMetrics::from_trades(&eval_engine.trades, initial_capital, &bars);
// Verify all metrics are scalars
assert_metrics_are_scalars(&eval_metrics, "evaluation_engine_to_metrics");
// Additional sanity checks
assert!(
eval_metrics.total_trades > 0,
"Should have executed some trades"
);
assert!(
eval_metrics.final_equity.is_finite(),
"Final equity should be finite"
);
Ok(())
}
// ================================================================================================
// TEST 6: EMPTY TRADES EDGE CASE
// ================================================================================================
#[test]
fn test_empty_trades_returns_zero_scalars() -> Result<()> {
// Edge case: No trades executed
// Should return zero metrics, but still as scalars.
let trades: Vec<Trade> = vec![];
let bars = create_synthetic_bars(10);
let initial_capital = 10000.0;
let metrics = PerformanceMetrics::from_trades(&trades, initial_capital, &bars);
// Verify all metrics are scalars
assert_metrics_are_scalars(&metrics, "empty_trades");
// Specific checks for zero case
assert_eq!(metrics.total_trades, 0, "Should have zero trades");
assert_eq!(metrics.sharpe_ratio, 0.0, "Sharpe ratio should be zero");
assert_eq!(metrics.win_rate, 0.0, "Win rate should be zero");
assert_eq!(metrics.max_drawdown_pct, 0.0, "Max drawdown should be zero");
assert_eq!(metrics.total_return_pct, 0.0, "Total return should be zero");
assert_eq!(
metrics.final_equity, initial_capital as f64,
"Final equity should equal initial capital"
);
Ok(())
}
// ================================================================================================
// TEST 7: SINGLE LOSING TRADE EDGE CASE
// ================================================================================================
#[test]
fn test_single_losing_trade_scalar_metrics() -> Result<()> {
// Edge case: Single losing trade
// Tests Sharpe ratio calculation with variance = 0
let trades = vec![Trade {
entry_bar_idx: 0,
exit_bar_idx: 1,
entry_price: 100.0,
exit_price: 95.0,
direction: "long".to_string(),
pnl: -5.0,
}];
let bars = create_synthetic_bars(2);
let initial_capital = 10000.0;
let metrics = PerformanceMetrics::from_trades(&trades, initial_capital, &bars);
// Verify all metrics are scalars
assert_metrics_are_scalars(&metrics, "single_losing_trade");
// Specific checks
assert_eq!(metrics.total_trades, 1, "Should have one trade");
assert_eq!(
metrics.sharpe_ratio, 0.0,
"Sharpe ratio should be zero (variance = 0)"
);
assert_eq!(metrics.win_rate, 0.0, "Win rate should be zero");
assert!(
metrics.total_return_pct < 0.0,
"Total return should be negative"
);
Ok(())
}
// ================================================================================================
// TEST 8: HIGH VARIANCE TRADES
// ================================================================================================
#[test]
fn test_high_variance_trades_scalar_metrics() -> Result<()> {
// Test with highly variable trade P&L to ensure Sharpe calculation is stable
let trades = vec![
Trade {
entry_bar_idx: 0,
exit_bar_idx: 1,
entry_price: 100.0,
exit_price: 150.0,
direction: "long".to_string(),
pnl: 50.0,
},
Trade {
entry_bar_idx: 1,
exit_bar_idx: 2,
entry_price: 150.0,
exit_price: 80.0,
direction: "long".to_string(),
pnl: -70.0,
},
Trade {
entry_bar_idx: 2,
exit_bar_idx: 3,
entry_price: 80.0,
exit_price: 120.0,
direction: "long".to_string(),
pnl: 40.0,
},
];
let bars = create_synthetic_bars(4);
let initial_capital = 10000.0;
let metrics = PerformanceMetrics::from_trades(&trades, initial_capital, &bars);
// Verify all metrics are scalars
assert_metrics_are_scalars(&metrics, "high_variance_trades");
// Sharpe ratio should be finite despite high variance
assert!(
metrics.sharpe_ratio.is_finite(),
"Sharpe ratio should be finite with high variance: {}",
metrics.sharpe_ratio
);
Ok(())
}