//! Component 5: Metrics Calculator for DQN Evaluation //! //! Aggregates inference results into comprehensive validation metrics. //! Provides action distribution, Q-value statistics, latency analysis, //! and policy consistency measurements for production readiness assessment. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; /// DQN-specific inference result /// /// Captures the complete inference output for a single market bar: /// - Action decision (BUY=0, SELL=1, HOLD=2) /// - Q-values for all three actions /// - Inference latency in microseconds /// /// # Example /// /// ```no_run /// let result = DQNInferenceResult { /// action: 0, // BUY /// q_values: [1.25, -0.50, 0.10], // BUY has highest Q /// latency_us: 324, // 324 microseconds /// }; /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DQNInferenceResult { /// Chosen action: BUY (0), SELL (1), HOLD (2) pub action: usize, /// Q-values for [BUY, SELL, HOLD] pub q_values: [f64; 3], /// Inference latency in microseconds pub latency_us: u64, } /// Comprehensive evaluation metrics for DQN model validation /// /// Contains all metrics required for production certification: /// - Action distribution (trading activity) /// - Q-value statistics (confidence levels) /// - Latency statistics (real-time suitability) /// - Policy consistency (adaptive behavior) /// /// # Production Thresholds /// /// - Latency P99: <5,000μs (real-time constraint) /// - Switch rate: 10-30% (healthy adaptability) /// - No NaN/Inf in Q-values (numerical stability) /// /// # Example /// /// ```no_run /// let metrics = calculate_metrics(&inference_results)?; /// /// // Check production readiness /// assert!(metrics.latency_stats.p99_us < 5_000); /// assert!(metrics.policy_consistency.switch_rate > 0.10); /// assert!(metrics.policy_consistency.switch_rate < 0.30); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EvaluationMetrics { /// Total number of bars evaluated pub total_bars: usize, /// Action distribution statistics pub action_distribution: ActionDistribution, /// Average Q-values per action pub avg_q_values: AvgQValues, /// Latency statistics (real-time performance) pub latency_stats: LatencyStats, /// Policy consistency (adaptive behavior) pub policy_consistency: PolicyConsistency, } /// Action distribution statistics /// /// Tracks how frequently the DQN agent takes each action: /// - Counts: Absolute number of BUY/SELL/HOLD decisions /// - Percentages: Relative frequency (0-100%) /// /// # Production Interpretation /// /// - High BUY%: Bullish bias (check for data leakage or regime shift) /// - High HOLD%: Conservative policy (may miss opportunities) /// - Balanced distribution: Healthy adaptive behavior /// /// # Validation /// /// - buy_count + sell_count + hold_count MUST equal total_bars /// - buy_pct + sell_pct + hold_pct MUST equal 100.0% (within float precision) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActionDistribution { /// Number of BUY actions (action=0) pub buy_count: usize, /// Number of SELL actions (action=1) pub sell_count: usize, /// Number of HOLD actions (action=2) pub hold_count: usize, /// BUY percentage (0-100) pub buy_pct: f64, /// SELL percentage (0-100) pub sell_pct: f64, /// HOLD percentage (0-100) pub hold_pct: f64, } /// Average Q-values per action type /// /// Measures the agent's confidence in each action: /// - High Q-value: Strong conviction in action's value /// - Low Q-value: Uncertain or unfavorable action /// /// # Production Interpretation /// /// - buy_avg > sell_avg: Bullish market regime /// - hold_avg >> buy_avg/sell_avg: Conservative policy (low volatility) /// - NaN/Inf: CRITICAL ERROR - numerical instability /// /// # Example /// /// ```no_run /// let avg_q = AvgQValues { /// buy_avg: 1.25, // Strong bullish signal /// sell_avg: -0.50, // Weak bearish signal /// hold_avg: 0.10, // Neutral baseline /// }; /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AvgQValues { /// Average Q-value when BUY action was taken pub buy_avg: f64, /// Average Q-value when SELL action was taken pub sell_avg: f64, /// Average Q-value when HOLD action was taken pub hold_avg: f64, } /// Latency statistics for real-time performance validation /// /// Captures the distribution of inference latencies: /// - Mean/Median: Central tendency /// - P50/P95/P99: Tail latency (critical for HFT) /// - Min/Max: Outliers /// /// # Production Thresholds /// /// - P99 < 5,000μs: Real-time suitability for HFT (200Hz tick rate) /// - P95 < 2,000μs: Low-latency suitability /// - Mean < 1,000μs: Efficient baseline performance /// /// # Example /// /// ```no_run /// let latency = LatencyStats { /// mean_us: 324.5, /// median_us: 310, /// p50_us: 310, /// p95_us: 450, /// p99_us: 520, /// min_us: 200, /// max_us: 600, /// }; /// /// // Validate real-time suitability /// assert!(latency.p99_us < 5_000); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LatencyStats { /// Mean latency in microseconds pub mean_us: f64, /// Median latency in microseconds (50th percentile) pub median_us: u64, /// 50th percentile latency (same as median) pub p50_us: u64, /// 95th percentile latency pub p95_us: u64, /// 99th percentile latency pub p99_us: u64, /// Minimum latency observed pub min_us: u64, /// Maximum latency observed pub max_us: u64, } /// Policy consistency statistics /// /// Measures how frequently the agent changes its action decision: /// - Total switches: Number of action changes (results[i] != results[i-1]) /// - Switch rate: Switches / (total - 1) as percentage /// - Interpretation: Qualitative assessment of adaptive behavior /// /// # Production Thresholds /// /// - <10%: "Stable - Low adaptability" (may miss regime changes) /// - 10-30%: "Moderate - Healthy adaptive behavior" (PRODUCTION READY) /// - >30%: "Volatile - High uncertainty or noise" (investigate overfitting) /// /// # Example /// /// ```no_run /// let consistency = PolicyConsistency { /// total_switches: 45, /// switch_rate: 0.225, // 22.5% /// interpretation: "Moderate - Healthy adaptive behavior".to_string(), /// }; /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PolicyConsistency { /// Total number of action switches pub total_switches: usize, /// Switch rate as decimal (0.0 to 1.0) pub switch_rate: f64, /// Qualitative interpretation pub interpretation: String, } /// Calculate evaluation metrics from DQN inference results /// /// Aggregates raw inference outputs into comprehensive validation metrics /// suitable for production readiness assessment. /// /// # Arguments /// /// * `results` - Vector of inference results (action, Q-values, latency) /// /// # Returns /// /// Comprehensive evaluation metrics for validation report /// /// # Errors /// /// Returns an error if: /// - Results vector is empty /// - Q-values contain NaN or Inf (numerical instability) /// - Action counts don't sum to total_bars (validation failure) /// /// # Example /// /// ```no_run /// use evaluate_dqn_component5::*; /// /// let results = vec![ /// DQNInferenceResult { action: 0, q_values: [1.2, -0.5, 0.1], latency_us: 310 }, /// DQNInferenceResult { action: 0, q_values: [1.3, -0.4, 0.2], latency_us: 320 }, /// DQNInferenceResult { action: 2, q_values: [0.8, -0.6, 0.9], latency_us: 305 }, /// ]; /// /// let metrics = calculate_metrics(&results)?; /// /// assert_eq!(metrics.total_bars, 3); /// assert_eq!(metrics.action_distribution.buy_count, 2); /// assert_eq!(metrics.action_distribution.hold_count, 1); /// ``` pub fn calculate_metrics(results: &[DQNInferenceResult]) -> Result { // Validate input if results.is_empty() { return Err(anyhow::anyhow!( "Cannot calculate metrics: results vector is empty" )); } let total_bars = results.len(); // 1. Calculate action distribution let action_distribution = calculate_action_distribution(results, total_bars) .context("Failed to calculate action distribution")?; // 2. Calculate average Q-values let avg_q_values = calculate_avg_q_values(results, &action_distribution) .context("Failed to calculate average Q-values")?; // 3. Calculate latency statistics let latency_stats = calculate_latency_stats(results).context("Failed to calculate latency statistics")?; // 4. Calculate policy consistency let policy_consistency = calculate_policy_consistency(results).context("Failed to calculate policy consistency")?; Ok(EvaluationMetrics { total_bars, action_distribution, avg_q_values, latency_stats, policy_consistency, }) } /// Calculate action distribution from inference results /// /// Counts BUY/SELL/HOLD actions and computes percentages. /// /// # Validation /// /// - buy_count + sell_count + hold_count MUST equal total_bars /// - All percentages MUST be in range [0.0, 100.0] fn calculate_action_distribution( results: &[DQNInferenceResult], total_bars: usize, ) -> Result { // Count actions using iterator let buy_count = results.iter().filter(|r| r.action == 0).count(); let sell_count = results.iter().filter(|r| r.action == 1).count(); let hold_count = results.iter().filter(|r| r.action == 2).count(); // Validate: counts must sum to total let sum = buy_count + sell_count + hold_count; if sum != total_bars { return Err(anyhow::anyhow!( "Action count validation failed: {} + {} + {} = {} != {}", buy_count, sell_count, hold_count, sum, total_bars )); } // Calculate percentages (0-100 scale) let total_f64 = total_bars as f64; let buy_pct = (buy_count as f64 / total_f64) * 100.0; let sell_pct = (sell_count as f64 / total_f64) * 100.0; let hold_pct = (hold_count as f64 / total_f64) * 100.0; // Validate: percentages must be in valid range if buy_pct < 0.0 || buy_pct > 100.0 || sell_pct < 0.0 || sell_pct > 100.0 || hold_pct < 0.0 || hold_pct > 100.0 { return Err(anyhow::anyhow!( "Percentage validation failed: buy={:.2}%, sell={:.2}%, hold={:.2}%", buy_pct, sell_pct, hold_pct )); } Ok(ActionDistribution { buy_count, sell_count, hold_count, buy_pct, sell_pct, hold_pct, }) } /// Calculate average Q-values per action type /// /// For each action, computes the mean Q-value when that action was taken. /// /// # Algorithm /// /// - buy_avg = mean of q_values[0] where action == 0 /// - sell_avg = mean of q_values[1] where action == 1 /// - hold_avg = mean of q_values[2] where action == 2 /// /// # Validation /// /// - Q-values MUST NOT contain NaN or Inf /// - Action counts MUST be non-zero (avoid division by zero) fn calculate_avg_q_values( results: &[DQNInferenceResult], distribution: &ActionDistribution, ) -> Result { // Calculate BUY average (only when action == 0) let buy_avg = if distribution.buy_count > 0 { let sum: f64 = results .iter() .filter(|r| r.action == 0) .map(|r| r.q_values[0]) .sum(); sum / (distribution.buy_count as f64) } else { 0.0 // No BUY actions taken }; // Calculate SELL average (only when action == 1) let sell_avg = if distribution.sell_count > 0 { let sum: f64 = results .iter() .filter(|r| r.action == 1) .map(|r| r.q_values[1]) .sum(); sum / (distribution.sell_count as f64) } else { 0.0 // No SELL actions taken }; // Calculate HOLD average (only when action == 2) let hold_avg = if distribution.hold_count > 0 { let sum: f64 = results .iter() .filter(|r| r.action == 2) .map(|r| r.q_values[2]) .sum(); sum / (distribution.hold_count as f64) } else { 0.0 // No HOLD actions taken }; // Validate: No NaN or Inf in averages if !buy_avg.is_finite() || !sell_avg.is_finite() || !hold_avg.is_finite() { return Err(anyhow::anyhow!( "Q-value validation failed: NaN or Inf detected (buy={:.6}, sell={:.6}, hold={:.6})", buy_avg, sell_avg, hold_avg )); } Ok(AvgQValues { buy_avg, sell_avg, hold_avg, }) } /// Calculate latency statistics from inference results /// /// Computes mean, median, percentiles (P50/P95/P99), and min/max. /// /// # Algorithm /// /// 1. Extract all latencies into a sorted vector /// 2. Calculate mean (sum / count) /// 3. Calculate percentiles using sorted indices /// /// # Percentile Calculation /// /// - P50 (median): sorted[len * 0.50] /// - P95: sorted[len * 0.95] /// - P99: sorted[len * 0.99] fn calculate_latency_stats(results: &[DQNInferenceResult]) -> Result { // Extract and sort latencies let mut latencies: Vec = results.iter().map(|r| r.latency_us).collect(); latencies.sort_unstable(); let len = latencies.len(); // Calculate mean let sum: u64 = latencies.iter().sum(); let mean_us = sum as f64 / len as f64; // Calculate median (P50) let median_us = calculate_percentile(&latencies, 0.50); let p50_us = median_us; // Median == P50 // Calculate P95 and P99 let p95_us = calculate_percentile(&latencies, 0.95); let p99_us = calculate_percentile(&latencies, 0.99); // Min and max let min_us = *latencies.first().unwrap(); // Safe: we validated non-empty let max_us = *latencies.last().unwrap(); // Safe: we validated non-empty Ok(LatencyStats { mean_us, median_us, p50_us, p95_us, p99_us, min_us, max_us, }) } /// Calculate percentile from sorted vector /// /// Uses linear interpolation for fractional indices. /// /// # Arguments /// /// * `sorted_values` - Sorted vector of values (ascending order) /// * `percentile` - Percentile to calculate (0.0 to 1.0) /// /// # Example /// /// ```no_run /// let sorted = vec![100, 200, 300, 400, 500]; /// let p50 = calculate_percentile(&sorted, 0.50); // 300 /// let p95 = calculate_percentile(&sorted, 0.95); // 480 (interpolated) /// ``` fn calculate_percentile(sorted_values: &[u64], percentile: f64) -> u64 { let len = sorted_values.len(); let index = (len as f64 * percentile).floor() as usize; // Clamp index to valid range [0, len-1] let clamped_index = index.min(len - 1); sorted_values[clamped_index] } /// Calculate policy consistency from inference results /// /// Measures how frequently the agent changes its action decision. /// /// # Algorithm /// /// 1. Count action switches: when results[i].action != results[i-1].action /// 2. Calculate switch rate: switches / (total - 1) /// 3. Interpret switch rate: /// - <10%: "Stable - Low adaptability" /// - 10-30%: "Moderate - Healthy adaptive behavior" /// - >30%: "Volatile - High uncertainty or noise" /// /// # Edge Cases /// /// - Single result: 0 switches, 0.0% rate, "Stable - Insufficient data" /// - All same action: 0 switches, 0.0% rate, "Stable - Low adaptability" fn calculate_policy_consistency(results: &[DQNInferenceResult]) -> Result { // Handle edge case: single result (no switches possible) if results.len() == 1 { return Ok(PolicyConsistency { total_switches: 0, switch_rate: 0.0, interpretation: "Stable - Insufficient data (single bar)".to_string(), }); } // Count switches using iterator windows let total_switches = results .windows(2) .filter(|pair| pair[0].action != pair[1].action) .count(); // Calculate switch rate (0.0 to 1.0) let switch_rate = total_switches as f64 / (results.len() - 1) as f64; // Interpret switch rate 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() }; Ok(PolicyConsistency { total_switches, switch_rate, interpretation, }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_calculate_metrics_basic() { let results = vec![ DQNInferenceResult { action: 0, q_values: [1.2, -0.5, 0.1], latency_us: 310, }, DQNInferenceResult { action: 0, q_values: [1.3, -0.4, 0.2], latency_us: 320, }, DQNInferenceResult { action: 2, q_values: [0.8, -0.6, 0.9], latency_us: 305, }, ]; let metrics = calculate_metrics(&results).unwrap(); assert_eq!(metrics.total_bars, 3); assert_eq!(metrics.action_distribution.buy_count, 2); assert_eq!(metrics.action_distribution.sell_count, 0); assert_eq!(metrics.action_distribution.hold_count, 1); // Percentages (within float precision) assert!((metrics.action_distribution.buy_pct - 66.666).abs() < 0.01); assert!((metrics.action_distribution.sell_pct - 0.0).abs() < 0.01); assert!((metrics.action_distribution.hold_pct - 33.333).abs() < 0.01); } #[test] fn test_calculate_metrics_empty_results() { let results: Vec = vec![]; let result = calculate_metrics(&results); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("results vector is empty")); } #[test] fn test_action_distribution_all_actions() { let results = vec![ DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 100, }, // BUY DQNInferenceResult { action: 1, q_values: [0.0, 1.0, 0.0], latency_us: 100, }, // SELL DQNInferenceResult { action: 2, q_values: [0.0, 0.0, 1.0], latency_us: 100, }, // HOLD DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 100, }, // BUY ]; let dist = calculate_action_distribution(&results, 4).unwrap(); assert_eq!(dist.buy_count, 2); assert_eq!(dist.sell_count, 1); assert_eq!(dist.hold_count, 1); assert_eq!(dist.buy_pct, 50.0); assert_eq!(dist.sell_pct, 25.0); assert_eq!(dist.hold_pct, 25.0); } #[test] fn test_avg_q_values_calculation() { let results = vec![ DQNInferenceResult { action: 0, q_values: [2.0, -1.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 0, q_values: [3.0, -1.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 1, q_values: [0.0, 4.0, 0.0], latency_us: 100, }, ]; let dist = ActionDistribution { buy_count: 2, sell_count: 1, hold_count: 0, buy_pct: 66.67, sell_pct: 33.33, hold_pct: 0.0, }; let avg_q = calculate_avg_q_values(&results, &dist).unwrap(); // BUY avg: (2.0 + 3.0) / 2 = 2.5 assert_eq!(avg_q.buy_avg, 2.5); // SELL avg: 4.0 / 1 = 4.0 assert_eq!(avg_q.sell_avg, 4.0); // HOLD avg: 0.0 (no HOLD actions) assert_eq!(avg_q.hold_avg, 0.0); } #[test] fn test_avg_q_values_nan_detection() { let results = vec![DQNInferenceResult { action: 0, q_values: [f64::NAN, -1.0, 0.0], latency_us: 100, }]; let dist = ActionDistribution { buy_count: 1, sell_count: 0, hold_count: 0, buy_pct: 100.0, sell_pct: 0.0, hold_pct: 0.0, }; let result = calculate_avg_q_values(&results, &dist); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("NaN or Inf")); } #[test] fn test_latency_stats_calculation() { let results = vec![ DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 200, }, DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 300, }, DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 400, }, DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 500, }, ]; let stats = calculate_latency_stats(&results).unwrap(); // Mean: (100 + 200 + 300 + 400 + 500) / 5 = 300 assert_eq!(stats.mean_us, 300.0); // Median (P50): 300 (middle value) assert_eq!(stats.median_us, 300); assert_eq!(stats.p50_us, 300); // P95: index = floor(5 * 0.95) = 4 → 500 assert_eq!(stats.p95_us, 500); // P99: index = floor(5 * 0.99) = 4 → 500 assert_eq!(stats.p99_us, 500); // Min/Max assert_eq!(stats.min_us, 100); assert_eq!(stats.max_us, 500); } #[test] fn test_policy_consistency_stable() { // All same action (no switches) let results = vec![ DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 100, }, ]; let consistency = calculate_policy_consistency(&results).unwrap(); assert_eq!(consistency.total_switches, 0); assert_eq!(consistency.switch_rate, 0.0); assert!(consistency.interpretation.contains("Stable")); } #[test] fn test_policy_consistency_moderate() { // 2 switches in 10 bars = 22.2% (moderate) let results = vec![ DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 1, q_values: [0.0, 1.0, 0.0], latency_us: 100, }, // Switch 1 DQNInferenceResult { action: 1, q_values: [0.0, 1.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 1, q_values: [0.0, 1.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 1, q_values: [0.0, 1.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 2, q_values: [0.0, 0.0, 1.0], latency_us: 100, }, // Switch 2 DQNInferenceResult { action: 2, q_values: [0.0, 0.0, 1.0], latency_us: 100, }, ]; let consistency = calculate_policy_consistency(&results).unwrap(); assert_eq!(consistency.total_switches, 2); // 2 / 9 = 0.222 (22.2%) assert!((consistency.switch_rate - 0.222).abs() < 0.01); assert!(consistency.interpretation.contains("Moderate")); } #[test] fn test_policy_consistency_volatile() { // Alternating actions (50% switch rate) let results = vec![ DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 1, q_values: [0.0, 1.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 100, }, DQNInferenceResult { action: 1, q_values: [0.0, 1.0, 0.0], latency_us: 100, }, ]; let consistency = calculate_policy_consistency(&results).unwrap(); assert_eq!(consistency.total_switches, 3); assert_eq!(consistency.switch_rate, 1.0); // 3/3 = 100% assert!(consistency.interpretation.contains("Volatile")); } #[test] fn test_policy_consistency_single_result() { // Edge case: single result let results = vec![DQNInferenceResult { action: 0, q_values: [1.0, 0.0, 0.0], latency_us: 100, }]; let consistency = calculate_policy_consistency(&results).unwrap(); assert_eq!(consistency.total_switches, 0); assert_eq!(consistency.switch_rate, 0.0); assert!(consistency.interpretation.contains("Insufficient data")); } #[test] fn test_percentile_calculation() { let sorted = vec![100, 200, 300, 400, 500]; // P50 (median): index = floor(5 * 0.50) = 2 → 300 assert_eq!(calculate_percentile(&sorted, 0.50), 300); // P95: index = floor(5 * 0.95) = 4 → 500 assert_eq!(calculate_percentile(&sorted, 0.95), 500); // P99: index = floor(5 * 0.99) = 4 → 500 assert_eq!(calculate_percentile(&sorted, 0.99), 500); // P0: index = floor(5 * 0.00) = 0 → 100 assert_eq!(calculate_percentile(&sorted, 0.00), 100); // P100: index = floor(5 * 1.00) = 5 → clamped to 4 → 500 assert_eq!(calculate_percentile(&sorted, 1.00), 500); } #[test] fn test_calculate_metrics_integration() { // Integration test with realistic data let results = vec![ DQNInferenceResult { action: 0, q_values: [1.25, -0.50, 0.10], latency_us: 310, }, DQNInferenceResult { action: 0, q_values: [1.30, -0.40, 0.20], latency_us: 320, }, DQNInferenceResult { action: 2, q_values: [0.80, -0.60, 0.90], latency_us: 305, }, DQNInferenceResult { action: 1, q_values: [-0.20, 1.50, 0.30], latency_us: 315, }, DQNInferenceResult { action: 0, q_values: [1.40, -0.30, 0.15], latency_us: 325, }, ]; let metrics = calculate_metrics(&results).unwrap(); // Validate total bars assert_eq!(metrics.total_bars, 5); // Validate action distribution assert_eq!(metrics.action_distribution.buy_count, 3); assert_eq!(metrics.action_distribution.sell_count, 1); assert_eq!(metrics.action_distribution.hold_count, 1); assert_eq!(metrics.action_distribution.buy_pct, 60.0); assert_eq!(metrics.action_distribution.sell_pct, 20.0); assert_eq!(metrics.action_distribution.hold_pct, 20.0); // Validate average Q-values // BUY avg: (1.25 + 1.30 + 1.40) / 3 = 1.3166... assert!((metrics.avg_q_values.buy_avg - 1.3166).abs() < 0.01); // SELL avg: 1.50 / 1 = 1.50 assert_eq!(metrics.avg_q_values.sell_avg, 1.50); // HOLD avg: 0.90 / 1 = 0.90 assert_eq!(metrics.avg_q_values.hold_avg, 0.90); // Validate latency stats // Mean: (310 + 320 + 305 + 315 + 325) / 5 = 315.0 assert_eq!(metrics.latency_stats.mean_us, 315.0); // Sorted: [305, 310, 315, 320, 325] // Median (P50): 315 assert_eq!(metrics.latency_stats.median_us, 315); // Min/Max assert_eq!(metrics.latency_stats.min_us, 305); assert_eq!(metrics.latency_stats.max_us, 325); // Validate policy consistency // Switches: 0→0 (no), 0→2 (yes), 2→1 (yes), 1→0 (yes) = 3 switches assert_eq!(metrics.policy_consistency.total_switches, 3); // Switch rate: 3 / 4 = 0.75 (75%) assert_eq!(metrics.policy_consistency.switch_rate, 0.75); assert!(metrics .policy_consistency .interpretation .contains("Volatile")); } }