Files
foxhunt/ml/examples/evaluate_dqn.rs
jgrusewski f946dcd952 feat: Wave 2 - Update MEDIUM RISK files (225→54 features)
WAVE 22: All examples, benchmarks, and data loaders updated

Files Modified (41 files):
- DQN examples: 7 files (train_dqn, evaluate_dqn, validate_dqn, etc.)
- PPO examples: 6 files (train_ppo, continuous_ppo, benchmark_ppo, etc.)
- TFT examples: 9 files (train_tft, validate_tft, benchmark_tft, etc.)
- MAMBA-2 examples: 3 files (train_mamba2, verify_dimensions, etc.)
- Benchmarks: 5 files (cuda_speedup, weight_caching, future_decoder, etc.)
- Data loaders: 7 files (parquet_utils, dbn_sequence_loader, tlob_loader, etc.)
- Integration: 4 files (load_parquet_data, streaming loaders, etc.)

Key Changes:
- state_dim: 225 → 54 (DQN, PPO)
- input_dim: 225 → 54 (TFT)
- d_model: 225 → 54 (MAMBA-2)
- Memory: 1.8KB → 0.43KB per vector (76% reduction)
- All tensor shapes updated: (batch, 225) → (batch, 54)

Agents Deployed: 5 parallel agents
Validation: cargo check PASSING

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 00:57:17 +01:00

1450 lines
48 KiB
Rust

//! DQN Model Evaluation Example
//!
//! Evaluates a trained DQN model on unseen OHLCV data from Parquet files.
//! Generates comprehensive performance metrics including win rate, Sharpe ratio,
//! average reward, and detailed trade statistics for production readiness assessment.
//!
//! # Features
//!
//! - **Model Loading**: SafeTensors format with checksum validation
//! - **Parquet Data**: Fast OHLCV loading with DBN-compatible schema
//! - **Production Metrics**: Sharpe, win rate, drawdown, PnL curve
//! - **Device Flexibility**: CPU, CUDA, or auto-detection
//! - **CI/CD Integration**: JSON output for automated testing pipelines
//!
//! # Usage
//!
//! ```bash
//! # Evaluate with default settings (auto-detect CUDA)
//! cargo run -p ml --example evaluate_dqn --release --features cuda
//!
//! # Evaluate on CPU with custom model
//! cargo run -p ml --example evaluate_dqn --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 --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 --release --features cuda -- \
//! --warmup-bars 30 \
//! --parquet-file test_data/ES_FUT_validation.parquet
//! ```
//!
//! # Output Metrics
//!
//! - **Sharpe Ratio**: Risk-adjusted return (target: >2.0 for production)
//! - **Win Rate**: Percentage of profitable trades (target: >55%)
//! - **Max Drawdown**: Largest peak-to-trough decline (target: <20%)
//! - **Average Reward**: Mean reward per action
//! - **Total PnL**: Cumulative profit/loss
//! - **Trade Count**: Number of executed trades
//!
//! # File Format Requirements
//!
//! **Model File** (SafeTensors):
//! - DQN Q-network weights and biases
//! - Trained via `train_dqn.rs` with `--output` flag
//! - Must match expected architecture (input: 50 features, output: 3 actions)
//!
//! **Parquet File** (OHLCV):
//! - Columns: `ts_event`, `open`, `high`, `low`, `close`, `volume`
//! - Must be disjoint from training data (temporal split)
//! - Minimum length: warmup_bars + 100 rows
//!
//! # Production Certification Criteria
//!
//! A model is **production-ready** if it meets ALL thresholds:
//! - Sharpe Ratio >= 2.0
//! - Win Rate >= 55%
//! - Max Drawdown <= 20%
//! - No NaN/Inf in Q-values (checked during evaluation)
//! - Evaluation time < 5 minutes (real-time constraint)
use anyhow::{Context, Result};
use clap::Parser;
use std::path::PathBuf;
use tracing::info;
use tracing_subscriber::FmtSubscriber;
// NOTE: The following imports are placeholders for Component 5 integration
// Once component 5 (evaluate_dqn_component5.rs) is integrated into this file,
// these types will be defined as structs in this module.
// For now, we're keeping the import commented to allow compilation.
//
// Expected types from component 5:
// - EvaluationMetrics (main metrics struct)
// - ActionDistribution (buy/sell/hold counts and percentages)
// - AvgQValues (average Q-values per action)
// - LatencyStats (inference performance metrics)
// - PolicyConsistency (action switching behavior)
//
// These types will be moved from evaluate_dqn_component5.rs into this file
// during the final integration phase.
// TODO: UNCOMMENT THIS AFTER COMPONENT 5 INTEGRATION
// use evaluate_dqn_component5::{
// AvgQValues, ActionDistribution, EvaluationMetrics, LatencyStats, PolicyConsistency,
// };
// STUB TYPE DEFINITIONS - WILL BE REPLACED WITH ACTUAL TYPES FROM COMPONENT 5
// These are minimal stubs to allow generate_report() to compile.
// During integration, these will be deleted and replaced with the full types
// from evaluate_dqn_component5.rs (which includes serde derives, validation, etc.)
#[allow(dead_code)]
struct EvaluationMetrics {
total_bars: usize,
action_distribution: ActionDistribution,
avg_q_values: AvgQValues,
latency_stats: LatencyStats,
policy_consistency: PolicyConsistency,
}
#[allow(dead_code)]
struct ActionDistribution {
buy_count: usize,
sell_count: usize,
hold_count: usize,
buy_pct: f64,
sell_pct: f64,
hold_pct: f64,
}
#[allow(dead_code)]
struct AvgQValues {
buy_avg: f64,
sell_avg: f64,
hold_avg: f64,
}
#[allow(dead_code)]
struct LatencyStats {
mean_us: f64,
median_us: u64,
p50_us: u64,
p95_us: u64,
p99_us: u64,
min_us: u64,
max_us: u64,
}
#[allow(dead_code)]
struct PolicyConsistency {
total_switches: usize,
switch_rate: f64,
interpretation: String,
}
/// 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)
///
/// # Examples
///
/// ```no_run
/// use clap::Parser;
/// use std::path::PathBuf;
///
/// let config = EvaluationConfig::parse_from(&[
/// "evaluate_dqn",
/// "--model-path", "ml/trained_models/dqn_final_epoch100.safetensors",
/// "--parquet-file", "test_data/ES_FUT_unseen.parquet",
/// "--device", "cuda",
/// "--warmup-bars", "50",
/// ]);
///
/// config.validate().expect("Invalid configuration");
/// ```
#[derive(Parser, Debug)]
#[command(
name = "evaluate_dqn",
about = "Evaluate DQN model on unseen market data",
long_about = "Evaluates a trained DQN model on unseen OHLCV data from Parquet files. \
Generates comprehensive performance metrics including Sharpe ratio, win rate, \
drawdown, and trade statistics for production readiness assessment."
)]
struct EvaluationConfig {
/// Path to trained DQN model (SafeTensors format)
///
/// The model file must be in SafeTensors format, typically generated by the
/// `train_dqn.rs` example with the `--output` flag. The file should contain
/// the Q-network weights and biases for a DQN agent trained on market data.
///
/// Expected architecture:
/// - Input: 50 OHLCV-derived features (close, volume, returns, etc.)
/// - Output: 3 actions (buy=0, hold=1, sell=2)
///
/// # Example
///
/// ```bash
/// --model-path ml/trained_models/dqn_final_epoch100.safetensors
/// ```
#[arg(long, default_value = "/tmp/dqn_final_model.safetensors")]
model_path: PathBuf,
/// Path to Parquet file with unseen OHLCV data
///
/// The Parquet file must contain the following columns:
/// - `ts_event`: Timestamp (nanoseconds since epoch)
/// - `open`: Opening price
/// - `high`: Highest price
/// - `low`: Lowest price
/// - `close`: Closing price
/// - `volume`: Trading volume
///
/// The data MUST be temporally disjoint from training data to ensure
/// unbiased evaluation. Recommend using data from a different time period
/// or market regime.
///
/// Minimum length: warmup_bars + 100 rows (ensures sufficient evaluation period)
///
/// # Example
///
/// ```bash
/// --parquet-file test_data/ES_FUT_unseen.parquet
/// ```
#[arg(long, default_value = "test_data/ES_FUT_unseen.parquet")]
parquet_file: PathBuf,
/// Device selection: cpu, cuda, or auto
///
/// - `cpu`: Force CPU execution (slow but guaranteed compatible)
/// - `cuda`: Force CUDA GPU execution (requires NVIDIA GPU with CUDA 12.4+)
/// - `auto`: Auto-detect CUDA availability (recommended)
///
/// Auto-detection uses the following logic:
/// 1. Check if CUDA is available via `Device::cuda_if_available()`
/// 2. If CUDA available and `--features cuda` enabled, use GPU
/// 3. Otherwise, fallback to CPU with a warning
///
/// # Performance Impact
///
/// - CPU: ~5-10ms per inference
/// - CUDA: ~200-500μs per inference (10-50x speedup)
///
/// # Example
///
/// ```bash
/// --device auto # Recommended for most use cases
/// --device cuda # Force GPU (fails if CUDA unavailable)
/// --device cpu # Force CPU (for debugging or compatibility)
/// ```
#[arg(long, default_value = "auto")]
device: String,
/// Number of warmup bars to skip (insufficient history)
///
/// The DQN model requires historical features (e.g., returns, moving averages)
/// to make informed decisions. The first N bars of the Parquet file will be
/// skipped to allow sufficient history accumulation.
///
/// Valid range: [10, 100]
/// - Minimum 10: Ensures basic features (10-bar SMA) are available
/// - Maximum 100: Prevents excessive data waste in evaluation
///
/// Recommended values:
/// - 20: For simple features (returns, 5-bar MA)
/// - 50: For complex features (RSI, Bollinger Bands, 50-bar MA) - DEFAULT
/// - 100: For very long-term features (100-bar MA, regime detection)
///
/// # Example
///
/// ```bash
/// --warmup-bars 50 # Default, suitable for most models
/// --warmup-bars 20 # For fast models with short-term features
/// --warmup-bars 100 # For models with long-term regime detection
/// ```
#[arg(long, default_value_t = 50)]
warmup_bars: usize,
/// Optional JSON output path for CI/CD integration
///
/// If specified, evaluation results will be written to a JSON file with the
/// following schema:
///
/// ```json
/// {
/// "sharpe_ratio": 2.34,
/// "win_rate": 0.58,
/// "max_drawdown": 0.15,
/// "total_pnl": 1250.75,
/// "avg_reward": 0.012,
/// "trade_count": 432,
/// "evaluation_time_seconds": 12.5,
/// "model_path": "ml/trained_models/dqn_final_epoch100.safetensors",
/// "data_path": "test_data/ES_FUT_unseen.parquet",
/// "production_ready": true
/// }
/// ```
///
/// This is useful for:
/// - CI/CD pipelines (automated testing, deployment gates)
/// - A/B testing (comparing model versions)
/// - Hyperparameter optimization (tracking best model)
/// - Production monitoring (regression detection)
///
/// # Example
///
/// ```bash
/// --output-json evaluation_results.json
/// ```
#[arg(long)]
output_json: Option<PathBuf>,
/// Verbose logging (DEBUG level)
///
/// Enables detailed logging of evaluation progress, including:
/// - Bar-by-bar action decisions
/// - Q-value distributions
/// - Feature normalization statistics
/// - Memory usage tracking
///
/// Useful for debugging model behavior and identifying edge cases.
///
/// # Example
///
/// ```bash
/// -v # Enable verbose logging
/// ```
#[arg(short, long)]
verbose: bool,
}
impl EvaluationConfig {
/// Validates the configuration parameters
///
/// # Validation Rules
///
/// 1. **Model Path**: Must exist and be a regular file
/// 2. **Parquet File**: Must exist and be a regular file
/// 3. **Device**: Must be one of "cpu", "cuda", or "auto"
/// 4. **Warmup Bars**: Must be in range [10, 100]
///
/// # Errors
///
/// Returns an error if any validation rule fails. Error messages are
/// actionable and include suggestions for fixing the issue.
///
/// # Examples
///
/// ```no_run
/// use clap::Parser;
///
/// let config = EvaluationConfig::parse_from(&[
/// "evaluate_dqn",
/// "--model-path", "ml/trained_models/dqn_final_epoch100.safetensors",
/// "--parquet-file", "test_data/ES_FUT_unseen.parquet",
/// ]);
///
/// // Validate configuration before evaluation
/// config.validate().expect("Configuration validation failed");
/// ```
pub 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(())
}
}
#[tokio::main]
async fn main() -> Result<()> {
// Parse CLI options
let config = EvaluationConfig::parse();
// Setup logging
let level = if config.verbose {
tracing::Level::DEBUG
} else {
tracing::Level::INFO
};
let subscriber = FmtSubscriber::builder().with_max_level(level).finish();
tracing::subscriber::set_global_default(subscriber)
.context("Failed to set tracing subscriber")?;
info!("🚀 Starting DQN Model Evaluation");
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());
}
// Validate configuration
info!("🔍 Validating configuration...");
config
.validate()
.context("Configuration validation failed")?;
info!("✅ Configuration validated successfully");
// TODO: Component 2 - Model Loading
// Load DQN model from SafeTensors file
// Expected signature: DQNModel::load(model_path, device) -> Result<DQNModel>
// TODO: Component 3 - Parquet Data Loading
// Load OHLCV data from Parquet file
// Expected signature: load_ohlcv_from_parquet(parquet_file) -> Result<Vec<OHLCVBar>>
// TODO: Component 4 - Feature Computation
// Compute features for each bar (skip warmup period)
// Expected signature: compute_features(bars, warmup_bars) -> Result<Vec<Features>>
// TODO: Component 5 - Evaluation Loop
// Run DQN model on each bar and collect metrics
// Expected signature: evaluate_model(model, features) -> Result<EvaluationMetrics>
// TODO: Component 6 - Report Generation
// Generate comprehensive evaluation report
// Expected signature: generate_report(metrics, config, elapsed) -> Result<()>
info!("✅ DQN evaluation complete!");
Ok(())
}
/// Generate comprehensive evaluation report
///
/// Outputs a beautifully formatted evaluation report with:
/// - Header section (timestamp, paths, device, elapsed time)
/// - Data summary (total/warmup/remaining bars)
/// - Action distribution with ASCII bar charts and color coding
/// - Q-value analysis (average per action)
/// - Latency performance (mean, median, P95, P99)
/// - Policy consistency (switch rate with interpretation)
/// - Optional JSON export
///
/// # Arguments
///
/// * `metrics` - Calculated evaluation metrics
/// * `config` - CLI configuration (paths, device info)
/// * `elapsed` - Total evaluation duration
///
/// # Returns
///
/// Result with optional JSON export confirmation
///
/// # Errors
///
/// Returns an error if:
/// - JSON export fails (file write error)
/// - Metrics contain invalid values (NaN/Inf)
///
/// # Example
///
/// ```no_run
/// let metrics = calculate_metrics(&inference_results)?;
/// let elapsed = start_time.elapsed();
/// generate_report(&metrics, &config, elapsed)?;
/// ```
fn generate_report(
metrics: &EvaluationMetrics,
config: &EvaluationConfig,
elapsed: std::time::Duration,
) -> Result<()> {
use colored::Colorize;
// Print header section
println!();
println!(
"{}",
"═══════════════════════════════════════════════════════════"
.bright_blue()
.bold()
);
println!("{}", " DQN MODEL EVALUATION REPORT".bright_blue().bold());
println!(
"{}",
"═══════════════════════════════════════════════════════════"
.bright_blue()
.bold()
);
println!();
// Timestamp and paths
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
println!("{}", format!("Timestamp: {}", timestamp).cyan());
// Model file info
if let Ok(metadata) = std::fs::metadata(&config.model_path) {
let file_size_mb = metadata.len() as f64 / 1_048_576.0;
println!(
"{}",
format!(
"Model path: {} ({:.2} MB)",
config.model_path.display(),
file_size_mb
)
.cyan()
);
} else {
println!(
"{}",
format!("Model path: {}", config.model_path.display()).cyan()
);
}
// Parquet file info
if let Ok(metadata) = std::fs::metadata(&config.parquet_file) {
let file_size_mb = metadata.len() as f64 / 1_048_576.0;
println!(
"{}",
format!(
"Data path: {} ({:.2} MB)",
config.parquet_file.display(),
file_size_mb
)
.cyan()
);
} else {
println!(
"{}",
format!("Data path: {}", config.parquet_file.display()).cyan()
);
}
println!(
"{}",
format!("Device: {}", config.device.to_uppercase()).cyan()
);
println!(
"{}",
format!("Total evaluation time: {:.2}s", elapsed.as_secs_f64()).cyan()
);
println!();
// Data summary section
println!(
"{}",
"───────────────────────────────────────────────────────────".bright_black()
);
println!("{}", " DATA SUMMARY".bright_white().bold());
println!(
"{}",
"───────────────────────────────────────────────────────────".bright_black()
);
println!();
let total_bars_before_warmup = metrics.total_bars + config.warmup_bars;
println!(
"{}",
format!(
" Total bars in dataset: {}",
format_number(total_bars_before_warmup)
)
.white()
);
println!(
"{}",
format!(
" Warmup bars skipped: {}",
format_number(config.warmup_bars)
)
.yellow()
);
println!(
"{}",
format!(" Bars evaluated: {}", format_number(metrics.total_bars))
.green()
.bold()
);
println!();
// Action distribution section with ASCII bar charts
println!(
"{}",
"───────────────────────────────────────────────────────────".bright_black()
);
println!("{}", " ACTION DISTRIBUTION".bright_white().bold());
println!(
"{}",
"───────────────────────────────────────────────────────────".bright_black()
);
println!();
let dist = &metrics.action_distribution;
// BUY (green)
let buy_bar = create_ascii_bar(dist.buy_pct, 33);
println!(
"{}",
format!(
" BUY: {:>6} ({:>5.2}%) {}",
format_number(dist.buy_count),
dist.buy_pct,
buy_bar
)
.green()
);
// SELL (red)
let sell_bar = create_ascii_bar(dist.sell_pct, 33);
println!(
"{}",
format!(
" SELL: {:>6} ({:>5.2}%) {}",
format_number(dist.sell_count),
dist.sell_pct,
sell_bar
)
.red()
);
// HOLD (blue)
let hold_bar = create_ascii_bar(dist.hold_pct, 33);
println!(
"{}",
format!(
" HOLD: {:>6} ({:>5.2}%) {}",
format_number(dist.hold_count),
dist.hold_pct,
hold_bar
)
.blue()
);
println!();
// Q-value analysis section
println!(
"{}",
"───────────────────────────────────────────────────────────".bright_black()
);
println!("{}", " Q-VALUE ANALYSIS".bright_white().bold());
println!(
"{}",
"───────────────────────────────────────────────────────────".bright_black()
);
println!();
let avg_q = &metrics.avg_q_values;
println!(
"{}",
format!(" Avg Q-Value (BUY): {:>8.4}", avg_q.buy_avg).green()
);
println!(
"{}",
format!(" Avg Q-Value (SELL): {:>8.4}", avg_q.sell_avg).red()
);
println!(
"{}",
format!(" Avg Q-Value (HOLD): {:>8.4}", avg_q.hold_avg).blue()
);
println!();
// Latency performance section
println!(
"{}",
"───────────────────────────────────────────────────────────".bright_black()
);
println!("{}", " LATENCY PERFORMANCE".bright_white().bold());
println!(
"{}",
"───────────────────────────────────────────────────────────".bright_black()
);
println!();
let lat = &metrics.latency_stats;
println!("{}", format!(" Mean: {:>8.1} μs", lat.mean_us).white());
println!("{}", format!(" Median: {:>8} μs", lat.median_us).white());
println!("{}", format!(" P95: {:>8} μs", lat.p95_us).yellow());
// Color code P99 based on production threshold (<5,000μs)
let p99_display = if lat.p99_us < 5_000 {
format!(" P99: {:>8} μs ✅ Real-time suitable", lat.p99_us).green()
} else {
format!(
" P99: {:>8} μs ⚠️ Exceeds 5ms threshold",
lat.p99_us
)
.red()
};
println!("{}", p99_display);
println!(
"{}",
format!(" Min/Max: {:>8} / {} μs", lat.min_us, lat.max_us).bright_black()
);
println!();
// Policy consistency section
println!(
"{}",
"───────────────────────────────────────────────────────────".bright_black()
);
println!("{}", " POLICY CONSISTENCY".bright_white().bold());
println!(
"{}",
"───────────────────────────────────────────────────────────".bright_black()
);
println!();
let pol = &metrics.policy_consistency;
println!(
"{}",
format!(" Total switches: {}", format_number(pol.total_switches)).white()
);
// Color code switch rate based on thresholds
let switch_rate_pct = pol.switch_rate * 100.0;
let switch_rate_display = if pol.switch_rate < 0.10 {
format!(
" Switch rate: {:>5.2}% 🟡 {}",
switch_rate_pct, pol.interpretation
)
.yellow()
} else if pol.switch_rate <= 0.30 {
format!(
" Switch rate: {:>5.2}% ✅ {}",
switch_rate_pct, pol.interpretation
)
.green()
} else {
format!(
" Switch rate: {:>5.2}% 🔴 {}",
switch_rate_pct, pol.interpretation
)
.red()
};
println!("{}", switch_rate_display);
println!();
// Footer
println!(
"{}",
"═══════════════════════════════════════════════════════════"
.bright_blue()
.bold()
);
println!();
// JSON export (if specified)
if let Some(ref output_path) = config.output_json {
info!("💾 Exporting results to JSON: {}", output_path.display());
let json_output = serde_json::json!({
"timestamp": timestamp.to_string(),
"model_path": config.model_path.to_string_lossy(),
"data_path": config.parquet_file.to_string_lossy(),
"device": config.device,
"evaluation_time_seconds": elapsed.as_secs_f64(),
"warmup_bars": config.warmup_bars,
"total_bars": metrics.total_bars,
"action_distribution": {
"buy_count": dist.buy_count,
"buy_pct": dist.buy_pct,
"sell_count": dist.sell_count,
"sell_pct": dist.sell_pct,
"hold_count": dist.hold_count,
"hold_pct": dist.hold_pct,
},
"avg_q_values": {
"buy_avg": avg_q.buy_avg,
"sell_avg": avg_q.sell_avg,
"hold_avg": avg_q.hold_avg,
},
"latency_stats": {
"mean_us": lat.mean_us,
"median_us": lat.median_us,
"p50_us": lat.p50_us,
"p95_us": lat.p95_us,
"p99_us": lat.p99_us,
"min_us": lat.min_us,
"max_us": lat.max_us,
},
"policy_consistency": {
"total_switches": pol.total_switches,
"switch_rate": pol.switch_rate,
"interpretation": pol.interpretation,
},
"production_ready": lat.p99_us < 5_000 && pol.switch_rate >= 0.10 && pol.switch_rate <= 0.30,
});
let json_file = std::fs::File::create(output_path).context(format!(
"Failed to create JSON output file: {}",
output_path.display()
))?;
serde_json::to_writer_pretty(json_file, &json_output)
.context("Failed to write JSON output")?;
info!("✅ JSON export complete: {}", output_path.display());
}
Ok(())
}
/// Create ASCII bar chart
///
/// Generates a visual bar chart using block characters.
///
/// # Arguments
///
/// * `percentage` - Value from 0-100
/// * `max_width` - Maximum bar width in characters
///
/// # Returns
///
/// String of filled blocks representing the percentage
///
/// # Example
///
/// ```no_run
/// let bar = create_ascii_bar(66.67, 33);
/// // Returns: "██████████████████████ " (22 filled, 11 empty)
/// ```
fn create_ascii_bar(percentage: f64, max_width: usize) -> String {
let filled_width = ((percentage / 100.0) * max_width as f64).round() as usize;
let filled_width = filled_width.min(max_width); // Clamp to max_width
let filled = "".repeat(filled_width);
let empty = " ".repeat(max_width.saturating_sub(filled_width));
format!("{}{}", filled, empty)
}
/// Format number with thousands separators
///
/// Converts a number to a string with comma separators for readability.
///
/// # Arguments
///
/// * `n` - Number to format
///
/// # Returns
///
/// Formatted string with commas (e.g., "1,234,567")
///
/// # Example
///
/// ```no_run
/// assert_eq!(format_number(1234567), "1,234,567");
/// assert_eq!(format_number(42), "42");
/// ```
fn format_number(n: usize) -> String {
n.to_string()
.as_bytes()
.rchunks(3)
.rev()
.map(std::str::from_utf8)
.collect::<Result<Vec<&str>, _>>()
.unwrap()
.join(",")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_device_validation_valid() {
// Valid devices
let valid_devices = vec!["cpu", "cuda", "auto"];
for device in valid_devices {
let config = EvaluationConfig::parse_from(&[
"evaluate_dqn",
"--model-path",
"ml/trained_models/dqn_final_epoch100.safetensors",
"--parquet-file",
"test_data/ES_FUT_unseen.parquet",
"--device",
device,
]);
assert_eq!(config.device, device);
}
}
#[test]
fn test_device_validation_invalid() {
use std::io::Write;
use tempfile::NamedTempFile;
// Create temporary files to pass file existence checks
let mut model_file = NamedTempFile::new().unwrap();
model_file.write_all(b"dummy model data").unwrap();
let model_path = model_file.path().to_string_lossy().to_string();
let mut parquet_file = NamedTempFile::new().unwrap();
parquet_file.write_all(b"dummy parquet data").unwrap();
let parquet_path = parquet_file.path().to_string_lossy().to_string();
let config = EvaluationConfig::parse_from(&[
"evaluate_dqn",
"--model-path",
&model_path,
"--parquet-file",
&parquet_path,
"--device",
"gpu", // Invalid device (should be "cuda", not "gpu")
]);
let result = config.validate();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Invalid device: 'gpu'"));
assert!(err_msg.contains("Valid options"));
}
#[test]
fn test_warmup_bars_validation_min() {
use std::io::Write;
use tempfile::NamedTempFile;
// Create temporary files to pass file existence checks
let mut model_file = NamedTempFile::new().unwrap();
model_file.write_all(b"dummy model data").unwrap();
let model_path = model_file.path().to_string_lossy().to_string();
let mut parquet_file = NamedTempFile::new().unwrap();
parquet_file.write_all(b"dummy parquet data").unwrap();
let parquet_path = parquet_file.path().to_string_lossy().to_string();
let config = EvaluationConfig::parse_from(&[
"evaluate_dqn",
"--model-path",
&model_path,
"--parquet-file",
&parquet_path,
"--warmup-bars",
"5", // Below minimum (10)
]);
let result = config.validate();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Warmup bars too small"));
assert!(err_msg.contains("minimum: 10"));
}
#[test]
fn test_warmup_bars_validation_max() {
use std::io::Write;
use tempfile::NamedTempFile;
// Create temporary files to pass file existence checks
let mut model_file = NamedTempFile::new().unwrap();
model_file.write_all(b"dummy model data").unwrap();
let model_path = model_file.path().to_string_lossy().to_string();
let mut parquet_file = NamedTempFile::new().unwrap();
parquet_file.write_all(b"dummy parquet data").unwrap();
let parquet_path = parquet_file.path().to_string_lossy().to_string();
let config = EvaluationConfig::parse_from(&[
"evaluate_dqn",
"--model-path",
&model_path,
"--parquet-file",
&parquet_path,
"--warmup-bars",
"150", // Above maximum (100)
]);
let result = config.validate();
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Warmup bars too large"));
assert!(err_msg.contains("maximum: 100"));
}
#[test]
fn test_warmup_bars_validation_valid() {
let config = EvaluationConfig::parse_from(&[
"evaluate_dqn",
"--model-path",
"ml/trained_models/dqn_final_epoch100.safetensors",
"--parquet-file",
"test_data/ES_FUT_unseen.parquet",
"--warmup-bars",
"50", // Within valid range [10, 100]
]);
assert_eq!(config.warmup_bars, 50);
// Note: Full validation will fail due to non-existent files in test env
// This test only validates the warmup_bars parsing
}
#[test]
fn test_default_values() {
let config = EvaluationConfig::parse_from(&["evaluate_dqn"]);
assert_eq!(
config.model_path,
PathBuf::from("/tmp/dqn_final_model.safetensors")
);
assert_eq!(
config.parquet_file,
PathBuf::from("test_data/ES_FUT_unseen.parquet")
);
assert_eq!(config.device, "auto");
assert_eq!(config.warmup_bars, 50);
assert_eq!(config.output_json, None);
assert_eq!(config.verbose, false);
}
#[test]
fn test_output_json_parsing() {
let config = EvaluationConfig::parse_from(&[
"evaluate_dqn",
"--output-json",
"results/evaluation.json",
]);
assert_eq!(
config.output_json,
Some(PathBuf::from("results/evaluation.json"))
);
}
#[test]
fn test_verbose_flag() {
let config = EvaluationConfig::parse_from(&["evaluate_dqn", "-v"]);
assert_eq!(config.verbose, true);
let config = EvaluationConfig::parse_from(&["evaluate_dqn", "--verbose"]);
assert_eq!(config.verbose, true);
}
// ===========================
// Report Generator Tests
// ===========================
#[test]
fn test_create_ascii_bar_full() {
// 100% should create full bar (33 characters)
let bar = create_ascii_bar(100.0, 33);
let char_count = bar.chars().count();
assert_eq!(char_count, 33);
assert_eq!(bar, "".repeat(33));
}
#[test]
fn test_create_ascii_bar_half() {
// 50% should create half-filled bar (20 characters total)
let bar = create_ascii_bar(50.0, 20);
let char_count = bar.chars().count();
assert_eq!(char_count, 20);
// 50% of 20 = 10 filled blocks
let filled_blocks = bar.chars().filter(|&c| c == '█').count();
assert_eq!(filled_blocks, 10);
assert!(bar.starts_with(&"".repeat(10)));
assert!(bar.ends_with(&" ".repeat(10)));
}
#[test]
fn test_create_ascii_bar_empty() {
// 0% should create empty bar (33 characters)
let bar = create_ascii_bar(0.0, 33);
let char_count = bar.chars().count();
assert_eq!(char_count, 33);
assert_eq!(bar, " ".repeat(33));
}
#[test]
fn test_create_ascii_bar_third() {
// 33.33% should create ~1/3 filled bar (33 characters)
let bar = create_ascii_bar(33.33, 33);
let char_count = bar.chars().count();
assert_eq!(char_count, 33);
// 33.33% of 33 = 11 filled blocks (rounded)
let filled_blocks = bar.chars().filter(|&c| c == '█').count();
assert_eq!(filled_blocks, 11);
}
#[test]
fn test_create_ascii_bar_over_100() {
// >100% should clamp to max_width (20 characters)
let bar = create_ascii_bar(150.0, 20);
let char_count = bar.chars().count();
assert_eq!(char_count, 20);
assert_eq!(bar, "".repeat(20)); // Should be fully filled
}
#[test]
fn test_format_number_small() {
// Small numbers (no commas)
assert_eq!(format_number(0), "0");
assert_eq!(format_number(42), "42");
assert_eq!(format_number(999), "999");
}
#[test]
fn test_format_number_thousands() {
// Thousands separator
assert_eq!(format_number(1_000), "1,000");
assert_eq!(format_number(1_234), "1,234");
assert_eq!(format_number(9_999), "9,999");
}
#[test]
fn test_format_number_millions() {
// Millions separator
assert_eq!(format_number(1_000_000), "1,000,000");
assert_eq!(format_number(1_234_567), "1,234,567");
}
#[test]
fn test_format_number_realistic() {
// Realistic evaluation counts
assert_eq!(format_number(13_602), "13,602");
assert_eq!(format_number(4_521), "4,521");
assert_eq!(format_number(100_000), "100,000");
}
// TODO: UNCOMMENT AFTER COMPONENT 5 INTEGRATION
// The following tests require the full EvaluationMetrics types from component 5
// Once integrated, these tests will validate the report generation functionality
#[test]
#[ignore] // Ignored until component 5 integration
fn test_generate_report_mock() {
// Note: This is a smoke test to ensure generate_report() compiles correctly
// Full integration test will be added when all components are integrated
// use evaluate_dqn_component5::*; // Will be uncommented during integration
use std::io::Write;
use tempfile::NamedTempFile;
// Create temporary files for config paths
let mut model_file = NamedTempFile::new().unwrap();
model_file.write_all(b"dummy model data").unwrap();
let model_path = model_file.path().to_path_buf();
let mut parquet_file = NamedTempFile::new().unwrap();
parquet_file.write_all(b"dummy parquet data").unwrap();
let parquet_path = parquet_file.path().to_path_buf();
// Create mock config
let config = EvaluationConfig {
model_path,
parquet_file: parquet_path,
device: "cpu".to_string(),
warmup_bars: 50,
output_json: None,
verbose: false,
};
// Create mock metrics
let metrics = EvaluationMetrics {
total_bars: 1000,
action_distribution: ActionDistribution {
buy_count: 333,
buy_pct: 33.3,
sell_count: 333,
sell_pct: 33.3,
hold_count: 334,
hold_pct: 33.4,
},
avg_q_values: AvgQValues {
buy_avg: 1.25,
sell_avg: -0.50,
hold_avg: 0.10,
},
latency_stats: LatencyStats {
mean_us: 324.5,
median_us: 310,
p50_us: 310,
p95_us: 450,
p99_us: 520,
min_us: 200,
max_us: 600,
},
policy_consistency: PolicyConsistency {
total_switches: 54,
switch_rate: 0.225,
interpretation: "Moderate - Healthy adaptive behavior".to_string(),
},
};
let elapsed = std::time::Duration::from_secs(45);
// Test that report generation doesn't panic
// (Output will go to stdout, which is fine for tests)
let result = generate_report(&metrics, &config, elapsed);
assert!(result.is_ok());
}
#[test]
#[ignore] // Ignored until component 5 integration
fn test_generate_report_with_json_export() {
// use evaluate_dqn_component5::*; // Will be uncommented during integration
use std::io::Write;
use tempfile::{NamedTempFile, TempDir};
// Create temporary files for config paths
let mut model_file = NamedTempFile::new().unwrap();
model_file.write_all(b"dummy model data").unwrap();
let model_path = model_file.path().to_path_buf();
let mut parquet_file = NamedTempFile::new().unwrap();
parquet_file.write_all(b"dummy parquet data").unwrap();
let parquet_path = parquet_file.path().to_path_buf();
// Create temp directory for JSON output
let temp_dir = TempDir::new().unwrap();
let json_output_path = temp_dir.path().join("test_output.json");
// Create config with JSON output
let config = EvaluationConfig {
model_path,
parquet_file: parquet_path,
device: "cuda".to_string(),
warmup_bars: 20,
output_json: Some(json_output_path.clone()),
verbose: false,
};
// Create mock metrics (production-ready)
let metrics = EvaluationMetrics {
total_bars: 5000,
action_distribution: ActionDistribution {
buy_count: 1666,
buy_pct: 33.32,
sell_count: 1667,
sell_pct: 33.34,
hold_count: 1667,
hold_pct: 33.34,
},
avg_q_values: AvgQValues {
buy_avg: 2.15,
sell_avg: -1.25,
hold_avg: 0.50,
},
latency_stats: LatencyStats {
mean_us: 187.3,
median_us: 180,
p50_us: 180,
p95_us: 250,
p99_us: 320, // <5,000μs (production-ready)
min_us: 150,
max_us: 500,
},
policy_consistency: PolicyConsistency {
total_switches: 1000,
switch_rate: 0.20, // 20% (healthy range)
interpretation: "Moderate - Healthy adaptive behavior".to_string(),
},
};
let elapsed = std::time::Duration::from_secs_f64(12.5);
// Generate report with JSON export
let result = generate_report(&metrics, &config, elapsed);
assert!(result.is_ok());
// Verify JSON file was created
assert!(json_output_path.exists());
// Read and parse JSON
let json_content = std::fs::read_to_string(&json_output_path).unwrap();
let json_value: serde_json::Value = serde_json::from_str(&json_content).unwrap();
// Verify JSON structure
assert!(json_value["timestamp"].is_string());
assert_eq!(json_value["device"], "cuda");
assert_eq!(json_value["warmup_bars"], 20);
assert_eq!(json_value["total_bars"], 5000);
assert_eq!(json_value["evaluation_time_seconds"], 12.5);
// Verify action distribution
assert_eq!(json_value["action_distribution"]["buy_count"], 1666);
assert_eq!(json_value["action_distribution"]["sell_count"], 1667);
assert_eq!(json_value["action_distribution"]["hold_count"], 1667);
// Verify Q-values
assert_eq!(json_value["avg_q_values"]["buy_avg"], 2.15);
assert_eq!(json_value["avg_q_values"]["sell_avg"], -1.25);
assert_eq!(json_value["avg_q_values"]["hold_avg"], 0.50);
// Verify latency stats
assert_eq!(json_value["latency_stats"]["p99_us"], 320);
assert_eq!(json_value["latency_stats"]["mean_us"], 187.3);
// Verify policy consistency
assert_eq!(json_value["policy_consistency"]["total_switches"], 1000);
assert_eq!(json_value["policy_consistency"]["switch_rate"], 0.20);
// Verify production_ready flag (P99 < 5000 && switch_rate in [0.10, 0.30])
assert_eq!(json_value["production_ready"], true);
}
#[test]
#[ignore] // Ignored until component 5 integration
fn test_generate_report_not_production_ready() {
// use evaluate_dqn_component5::*; // Will be uncommented during integration
use std::io::Write;
use tempfile::{NamedTempFile, TempDir};
// Create temporary files
let mut model_file = NamedTempFile::new().unwrap();
model_file.write_all(b"dummy model data").unwrap();
let model_path = model_file.path().to_path_buf();
let mut parquet_file = NamedTempFile::new().unwrap();
parquet_file.write_all(b"dummy parquet data").unwrap();
let parquet_path = parquet_file.path().to_path_buf();
let temp_dir = TempDir::new().unwrap();
let json_output_path = temp_dir.path().join("test_output.json");
let config = EvaluationConfig {
model_path,
parquet_file: parquet_path,
device: "cpu".to_string(),
warmup_bars: 50,
output_json: Some(json_output_path.clone()),
verbose: false,
};
// Create metrics that are NOT production-ready
// (P99 latency exceeds 5,000μs threshold)
let metrics = EvaluationMetrics {
total_bars: 1000,
action_distribution: ActionDistribution {
buy_count: 400,
buy_pct: 40.0,
sell_count: 300,
sell_pct: 30.0,
hold_count: 300,
hold_pct: 30.0,
},
avg_q_values: AvgQValues {
buy_avg: 0.50,
sell_avg: 0.25,
hold_avg: 0.10,
},
latency_stats: LatencyStats {
mean_us: 2500.0,
median_us: 2400,
p50_us: 2400,
p95_us: 4800,
p99_us: 6500, // EXCEEDS 5,000μs threshold
min_us: 1000,
max_us: 8000,
},
policy_consistency: PolicyConsistency {
total_switches: 150,
switch_rate: 0.15, // Within healthy range
interpretation: "Moderate - Healthy adaptive behavior".to_string(),
},
};
let elapsed = std::time::Duration::from_secs(30);
// Generate report
let result = generate_report(&metrics, &config, elapsed);
assert!(result.is_ok());
// Verify JSON production_ready flag is false
let json_content = std::fs::read_to_string(&json_output_path).unwrap();
let json_value: serde_json::Value = serde_json::from_str(&json_content).unwrap();
assert_eq!(json_value["production_ready"], false);
}
}