//! A/B testing framework for ensemble vs single-model comparison //! //! This module provides statistical testing infrastructure to compare ensemble //! predictions against single-model baselines with rigorous significance testing. use serde::{Deserialize, Serialize}; use statrs::distribution::{ContinuousCDF, Normal, StudentsT}; use std::collections::HashMap; use std::sync::Arc; use thiserror::Error; use tokio::sync::RwLock; use uuid::Uuid; use crate::EnsembleError; /// A/B test group assignment #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] pub enum ABGroup { /// Control group (baseline single model) Control, /// Treatment group (ensemble model) Treatment, } impl ABGroup { pub fn as_str(&self) -> &'static str { match self { ABGroup::Control => "control", ABGroup::Treatment => "treatment", } } } /// Configuration for an A/B test #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ABTestConfig { /// Unique test identifier pub test_id: String, /// Control model variant (e.g., "DQN") pub control_model: String, /// Treatment model variant (e.g., "Ensemble") pub treatment_model: String, /// Traffic split (0.0-1.0, where 0.5 = 50/50) pub traffic_split: f64, /// Minimum sample size per group before computing significance pub min_sample_size: usize, /// Statistical significance level (alpha) pub significance_level: f64, /// Maximum test duration in hours pub max_duration_hours: u64, /// Test start timestamp pub start_time: i64, } impl Default for ABTestConfig { fn default() -> Self { Self { test_id: Uuid::new_v4().to_string(), control_model: "DQN".to_owned(), treatment_model: "Ensemble".to_owned(), traffic_split: 0.5, min_sample_size: 1000, significance_level: 0.05, max_duration_hours: 168, // 1 week start_time: chrono::Utc::now().timestamp(), } } } /// Metrics tracked for each A/B test group #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct GroupMetrics { /// Total number of predictions pub predictions: u64, /// Number of correct predictions pub correct_predictions: u64, /// Total profit and loss pub total_pnl: f64, /// Individual PnL samples for statistical tests pub pnl_samples: Vec, /// Individual returns for Sharpe ratio calculation pub returns: Vec, /// Average latency in microseconds pub avg_latency_us: f64, /// Latency samples pub latency_samples: Vec, } impl GroupMetrics { /// Win rate (correct predictions / total predictions) pub fn win_rate(&self) -> f64 { if self.predictions == 0 { 0.0 } else { self.correct_predictions as f64 / self.predictions as f64 } } /// Sharpe ratio (annualized) pub fn sharpe_ratio(&self) -> f64 { if self.returns.is_empty() { 0.0 } else { let mean_return = self.returns.iter().sum::() / self.returns.len() as f64; let variance = self .returns .iter() .map(|r| (r - mean_return).powi(2)) .sum::() / self.returns.len() as f64; let std_dev = variance.sqrt(); if std_dev < 1e-10 { 0.0 } else { use common::thresholds::time::ANNUALIZATION_FACTOR; mean_return / std_dev * ANNUALIZATION_FACTOR } } } /// Average PnL per prediction pub fn avg_pnl(&self) -> f64 { if self.predictions == 0 { 0.0 } else { self.total_pnl / self.predictions as f64 } } /// Record a new prediction outcome pub fn record_prediction(&mut self, correct: bool, pnl: f64, return_pct: f64, latency_us: u64) { self.predictions += 1; if correct { self.correct_predictions += 1; } self.total_pnl += pnl; self.pnl_samples.push(pnl); self.returns.push(return_pct); self.latency_samples.push(latency_us); // Update rolling average latency let total_latency: u64 = self.latency_samples.iter().sum(); self.avg_latency_us = total_latency as f64 / self.latency_samples.len() as f64; } } /// Statistical test results #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StatisticalTestResult { /// Test statistic value pub test_statistic: f64, /// P-value (probability of observing results under null hypothesis) pub p_value: f64, /// Whether result is statistically significant pub is_significant: bool, /// Confidence interval (95%) pub confidence_interval: (f64, f64), } /// Complete A/B test results with all metrics #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ABTestResults { /// Test configuration pub test_id: String, /// Control group metrics pub control_group: GroupMetrics, /// Treatment group metrics pub treatment_group: GroupMetrics, /// Sharpe ratio difference (treatment - control) pub sharpe_diff: f64, /// Sharpe ratio test result pub sharpe_test: StatisticalTestResult, /// Win rate difference (treatment - control) pub win_rate_diff: f64, /// Win rate test result pub win_rate_test: StatisticalTestResult, /// PnL difference (treatment - control) pub pnl_diff: f64, /// PnL test result pub pnl_test: StatisticalTestResult, /// Overall recommendation pub recommendation: Recommendation, } /// Recommendation based on A/B test results #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Recommendation { /// Treatment is significantly better, roll out to 100% RolloutTreatment(String), /// Control is significantly better, revert to baseline RevertToControl(String), /// No significant difference, continue testing or use simpler model Neutral(String), /// Not enough data yet, continue testing Inconclusive(String), } /// A/B test router for traffic splitting and group assignment pub struct ABTestRouter { config: ABTestConfig, group_assignments: Arc>>, metrics_tracker: Arc, } impl std::fmt::Debug for ABTestRouter { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ABTestRouter") .field("config", &self.config) .field("group_assignments", &"Arc>>") .field("metrics_tracker", &"Arc") .finish() } } impl ABTestRouter { /// Create a new A/B test router pub fn new(config: ABTestConfig) -> Self { Self { config: config.clone(), group_assignments: Arc::new(RwLock::new(HashMap::new())), metrics_tracker: Arc::new(ABMetricsTracker::new(config)), } } /// Get or assign a user to a group using deterministic hashing pub async fn get_or_assign_group(&self, user_id: &str) -> ABGroup { // Check if already assigned { let assignments = self.group_assignments.read().await; if let Some(&group) = assignments.get(user_id) { return group; } } // Assign new user using consistent hashing let group = self.assign_group(user_id); // Cache assignment self.group_assignments .write() .await .insert(user_id.to_string(), group); group } /// Assign user to group using deterministic hash fn assign_group(&self, user_id: &str) -> ABGroup { // Use simple hash for deterministic assignment let hash = user_id.bytes().enumerate().fold(0_u64, |acc, (i, b)| { acc.wrapping_add((b as u64).wrapping_mul((i as u64).wrapping_add(1))) }); // Convert to 0-100 range let bucket = (hash % 100) as f64 / 100.0; if bucket < self.config.traffic_split { ABGroup::Treatment } else { ABGroup::Control } } /// Record prediction outcome for metrics tracking pub async fn record_outcome( &self, group: ABGroup, correct: bool, pnl: f64, return_pct: f64, latency_us: u64, ) { self.metrics_tracker .record_prediction(group, correct, pnl, return_pct, latency_us) .await; } /// Get current A/B test results pub async fn get_results(&self) -> Result { self.metrics_tracker.compute_significance().await } /// Get configuration pub fn config(&self) -> &ABTestConfig { &self.config } } /// Metrics tracker for A/B test groups pub struct ABMetricsTracker { config: ABTestConfig, control_metrics: Arc>, treatment_metrics: Arc>, } impl std::fmt::Debug for ABMetricsTracker { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ABMetricsTracker") .field("config", &self.config) .field("control_metrics", &"Arc>") .field("treatment_metrics", &"Arc>") .finish() } } impl ABMetricsTracker { /// Create new metrics tracker pub fn new(config: ABTestConfig) -> Self { Self { config, control_metrics: Arc::new(RwLock::new(GroupMetrics::default())), treatment_metrics: Arc::new(RwLock::new(GroupMetrics::default())), } } /// Record a prediction outcome pub async fn record_prediction( &self, group: ABGroup, correct: bool, pnl: f64, return_pct: f64, latency_us: u64, ) { match group { ABGroup::Control => { self.control_metrics .write() .await .record_prediction(correct, pnl, return_pct, latency_us); }, ABGroup::Treatment => { self.treatment_metrics .write() .await .record_prediction(correct, pnl, return_pct, latency_us); }, } } /// Compute statistical significance of A/B test results pub async fn compute_significance(&self) -> Result { let control = self.control_metrics.read().await.clone(); let treatment = self.treatment_metrics.read().await.clone(); // Check minimum sample size if control.predictions < self.config.min_sample_size as u64 || treatment.predictions < self.config.min_sample_size as u64 { return Err(ABTestError::InsufficientSamples { required: self.config.min_sample_size, control: control.predictions as usize, treatment: treatment.predictions as usize, }); } // Sharpe ratio comparison (Welch's t-test on returns) let sharpe_diff = treatment.sharpe_ratio() - control.sharpe_ratio(); let sharpe_test = self.welch_t_test(&control.returns, &treatment.returns)?; // Win rate comparison (proportion z-test) let win_rate_diff = treatment.win_rate() - control.win_rate(); let win_rate_test = self.proportion_z_test( control.correct_predictions, control.predictions, treatment.correct_predictions, treatment.predictions, )?; // PnL comparison (Mann-Whitney U test for non-normal distributions) let pnl_diff = treatment.total_pnl - control.total_pnl; let pnl_test = self.mann_whitney_u_test(&control.pnl_samples, &treatment.pnl_samples)?; // Generate recommendation let recommendation = self.generate_recommendation(sharpe_diff, &sharpe_test, pnl_diff, &pnl_test); Ok(ABTestResults { test_id: self.config.test_id.clone(), control_group: control, treatment_group: treatment, sharpe_diff, sharpe_test, win_rate_diff, win_rate_test, pnl_diff, pnl_test, recommendation, }) } /// Welch's t-test for two samples with unequal variances pub fn welch_t_test( &self, sample1: &[f64], sample2: &[f64], ) -> Result { if sample1.is_empty() || sample2.is_empty() { return Err(ABTestError::EmptySamples); } let n1 = sample1.len() as f64; let n2 = sample2.len() as f64; // Calculate means let mean1 = sample1.iter().sum::() / n1; let mean2 = sample2.iter().sum::() / n2; // Calculate variances let var1 = sample1.iter().map(|x| (x - mean1).powi(2)).sum::() / (n1 - 1.0); let var2 = sample2.iter().map(|x| (x - mean2).powi(2)).sum::() / (n2 - 1.0); // Welch's t-statistic let t_stat = (mean1 - mean2) / ((var1 / n1) + (var2 / n2)).sqrt(); // Welch-Satterthwaite degrees of freedom let numerator = ((var1 / n1) + (var2 / n2)).powi(2); let denominator = (var1 / n1).powi(2) / (n1 - 1.0) + (var2 / n2).powi(2) / (n2 - 1.0); let df = numerator / denominator; // Approximate p-value using t-distribution (two-tailed) let p_value = self.t_distribution_p_value(t_stat.abs(), df); // 95% confidence interval for difference in means let t_critical = self.t_critical_value(0.05, df); let se = ((var1 / n1) + (var2 / n2)).sqrt(); let diff = mean1 - mean2; let ci = (diff - t_critical * se, diff + t_critical * se); Ok(StatisticalTestResult { test_statistic: t_stat, p_value, is_significant: p_value < self.config.significance_level, confidence_interval: ci, }) } /// Proportion z-test for comparing two proportions (win rates) pub fn proportion_z_test( &self, successes1: u64, total1: u64, successes2: u64, total2: u64, ) -> Result { if total1 == 0 || total2 == 0 { return Err(ABTestError::EmptySamples); } let p1 = successes1 as f64 / total1 as f64; let p2 = successes2 as f64 / total2 as f64; // Pooled proportion let p_pool = (successes1 + successes2) as f64 / (total1 + total2) as f64; // Standard error let se = (p_pool * (1.0 - p_pool) * (1.0 / total1 as f64 + 1.0 / total2 as f64)).sqrt(); // Z-statistic let z_stat = (p1 - p2) / se; // Two-tailed p-value let p_value = 2.0 * (1.0 - self.standard_normal_cdf(z_stat.abs())); // 95% confidence interval for difference in proportions let z_critical = 1.96; // 95% CI let se_diff = ((p1 * (1.0 - p1) / total1 as f64) + (p2 * (1.0 - p2) / total2 as f64)).sqrt(); let diff = p1 - p2; let ci = (diff - z_critical * se_diff, diff + z_critical * se_diff); Ok(StatisticalTestResult { test_statistic: z_stat, p_value, is_significant: p_value < self.config.significance_level, confidence_interval: ci, }) } /// Mann-Whitney U test for non-parametric comparison (PnL distributions) pub fn mann_whitney_u_test( &self, sample1: &[f64], sample2: &[f64], ) -> Result { if sample1.is_empty() || sample2.is_empty() { return Err(ABTestError::EmptySamples); } let n1 = sample1.len(); let n2 = sample2.len(); // Combine samples with group labels let mut combined: Vec<(f64, u8)> = sample1.iter().map(|&x| (x, 1)).collect(); combined.extend(sample2.iter().map(|&x| (x, 2))); // Sort by value combined.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); // Assign ranks (handle ties with average rank) let mut ranks1 = 0.0; let mut i = 0; while i < combined.len() { let mut j = i; while j < combined.len() && (combined[j].0 - combined[i].0).abs() < 1e-10 { j += 1; } // Average rank for ties let avg_rank = (i + j + 1) as f64 / 2.0; for k in i..j { if combined[k].1 == 1 { ranks1 += avg_rank; } } i = j; } // U statistic for sample 1 let u1 = ranks1 - (n1 * (n1 + 1)) as f64 / 2.0; // Mean and standard deviation under null hypothesis let mean_u = (n1 * n2) as f64 / 2.0; let std_u = ((n1 * n2 * (n1 + n2 + 1)) as f64 / 12.0).sqrt(); // Z-statistic (normal approximation for large samples) let z_stat = (u1 - mean_u) / std_u; // Two-tailed p-value let p_value = 2.0 * (1.0 - self.standard_normal_cdf(z_stat.abs())); // For Mann-Whitney, CI is more complex, use point estimate difference let median1 = self.median(sample1); let median2 = self.median(sample2); let ci = (median1 - median2, median1 - median2); // Simplified Ok(StatisticalTestResult { test_statistic: z_stat, p_value, is_significant: p_value < self.config.significance_level, confidence_interval: ci, }) } /// Generate recommendation based on test results fn generate_recommendation( &self, sharpe_diff: f64, sharpe_test: &StatisticalTestResult, pnl_diff: f64, pnl_test: &StatisticalTestResult, ) -> Recommendation { // Check if results are statistically significant if !sharpe_test.is_significant && !pnl_test.is_significant { return Recommendation::Inconclusive( "Results not statistically significant. Continue testing or increase sample size." .to_string(), ); } // If only one metric is significant, be cautious let both_significant = sharpe_test.is_significant && pnl_test.is_significant; // Strong positive signal: both metrics significantly better if both_significant && sharpe_diff > 0.2 && pnl_diff > 0.0 { return Recommendation::RolloutTreatment( format!( "Ensemble significantly outperforms baseline: Sharpe +{:.2} (p={:.4}), PnL +${:.2} (p={:.4}). Roll out to 100%.", sharpe_diff, sharpe_test.p_value, pnl_diff, pnl_test.p_value ) ); } // Strong negative signal: both metrics significantly worse if both_significant && sharpe_diff < -0.2 && pnl_diff < 0.0 { return Recommendation::RevertToControl( format!( "Ensemble significantly underperforms baseline: Sharpe {:.2} (p={:.4}), PnL ${:.2} (p={:.4}). Revert to control.", sharpe_diff, sharpe_test.p_value, pnl_diff, pnl_test.p_value ) ); } // Moderate positive signal if sharpe_diff > 0.1 && pnl_diff > 0.0 { return Recommendation::RolloutTreatment( format!( "Ensemble shows improvement: Sharpe +{:.2}, PnL +${:.2}. Consider gradual rollout to 100%.", sharpe_diff, pnl_diff ) ); } // Moderate negative signal if sharpe_diff < -0.1 && pnl_diff < 0.0 { return Recommendation::RevertToControl( format!( "Ensemble shows degradation: Sharpe {:.2}, PnL ${:.2}. Consider reverting to control.", sharpe_diff, pnl_diff ) ); } // No meaningful difference Recommendation::Neutral( format!( "No meaningful difference detected (Sharpe diff: {:.2}, PnL diff: ${:.2}). Use simpler single-model for operational efficiency.", sharpe_diff, pnl_diff ) ) } /// Calculate median of a sample fn median(&self, samples: &[f64]) -> f64 { if samples.is_empty() { return 0.0; } let mut sorted = samples.to_vec(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let mid = sorted.len() / 2; if sorted.len() % 2 == 0 { (sorted[mid - 1] + sorted[mid]) / 2.0 } else { sorted[mid] } } /// CDF of standard normal distribution using statrs fn standard_normal_cdf(&self, x: f64) -> f64 { // Fallback to 0.5 if Normal::new fails (should never happen with 0.0, 1.0) match Normal::new(0.0, 1.0) { Ok(dist) => dist.cdf(x), Err(_) => 0.5, } } /// P-value for t-distribution using statrs StudentsT fn t_distribution_p_value(&self, t: f64, df: f64) -> f64 { // Use statrs StudentsT for exact CDF computation match StudentsT::new(0.0, 1.0, df) { Ok(dist) => 2.0 * (1.0 - dist.cdf(t.abs())), Err(_) => { // Fallback to normal approximation if StudentsT::new fails // (e.g., df <= 0 which shouldn't happen in practice) 2.0 * (1.0 - self.standard_normal_cdf(t.abs())) }, } } /// Critical t-value for given alpha and df using statrs inverse CDF fn t_critical_value(&self, alpha: f64, df: f64) -> f64 { match StudentsT::new(0.0, 1.0, df) { Ok(dist) => dist.inverse_cdf(1.0 - alpha / 2.0), Err(_) => 1.96, // Fallback to normal approximation } } /// Calculate minimum sample size needed for desired power using statrs Normal inverse CDF pub fn calculate_min_sample_size(effect_size: f64, power: f64, alpha: f64) -> usize { if effect_size.abs() < 1e-15 { return usize::MAX; } let normal = match Normal::new(0.0, 1.0) { Ok(dist) => dist, Err(_) => return usize::MAX, }; let z_alpha = normal.inverse_cdf(1.0 - alpha / 2.0); let z_beta = normal.inverse_cdf(power); let n = 2.0 * ((z_alpha + z_beta) / effect_size).powi(2); n.ceil() as usize } } /// Errors that can occur in A/B testing #[derive(Error, Debug)] pub enum ABTestError { #[error( "Insufficient samples: required {required}, got control={control}, treatment={treatment}" )] InsufficientSamples { required: usize, control: usize, treatment: usize, }, #[error("Empty samples provided for statistical test")] EmptySamples, #[error("Invalid configuration: {0}")] InvalidConfiguration(String), #[error("Test expired: duration {elapsed_hours}h exceeds maximum {max_hours}h")] TestExpired { elapsed_hours: u64, max_hours: u64 }, } impl From for EnsembleError { fn from(err: ABTestError) -> Self { EnsembleError::AggregationFailed(err.to_string()) } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_group_assignment_deterministic() { let config = ABTestConfig { traffic_split: 0.5, ..Default::default() }; let router = ABTestRouter::new(config); // Same user ID should always get same group let user_id = "user123"; let group1 = router.get_or_assign_group(user_id).await; let group2 = router.get_or_assign_group(user_id).await; assert_eq!(group1, group2); } #[tokio::test] async fn test_traffic_split() { let config = ABTestConfig { traffic_split: 0.3, // 30% treatment, 70% control ..Default::default() }; let router = ABTestRouter::new(config); let mut treatment_count = 0; let mut _control_count = 0; let total_users = 10000; for i in 0..total_users { let user_id = format!("user{}", i); let group = router.get_or_assign_group(&user_id).await; match group { ABGroup::Treatment => treatment_count += 1, ABGroup::Control => _control_count += 1, } } let treatment_pct = treatment_count as f64 / total_users as f64; // Should be close to 30% (within 2%) assert!( (treatment_pct - 0.3).abs() < 0.02, "Treatment percentage {} not close to 30%", treatment_pct ); } #[tokio::test] async fn test_welch_t_test_significant_difference() { let config = ABTestConfig::default(); let tracker = ABMetricsTracker::new(config); // Sample 1: mean = 0, std = 1 let sample1: Vec = (0..1000).map(|_| rand::random::() - 0.5).collect(); // Sample 2: mean = 0.3, std = 1 (shifted distribution) let sample2: Vec = (0..1000).map(|_| rand::random::() - 0.2).collect(); let result = tracker.welch_t_test(&sample1, &sample2).unwrap(); // Should detect significant difference assert!( result.is_significant, "Should detect significant difference" ); assert!(result.p_value < 0.05, "P-value should be < 0.05"); } #[tokio::test] async fn test_proportion_z_test() { let config = ABTestConfig::default(); let tracker = ABMetricsTracker::new(config); // Control: 52% win rate let result = tracker.proportion_z_test(520, 1000, 580, 1000).unwrap(); // Should detect significant difference (52% vs 58%) assert!( result.is_significant, "Should detect significant win rate difference" ); } #[tokio::test] async fn test_min_sample_size_calculation() { // Detect 10% Sharpe improvement with 80% power let effect_size = 0.2; // Small to medium effect let power = 0.8; let alpha = 0.05; let min_n = ABMetricsTracker::calculate_min_sample_size(effect_size, power, alpha); // Should be around 393 per group for 0.2 effect size assert!( min_n > 300 && min_n < 500, "Min sample size {} out of expected range", min_n ); } #[tokio::test] async fn test_sharpe_ratio_calculation() { let mut metrics = GroupMetrics::default(); // Add some sample returns let returns = vec![0.01, -0.005, 0.02, 0.015, -0.01, 0.03, 0.005, -0.002]; for &ret in &returns { metrics.record_prediction(true, ret * 10000.0, ret, 50); } let sharpe = metrics.sharpe_ratio(); // Should be positive with these returns assert!(sharpe > 0.0, "Sharpe ratio should be positive"); // Rough check: mean ~0.008, std ~0.013, annualized Sharpe ~195 // (1-minute bar annualization: sqrt(390 * 252) ≈ 313.5) assert!( sharpe > 100.0 && sharpe < 300.0, "Sharpe ratio {} out of expected range", sharpe ); } #[tokio::test] async fn test_full_ab_test_workflow() { let config = ABTestConfig { min_sample_size: 90, // Slightly under to handle split variance ..Default::default() }; let router = ABTestRouter::new(config); let _rng = rand::thread_rng(); // Simulate 200 predictions (~100 per group with 50/50 split) for i in 0..200 { let user_id = format!("user{}", i); let group = router.get_or_assign_group(&user_id).await; // Treatment has better performance (deterministic with seeded values) let (correct, pnl, return_pct) = match group { ABGroup::Control => { let correct = (i % 2) == 0; // 50% win rate let return_pct = 0.001; // Low return let pnl = return_pct * 10000.0; (correct, pnl, return_pct) }, ABGroup::Treatment => { let correct = (i % 3) != 0; // 66% win rate (better) let return_pct = 0.003; // Higher return let pnl = return_pct * 10000.0; (correct, pnl, return_pct) }, }; router .record_outcome(group, correct, pnl, return_pct, 50) .await; } // Get results let results = router.get_results().await.unwrap(); assert!(results.control_group.predictions >= 90); assert!(results.treatment_group.predictions >= 90); // Treatment should have better metrics (deterministic) assert!( results.treatment_group.win_rate() >= results.control_group.win_rate(), "Treatment win rate {} should be >= control {}", results.treatment_group.win_rate(), results.control_group.win_rate() ); } }