Extract 9 new sub-crates from the ml monolith to enable parallel compilation across the workspace: New crates (this commit): - ml-features (282 tests): feature engineering, 21 modules - ml-labeling (45 tests): triple barrier, meta-labeling, fractional diff - ml-ensemble (116 tests): ensemble coordination, voting, confidence - ml-hyperopt (47 tests): core PSO/TPE optimizer, parameter space - ml-checkpoint (41 tests): checkpoint persistence, compression, signing - ml-regime (68 tests): CUSUM, Bayesian changepoint, regime classification - ml-data-validation (67 tests): FDR correction, CPCV, data quality - ml-risk (33 tests): neural VaR, Kelly criterion, circuit breakers - ml-validation (43 tests): statistical validation, walk-forward, DSR Extended existing crates: - ml-dqn: added evaluation/ (backtesting engine, metrics, reports) and checkpoint implementation - ml-supervised: added checkpoint implementations - ml-core: added shared types needed by new sub-crates Pattern: each module in ml/ becomes a thin facade (pub use subcrate::*) with bridge modules staying in ml for cross-model adapter code. Dead code deleted (~7K lines): - 13 undeclared files in microstructure/ (never compiled) - 7 undeclared files + tests/ in risk/ (never compiled) - parquet_io, cache_service, cache_storage, minio_integration (unused) - extraction_wave_d_impl.rs (bare fn outside impl block) All 2,746 sub-crate tests + 951 ml tests pass. Full workspace builds clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
885 lines
29 KiB
Rust
885 lines
29 KiB
Rust
//! 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<f64>,
|
|
/// Individual returns for Sharpe ratio calculation
|
|
pub returns: Vec<f64>,
|
|
/// Average latency in microseconds
|
|
pub avg_latency_us: f64,
|
|
/// Latency samples
|
|
pub latency_samples: Vec<u64>,
|
|
}
|
|
|
|
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::<f64>() / self.returns.len() as f64;
|
|
let variance = self
|
|
.returns
|
|
.iter()
|
|
.map(|r| (r - mean_return).powi(2))
|
|
.sum::<f64>()
|
|
/ self.returns.len() as f64;
|
|
let std_dev = variance.sqrt();
|
|
|
|
if std_dev < 1e-10 {
|
|
0.0
|
|
} else {
|
|
// Annualize: assume 252 trading days, multiply by sqrt(252)
|
|
mean_return / std_dev * 15.874 // sqrt(252) ≈ 15.874
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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<RwLock<HashMap<String, ABGroup>>>,
|
|
metrics_tracker: Arc<ABMetricsTracker>,
|
|
}
|
|
|
|
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<RwLock<HashMap<..>>>")
|
|
.field("metrics_tracker", &"Arc<ABMetricsTracker>")
|
|
.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<ABTestResults, ABTestError> {
|
|
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<RwLock<GroupMetrics>>,
|
|
treatment_metrics: Arc<RwLock<GroupMetrics>>,
|
|
}
|
|
|
|
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<RwLock<GroupMetrics>>")
|
|
.field("treatment_metrics", &"Arc<RwLock<GroupMetrics>>")
|
|
.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<ABTestResults, ABTestError> {
|
|
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<StatisticalTestResult, ABTestError> {
|
|
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::<f64>() / n1;
|
|
let mean2 = sample2.iter().sum::<f64>() / n2;
|
|
|
|
// Calculate variances
|
|
let var1 = sample1.iter().map(|x| (x - mean1).powi(2)).sum::<f64>() / (n1 - 1.0);
|
|
let var2 = sample2.iter().map(|x| (x - mean2).powi(2)).sum::<f64>() / (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<StatisticalTestResult, ABTestError> {
|
|
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<StatisticalTestResult, ABTestError> {
|
|
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<ABTestError> 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<f64> = (0..1000).map(|_| rand::random::<f64>() - 0.5).collect();
|
|
|
|
// Sample 2: mean = 0.3, std = 1 (shifted distribution)
|
|
let sample2: Vec<f64> = (0..1000).map(|_| rand::random::<f64>() - 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.0085, std ~0.013, annualized Sharpe ~10
|
|
assert!(
|
|
sharpe > 5.0 && sharpe < 15.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()
|
|
);
|
|
}
|
|
}
|