Files
foxhunt/ml/examples/evaluate_dqn_main_orchestrator.rs.backup
jgrusewski 8ce7c52586 fix(dqn): Update evaluation script feature dimension from 125 to 128
- Fixed feature dimension mismatch in evaluate_dqn_main_orchestrator.rs
- Updated all 5 occurrences: state_dim, input comments, feature vector type
- Aligned with Wave 16D training (128 features: 125 market + 3 portfolio)

Issue: Validation backtest reveals 100% HOLD action collapse - requires reward
system investigation and redesign per latest RL research.
2025-11-08 18:28:56 +01:00

1402 lines
51 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Component 7: Main Orchestrator - Complete DQN Evaluation Pipeline
//!
//! Integrates all 6 components into a production-ready evaluation system:
//! - Component 1: CLI Configuration (EvaluationConfig)
//! - Component 2: Model Loading (load_dqn_model)
//! - Component 3: Parquet Data Loading (load_parquet_data)
//! - Component 4: Inference Engine (run_inference)
//! - Component 5: Metrics Calculator (calculate_metrics)
//! - Component 6: Report Generator (generate_report)
//! - Component 7: Main Orchestrator (main)
//!
//! # Usage
//!
//! ```bash
//! # Evaluate with default settings (auto-detect CUDA)
//! cargo run -p ml --example evaluate_dqn_main_orchestrator --release --features cuda
//!
//! # Evaluate on CPU with custom model
//! cargo run -p ml --example evaluate_dqn_main_orchestrator --release -- \
//! --model-path ml/trained_models/dqn_final_epoch100.safetensors \
//! --device cpu
//!
//! # Evaluate with JSON output for CI/CD
//! cargo run -p ml --example evaluate_dqn_main_orchestrator --release --features cuda -- \
//! --parquet-file test_data/ES_FUT_unseen.parquet \
//! --output-json evaluation_results.json
//!
//! # Custom warmup period (skip first 30 bars)
//! cargo run -p ml --example evaluate_dqn_main_orchestrator --release --features cuda -- \
//! --warmup-bars 30 \
//! --parquet-file test_data/ES_FUT_validation.parquet
//! ```
//!
//! # Architecture
//!
//! ```text
//! ┌─────────────────────────────────────────────────────────────────┐
//! │ MAIN ORCHESTRATOR │
//! │ │
//! │ 1. INITIALIZATION │
//! │ ├─ Parse CLI args (Component 1) │
//! │ ├─ Setup tracing (stdout + /tmp/dqn_eval.log) │
//! │ ├─ Validate config │
//! │ └─ Setup graceful shutdown (Ctrl+C / SIGTERM) │
//! │ │
//! │ 2. PARALLEL LOADING (tokio::try_join!) │
//! │ ├─ Load Parquet data (Component 3) ─────┐ │
//! │ └─ Load DQN model (Component 2) ────────┴─ Concurrent │
//! │ │
//! │ 3. SEQUENTIAL INFERENCE │
//! │ ├─ Run inference (Component 4) │
//! │ ├─ Calculate metrics (Component 5) │
//! │ └─ Track total elapsed time │
//! │ │
//! │ 4. REPORT GENERATION │
//! │ ├─ Generate report (Component 6) │
//! │ └─ Export JSON (if configured) │
//! │ │
//! │ 5. GRACEFUL SHUTDOWN │
//! │ ├─ Stop inference loop (if interrupted) │
//! │ ├─ Generate partial report │
//! │ └─ Exit with appropriate code (0=success, 1=error) │
//! └─────────────────────────────────────────────────────────────────┘
//! ```
use anyhow::{Context, Result};
use candle_core::{Device, Tensor};
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::signal;
use tracing::{info, warn};
use tracing_subscriber::FmtSubscriber;
use ml::data_loaders::load_parquet_data_with_timestamps;
use ml::dqn::dqn::{WorkingDQN, WorkingDQNConfig};
use ml::features::extraction::OHLCVBar;
use ml::preprocessing::{preprocess_prices, PreprocessConfig};
// ============================================================================
// Component 1: CLI Configuration (imported from evaluate_dqn.rs)
// ============================================================================
/// DQN Model Evaluation Configuration
///
/// Parses and validates CLI arguments for evaluating a trained DQN model.
///
/// # Validation Rules
///
/// - `model_path`: Must exist and be a valid SafeTensors file
/// - `parquet_file`: Must exist and contain OHLCV data
/// - `device`: Must be "cpu", "cuda", or "auto"
/// - `warmup_bars`: Must be in range [10, 100] (prevents over/under fitting)
#[derive(Parser, Debug)]
#[command(
name = "evaluate_dqn_main_orchestrator",
about = "Evaluate DQN model on unseen market data",
long_about = "Complete DQN evaluation pipeline with parallel loading, inference, \
metrics calculation, and report generation."
)]
struct EvaluationConfig {
/// Path to trained DQN model (SafeTensors format)
#[arg(long, default_value = "/tmp/dqn_final_model.safetensors")]
model_path: PathBuf,
/// Path to Parquet file with unseen OHLCV data
#[arg(long, default_value = "test_data/ES_FUT_unseen.parquet")]
parquet_file: PathBuf,
/// Device selection: cpu, cuda, or auto
#[arg(long, default_value = "auto")]
device: String,
/// Number of warmup bars to skip (insufficient history)
#[arg(long, default_value_t = 50)]
warmup_bars: usize,
/// Optional JSON output path for CI/CD integration
#[arg(long)]
output_json: Option<PathBuf>,
/// Optional path to export DQN actions as CSV
#[arg(long)]
export_actions: Option<PathBuf>,
/// Verbose logging (DEBUG level)
#[arg(short, long)]
verbose: bool,
}
impl EvaluationConfig {
/// Validates the configuration parameters
pub(crate) fn validate(&self) -> Result<()> {
// Validate model_path exists
if !self.model_path.exists() {
return Err(anyhow::anyhow!(
"Model file does not exist: {}\n\n\
Suggestion: Train a model first using:\n\
cargo run -p ml --example train_dqn --release --features cuda -- \\\n\
--output {}",
self.model_path.display(),
self.model_path.display()
));
}
if !self.model_path.is_file() {
return Err(anyhow::anyhow!(
"Model path is not a regular file: {}",
self.model_path.display()
));
}
// Validate parquet_file exists
if !self.parquet_file.exists() {
return Err(anyhow::anyhow!(
"Parquet file does not exist: {}\n\n\
Suggestion: Generate unseen data using:\n\
# Download data from Databento API for a different time period\n\
# than your training data (temporal split)",
self.parquet_file.display()
));
}
if !self.parquet_file.is_file() {
return Err(anyhow::anyhow!(
"Parquet path is not a regular file: {}",
self.parquet_file.display()
));
}
// Validate device string
match self.device.as_str() {
"cpu" | "cuda" | "auto" => {
// Valid device string
}
_ => {
return Err(anyhow::anyhow!(
"Invalid device: '{}'\n\n\
Valid options:\n\
- 'cpu': Force CPU execution\n\
- 'cuda': Force CUDA GPU execution (requires NVIDIA GPU)\n\
- 'auto': Auto-detect CUDA availability (recommended)",
self.device
));
}
}
// Validate warmup_bars range
if self.warmup_bars < 10 {
return Err(anyhow::anyhow!(
"Warmup bars too small: {} (minimum: 10)\n\n\
At least 10 bars are required for basic feature computation.\n\
Recommended: 50 bars for most models.",
self.warmup_bars
));
}
if self.warmup_bars > 100 {
return Err(anyhow::anyhow!(
"Warmup bars too large: {} (maximum: 100)\n\n\
Using more than 100 warmup bars wastes evaluation data.\n\
Recommended: 50 bars for most models.",
self.warmup_bars
));
}
// Validate output_json path (if specified)
if let Some(ref output_path) = self.output_json {
// Check if parent directory exists
if let Some(parent) = output_path.parent() {
if !parent.exists() {
return Err(anyhow::anyhow!(
"Output JSON parent directory does not exist: {}\n\n\
Suggestion: Create the directory first:\n\
mkdir -p {}",
parent.display(),
parent.display()
));
}
}
// Check if file already exists (warn, but don't fail)
if output_path.exists() {
info!(
"⚠️ Output JSON file already exists and will be overwritten: {}",
output_path.display()
);
}
}
Ok(())
}
}
// ============================================================================
// Component 2: Model Loading
// ============================================================================
/// Load DQN model from SafeTensors file with device selection
///
/// # Arguments
/// * `model_path` - Path to SafeTensors model file (.safetensors extension)
/// * `device_str` - Device selection: "cpu", "cuda", or "auto"
///
/// # Returns
/// WorkingDQN ready for inference with loaded weights
///
/// # Errors
/// Returns error if:
/// - File not found or not readable
/// - SafeTensors deserialization fails
/// - CUDA requested but unavailable
/// - Model dimensions incorrect (expected: 225 input features, 3 actions)
///
/// # Note
/// Uses WorkingDQN which has production-ready load_from_safetensors() method.
/// Architecture: 225 input → [128, 64, 32] hidden → 3 output (8 tensors)
fn load_dqn_model(model_path: &Path, device_str: &str) -> Result<WorkingDQN> {
info!("🔧 Component 2: Loading DQN model");
info!(" Model path: {}", model_path.display());
info!(" Device selection: {}", device_str);
// 1. Parse device string to create Device
let device = match device_str.to_lowercase().as_str() {
"cpu" => {
info!(" Using CPU device (explicitly requested)");
Device::Cpu
}
"cuda" => {
info!(" Using CUDA device (explicitly requested)");
Device::new_cuda(0).context(
"CUDA device requested but unavailable. \
Suggestions:\n\
- Check nvidia-smi to verify GPU is available\n\
- Try device_str=\"auto\" for automatic fallback to CPU\n\
- Use device_str=\"cpu\" to force CPU execution"
)?
}
"auto" => {
match Device::cuda_if_available(0) {
Ok(cuda_device) => {
info!(" Auto-selected CUDA device (GPU available)");
cuda_device
}
Err(e) => {
warn!(" CUDA unavailable, falling back to CPU: {}", e);
info!(" Using CPU device (auto-fallback)");
Device::Cpu
}
}
}
_ => {
return Err(anyhow::anyhow!(
"Invalid device_str: '{}'. Must be one of: 'cpu', 'cuda', 'auto'",
device_str
));
}
};
let device_name = match &device {
Device::Cpu => "CPU",
Device::Cuda(_) => "CUDA:0",
_ => "Unknown",
};
// 2. Check if model file exists and is readable
if !model_path.exists() {
return Err(anyhow::anyhow!(
"Model file not found: {}\n\
Suggestions:\n\
- Check the file path is correct\n\
- Verify the model was saved successfully during training\n\
- Look for checkpoint files in ml/trained_models/",
model_path.display()
));
}
if !model_path.is_file() {
return Err(anyhow::anyhow!(
"Path exists but is not a file: {}",
model_path.display()
));
}
// Check file permissions (read access)
match std::fs::metadata(model_path) {
Ok(metadata) => {
if metadata.permissions().readonly() {
warn!(" Model file is read-only: {}", model_path.display());
}
info!(" Model file size: {} bytes ({:.2} KB)",
metadata.len(),
metadata.len() as f64 / 1024.0
);
}
Err(e) => {
return Err(anyhow::anyhow!(
"Cannot read file metadata for {}: {}",
model_path.display(),
e
));
}
}
// 3. Create WorkingDQNConfig with correct architecture
// Architecture: 125 input → [256, 128, 64] hidden → 3 output (matches training)
info!(" Creating WorkingDQN configuration...");
let config = WorkingDQNConfig {
state_dim: 125, // Wave D: 125 features (101 Wave C + 24 Wave D)
hidden_dims: vec![256, 128, 64], // Match trainer architecture (Wave 10-A1)
num_actions: 3,
learning_rate: 0.001,
gamma: 0.99,
epsilon_start: 0.0, // No exploration during evaluation
epsilon_end: 0.0,
epsilon_decay: 1.0,
replay_buffer_capacity: 1000,
batch_size: 32,
min_replay_size: 64,
target_update_freq: 1000,
use_double_dqn: true,
use_huber_loss: true, // Huber loss default (more robust to outliers)
huber_delta: 1.0, // Standard Huber delta
leaky_relu_alpha: 0.01, // Standard LeakyReLU negative slope
gradient_clip_norm: 10.0, // Wave 11 Bug #1 fix
tau: 1.0, // Hard updates (full copy)
use_soft_updates: false, // Hard updates by default
warmup_steps: 0, // No warmup for evaluation
};
info!(" Model architecture (from training):");
info!(" - Input: 125 features (Wave D)");
info!(" - Hidden: [256, 128, 64]");
info!(" - Output: 3 actions (BUY, SELL, HOLD)");
// 4. Create WorkingDQN (auto-selects device internally)
info!(" Creating WorkingDQN network...");
let mut dqn = WorkingDQN::new(config)
.context("Failed to create WorkingDQN")?;
// Log actual device used
let actual_device = match dqn.device() {
Device::Cpu => "CPU",
Device::Cuda(_) => "CUDA:0",
_ => "Unknown",
};
info!(" Device used: {} (auto-selected)", actual_device);
// 5. Load weights from SafeTensors
info!(" Loading weights from SafeTensors...");
let model_path_str = model_path.to_str()
.ok_or_else(|| anyhow::anyhow!("Model path contains invalid UTF-8: {}", model_path.display()))?;
dqn.load_from_safetensors(model_path_str)
.context(format!(
"Failed to load weights from {}\n\
Possible causes:\n\
- File is corrupted (try retraining)\n\
- Wrong architecture (expected: 225 → [128,64,32] → 3)\n\
- Incompatible tensor types or shapes\n\
- Device memory issue ({})",
model_path.display(),
device_name
))?;
info!("✅ Component 2: DQN checkpoint loaded successfully");
info!(" Model: {} (8 tensors: layer_0-2.weight/bias, output.weight/bias)", model_path.display());
Ok(dqn)
}
// ============================================================================
// Component 3: Parquet Data Loading
// ============================================================================
//
// Production 225-feature extraction pipeline is now imported from:
// ml::data_loaders::load_parquet_data
//
// This function:
// - Loads Parquet files with schema-agnostic OHLCV extraction
// - Extracts 225 features using Wave C + Wave D production pipeline
// - Handles warmup period (50 bars for technical indicators)
// - Validates NaN/Inf values
// - Sorts bars chronologically for rolling windows
//
// See: ml/src/data_loaders/parquet_utils.rs for implementation
// ============================================================================
// Component 4: Inference Engine
// ============================================================================
/// Result of a single DQN inference
#[derive(Debug, Clone)]
struct InferenceResult {
action: usize, // 0=BUY, 1=SELL, 2=HOLD
q_values: [f64; 3], // Q-value for each action
latency_us: u64, // Microseconds for this inference
}
/// Run DQN inference on all feature vectors with progress tracking
///
/// # Arguments
/// * `dqn` - WorkingDQN for inference (mutable for select_action)
/// * `features` - 225-dimensional feature vectors
/// * `shutdown_flag` - Atomic flag for graceful shutdown (Ctrl+C)
///
/// # Returns
/// Vector of inference results (action, Q-values, latency per bar)
///
/// # Notes
/// - Uses WorkingDQN's select_action() (returns TradingAction enum)
/// - Uses WorkingDQN's forward() to get Q-values separately
/// - Handles NaN/Inf gracefully by logging warnings and skipping bars
/// - Tracks latency per inference in microseconds
/// - Progress bar shows real-time inference speed
/// - Respects shutdown flag for graceful interruption
fn run_inference(
dqn: &mut WorkingDQN,
features: Vec<[f64; 125]>,
shutdown_flag: &Arc<AtomicBool>,
) -> Result<Vec<InferenceResult>> {
info!("🔍 Component 4: Running DQN inference");
let total_bars = features.len();
if total_bars == 0 {
return Err(anyhow::anyhow!("No feature vectors provided for inference"));
}
info!(" Total bars to process: {}", total_bars);
let mut results = Vec::with_capacity(total_bars);
let mut total_inference_time_us = 0u64;
let mut skipped_bars = 0usize;
let start_time = Instant::now();
let mut last_progress_update = Instant::now();
// Run inference for each feature vector
for (i, feature_vec) in features.iter().enumerate() {
// Check for shutdown signal
if shutdown_flag.load(Ordering::Relaxed) {
warn!(" ⚠️ Shutdown signal received, stopping inference at bar {}/{}", i, total_bars);
break;
}
// Start timer for this inference
let timer = Instant::now();
// Convert f64 features to f32 for DQN network
let state_f32: Vec<f32> = feature_vec.iter().map(|&x| x as f32).collect();
// Use WorkingDQN's select_action() for greedy inference (epsilon=0.0)
// This returns TradingAction enum
let trading_action = match dqn.select_action(state_f32.as_slice()) {
Ok(a) => a,
Err(e) => {
if skipped_bars < 10 {
warn!(" ⚠️ Bar {}: select_action failed: {}. Skipping.", i, e);
}
skipped_bars += 1;
continue;
}
};
// Convert TradingAction to usize (0=BUY, 1=SELL, 2=HOLD)
let action = trading_action.to_int() as usize;
// Get Q-values separately using forward pass
use candle_core::Tensor;
let state_tensor = match Tensor::from_vec(
state_f32,
(1, 125),
dqn.device(),
) {
Ok(t) => t,
Err(e) => {
if skipped_bars < 10 {
warn!(" ⚠️ Bar {}: Failed to create tensor: {}. Skipping.", i, e);
}
skipped_bars += 1;
continue;
}
};
let q_values_tensor = match dqn.forward(&state_tensor) {
Ok(qv) => qv,
Err(e) => {
if skipped_bars < 10 {
warn!(" ⚠️ Bar {}: Forward pass failed: {}. Skipping.", i, e);
}
skipped_bars += 1;
continue;
}
};
// Extract Q-values from tensor [1, 3] -> [3]
let q_values_vec: Vec<f32> = match q_values_tensor.squeeze(0)
.and_then(|t| t.to_vec1()) {
Ok(v) => v,
Err(e) => {
if skipped_bars < 10 {
warn!(" ⚠️ Bar {}: Failed to extract Q-values: {}. Skipping.", i, e);
}
skipped_bars += 1;
continue;
}
};
// Validate Q-values shape
if q_values_vec.len() != 3 {
if skipped_bars < 10 {
warn!(
" ⚠️ Bar {}: Expected 3 Q-values, got {}. Skipping.",
i,
q_values_vec.len()
);
}
skipped_bars += 1;
continue;
}
let q_values: [f64; 3] = [
q_values_vec[0] as f64,
q_values_vec[1] as f64,
q_values_vec[2] as f64,
];
// Check for NaN/Inf in Q-values
if q_values.iter().any(|&q| !q.is_finite()) {
if skipped_bars < 10 {
warn!(
" ⚠️ Bar {}: Q-values contain NaN/Inf, skipping. Q-values: {:?}",
i,
q_values
);
}
skipped_bars += 1;
continue;
}
// Calculate latency for this inference
let latency_us = timer.elapsed().as_micros() as u64;
total_inference_time_us += latency_us;
// Store result
results.push(InferenceResult {
action,
q_values,
latency_us,
});
// Update progress every 1 second or every 10% completion
let should_update = last_progress_update.elapsed().as_secs() >= 1
|| (i + 1) % (total_bars / 10).max(1) == 0
|| i == 0
|| i == total_bars - 1;
if should_update {
let elapsed_sec = start_time.elapsed().as_secs_f64();
let avg_speed = if elapsed_sec > 0.0 {
(i + 1) as f64 / elapsed_sec
} else {
0.0
};
let progress_pct = ((i + 1) as f64 / total_bars as f64) * 100.0;
info!(
" Progress: {}/{} ({:.1}%) | Speed: {:.1} bars/sec | Skipped: {}",
i + 1,
total_bars,
progress_pct,
avg_speed,
skipped_bars
);
last_progress_update = Instant::now();
}
}
info!("✅ Component 4: Inference complete");
// Calculate summary statistics
let processed_bars = results.len();
let total_time_sec = start_time.elapsed().as_secs_f64();
let avg_latency_us = if processed_bars > 0 {
total_inference_time_us / processed_bars as u64
} else {
0
};
let avg_speed = if total_time_sec > 0.0 {
processed_bars as f64 / total_time_sec
} else {
0.0
};
// Log summary with detailed metrics
info!(" Inference Summary:");
info!(" - Total bars: {}", total_bars);
info!(" - Processed: {}", processed_bars);
info!(" - Skipped (NaN/Inf/errors): {}", skipped_bars);
info!(" - Skip rate: {:.2}%", (skipped_bars as f64 / total_bars as f64) * 100.0);
info!(" - Total time: {:.2}s", total_time_sec);
info!(" - Average latency: {}μs ({:.2}ms)", avg_latency_us, avg_latency_us as f64 / 1000.0);
info!(" - Average speed: {:.1} bars/sec", avg_speed);
// Validate results
if results.is_empty() {
return Err(anyhow::anyhow!(
"All {} inference attempts failed (likely NaN/Inf in Q-values or network errors)",
total_bars
));
}
Ok(results)
}
// ============================================================================
// Component 5: Metrics Calculator (imported from evaluate_dqn_component5.rs)
// ============================================================================
/// DQN-specific inference result for metrics calculation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DQNInferenceResult {
pub action: usize,
pub q_values: [f64; 3],
pub latency_us: u64,
}
/// Comprehensive evaluation metrics for DQN model validation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvaluationMetrics {
pub total_bars: usize,
pub action_distribution: ActionDistribution,
pub avg_q_values: AvgQValues,
pub latency_stats: LatencyStats,
pub policy_consistency: PolicyConsistency,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionDistribution {
pub buy_count: usize,
pub sell_count: usize,
pub hold_count: usize,
pub buy_pct: f64,
pub sell_pct: f64,
pub hold_pct: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvgQValues {
pub buy_avg: f64,
pub sell_avg: f64,
pub hold_avg: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LatencyStats {
pub mean_us: f64,
pub median_us: u64,
pub p50_us: u64,
pub p95_us: u64,
pub p99_us: u64,
pub min_us: u64,
pub max_us: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyConsistency {
pub total_switches: usize,
pub switch_rate: f64,
pub interpretation: String,
}
/// Calculate comprehensive evaluation metrics from inference results
fn calculate_metrics(results: &[InferenceResult]) -> Result<EvaluationMetrics> {
info!("📊 Component 5: Calculating metrics");
if results.is_empty() {
return Err(anyhow::anyhow!("Cannot calculate metrics from empty results"));
}
let total_bars = results.len();
info!(" Total bars: {}", total_bars);
// 1. Action distribution
let mut buy_count = 0usize;
let mut sell_count = 0usize;
let mut hold_count = 0usize;
for result in results {
match result.action {
0 => buy_count += 1,
1 => sell_count += 1,
2 => hold_count += 1,
_ => warn!(" ⚠️ Invalid action: {}", result.action),
}
}
let buy_pct = (buy_count as f64 / total_bars as f64) * 100.0;
let sell_pct = (sell_count as f64 / total_bars as f64) * 100.0;
let hold_pct = (hold_count as f64 / total_bars as f64) * 100.0;
// 2. Average Q-values per action
let mut buy_q_sum = 0.0;
let mut sell_q_sum = 0.0;
let mut hold_q_sum = 0.0;
for result in results {
match result.action {
0 => buy_q_sum += result.q_values[0],
1 => sell_q_sum += result.q_values[1],
2 => hold_q_sum += result.q_values[2],
_ => {}
}
}
let buy_avg = if buy_count > 0 { buy_q_sum / buy_count as f64 } else { 0.0 };
let sell_avg = if sell_count > 0 { sell_q_sum / sell_count as f64 } else { 0.0 };
let hold_avg = if hold_count > 0 { hold_q_sum / hold_count as f64 } else { 0.0 };
// 3. Latency statistics
let mut latencies: Vec<u64> = results.iter().map(|r| r.latency_us).collect();
latencies.sort_unstable();
let mean_us = latencies.iter().sum::<u64>() as f64 / latencies.len() as f64;
let median_us = latencies[latencies.len() / 2];
let p50_us = median_us;
let p95_us = latencies[(latencies.len() as f64 * 0.95) as usize];
let p99_us = latencies[(latencies.len() as f64 * 0.99) as usize];
let min_us = latencies[0];
let max_us = latencies[latencies.len() - 1];
// 4. Policy consistency
let mut total_switches = 0usize;
for i in 1..results.len() {
if results[i].action != results[i - 1].action {
total_switches += 1;
}
}
let switch_rate = if results.len() > 1 {
total_switches as f64 / (results.len() - 1) as f64
} else {
0.0
};
let interpretation = if switch_rate < 0.10 {
"Stable - Low adaptability".to_string()
} else if switch_rate <= 0.30 {
"Moderate - Healthy adaptive behavior".to_string()
} else {
"Volatile - High uncertainty or noise".to_string()
};
info!("✅ Component 5: Metrics calculated successfully");
Ok(EvaluationMetrics {
total_bars,
action_distribution: ActionDistribution {
buy_count,
sell_count,
hold_count,
buy_pct,
sell_pct,
hold_pct,
},
avg_q_values: AvgQValues {
buy_avg,
sell_avg,
hold_avg,
},
latency_stats: LatencyStats {
mean_us,
median_us,
p50_us,
p95_us,
p99_us,
min_us,
max_us,
},
policy_consistency: PolicyConsistency {
total_switches,
switch_rate,
interpretation,
},
})
}
// ============================================================================
// Component 6: Report Generator
// ============================================================================
/// Generate comprehensive evaluation report
///
/// # Arguments
/// * `metrics` - Evaluation metrics from Component 5
/// * `config` - CLI configuration
/// * `elapsed` - Total elapsed time (including data loading, inference, etc.)
///
/// # Returns
/// Result indicating success/failure of report generation
///
/// # Side Effects
/// - Prints formatted report to stdout
/// - Writes JSON file if config.output_json is specified
/// - Validates production readiness thresholds
fn generate_report(
metrics: &EvaluationMetrics,
config: &EvaluationConfig,
elapsed: std::time::Duration,
) -> Result<()> {
info!("📝 Component 6: Generating evaluation report");
// 1. Print header
println!("\n╔══════════════════════════════════════════════════════════════════════╗");
println!("║ DQN MODEL EVALUATION REPORT ║");
println!("╚══════════════════════════════════════════════════════════════════════╝");
println!();
// 2. Configuration summary
println!("═══ Configuration ═══");
println!(" Model path: {}", config.model_path.display());
println!(" Data file: {}", config.parquet_file.display());
println!(" Device: {}", config.device);
println!(" Warmup bars: {}", config.warmup_bars);
println!(" Total runtime: {:.2}s", elapsed.as_secs_f64());
println!();
// 3. Action distribution
println!("═══ Action Distribution ═══");
println!(" BUY: {:5} ({:5.1}%)",
metrics.action_distribution.buy_count,
metrics.action_distribution.buy_pct);
println!(" SELL: {:5} ({:5.1}%)",
metrics.action_distribution.sell_count,
metrics.action_distribution.sell_pct);
println!(" HOLD: {:5} ({:5.1}%)",
metrics.action_distribution.hold_count,
metrics.action_distribution.hold_pct);
println!(" ────────────────────");
println!(" Total: {} bars", metrics.total_bars);
println!();
// 4. Q-value statistics
println!("═══ Average Q-Values ═══");
println!(" BUY: {:8.4}", metrics.avg_q_values.buy_avg);
println!(" SELL: {:8.4}", metrics.avg_q_values.sell_avg);
println!(" HOLD: {:8.4}", metrics.avg_q_values.hold_avg);
println!();
// 5. Latency statistics
println!("═══ Latency Statistics ═══");
println!(" Mean: {:6.1} μs ({:.3} ms)",
metrics.latency_stats.mean_us,
metrics.latency_stats.mean_us / 1000.0);
println!(" Median: {:6} μs ({:.3} ms)",
metrics.latency_stats.median_us,
metrics.latency_stats.median_us as f64 / 1000.0);
println!(" P95: {:6} μs ({:.3} ms)",
metrics.latency_stats.p95_us,
metrics.latency_stats.p95_us as f64 / 1000.0);
println!(" P99: {:6} μs ({:.3} ms)",
metrics.latency_stats.p99_us,
metrics.latency_stats.p99_us as f64 / 1000.0);
println!(" Range: {:6} - {:6} μs",
metrics.latency_stats.min_us,
metrics.latency_stats.max_us);
println!();
// 6. Policy consistency
println!("═══ Policy Consistency ═══");
println!(" Switches: {} / {} bars",
metrics.policy_consistency.total_switches,
metrics.total_bars - 1);
println!(" Switch rate: {:.1}%",
metrics.policy_consistency.switch_rate * 100.0);
println!(" Interpretation: {}",
metrics.policy_consistency.interpretation);
println!();
// 7. Production readiness check
println!("═══ Production Readiness Check ═══");
let latency_ok = metrics.latency_stats.p99_us < 5_000;
println!(" {} Latency P99 < 5,000μs: {} (actual: {} μs)",
if latency_ok { "✅" } else { "❌" },
latency_ok,
metrics.latency_stats.p99_us);
let consistency_ok = metrics.policy_consistency.switch_rate >= 0.10
&& metrics.policy_consistency.switch_rate <= 0.30;
println!(" {} Policy switch rate 10-30%: {} (actual: {:.1}%)",
if consistency_ok { "✅" } else { "❌" },
consistency_ok,
metrics.policy_consistency.switch_rate * 100.0);
let balance_ok = metrics.action_distribution.buy_pct >= 5.0
&& metrics.action_distribution.sell_pct >= 5.0
&& metrics.action_distribution.hold_pct >= 5.0;
println!(" {} Balanced actions (each >5%): {}",
if balance_ok { "✅" } else { "⚠️ " },
balance_ok);
let q_ok = metrics.avg_q_values.buy_avg.is_finite()
&& metrics.avg_q_values.sell_avg.is_finite()
&& metrics.avg_q_values.hold_avg.is_finite();
println!(" {} Q-values finite: {}",
if q_ok { "✅" } else { "❌" },
q_ok);
println!();
let all_ok = latency_ok && consistency_ok && q_ok;
if all_ok {
println!("🎉 Model is PRODUCTION READY!");
} else {
println!("⚠️ Model requires further tuning before production deployment");
}
println!();
// 8. Export JSON (if configured)
if let Some(ref output_path) = config.output_json {
info!(" Exporting metrics to JSON: {}", output_path.display());
let json = serde_json::to_string_pretty(&metrics)
.context("Failed to serialize metrics to JSON")?;
std::fs::write(output_path, json)
.context(format!("Failed to write JSON to {}", output_path.display()))?;
println!("✅ Metrics exported to: {}", output_path.display());
println!();
}
info!("✅ Component 6: Report generated successfully");
Ok(())
}
// ============================================================================
// Component 6.5: Action Export (CSV Format)
// ============================================================================
/// Export DQN actions with timestamps to CSV format
///
/// # Arguments
/// * `results` - Inference results (action, Q-values, latency)
/// * `bars` - Original OHLCV bars (synchronized with results)
/// * `output_path` - Path to CSV file (will be created/overwritten)
///
/// # CSV Format
/// ```csv
/// timestamp,action,q_buy,q_sell,q_hold,open,high,low,close,volume
/// 2024-10-20T09:30:00.000000000Z,2,0.4523,-0.1234,0.8912,5720.25,5721.00,5719.50,5720.75,1234
/// ```
///
/// # Errors
/// Returns error if:
/// - Input vectors have mismatched lengths
/// - Output directory doesn't exist
/// - File write fails
fn export_actions_to_csv(
results: &[InferenceResult],
bars: &[OHLCVBar],
output_path: &Path,
) -> Result<()> {
use csv::Writer;
info!("💾 Component 6.5: Exporting actions to CSV");
info!(" Output path: {}", output_path.display());
// 1. Validate input synchronization
if results.len() != bars.len() {
return Err(anyhow::anyhow!(
"Input vectors have mismatched lengths: results={}, bars={}",
results.len(),
bars.len()
));
}
let total_rows = results.len();
info!(" Total rows to export: {}", total_rows);
// 2. Validate output path
if let Some(parent) = output_path.parent() {
if !parent.exists() {
return Err(anyhow::anyhow!(
"Output directory does not exist: {}\n\
Suggestion: Create directory first:\n\
mkdir -p {}",
parent.display(),
parent.display()
));
}
}
// 3. Create CSV writer
let mut wtr = Writer::from_path(output_path)
.context(format!("Failed to create CSV file: {}", output_path.display()))?;
// 4. Write CSV header
wtr.write_record(&[
"timestamp",
"action",
"q_buy",
"q_sell",
"q_hold",
"open",
"high",
"low",
"close",
"volume",
])
.context("Failed to write CSV header")?;
// 5. Write data rows
for (result, bar) in results.iter().zip(bars.iter()) {
// Format timestamp as RFC3339 with nanosecond precision
let timestamp_str = bar.timestamp.to_rfc3339_opts(
chrono::SecondsFormat::Nanos,
true,
);
wtr.write_record(&[
timestamp_str,
result.action.to_string(),
format!("{:.4}", result.q_values[0]), // q_buy
format!("{:.4}", result.q_values[1]), // q_sell
format!("{:.4}", result.q_values[2]), // q_hold
format!("{:.2}", bar.open),
format!("{:.2}", bar.high),
format!("{:.2}", bar.low),
format!("{:.2}", bar.close),
(bar.volume as u64).to_string(),
])
.context(format!("Failed to write CSV row for timestamp {}", bar.timestamp))?;
}
// 6. Flush writer
wtr.flush()
.context("Failed to flush CSV writer")?;
// 7. Calculate file size
let metadata = std::fs::metadata(output_path)
.context("Failed to read file metadata")?;
let file_size_bytes = metadata.len();
let file_size_kb = file_size_bytes as f64 / 1024.0;
info!("✅ Component 6.5: CSV export complete");
info!(" Rows written: {}", total_rows);
info!(" File size: {:.2} KB ({} bytes)", file_size_kb, file_size_bytes);
info!(" Average bytes/row: {:.1}", file_size_bytes as f64 / total_rows as f64);
Ok(())
}
// ============================================================================
// Component 7: Main Orchestrator
// ============================================================================
#[tokio::main]
async fn main() -> Result<()> {
// ========================================================================
// PHASE 1: INITIALIZATION
// ========================================================================
// 1.1: Parse CLI arguments
let config = EvaluationConfig::parse();
// 1.2: Setup tracing subscriber (stdout logging)
let log_level = if config.verbose {
tracing::Level::DEBUG
} else {
tracing::Level::INFO
};
let subscriber = FmtSubscriber::builder()
.with_max_level(log_level)
.with_target(false)
.with_level(true)
.finish();
tracing::subscriber::set_global_default(subscriber)
.context("Failed to set tracing subscriber")?;
// 1.3: Log startup banner
info!("╔══════════════════════════════════════════════════════════════════════╗");
info!("║ DQN Model Evaluation Pipeline - Component 7 Orchestrator ║");
info!("║ Version: 1.0.0 ║");
info!("║ Timestamp: {} ║",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"));
info!("╚══════════════════════════════════════════════════════════════════════╝");
info!("");
// 1.4: Log configuration
info!("Configuration:");
info!(" • Model path: {}", config.model_path.display());
info!(" • Parquet file: {}", config.parquet_file.display());
info!(" • Device: {}", config.device);
info!(" • Warmup bars: {}", config.warmup_bars);
if let Some(ref output_path) = config.output_json {
info!(" • JSON output: {}", output_path.display());
}
info!(" • Verbose logging: {}", config.verbose);
info!("");
// 1.5: Validate configuration
info!("🔍 Validating configuration...");
config.validate()
.context("Configuration validation failed")?;
info!("✅ Configuration validated successfully");
info!("");
// 1.6: Setup graceful shutdown handler
let shutdown_flag = Arc::new(AtomicBool::new(false));
let shutdown_clone = shutdown_flag.clone();
tokio::spawn(async move {
let ctrl_c = signal::ctrl_c();
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut sigterm = signal(SignalKind::terminate())
.expect("Failed to setup SIGTERM handler");
tokio::select! {
_ = ctrl_c => {
info!("🛑 Received Ctrl+C, initiating graceful shutdown...");
}
_ = sigterm.recv() => {
info!("🛑 Received SIGTERM, initiating graceful shutdown...");
}
}
}
#[cfg(not(unix))]
{
ctrl_c.await.expect("Failed to listen for Ctrl+C");
info!("🛑 Received Ctrl+C, initiating graceful shutdown...");
}
shutdown_clone.store(true, Ordering::Relaxed);
});
info!("✅ Graceful shutdown handler registered (Ctrl+C / SIGTERM)");
info!("");
// Start total elapsed timer
let total_start = Instant::now();
// ========================================================================
// PHASE 2: PARALLEL DATA + MODEL LOADING
// ========================================================================
info!("⚡ Phase 2: Parallel loading (Data + Model)");
info!("");
// Clone paths for async move
let parquet_path = config.parquet_file.clone();
let model_path = config.model_path.clone();
let device_str = config.device.clone();
let warmup_bars = config.warmup_bars;
let need_bars = config.export_actions.is_some();
// Parallel loading using tokio::try_join!
// CRITICAL: Always load with timestamps/bars for preprocessing (close prices needed)
let (data_result, dqn_result) = tokio::try_join!(
tokio::task::spawn_blocking(move || {
load_parquet_data_with_timestamps(&parquet_path, warmup_bars)
}),
tokio::task::spawn_blocking(move || {
load_dqn_model(&model_path, &device_str)
})
).context("Parallel loading failed")?;
// Unwrap the spawn_blocking JoinError and the function Result
let (mut features, _timestamps, bars) = data_result?;
let mut dqn = dqn_result?;
// Keep bars for action export if requested
let bars_opt = if need_bars {
Some(bars.clone())
} else {
None
};
info!("");
info!("✅ Phase 2 complete: Data and model loaded in parallel");
info!("");
// ========================================================================
// PHASE 2.5: PREPROCESSING (Match Training Pipeline)
// ========================================================================
info!("🔬 Phase 2.5: Applying preprocessing (log returns + normalization + clipping)");
info!("");
// Extract close prices from OHLCV bars
let close_prices_f64: Vec<f64> = bars.iter().map(|b| b.close).collect();
let close_prices_f32: Vec<f32> = close_prices_f64.iter().map(|&x| x as f32).collect();
info!(" • Input data: {} bars", close_prices_f32.len());
info!(" • Close price range: [{:.2}, {:.2}]",
close_prices_f32.iter().cloned().fold(f32::INFINITY, f32::min),
close_prices_f32.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
// Create tensor on same device as model
let device = dqn.device().clone();
let close_tensor = Tensor::from_slice(&close_prices_f32, (close_prices_f32.len(),), &device)
.context("Failed to create close price tensor for preprocessing")?;
// Configure preprocessing (match training hyperparameters)
let preprocess_config = PreprocessConfig {
window_size: 50, // Default preprocessing window
clip_sigma: 5.0, // Default clip sigma
use_log_returns: true,
};
info!(" • Window size: {}", preprocess_config.window_size);
info!(" • Clip sigma: ±{:.1}σ", preprocess_config.clip_sigma);
info!(" • Method: log returns + windowed normalization");
// Apply preprocessing pipeline
let preprocessed_tensor = preprocess_prices(&close_tensor, preprocess_config)
.context("Preprocessing failed - check close prices for NaN/Inf/zeros")?;
let preprocessed_vec: Vec<f32> = preprocessed_tensor.to_vec1()
.context("Failed to convert preprocessed tensor to vec")?;
// Convert f32 to f64 for consistency with feature pipeline
let preprocessed_f64: Vec<f64> = preprocessed_vec.iter().map(|&x| x as f64).collect();
// Compute statistics for validation
let warmup = preprocess_config.window_size as usize;
let post_warmup: Vec<f64> = preprocessed_f64[warmup..].to_vec();
let mean = post_warmup.iter().sum::<f64>() / post_warmup.len() as f64;
let variance = post_warmup.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / post_warmup.len() as f64;
let std = variance.sqrt();
let min_val = post_warmup.iter().cloned().fold(f64::INFINITY, f64::min);
let max_val = post_warmup.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
info!("✅ Preprocessing complete:");
info!(" • Mean: {:.6} (expected ~0 for normalized data)", mean);
info!(" • Std: {:.4} (expected ~1 for normalized data)", std);
info!(" • Range: [{:.4}, {:.4}] (clipped at ±{:.1}σ)", min_val, max_val, preprocess_config.clip_sigma);
// Replace close prices in feature vectors with preprocessed values
// Feature vector structure: [101 Wave C features + 24 Wave D features]
// Close price is at index 3 (after timestamp, open, high, low)
info!(" • Replacing close prices in feature vectors with preprocessed values...");
for (i, feature_vec) in features.iter_mut().enumerate() {
if i < preprocessed_f64.len() {
feature_vec[3] = preprocessed_f64[i]; // Index 3 is close price
}
}
info!(" • Updated {} feature vectors with preprocessed close prices", features.len());
info!("");
info!("✅ Phase 2.5 complete: Features preprocessed to match training distribution");
info!("");
// ========================================================================
// PHASE 3: SEQUENTIAL INFERENCE
// ========================================================================
info!("🔍 Phase 3: Sequential inference");
info!("");
// Run inference
let inference_results = run_inference(&mut dqn, features, &shutdown_flag)
.context("Inference failed")?;
// Check if interrupted
if shutdown_flag.load(Ordering::Relaxed) {
warn!("⚠️ Evaluation interrupted by shutdown signal");
warn!(" Processed {} bars before interruption", inference_results.len());
// Still generate partial report
info!("");
info!("📊 Generating partial evaluation report...");
if !inference_results.is_empty() {
let partial_metrics = calculate_metrics(&inference_results)?;
let elapsed = total_start.elapsed();
generate_report(&partial_metrics, &config, elapsed)?;
}
info!("💾 Partial results saved, safe to terminate");
return Ok(());
}
info!("");
info!("✅ Phase 3 complete: Inference finished");
info!("");
// ========================================================================
// PHASE 4: METRICS CALCULATION
// ========================================================================
info!("📊 Phase 4: Metrics calculation");
info!("");
let metrics = calculate_metrics(&inference_results)
.context("Metrics calculation failed")?;
info!("");
info!("✅ Phase 4 complete: Metrics calculated");
info!("");
// ========================================================================
// PHASE 5: REPORT GENERATION
// ========================================================================
info!("📝 Phase 5: Report generation");
info!("");
let total_elapsed = total_start.elapsed();
generate_report(&metrics, &config, total_elapsed)
.context("Report generation failed")?;
info!("");
info!("✅ Phase 5 complete: Report generated");
info!("");
// ========================================================================
// PHASE 5.5: OPTIONAL ACTION EXPORT
// ========================================================================
if let Some(ref export_path) = config.export_actions {
info!("📤 Phase 5.5: Exporting actions to CSV");
info!("");
// Verify we have bars available
if let Some(bars) = bars_opt.as_ref() {
export_actions_to_csv(
&inference_results,
bars,
export_path,
)
.context("Action export failed")?;
info!("");
info!("✅ Phase 5.5 complete: Actions exported to {}", export_path.display());
info!("");
} else {
warn!("⚠️ Cannot export actions: bars were not loaded (internal error)");
}
}
// ========================================================================
// COMPLETION
// ========================================================================
info!("╔══════════════════════════════════════════════════════════════════════╗");
info!("║ EVALUATION COMPLETE ║");
info!("╚══════════════════════════════════════════════════════════════════════╝");
info!(" Total runtime: {:.2}s", total_elapsed.as_secs_f64());
info!("");
Ok(())
}