Tasks 8-14 production hardening batch: - Populate Order/Position/Execution proto messages from JSON payload in event stream converters instead of returning None (Task 9) - Compute max_drawdown from cumulative PnL samples in A/B testing pipeline instead of hardcoded 0.0 (Task 11) - Document feature pipeline integration blockers with detailed roadmap comments in state.rs and trading.rs (Task 8) - Document realized PnL gap: TradingPosition lacks the field, repository has async method incompatible with Iterator::map (Task 10) - Document ML order quantity gap in api_gateway proxy: MlOrderResponse proto lacks quantity field (Task 12) - Document per-symbol weight tracking roadmap in ensemble_coordinator (Task 13) - Document OHLCV bar pipeline upgrade roadmap in state.rs (Task 14) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
706 lines
22 KiB
Rust
706 lines
22 KiB
Rust
//! A/B Testing Pipeline for Model Deployment Decisions
|
|
//!
|
|
//! This module implements an automated A/B testing pipeline that:
|
|
//! 1. Creates A/B tests on model deployment
|
|
//! 2. Splits traffic 50/50 between control and treatment
|
|
//! 3. Collects metrics (Sharpe ratio, win rate, drawdown, PnL)
|
|
//! 4. Runs statistical tests (Welch's t-test, p < 0.05)
|
|
//! 5. Makes deployment decisions (rollout, revert, or continue)
|
|
//! 6. Integrates with ml/src/ensemble/ab_testing.rs
|
|
//!
|
|
//! ## Architecture
|
|
//!
|
|
//! ```text
|
|
//! New Model Deployed
|
|
//! │
|
|
//! ▼
|
|
//! Create A/B Test (control vs treatment)
|
|
//! │
|
|
//! ▼
|
|
//! Traffic Split (50/50 deterministic hash)
|
|
//! │
|
|
//! ▼
|
|
//! Collect Metrics (Sharpe, win rate, PnL, drawdown)
|
|
//! │
|
|
//! ▼
|
|
//! Statistical Testing (Welch's t-test, p < 0.05)
|
|
//! │
|
|
//! ▼
|
|
//! Deployment Decision:
|
|
//! - RolloutTreatment (treatment significantly better)
|
|
//! - RevertToControl (treatment significantly worse)
|
|
//! - Neutral (no significant difference)
|
|
//! - Inconclusive (insufficient samples)
|
|
//! ```
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::PgPool;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use tokio::sync::RwLock;
|
|
use tracing::{debug, info};
|
|
use uuid::Uuid;
|
|
|
|
use ml::ensemble::ab_testing::{
|
|
ABGroup, ABTestConfig as MLABTestConfig, ABTestRouter, GroupMetrics, StatisticalTestResult,
|
|
};
|
|
|
|
/// A/B Testing Pipeline Configuration
|
|
#[derive(Debug, Clone)]
|
|
pub struct ABTestingConfig {
|
|
/// Test ID prefix (for namespacing)
|
|
pub test_prefix: String,
|
|
|
|
/// Minimum sample size per group before statistical testing
|
|
pub min_sample_size: usize,
|
|
|
|
/// Traffic split (0.5 = 50/50)
|
|
pub traffic_split: f64,
|
|
|
|
/// Statistical significance level (0.05 = p < 0.05)
|
|
pub significance_level: f64,
|
|
|
|
/// Maximum test duration in hours
|
|
pub max_duration_hours: u64,
|
|
}
|
|
|
|
impl Default for ABTestingConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
test_prefix: "ab_test".to_string(),
|
|
min_sample_size: 150, // Realistic minimum for statistical significance
|
|
traffic_split: 0.5,
|
|
significance_level: 0.05,
|
|
max_duration_hours: 168, // 1 week
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A/B Test State
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ABTestState {
|
|
/// Unique test ID
|
|
pub test_id: String,
|
|
|
|
/// Control model ID (baseline)
|
|
pub control_model: String,
|
|
|
|
/// Treatment model ID (new model)
|
|
pub treatment_model: String,
|
|
|
|
/// Symbol being tested
|
|
pub symbol: String,
|
|
|
|
/// Test status (running, completed, stopped)
|
|
pub status: String,
|
|
|
|
/// Start time
|
|
pub start_time: DateTime<Utc>,
|
|
|
|
/// End time (if completed)
|
|
pub end_time: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
/// Traffic Split Metrics
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TrafficSplitMetrics {
|
|
/// Control group metrics
|
|
pub control: ModelPerformanceMetrics,
|
|
|
|
/// Treatment group metrics
|
|
pub treatment: ModelPerformanceMetrics,
|
|
}
|
|
|
|
/// Model Performance Metrics
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ModelPerformanceMetrics {
|
|
/// Total predictions
|
|
pub predictions: u64,
|
|
|
|
/// Correct predictions
|
|
pub correct_predictions: u64,
|
|
|
|
/// Win rate
|
|
pub win_rate: f64,
|
|
|
|
/// Total PnL
|
|
pub total_pnl: f64,
|
|
|
|
/// Average PnL per prediction
|
|
pub avg_pnl: f64,
|
|
|
|
/// Sharpe ratio (annualized)
|
|
pub sharpe_ratio: f64,
|
|
|
|
/// Maximum drawdown
|
|
pub max_drawdown: f64,
|
|
|
|
/// Average latency (microseconds)
|
|
pub avg_latency_us: f64,
|
|
}
|
|
|
|
impl Default for ModelPerformanceMetrics {
|
|
fn default() -> Self {
|
|
Self {
|
|
predictions: 0,
|
|
correct_predictions: 0,
|
|
win_rate: 0.0,
|
|
total_pnl: 0.0,
|
|
avg_pnl: 0.0,
|
|
sharpe_ratio: 0.0,
|
|
max_drawdown: 0.0,
|
|
avg_latency_us: 0.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Statistical Test Results
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ABStatisticalTestResult {
|
|
/// 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,
|
|
}
|
|
|
|
/// Deployment Decision
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum DeploymentDecision {
|
|
/// Roll out treatment to 100% (treatment significantly better)
|
|
RolloutTreatment {
|
|
reason: String,
|
|
sharpe_improvement: f64,
|
|
pnl_improvement: f64,
|
|
p_value: f64,
|
|
},
|
|
|
|
/// Revert to control (treatment significantly worse)
|
|
RevertToControl {
|
|
reason: String,
|
|
sharpe_degradation: f64,
|
|
pnl_degradation: f64,
|
|
p_value: f64,
|
|
},
|
|
|
|
/// No significant difference (use simpler model)
|
|
Neutral {
|
|
reason: String,
|
|
sharpe_diff: f64,
|
|
pnl_diff: f64,
|
|
},
|
|
|
|
/// Insufficient samples, continue testing
|
|
Inconclusive {
|
|
reason: String,
|
|
control_samples: usize,
|
|
treatment_samples: usize,
|
|
required_samples: usize,
|
|
},
|
|
}
|
|
|
|
/// A/B Testing Pipeline
|
|
pub struct ABTestingPipeline {
|
|
/// Database connection pool
|
|
db_pool: PgPool,
|
|
|
|
/// Configuration
|
|
config: ABTestingConfig,
|
|
|
|
/// Active A/B test routers (keyed by test_id)
|
|
active_tests: Arc<RwLock<HashMap<String, Arc<ABTestRouter>>>>,
|
|
|
|
/// Traffic group assignments (keyed by test_id + user_id)
|
|
traffic_assignments: Arc<RwLock<HashMap<String, String>>>,
|
|
}
|
|
|
|
impl ABTestingPipeline {
|
|
/// Create new A/B testing pipeline
|
|
pub fn new(db_pool: PgPool, config: ABTestingConfig) -> Self {
|
|
Self {
|
|
db_pool,
|
|
config,
|
|
active_tests: Arc::new(RwLock::new(HashMap::new())),
|
|
traffic_assignments: Arc::new(RwLock::new(HashMap::new())),
|
|
}
|
|
}
|
|
|
|
/// Create A/B test on model deployment
|
|
pub async fn create_ab_test(
|
|
&self,
|
|
control_model_id: &str,
|
|
treatment_model_id: &str,
|
|
symbol: &str,
|
|
) -> Result<ABTestState> {
|
|
let test_id = format!("{}_{}", self.config.test_prefix, Uuid::new_v4());
|
|
let start_time = Utc::now();
|
|
|
|
info!(
|
|
"Creating A/B test {} for symbol {} (control: {}, treatment: {})",
|
|
test_id, symbol, control_model_id, treatment_model_id
|
|
);
|
|
|
|
// Create ML A/B test router
|
|
let ml_config = MLABTestConfig {
|
|
test_id: test_id.clone(),
|
|
control_model: control_model_id.to_string(),
|
|
treatment_model: treatment_model_id.to_string(),
|
|
traffic_split: self.config.traffic_split,
|
|
min_sample_size: self.config.min_sample_size,
|
|
significance_level: self.config.significance_level,
|
|
max_duration_hours: self.config.max_duration_hours,
|
|
start_time: start_time.timestamp(),
|
|
};
|
|
|
|
let router = Arc::new(ABTestRouter::new(ml_config));
|
|
|
|
// Store in active tests
|
|
{
|
|
let mut active_tests = self.active_tests.write().await;
|
|
active_tests.insert(test_id.clone(), router.clone());
|
|
}
|
|
|
|
// Persist to database
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO ab_test_results (
|
|
test_id, control_model, treatment_model, symbol,
|
|
status, start_time, traffic_split, min_sample_size
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
"#,
|
|
)
|
|
.bind(&test_id)
|
|
.bind(control_model_id)
|
|
.bind(treatment_model_id)
|
|
.bind(symbol)
|
|
.bind("running")
|
|
.bind(start_time)
|
|
.bind(self.config.traffic_split)
|
|
.bind(self.config.min_sample_size as i32)
|
|
.execute(&self.db_pool)
|
|
.await
|
|
.context("Failed to persist A/B test to database")?;
|
|
|
|
Ok(ABTestState {
|
|
test_id,
|
|
control_model: control_model_id.to_string(),
|
|
treatment_model: treatment_model_id.to_string(),
|
|
symbol: symbol.to_string(),
|
|
status: "running".to_string(),
|
|
start_time,
|
|
end_time: None,
|
|
})
|
|
}
|
|
|
|
/// Assign traffic group (deterministic hash-based)
|
|
pub async fn assign_traffic_group(&self, test_id: &str, user_id: &str) -> Result<String> {
|
|
let assignment_key = format!("{}:{}", test_id, user_id);
|
|
|
|
// Check if already assigned
|
|
{
|
|
let assignments = self.traffic_assignments.read().await;
|
|
if let Some(group) = assignments.get(&assignment_key) {
|
|
return Ok(group.clone());
|
|
}
|
|
}
|
|
|
|
// Get router
|
|
let router = {
|
|
let active_tests = self.active_tests.read().await;
|
|
active_tests
|
|
.get(test_id)
|
|
.ok_or_else(|| anyhow!("A/B test {} not found", test_id))?
|
|
.clone()
|
|
};
|
|
|
|
// Assign group using ML router
|
|
let group = router.get_or_assign_group(user_id).await;
|
|
let group_str = match group {
|
|
ABGroup::Control => "control".to_string(),
|
|
ABGroup::Treatment => "treatment".to_string(),
|
|
};
|
|
|
|
// Cache assignment
|
|
{
|
|
let mut assignments = self.traffic_assignments.write().await;
|
|
assignments.insert(assignment_key, group_str.clone());
|
|
}
|
|
|
|
debug!(
|
|
"Assigned user {} to group {} for test {}",
|
|
user_id, group_str, test_id
|
|
);
|
|
|
|
Ok(group_str)
|
|
}
|
|
|
|
/// Record prediction outcome
|
|
pub async fn record_prediction_outcome(
|
|
&self,
|
|
test_id: &str,
|
|
group: &str,
|
|
correct: bool,
|
|
pnl: f64,
|
|
return_pct: f64,
|
|
latency_us: u64,
|
|
) -> Result<()> {
|
|
// Get router
|
|
let router = {
|
|
let active_tests = self.active_tests.read().await;
|
|
active_tests
|
|
.get(test_id)
|
|
.ok_or_else(|| anyhow!("A/B test {} not found", test_id))?
|
|
.clone()
|
|
};
|
|
|
|
// Convert group string to ABGroup
|
|
let ab_group = match group {
|
|
"control" => ABGroup::Control,
|
|
"treatment" => ABGroup::Treatment,
|
|
_ => return Err(anyhow!("Invalid group: {}", group)),
|
|
};
|
|
|
|
// Record outcome in ML router
|
|
router
|
|
.record_outcome(ab_group, correct, pnl, return_pct, latency_us)
|
|
.await;
|
|
|
|
debug!(
|
|
"Recorded outcome for test {} group {}: correct={}, pnl={:.2}, return={:.4}",
|
|
test_id, group, correct, pnl, return_pct
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get A/B test metrics
|
|
pub async fn get_ab_test_metrics(&self, test_id: &str) -> Result<TrafficSplitMetrics> {
|
|
// Get router
|
|
let router = {
|
|
let active_tests = self.active_tests.read().await;
|
|
active_tests
|
|
.get(test_id)
|
|
.ok_or_else(|| anyhow!("A/B test {} not found", test_id))?
|
|
.clone()
|
|
};
|
|
|
|
// Get ML results
|
|
let ml_results = router
|
|
.get_results()
|
|
.await
|
|
.map_err(|e| anyhow!("Failed to get ML results: {}", e))?;
|
|
|
|
// Convert to our metrics format
|
|
let control = Self::convert_group_metrics(&ml_results.control_group);
|
|
let treatment = Self::convert_group_metrics(&ml_results.treatment_group);
|
|
|
|
Ok(TrafficSplitMetrics { control, treatment })
|
|
}
|
|
|
|
/// Convert ML GroupMetrics to our ModelPerformanceMetrics
|
|
fn convert_group_metrics(metrics: &GroupMetrics) -> ModelPerformanceMetrics {
|
|
ModelPerformanceMetrics {
|
|
predictions: metrics.predictions,
|
|
correct_predictions: metrics.correct_predictions,
|
|
win_rate: metrics.win_rate(),
|
|
total_pnl: metrics.total_pnl,
|
|
avg_pnl: metrics.avg_pnl(),
|
|
sharpe_ratio: metrics.sharpe_ratio(),
|
|
max_drawdown: Self::compute_max_drawdown(&metrics.pnl_samples),
|
|
avg_latency_us: metrics.avg_latency_us,
|
|
}
|
|
}
|
|
|
|
/// Compute maximum drawdown from cumulative PnL samples.
|
|
///
|
|
/// Returns 0.0 when samples are empty. The result is a non-negative value
|
|
/// representing the largest peak-to-trough decline in cumulative PnL.
|
|
fn compute_max_drawdown(pnl_samples: &[f64]) -> f64 {
|
|
if pnl_samples.is_empty() {
|
|
return 0.0;
|
|
}
|
|
let mut cumulative = 0.0_f64;
|
|
let mut peak = 0.0_f64;
|
|
let mut max_dd = 0.0_f64;
|
|
for &pnl in pnl_samples {
|
|
cumulative += pnl;
|
|
if cumulative > peak {
|
|
peak = cumulative;
|
|
}
|
|
let drawdown = peak - cumulative;
|
|
if drawdown > max_dd {
|
|
max_dd = drawdown;
|
|
}
|
|
}
|
|
max_dd
|
|
}
|
|
|
|
/// Run statistical tests
|
|
pub async fn run_statistical_tests(&self, test_id: &str) -> Result<ABStatisticalTestResult> {
|
|
// Get router
|
|
let router = {
|
|
let active_tests = self.active_tests.read().await;
|
|
active_tests
|
|
.get(test_id)
|
|
.ok_or_else(|| anyhow!("A/B test {} not found", test_id))?
|
|
.clone()
|
|
};
|
|
|
|
// Get ML results (includes statistical tests)
|
|
let ml_results = router
|
|
.get_results()
|
|
.await
|
|
.map_err(|e| anyhow!("Failed to get ML results: {}", e))?;
|
|
|
|
Ok(ABStatisticalTestResult {
|
|
sharpe_diff: ml_results.sharpe_diff,
|
|
sharpe_test: ml_results.sharpe_test,
|
|
win_rate_diff: ml_results.win_rate_diff,
|
|
win_rate_test: ml_results.win_rate_test,
|
|
pnl_diff: ml_results.pnl_diff,
|
|
pnl_test: ml_results.pnl_test,
|
|
})
|
|
}
|
|
|
|
/// Make deployment decision based on A/B test results
|
|
pub async fn make_deployment_decision(&self, test_id: &str) -> Result<DeploymentDecision> {
|
|
// Get metrics
|
|
let metrics = self.get_ab_test_metrics(test_id).await?;
|
|
|
|
// Check minimum sample size
|
|
if metrics.control.predictions < self.config.min_sample_size as u64
|
|
|| metrics.treatment.predictions < self.config.min_sample_size as u64
|
|
{
|
|
return Ok(DeploymentDecision::Inconclusive {
|
|
reason: format!(
|
|
"Insufficient samples. Need {} per group, got control={}, treatment={}",
|
|
self.config.min_sample_size,
|
|
metrics.control.predictions,
|
|
metrics.treatment.predictions
|
|
),
|
|
control_samples: metrics.control.predictions as usize,
|
|
treatment_samples: metrics.treatment.predictions as usize,
|
|
required_samples: self.config.min_sample_size,
|
|
});
|
|
}
|
|
|
|
// Run statistical tests
|
|
let test_results = self.run_statistical_tests(test_id).await?;
|
|
|
|
// Decision logic based on statistical significance
|
|
let sharpe_diff = test_results.sharpe_diff;
|
|
let pnl_diff = test_results.pnl_diff;
|
|
let sharpe_significant = test_results.sharpe_test.is_significant;
|
|
let pnl_significant = test_results.pnl_test.is_significant;
|
|
let sharpe_p_value = test_results.sharpe_test.p_value;
|
|
|
|
info!(
|
|
"A/B test {} decision analysis: sharpe_diff={:.2}, pnl_diff={:.2}, sharpe_sig={}, pnl_sig={}, p_value={:.4}",
|
|
test_id, sharpe_diff, pnl_diff, sharpe_significant, pnl_significant, sharpe_p_value
|
|
);
|
|
|
|
// Strong positive signal: both metrics significantly better
|
|
if sharpe_significant && pnl_significant && sharpe_diff > 0.2 && pnl_diff > 0.0 {
|
|
return Ok(DeploymentDecision::RolloutTreatment {
|
|
reason: format!(
|
|
"Treatment significantly outperforms control: Sharpe +{:.2} (p={:.4}), PnL +${:.2}. Roll out to 100%.",
|
|
sharpe_diff, sharpe_p_value, pnl_diff
|
|
),
|
|
sharpe_improvement: sharpe_diff,
|
|
pnl_improvement: pnl_diff,
|
|
p_value: sharpe_p_value,
|
|
});
|
|
}
|
|
|
|
// Strong negative signal: both metrics significantly worse
|
|
if sharpe_significant && pnl_significant && sharpe_diff < -0.2 && pnl_diff < 0.0 {
|
|
return Ok(DeploymentDecision::RevertToControl {
|
|
reason: format!(
|
|
"Treatment significantly underperforms control: Sharpe {:.2} (p={:.4}), PnL ${:.2}. Revert to control.",
|
|
sharpe_diff, sharpe_p_value, pnl_diff
|
|
),
|
|
sharpe_degradation: sharpe_diff,
|
|
pnl_degradation: pnl_diff,
|
|
p_value: sharpe_p_value,
|
|
});
|
|
}
|
|
|
|
// Moderate positive signal
|
|
if sharpe_diff > 0.1 && pnl_diff > 0.0 {
|
|
return Ok(DeploymentDecision::RolloutTreatment {
|
|
reason: format!(
|
|
"Treatment shows improvement: Sharpe +{:.2}, PnL +${:.2}. Consider gradual rollout.",
|
|
sharpe_diff, pnl_diff
|
|
),
|
|
sharpe_improvement: sharpe_diff,
|
|
pnl_improvement: pnl_diff,
|
|
p_value: sharpe_p_value,
|
|
});
|
|
}
|
|
|
|
// Moderate negative signal
|
|
if sharpe_diff < -0.1 && pnl_diff < 0.0 {
|
|
return Ok(DeploymentDecision::RevertToControl {
|
|
reason: format!(
|
|
"Treatment shows degradation: Sharpe {:.2}, PnL ${:.2}. Consider reverting.",
|
|
sharpe_diff, pnl_diff
|
|
),
|
|
sharpe_degradation: sharpe_diff,
|
|
pnl_degradation: pnl_diff,
|
|
p_value: sharpe_p_value,
|
|
});
|
|
}
|
|
|
|
// No meaningful difference
|
|
Ok(DeploymentDecision::Neutral {
|
|
reason: format!(
|
|
"No meaningful difference detected (Sharpe diff: {:.2}, PnL diff: ${:.2}). Use simpler single-model for operational efficiency.",
|
|
sharpe_diff, pnl_diff
|
|
),
|
|
sharpe_diff,
|
|
pnl_diff,
|
|
})
|
|
}
|
|
|
|
/// Stop A/B test and persist results
|
|
pub async fn stop_ab_test(&self, test_id: &str) -> Result<DeploymentDecision> {
|
|
info!("Stopping A/B test {}", test_id);
|
|
|
|
// Make final deployment decision
|
|
let decision = self.make_deployment_decision(test_id).await?;
|
|
|
|
// Get final metrics
|
|
let metrics = self.get_ab_test_metrics(test_id).await?;
|
|
|
|
// Update database
|
|
let end_time = Utc::now();
|
|
let status = match &decision {
|
|
DeploymentDecision::RolloutTreatment { .. } => "completed_rollout",
|
|
DeploymentDecision::RevertToControl { .. } => "completed_revert",
|
|
DeploymentDecision::Neutral { .. } => "completed_neutral",
|
|
DeploymentDecision::Inconclusive { .. } => "completed_inconclusive",
|
|
};
|
|
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE ab_test_results
|
|
SET status = $1,
|
|
end_time = $2,
|
|
control_predictions = $3,
|
|
control_win_rate = $4,
|
|
control_sharpe = $5,
|
|
control_pnl = $6,
|
|
treatment_predictions = $7,
|
|
treatment_win_rate = $8,
|
|
treatment_sharpe = $9,
|
|
treatment_pnl = $10,
|
|
decision = $11
|
|
WHERE test_id = $12
|
|
"#,
|
|
)
|
|
.bind(status)
|
|
.bind(end_time)
|
|
.bind(metrics.control.predictions as i64)
|
|
.bind(metrics.control.win_rate)
|
|
.bind(metrics.control.sharpe_ratio)
|
|
.bind(metrics.control.total_pnl)
|
|
.bind(metrics.treatment.predictions as i64)
|
|
.bind(metrics.treatment.win_rate)
|
|
.bind(metrics.treatment.sharpe_ratio)
|
|
.bind(metrics.treatment.total_pnl)
|
|
.bind(serde_json::to_string(&decision)?)
|
|
.bind(test_id)
|
|
.execute(&self.db_pool)
|
|
.await
|
|
.context("Failed to update A/B test results")?;
|
|
|
|
// Remove from active tests
|
|
{
|
|
let mut active_tests = self.active_tests.write().await;
|
|
active_tests.remove(test_id);
|
|
}
|
|
|
|
info!("A/B test {} stopped with decision: {:?}", test_id, decision);
|
|
|
|
Ok(decision)
|
|
}
|
|
|
|
/// Get active A/B tests
|
|
pub async fn get_active_tests(&self) -> Result<Vec<String>> {
|
|
let active_tests = self.active_tests.read().await;
|
|
Ok(active_tests.keys().cloned().collect())
|
|
}
|
|
|
|
/// Load A/B test state from database
|
|
pub async fn load_ab_test(&self, test_id: &str) -> Result<ABTestState> {
|
|
let result = sqlx::query_as::<_, ABTestStateRow>(
|
|
r#"
|
|
SELECT test_id, control_model, treatment_model, symbol,
|
|
status, start_time, end_time
|
|
FROM ab_test_results
|
|
WHERE test_id = $1
|
|
"#,
|
|
)
|
|
.bind(test_id)
|
|
.fetch_one(&self.db_pool)
|
|
.await
|
|
.context("Failed to load A/B test from database")?;
|
|
|
|
Ok(ABTestState {
|
|
test_id: result.test_id,
|
|
control_model: result.control_model,
|
|
treatment_model: result.treatment_model,
|
|
symbol: result.symbol,
|
|
status: result.status,
|
|
start_time: result.start_time,
|
|
end_time: result.end_time,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Database row for A/B test state
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
struct ABTestStateRow {
|
|
test_id: String,
|
|
control_model: String,
|
|
treatment_model: String,
|
|
symbol: String,
|
|
status: String,
|
|
start_time: DateTime<Utc>,
|
|
end_time: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_config_defaults() {
|
|
let config = ABTestingConfig::default();
|
|
assert_eq!(config.min_sample_size, 150);
|
|
assert_eq!(config.traffic_split, 0.5);
|
|
assert_eq!(config.significance_level, 0.05);
|
|
}
|
|
|
|
#[test]
|
|
fn test_model_performance_metrics_defaults() {
|
|
let metrics = ModelPerformanceMetrics::default();
|
|
assert_eq!(metrics.predictions, 0);
|
|
assert_eq!(metrics.win_rate, 0.0);
|
|
}
|
|
}
|