From fbf9c8a5aa7de80e54af020125a117e5262a21b3 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sat, 21 Feb 2026 10:39:54 +0100 Subject: [PATCH] docs: capital-ready validation roadmap implementation plan 14 TDD tasks across 4 sections: data integrity (TemporalGuard, SlippageModel), walk-forward robustness (DegradationTracker, NoiseInjector, SensitivityAnalyzer), risk enforcement (RiskAction, RiskEnforcer, GraduatedRecovery, CorrelationMonitor), and E2E integration (pipeline traits, RiskGate, operating modes, DriftResponder, observability). Co-Authored-By: Claude Opus 4.6 --- ...ready-validation-roadmap-implementation.md | 1663 +++++++++++++++++ 1 file changed, 1663 insertions(+) create mode 100644 docs/plans/2026-02-21-capital-ready-validation-roadmap-implementation.md diff --git a/docs/plans/2026-02-21-capital-ready-validation-roadmap-implementation.md b/docs/plans/2026-02-21-capital-ready-validation-roadmap-implementation.md new file mode 100644 index 000000000..6d23e4448 --- /dev/null +++ b/docs/plans/2026-02-21-capital-ready-validation-roadmap-implementation.md @@ -0,0 +1,1663 @@ +# Capital-Ready Validation Roadmap — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Close the critical gaps between foxhunt's current state and capital-ready deployment across data integrity, model robustness, risk auto-enforcement, and end-to-end integration. + +**Architecture:** Bottom-up layered approach — each section builds on the previous. Data integrity prevents leakage, robustness proves models work, risk enforcement protects capital, E2E wires everything together. + +**Tech Stack:** Rust, Candle ML, tokio async, serde, chrono, Redis (kill switch coordination), Prometheus metrics. Build: `SQLX_OFFLINE=true cargo check -p `. Test: `SQLX_OFFLINE=true cargo test -p --lib`. + +--- + +## Section 1: Data Integrity Layer + +### Task 1: TemporalGuard — Forward-Looking Leakage Prevention + +**Files:** +- Create: `ml/src/validation/temporal_guard.rs` +- Modify: `ml/src/validation/mod.rs` +- Test: inline `#[cfg(test)]` module in `temporal_guard.rs` + +**Step 1: Write the failing test** + +In `ml/src/validation/temporal_guard.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + + fn make_timestamps(n: usize) -> Vec> { + (0..n) + .map(|i| { + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0) + .single() + .unwrap_or_else(Utc::now) + + chrono::Duration::days(i as i64) + }) + .collect() + } + + fn make_features(n: usize, dim: usize) -> Vec> { + (0..n).map(|_| vec![1.0_f32; dim]).collect() + } + + fn make_data(n: usize) -> TimeSeriesData { + let mut prices = Vec::with_capacity(n); + let mut price = 100.0_f64; + for i in 0..n { + prices.push(price); + price += 0.01 + 0.005 * ((i as f64).sin()); + } + TimeSeriesData::new(make_timestamps(n), make_features(n, 3), prices) + .unwrap_or_else(|e| panic!("Failed to create test data: {e}")) + } + + #[test] + fn test_guard_allows_access_within_cutoff() { + let data = make_data(100); + let cutoff_idx = 50; + let guard = TemporalGuard::new(&data, cutoff_idx); + let slice = guard.training_slice(); + assert!(slice.is_ok()); + let slice = slice.unwrap(); + assert_eq!(slice.len(), cutoff_idx); + } + + #[test] + fn test_guard_blocks_access_beyond_cutoff() { + let data = make_data(100); + let guard = TemporalGuard::new(&data, 50); + // Attempting to get data beyond cutoff should fail + let result = guard.slice(40, 60); + assert!(result.is_err()); + } + + #[test] + fn test_guard_test_slice_returns_post_cutoff() { + let data = make_data(100); + let guard = TemporalGuard::new(&data, 50); + let test_slice = guard.test_slice(50, 70); + assert!(test_slice.is_ok()); + let test_slice = test_slice.unwrap(); + assert_eq!(test_slice.len(), 20); + } + + #[test] + fn test_leakage_audit_detects_future_timestamps() { + let data = make_data(100); + let guard = TemporalGuard::new(&data, 50); + let audit = guard.audit_leakage(); + assert!(audit.is_ok()); + let report = audit.unwrap(); + assert!(!report.has_future_timestamps); + } + + #[test] + fn test_normalization_stats_computed_from_training_only() { + let data = make_data(100); + let guard = TemporalGuard::new(&data, 50); + let stats = guard.compute_normalization_stats(); + assert!(stats.is_ok()); + let stats = stats.unwrap(); + // Stats should be computed from first 50 bars only + assert_eq!(stats.sample_count, 50); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `SQLX_OFFLINE=true cargo test -p ml --lib validation::temporal_guard::tests -- --nocapture` +Expected: FAIL — `temporal_guard` module doesn't exist yet + +**Step 3: Write the implementation** + +```rust +//! Temporal Guard — prevents forward-looking data leakage in validation pipelines. +//! +//! Wraps [`TimeSeriesData`] with a cutoff index. Training code can only access +//! data up to the cutoff; test data is accessed separately. Normalization +//! statistics are computed exclusively from training data. + +use chrono::{DateTime, Utc}; +use crate::MLError; +use super::TimeSeriesData; + +/// Leakage audit report. +#[derive(Debug, Clone)] +pub struct LeakageAuditReport { + /// Whether any future timestamps were found in training window. + pub has_future_timestamps: bool, + /// Number of bars in training window. + pub training_bars: usize, + /// Cutoff timestamp (last timestamp in training window). + pub cutoff_timestamp: Option>, +} + +/// Normalization statistics computed from training data only. +#[derive(Debug, Clone)] +pub struct NormalizationStats { + /// Per-feature mean. + pub means: Vec, + /// Per-feature standard deviation. + pub stds: Vec, + /// Number of samples used to compute stats. + pub sample_count: usize, +} + +/// Wraps `TimeSeriesData` with a cutoff index to prevent leakage. +/// +/// Training code accesses data via [`training_slice`] (indices `[0, cutoff)`). +/// Test code accesses data via [`test_slice`] with explicit bounds. +/// Any attempt to cross the cutoff boundary returns an error. +#[derive(Debug)] +pub struct TemporalGuard<'a> { + data: &'a TimeSeriesData, + cutoff_idx: usize, +} + +impl<'a> TemporalGuard<'a> { + /// Create a guard with the given cutoff index. + /// + /// Data at indices `[0, cutoff_idx)` is the training window. + /// Data at indices `[cutoff_idx, data.len())` is the test window. + pub fn new(data: &'a TimeSeriesData, cutoff_idx: usize) -> Self { + let cutoff = cutoff_idx.min(data.len()); + Self { data, cutoff_idx: cutoff } + } + + /// Get the training slice `[0, cutoff)`. + pub fn training_slice(&self) -> Result { + if self.cutoff_idx < 2 { + return Err(MLError::InsufficientData( + "Training slice requires at least 2 bars".to_string(), + )); + } + self.data.slice(0, self.cutoff_idx) + } + + /// Get a test slice `[start, end)` — must be entirely at or after cutoff. + pub fn test_slice(&self, start: usize, end: usize) -> Result { + if start < self.cutoff_idx { + return Err(MLError::InvalidInput(format!( + "Test slice start ({start}) is before cutoff ({cutoff}), which would leak training data", + cutoff = self.cutoff_idx, + ))); + } + self.data.slice(start, end) + } + + /// Get a slice — errors if the range crosses the cutoff boundary. + pub fn slice(&self, start: usize, end: usize) -> Result { + if start < self.cutoff_idx && end > self.cutoff_idx { + return Err(MLError::InvalidInput(format!( + "Slice [{start}, {end}) crosses cutoff boundary at {cutoff} — this would leak future data into training", + cutoff = self.cutoff_idx, + ))); + } + self.data.slice(start, end) + } + + /// Audit the training window for leakage indicators. + pub fn audit_leakage(&self) -> Result { + let cutoff_ts = self.data.timestamps.get(self.cutoff_idx.saturating_sub(1)).copied(); + + let has_future = if let Some(cutoff) = cutoff_ts { + self.data.timestamps.iter() + .take(self.cutoff_idx) + .any(|ts| *ts > cutoff) + } else { + false + }; + + Ok(LeakageAuditReport { + has_future_timestamps: has_future, + training_bars: self.cutoff_idx, + cutoff_timestamp: cutoff_ts, + }) + } + + /// Compute normalization statistics from training data only. + pub fn compute_normalization_stats(&self) -> Result { + if self.cutoff_idx < 2 { + return Err(MLError::InsufficientData( + "Need at least 2 training bars for normalization stats".to_string(), + )); + } + + let training_features = self.data.features.get(..self.cutoff_idx) + .ok_or_else(|| MLError::InvalidInput("Cutoff exceeds data length".to_string()))?; + + let n = training_features.len() as f64; + let dim = training_features.first() + .map(|f| f.len()) + .unwrap_or(0); + + let mut means = vec![0.0_f64; dim]; + let mut stds = vec![0.0_f64; dim]; + + // Compute means + for row in training_features { + for (j, val) in row.iter().enumerate() { + if let Some(m) = means.get_mut(j) { + *m += f64::from(*val); + } + } + } + for m in &mut means { + *m /= n; + } + + // Compute std devs + for row in training_features { + for (j, val) in row.iter().enumerate() { + let mean_j = means.get(j).copied().unwrap_or(0.0); + if let Some(s) = stds.get_mut(j) { + *s += (f64::from(*val) - mean_j).powi(2); + } + } + } + for s in &mut stds { + *s = (*s / (n - 1.0)).sqrt(); + } + + Ok(NormalizationStats { + means, + stds, + sample_count: self.cutoff_idx, + }) + } + + /// The cutoff index. + pub fn cutoff(&self) -> usize { + self.cutoff_idx + } +} +``` + +**Step 4: Register the module** + +In `ml/src/validation/mod.rs`, add after line `pub mod walk_forward;`: +```rust +pub mod temporal_guard; +``` + +And add re-export: +```rust +pub use temporal_guard::{TemporalGuard, LeakageAuditReport, NormalizationStats}; +``` + +**Step 5: Run tests to verify they pass** + +Run: `SQLX_OFFLINE=true cargo test -p ml --lib validation::temporal_guard::tests -- --nocapture` +Expected: all 5 tests PASS + +**Step 6: Compile check** + +Run: `SQLX_OFFLINE=true cargo check -p ml` +Expected: compiles clean + +**Step 7: Commit** + +```bash +git add ml/src/validation/temporal_guard.rs ml/src/validation/mod.rs +git commit -m "feat(ml): add TemporalGuard for forward-looking leakage prevention" +``` + +--- + +### Task 2: SlippageModel Trait — Dynamic Slippage for Backtesting + +**Files:** +- Create: `backtesting/src/slippage.rs` +- Modify: `backtesting/src/lib.rs` (add `pub mod slippage;`) +- Test: inline `#[cfg(test)]` module in `slippage.rs` + +**Step 1: Write the failing test** + +In `backtesting/src/slippage.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn test_fixed_slippage_returns_constant() { + let model = FixedSlippage::new(dec!(0.0005)); + let ctx = SlippageContext { + order_size: dec!(100), + average_daily_volume: dec!(1_000_000), + current_spread: dec!(0.25), + volatility_regime: VolatilityRegime::Normal, + }; + let slippage = model.compute(&ctx); + assert_eq!(slippage, dec!(0.0005)); + } + + #[test] + fn test_volume_impact_scales_with_order_size() { + let model = VolumeImpactSlippage::new(dec!(0.1)); + let small_order = SlippageContext { + order_size: dec!(100), + average_daily_volume: dec!(1_000_000), + current_spread: dec!(0.25), + volatility_regime: VolatilityRegime::Normal, + }; + let large_order = SlippageContext { + order_size: dec!(10000), + average_daily_volume: dec!(1_000_000), + current_spread: dec!(0.25), + volatility_regime: VolatilityRegime::Normal, + }; + let small_slip = model.compute(&small_order); + let large_slip = model.compute(&large_order); + assert!(large_slip > small_slip, "Larger orders should have more slippage"); + } + + #[test] + fn test_regime_aware_scales_by_regime() { + let base = FixedSlippage::new(dec!(0.001)); + let model = RegimeAwareSlippage::new(Box::new(base)); + let normal = SlippageContext { + order_size: dec!(100), + average_daily_volume: dec!(1_000_000), + current_spread: dec!(0.25), + volatility_regime: VolatilityRegime::Normal, + }; + let crisis = SlippageContext { + volatility_regime: VolatilityRegime::Crisis, + ..normal.clone() + }; + let normal_slip = model.compute(&normal); + let crisis_slip = model.compute(&crisis); + assert!(crisis_slip > normal_slip, "Crisis regime should have higher slippage"); + } + + #[test] + fn test_spread_profile_es_default() { + let profile = SpreadProfile::es_futures(); + assert_eq!(profile.tick_size, dec!(0.25)); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `SQLX_OFFLINE=true cargo test -p backtesting --lib slippage::tests -- --nocapture` +Expected: FAIL — `slippage` module doesn't exist + +**Step 3: Write the implementation** + +```rust +//! Dynamic slippage models for realistic backtesting. +//! +//! Replaces the static `slippage_factor` field in `StrategyConfig` with +//! pluggable models that account for order size, volume, and volatility regime. + +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +/// Volatility regime for regime-aware slippage. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum VolatilityRegime { + /// Low volatility environment. + Low, + /// Normal market conditions. + Normal, + /// High volatility / crisis. + Crisis, +} + +/// Context needed to compute slippage for a single order. +#[derive(Debug, Clone)] +pub struct SlippageContext { + /// Number of contracts/shares in this order. + pub order_size: Decimal, + /// Average daily volume for this instrument. + pub average_daily_volume: Decimal, + /// Current bid-ask spread. + pub current_spread: Decimal, + /// Current volatility regime. + pub volatility_regime: VolatilityRegime, +} + +/// Per-instrument spread profile. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpreadProfile { + /// Instrument name. + pub name: String, + /// Minimum tick size. + pub tick_size: Decimal, + /// Typical spread in ticks. + pub typical_spread_ticks: Decimal, +} + +impl SpreadProfile { + /// E-mini S&P 500 futures. + #[must_use] + pub fn es_futures() -> Self { + Self { + name: "ES".to_string(), + tick_size: Decimal::new(25, 2), // 0.25 + typical_spread_ticks: Decimal::ONE, + } + } + + /// 10-Year Treasury Note futures. + #[must_use] + pub fn zn_futures() -> Self { + Self { + name: "ZN".to_string(), + tick_size: Decimal::new(15625, 7), // 1/64 ≈ 0.015625 + typical_spread_ticks: Decimal::ONE, + } + } + + /// Euro FX futures. + #[must_use] + pub fn sixe_futures() -> Self { + Self { + name: "6E".to_string(), + tick_size: Decimal::new(5, 4), // 0.0005 (half pip) + typical_spread_ticks: Decimal::ONE, + } + } +} + +/// Trait for computing slippage on a per-order basis. +pub trait SlippageModel: Send + Sync { + /// Compute the slippage factor for this order context. + /// Returns a decimal representing the proportional slippage (e.g. 0.001 = 0.1%). + fn compute(&self, ctx: &SlippageContext) -> Decimal; +} + +/// Fixed slippage — backward compatible with current `slippage_factor` field. +#[derive(Debug, Clone)] +pub struct FixedSlippage { + factor: Decimal, +} + +impl FixedSlippage { + #[must_use] + pub fn new(factor: Decimal) -> Self { + Self { factor } + } +} + +impl SlippageModel for FixedSlippage { + fn compute(&self, _ctx: &SlippageContext) -> Decimal { + self.factor + } +} + +/// Square-root volume impact model: slippage = k * sqrt(order_size / ADV). +/// +/// Standard institutional market impact model. `k` is a calibration constant +/// (typically 0.05–0.20 depending on the instrument). +#[derive(Debug, Clone)] +pub struct VolumeImpactSlippage { + k: Decimal, +} + +impl VolumeImpactSlippage { + #[must_use] + pub fn new(k: Decimal) -> Self { + Self { k } + } +} + +impl SlippageModel for VolumeImpactSlippage { + fn compute(&self, ctx: &SlippageContext) -> Decimal { + if ctx.average_daily_volume <= Decimal::ZERO { + return self.k; // Fallback to k when no volume data + } + let ratio = ctx.order_size / ctx.average_daily_volume; + // Use f64 for sqrt, convert back + let ratio_f64 = ratio.to_string().parse::().unwrap_or(0.0); + let sqrt_ratio = Decimal::try_from(ratio_f64.sqrt()).unwrap_or(Decimal::ZERO); + self.k * sqrt_ratio + } +} + +/// Wraps another slippage model and multiplies by a regime-dependent factor. +/// +/// | Regime | Multiplier | +/// |--------|------------| +/// | Low | 0.5x | +/// | Normal | 1.0x | +/// | Crisis | 3.0x | +#[derive(Debug)] +pub struct RegimeAwareSlippage { + base: Box, +} + +impl RegimeAwareSlippage { + #[must_use] + pub fn new(base: Box) -> Self { + Self { base } + } + + fn regime_multiplier(regime: VolatilityRegime) -> Decimal { + match regime { + VolatilityRegime::Low => Decimal::new(5, 1), // 0.5 + VolatilityRegime::Normal => Decimal::ONE, // 1.0 + VolatilityRegime::Crisis => Decimal::new(3, 0), // 3.0 + } + } +} + +impl SlippageModel for RegimeAwareSlippage { + fn compute(&self, ctx: &SlippageContext) -> Decimal { + let base_slip = self.base.compute(ctx); + base_slip * Self::regime_multiplier(ctx.volatility_regime) + } +} +``` + +**Step 4: Register the module in `backtesting/src/lib.rs`** + +Add after `pub mod strategy_tester;` (line 73): +```rust +pub mod slippage; +``` + +Add re-export after existing re-exports: +```rust +pub use slippage::{SlippageModel, FixedSlippage, VolumeImpactSlippage, RegimeAwareSlippage, SpreadProfile, SlippageContext, VolatilityRegime}; +``` + +**Step 5: Run tests** + +Run: `SQLX_OFFLINE=true cargo test -p backtesting --lib slippage::tests -- --nocapture` +Expected: all 4 tests PASS + +**Step 6: Compile check** + +Run: `SQLX_OFFLINE=true cargo check -p backtesting` +Expected: compiles clean + +**Step 7: Commit** + +```bash +git add backtesting/src/slippage.rs backtesting/src/lib.rs +git commit -m "feat(backtesting): add SlippageModel trait with fixed, volume-impact, and regime-aware models" +``` + +--- + +## Section 2: Walk-Forward Robustness + +### Task 3: DegradationTracker — Per-Fold Performance Decay + +**Files:** +- Create: `ml/src/validation/degradation.rs` +- Modify: `ml/src/validation/mod.rs` +- Modify: `ml/src/validation/harness.rs` (add `degradation` field to `ValidationReport`) +- Test: inline `#[cfg(test)]` module in `degradation.rs` + +**Step 1: Write the failing test** + +In `ml/src/validation/degradation.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stable_strategy_has_near_zero_slope() { + let mut tracker = DegradationTracker::new(); + // Stable Sharpe across folds + for i in 0..6 { + tracker.add_fold(FoldMetrics { + fold_index: i, + sharpe: 1.0, + calmar: 0.5, + win_rate: 0.55, + max_drawdown: 0.05, + trade_count: 100, + }); + } + let report = tracker.compute(); + assert!(report.sharpe_slope.abs() < 0.01, "Stable strategy should have near-zero slope"); + assert!(report.half_life_folds.is_none(), "Stable strategy should have no half-life"); + } + + #[test] + fn test_decaying_strategy_has_negative_slope() { + let mut tracker = DegradationTracker::new(); + // Sharpe decays from 2.0 to 0.2 + let sharpes = [2.0, 1.6, 1.2, 0.8, 0.4, 0.2]; + for (i, &s) in sharpes.iter().enumerate() { + tracker.add_fold(FoldMetrics { + fold_index: i, + sharpe: s, + calmar: s * 0.3, + win_rate: 0.5 + s * 0.05, + max_drawdown: 0.1, + trade_count: 100, + }); + } + let report = tracker.compute(); + assert!(report.sharpe_slope < -0.1, "Decaying strategy should have negative slope, got {}", report.sharpe_slope); + assert!(report.half_life_folds.is_some(), "Decaying strategy should have a half-life"); + } + + #[test] + fn test_cv_measures_stability() { + let mut tracker = DegradationTracker::new(); + // High variance Sharpes + let sharpes = [0.5, 2.0, 0.3, 1.8, 0.4, 2.1]; + for (i, &s) in sharpes.iter().enumerate() { + tracker.add_fold(FoldMetrics { + fold_index: i, + sharpe: s, + calmar: 0.5, + win_rate: 0.55, + max_drawdown: 0.05, + trade_count: 100, + }); + } + let report = tracker.compute(); + assert!(report.coefficient_of_variation > 0.3, "High variance should produce high CV"); + } + + #[test] + fn test_insufficient_folds_returns_neutral() { + let mut tracker = DegradationTracker::new(); + tracker.add_fold(FoldMetrics { + fold_index: 0, + sharpe: 1.5, + calmar: 0.5, + win_rate: 0.55, + max_drawdown: 0.05, + trade_count: 100, + }); + let report = tracker.compute(); + assert!((report.sharpe_slope - 0.0).abs() < f64::EPSILON, "Single fold should return zero slope"); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `SQLX_OFFLINE=true cargo test -p ml --lib validation::degradation::tests -- --nocapture` +Expected: FAIL — module doesn't exist + +**Step 3: Write the implementation** + +```rust +//! Degradation tracking across walk-forward folds. +//! +//! Measures how strategy performance decays over time by computing: +//! - Linear regression slope of Sharpe ratio across folds +//! - Coefficient of variation (stability measure) +//! - Recency-weighted score (recent folds weighted more) +//! - Half-life estimate (when Sharpe hits zero at current decay rate) + +use serde::{Deserialize, Serialize}; + +/// Per-fold performance metrics. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FoldMetrics { + /// Zero-based fold index (temporal order). + pub fold_index: usize, + /// Sharpe ratio for this fold. + pub sharpe: f64, + /// Calmar ratio for this fold. + pub calmar: f64, + /// Win rate (0.0 to 1.0). + pub win_rate: f64, + /// Maximum drawdown (as positive fraction, e.g. 0.10 = 10%). + pub max_drawdown: f64, + /// Number of trades in this fold. + pub trade_count: usize, +} + +/// Summary of degradation analysis. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DegradationReport { + /// Linear regression slope of Sharpe across folds. Negative = decaying. + pub sharpe_slope: f64, + /// P-value of the slope (via t-test). < 0.10 = statistically significant decay. + pub slope_pvalue: f64, + /// Coefficient of variation of Sharpe across folds. Higher = less stable. + pub coefficient_of_variation: f64, + /// Recency-weighted Sharpe (exponential decay, lambda=0.3). + pub recency_weighted_sharpe: f64, + /// Estimated number of folds until Sharpe reaches zero (None if slope >= 0). + pub half_life_folds: Option, + /// Raw per-fold metrics for plotting. + pub fold_metrics: Vec, +} + +/// Collects per-fold metrics and computes degradation statistics. +#[derive(Debug)] +pub struct DegradationTracker { + folds: Vec, +} + +impl DegradationTracker { + pub fn new() -> Self { + Self { folds: Vec::new() } + } + + pub fn add_fold(&mut self, metrics: FoldMetrics) { + self.folds.push(metrics); + } + + /// Compute degradation report from collected fold metrics. + pub fn compute(&self) -> DegradationReport { + let n = self.folds.len(); + + if n < 2 { + return DegradationReport { + sharpe_slope: 0.0, + slope_pvalue: 1.0, + coefficient_of_variation: 0.0, + recency_weighted_sharpe: self.folds.first().map(|f| f.sharpe).unwrap_or(0.0), + half_life_folds: None, + fold_metrics: self.folds.clone(), + }; + } + + let nf = n as f64; + + // Linear regression: sharpe = a + b * fold_index + let xs: Vec = (0..n).map(|i| i as f64).collect(); + let ys: Vec = self.folds.iter().map(|f| f.sharpe).collect(); + + let x_mean = xs.iter().sum::() / nf; + let y_mean = ys.iter().sum::() / nf; + + let mut ss_xy = 0.0; + let mut ss_xx = 0.0; + for i in 0..n { + let x = xs.get(i).copied().unwrap_or(0.0); + let y = ys.get(i).copied().unwrap_or(0.0); + ss_xy += (x - x_mean) * (y - y_mean); + ss_xx += (x - x_mean).powi(2); + } + + let slope = if ss_xx > 0.0 { ss_xy / ss_xx } else { 0.0 }; + let intercept = y_mean - slope * x_mean; + + // Residual standard error and t-test for slope + let mut ss_res = 0.0; + for i in 0..n { + let x = xs.get(i).copied().unwrap_or(0.0); + let y = ys.get(i).copied().unwrap_or(0.0); + let predicted = intercept + slope * x; + ss_res += (y - predicted).powi(2); + } + let se_slope = if n > 2 && ss_xx > 0.0 { + (ss_res / (nf - 2.0) / ss_xx).sqrt() + } else { + f64::INFINITY + }; + let t_stat = if se_slope > 0.0 && se_slope.is_finite() { + slope / se_slope + } else { + 0.0 + }; + // Approximate two-tailed p-value from t-distribution (n-2 df) + // Using normal approximation for simplicity (valid for n >= 10) + let slope_pvalue = 2.0 * normal_sf(t_stat.abs()); + + // Coefficient of variation + let std_dev = if n > 1 { + let variance = ys.iter().map(|y| (y - y_mean).powi(2)).sum::() / (nf - 1.0); + variance.sqrt() + } else { + 0.0 + }; + let cv = if y_mean.abs() > 1e-10 { + std_dev / y_mean.abs() + } else { + 0.0 + }; + + // Recency-weighted Sharpe (exponential decay, lambda=0.3) + let lambda = 0.3; + let mut weighted_sum = 0.0; + let mut weight_sum = 0.0; + for (i, fold) in self.folds.iter().enumerate() { + let weight = (lambda * i as f64).exp(); + weighted_sum += weight * fold.sharpe; + weight_sum += weight; + } + let recency_weighted = if weight_sum > 0.0 { + weighted_sum / weight_sum + } else { + 0.0 + }; + + // Half-life: folds until Sharpe reaches zero + let half_life = if slope < -1e-10 && intercept > 0.0 { + Some(-intercept / slope) + } else { + None + }; + + DegradationReport { + sharpe_slope: slope, + slope_pvalue, + coefficient_of_variation: cv, + recency_weighted_sharpe: recency_weighted, + half_life_folds: half_life, + fold_metrics: self.folds.clone(), + } + } +} + +impl Default for DegradationTracker { + fn default() -> Self { + Self::new() + } +} + +/// Survival function of standard normal (1 - CDF). +/// Uses Abramowitz & Stegun approximation. +fn normal_sf(x: f64) -> f64 { + let t = 1.0 / (1.0 + 0.2316419 * x.abs()); + let d = 0.3989422804014327; // 1/sqrt(2*pi) + let p = d * (-x * x / 2.0).exp() + * (0.319381530 * t + - 0.356563782 * t.powi(2) + + 1.781477937 * t.powi(3) + - 1.821255978 * t.powi(4) + + 1.330274429 * t.powi(5)); + if x >= 0.0 { p } else { 1.0 - p } +} +``` + +**Step 4: Register module and update harness** + +In `ml/src/validation/mod.rs`, add: +```rust +pub mod degradation; +``` +And re-export: +```rust +pub use degradation::{DegradationTracker, DegradationReport, FoldMetrics}; +``` + +In `ml/src/validation/harness.rs`, add to `ValidationReport` struct (after `per_regime_metrics` field): +```rust + /// Degradation analysis across walk-forward folds (None if not computed). + pub degradation: Option, +``` + +Update the `ValidationHarness::validate` method: after computing `per_fold_sharpes`, build a `DegradationTracker` and add fold metrics. Before returning the report, compute and include the degradation report. Set `degradation: Some(degradation_report)` in the returned struct. + +**Step 5: Run tests** + +Run: `SQLX_OFFLINE=true cargo test -p ml --lib validation::degradation::tests -- --nocapture` +Expected: all 4 tests PASS + +Run: `SQLX_OFFLINE=true cargo test -p ml --lib validation::harness::tests -- --nocapture` +Expected: existing tests still pass (new field is Option, existing constructions need updating) + +**Step 6: Compile check** + +Run: `SQLX_OFFLINE=true cargo check -p ml` +Expected: compiles clean + +**Step 7: Commit** + +```bash +git add ml/src/validation/degradation.rs ml/src/validation/mod.rs ml/src/validation/harness.rs +git commit -m "feat(ml): add DegradationTracker for walk-forward performance decay analysis" +``` + +--- + +### Task 4: NoiseInjector — Feature Robustness Testing + +**Files:** +- Create: `ml/src/validation/noise.rs` +- Modify: `ml/src/validation/mod.rs` +- Test: inline `#[cfg(test)]` module in `noise.rs` + +**Step 1: Write the failing test** + +In `ml/src/validation/noise.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use crate::validation::TimeSeriesData; + + fn make_data(n: usize) -> TimeSeriesData { + let timestamps: Vec<_> = (0..n) + .map(|i| { + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0) + .single() + .unwrap_or_else(Utc::now) + + chrono::Duration::days(i as i64) + }) + .collect(); + let features: Vec> = (0..n).map(|_| vec![1.0, 2.0, 3.0]).collect(); + let mut prices = Vec::with_capacity(n); + let mut p = 100.0; + for i in 0..n { + prices.push(p); + p += 0.01 + 0.005 * (i as f64).sin(); + } + TimeSeriesData::new(timestamps, features, prices) + .unwrap_or_else(|e| panic!("Failed: {e}")) + } + + #[test] + fn test_gaussian_noise_changes_features() { + let data = make_data(50); + let noisy = NoiseInjector::gaussian_noise(&data, 0.1, 42); + assert!(noisy.is_ok()); + let noisy = noisy.unwrap(); + // Features should be different from original + let orig_f = data.features.first().unwrap(); + let noisy_f = noisy.features.first().unwrap(); + let any_different = orig_f.iter().zip(noisy_f.iter()).any(|(a, b)| (a - b).abs() > 1e-10); + assert!(any_different, "Gaussian noise should modify at least one feature"); + // Prices should be unchanged + assert_eq!(data.prices, noisy.prices); + } + + #[test] + fn test_feature_dropout_zeros_some_features() { + let data = make_data(50); + let dropped = NoiseInjector::feature_dropout(&data, 0.5, 42); + assert!(dropped.is_ok()); + let dropped = dropped.unwrap(); + let total_zeros: usize = dropped.features.iter() + .flat_map(|row| row.iter()) + .filter(|v| v.abs() < 1e-10) + .count(); + assert!(total_zeros > 0, "Dropout should zero out some features"); + } + + #[test] + fn test_robustness_score_perfect_is_one() { + // If Sharpes are identical across noise levels, score should be ~1.0 + let sharpes = vec![1.5, 1.5, 1.5, 1.5]; + let score = compute_robustness_score(&sharpes); + assert!((score - 1.0).abs() < 0.01, "Identical Sharpes should give score ~1.0, got {score}"); + } + + #[test] + fn test_robustness_score_collapse_is_low() { + // If Sharpe drops to zero at first noise level + let sharpes = vec![2.0, 0.0, 0.0, 0.0]; + let score = compute_robustness_score(&sharpes); + assert!(score < 0.5, "Collapse should give low score, got {score}"); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `SQLX_OFFLINE=true cargo test -p ml --lib validation::noise::tests -- --nocapture` +Expected: FAIL + +**Step 3: Write the implementation** + +```rust +//! Feature noise injection for robustness testing. +//! +//! Tests whether models are genuinely learning signal vs overfitting +//! to exact feature values by perturbing inputs and measuring degradation. + +use crate::MLError; +use crate::validation::TimeSeriesData; + +/// Noise injection methods for robustness testing. +pub struct NoiseInjector; + +impl NoiseInjector { + /// Add Gaussian noise to features. `sigma` is relative to each feature's + /// standard deviation (e.g. 0.10 = 10% of std dev). + /// Prices and timestamps are unchanged. + pub fn gaussian_noise( + data: &TimeSeriesData, + sigma_fraction: f64, + seed: u64, + ) -> Result { + let mut rng = SimpleRng::new(seed); + let dim = data.features.first().map(|f| f.len()).unwrap_or(0); + + // Compute per-feature std dev + let n = data.features.len() as f64; + let mut means = vec![0.0_f64; dim]; + let mut vars = vec![0.0_f64; dim]; + + for row in &data.features { + for (j, val) in row.iter().enumerate() { + if let Some(m) = means.get_mut(j) { + *m += f64::from(*val); + } + } + } + for m in &mut means { *m /= n; } + + for row in &data.features { + for (j, val) in row.iter().enumerate() { + let mean = means.get(j).copied().unwrap_or(0.0); + if let Some(v) = vars.get_mut(j) { + *v += (f64::from(*val) - mean).powi(2); + } + } + } + let stds: Vec = vars.iter().map(|v| (v / (n - 1.0).max(1.0)).sqrt()).collect(); + + let noisy_features: Vec> = data.features.iter() + .map(|row| { + row.iter().enumerate().map(|(j, &val)| { + let std = stds.get(j).copied().unwrap_or(1.0); + let noise = rng.normal() * sigma_fraction * std; + (f64::from(val) + noise) as f32 + }).collect() + }) + .collect(); + + TimeSeriesData::new( + data.timestamps.clone(), + noisy_features, + data.prices.clone(), + ) + } + + /// Randomly zero out features with the given probability. + /// Prices and timestamps are unchanged. + pub fn feature_dropout( + data: &TimeSeriesData, + drop_rate: f64, + seed: u64, + ) -> Result { + let mut rng = SimpleRng::new(seed); + + let dropped_features: Vec> = data.features.iter() + .map(|row| { + row.iter().map(|&val| { + if rng.uniform() < drop_rate { 0.0 } else { val } + }).collect() + }) + .collect(); + + TimeSeriesData::new( + data.timestamps.clone(), + dropped_features, + data.prices.clone(), + ) + } +} + +/// Compute robustness score from Sharpe ratios at increasing noise levels. +/// +/// `sharpes[0]` = clean, `sharpes[1..]` = at noise levels [5%, 10%, 20%]. +/// Score = area under curve normalized to [0, 1]. +/// 1.0 = no degradation, 0.0 = immediate collapse. +pub fn compute_robustness_score(sharpes: &[f64]) -> f64 { + if sharpes.is_empty() { + return 0.0; + } + let baseline = sharpes.first().copied().unwrap_or(0.0); + if baseline.abs() < 1e-10 { + return 0.0; + } + + let n = sharpes.len() as f64; + let area: f64 = sharpes.iter().map(|s| s / baseline).sum::() / n; + area.clamp(0.0, 1.0) +} + +/// Noise configuration for the validation harness. +#[derive(Debug, Clone)] +pub struct NoiseConfig { + /// Noise levels to test (as fraction of feature std dev). + pub noise_levels: Vec, + /// RNG seed for reproducibility. + pub seed: u64, +} + +impl Default for NoiseConfig { + fn default() -> Self { + Self { + noise_levels: vec![0.05, 0.10, 0.20], + seed: 42, + } + } +} + +/// Minimal PRNG (xorshift64) to avoid external dependency. +struct SimpleRng { + state: u64, +} + +impl SimpleRng { + fn new(seed: u64) -> Self { + Self { state: seed.wrapping_add(1) } + } + + fn next_u64(&mut self) -> u64 { + self.state ^= self.state << 13; + self.state ^= self.state >> 7; + self.state ^= self.state << 17; + self.state + } + + fn uniform(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 + } + + /// Box-Muller transform for normal distribution. + fn normal(&mut self) -> f64 { + let u1 = self.uniform().max(1e-15); + let u2 = self.uniform(); + (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() + } +} +``` + +**Step 4: Register module** + +In `ml/src/validation/mod.rs`: +```rust +pub mod noise; +pub use noise::{NoiseInjector, NoiseConfig, compute_robustness_score}; +``` + +**Step 5: Run tests** + +Run: `SQLX_OFFLINE=true cargo test -p ml --lib validation::noise::tests -- --nocapture` +Expected: all 5 tests PASS + +**Step 6: Commit** + +```bash +git add ml/src/validation/noise.rs ml/src/validation/mod.rs +git commit -m "feat(ml): add NoiseInjector for feature robustness testing" +``` + +--- + +### Task 5: SensitivityAnalyzer — Hyperparameter Fragility Detection + +**Files:** +- Create: `ml/src/hyperopt/sensitivity.rs` +- Modify: `ml/src/hyperopt/mod.rs` (add `pub mod sensitivity;`) +- Test: inline `#[cfg(test)]` module + +**This task follows the same TDD pattern. The `SensitivityAnalyzer` takes a `Vec` of best parameters and a closure that evaluates a parameter set → Sharpe ratio. It perturbs each parameter by +/-5%, +/-10%, +/-20% and records Sharpe at each perturbation. Outputs per-parameter sensitivity scores and an overall fragility score.** + +**Key types:** +```rust +pub struct SensitivityResult { + pub per_param: Vec, + pub overall_fragility: f64, // mean sensitivity across all params +} +pub struct ParamSensitivity { + pub name: String, + pub base_value: f64, + pub sensitivity_score: f64, // |d(Sharpe)/d(param)| normalized + pub is_fragile: bool, // sensitivity > threshold + pub perturbation_results: Vec<(f64, f64)>, // (perturbation_pct, sharpe) +} +``` + +**Step 1–7:** Follow same pattern as Tasks 1–4. Write tests first, implement, register, compile, commit. + +```bash +git commit -m "feat(ml): add SensitivityAnalyzer for hyperparameter fragility detection" +``` + +--- + +## Section 3: Risk Auto-Enforcement + +### Task 6: RiskAction Trait and Enforcement Module Skeleton + +**Files:** +- Create: `risk/src/enforcement/mod.rs` +- Create: `risk/src/enforcement/actions.rs` +- Modify: `risk/src/lib.rs` (add `pub mod enforcement;`) +- Test: inline tests + +**Step 1: Write the failing test** + +In `risk/src/enforcement/actions.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_reduce_position_action_severity() { + let action = ReducePositionSizeAction::new(0.5); // Reduce by 50% + assert_eq!(action.severity(), AlertSeverity::Warning); + } + + #[test] + fn test_halt_new_entries_severity() { + let action = HaltNewEntriesAction; + assert_eq!(action.severity(), AlertSeverity::Alert); + } + + #[test] + fn test_close_all_positions_severity() { + let action = CloseAllPositionsAction; + assert_eq!(action.severity(), AlertSeverity::Emergency); + } + + #[test] + fn test_risk_context_default() { + let ctx = RiskContext::default(); + assert!((ctx.position_size_multiplier - 1.0).abs() < f64::EPSILON); + assert!(!ctx.halt_new_entries); + assert!(!ctx.close_all); + } +} +``` + +**Step 2: Run to verify fails** + +Run: `SQLX_OFFLINE=true cargo test -p risk --lib enforcement::actions::tests -- --nocapture` +Expected: FAIL + +**Step 3: Write the implementation** + +`risk/src/enforcement/mod.rs`: +```rust +//! Risk auto-enforcement module. +//! +//! Bridges risk detection (drawdown alerts, drift scores, kill switches) +//! to concrete actions (position reduction, trading halts, liquidation). + +pub mod actions; +pub mod enforcer; +pub mod recovery; +``` + +`risk/src/enforcement/actions.rs`: +```rust +//! Pluggable risk actions that execute in response to risk signals. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Alert severity levels matching drawdown monitor thresholds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AlertSeverity { + Warning, + Alert, + Emergency, +} + +impl fmt::Display for AlertSeverity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Warning => write!(f, "Warning"), + Self::Alert => write!(f, "Alert"), + Self::Emergency => write!(f, "Emergency"), + } + } +} + +/// Outcome of a risk action execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActionOutcome { + pub action_name: String, + pub success: bool, + pub message: String, +} + +/// Mutable risk context that actions modify. +#[derive(Debug, Clone)] +pub struct RiskContext { + /// Multiplier for new position sizes (1.0 = full, 0.5 = half, 0.0 = none). + pub position_size_multiplier: f64, + /// Whether new entries are halted. + pub halt_new_entries: bool, + /// Whether all positions should be closed. + pub close_all: bool, + /// Whether stops should be tightened (and by how much, as fraction). + pub tighten_stops_by: Option, +} + +impl Default for RiskContext { + fn default() -> Self { + Self { + position_size_multiplier: 1.0, + halt_new_entries: false, + close_all: false, + tighten_stops_by: None, + } + } +} + +/// Trait for pluggable risk response actions. +pub trait RiskAction: Send + Sync + fmt::Debug { + /// Severity level this action responds to. + fn severity(&self) -> AlertSeverity; + + /// Execute the action, modifying the risk context. + fn execute(&self, context: &mut RiskContext) -> ActionOutcome; + + /// Rollback the action (for false triggers). + fn rollback(&self, context: &mut RiskContext) -> ActionOutcome; + + /// Human-readable name. + fn name(&self) -> &str; +} + +/// Reduce new position sizes by a given fraction. +#[derive(Debug)] +pub struct ReducePositionSizeAction { + reduction_factor: f64, +} + +impl ReducePositionSizeAction { + pub fn new(reduction_factor: f64) -> Self { + Self { reduction_factor: reduction_factor.clamp(0.0, 1.0) } + } +} + +impl RiskAction for ReducePositionSizeAction { + fn severity(&self) -> AlertSeverity { AlertSeverity::Warning } + + fn execute(&self, context: &mut RiskContext) -> ActionOutcome { + context.position_size_multiplier *= self.reduction_factor; + ActionOutcome { + action_name: self.name().to_string(), + success: true, + message: format!("Position size multiplier reduced to {:.2}", context.position_size_multiplier), + } + } + + fn rollback(&self, context: &mut RiskContext) -> ActionOutcome { + if self.reduction_factor > 0.0 { + context.position_size_multiplier /= self.reduction_factor; + } + ActionOutcome { + action_name: self.name().to_string(), + success: true, + message: format!("Position size multiplier restored to {:.2}", context.position_size_multiplier), + } + } + + fn name(&self) -> &str { "ReducePositionSize" } +} + +/// Halt all new trade entries. +#[derive(Debug)] +pub struct HaltNewEntriesAction; + +impl RiskAction for HaltNewEntriesAction { + fn severity(&self) -> AlertSeverity { AlertSeverity::Alert } + + fn execute(&self, context: &mut RiskContext) -> ActionOutcome { + context.halt_new_entries = true; + context.tighten_stops_by = Some(0.25); + ActionOutcome { + action_name: self.name().to_string(), + success: true, + message: "New entries halted, stops tightened by 25%".to_string(), + } + } + + fn rollback(&self, context: &mut RiskContext) -> ActionOutcome { + context.halt_new_entries = false; + context.tighten_stops_by = None; + ActionOutcome { + action_name: self.name().to_string(), + success: true, + message: "New entries re-enabled, stops restored".to_string(), + } + } + + fn name(&self) -> &str { "HaltNewEntries" } +} + +/// Close all open positions (emergency). +#[derive(Debug)] +pub struct CloseAllPositionsAction; + +impl RiskAction for CloseAllPositionsAction { + fn severity(&self) -> AlertSeverity { AlertSeverity::Emergency } + + fn execute(&self, context: &mut RiskContext) -> ActionOutcome { + context.close_all = true; + context.halt_new_entries = true; + context.position_size_multiplier = 0.0; + ActionOutcome { + action_name: self.name().to_string(), + success: true, + message: "All positions marked for closure, trading halted".to_string(), + } + } + + fn rollback(&self, context: &mut RiskContext) -> ActionOutcome { + context.close_all = false; + // Don't restore halt_new_entries or multiplier — recovery mode handles that + ActionOutcome { + action_name: self.name().to_string(), + success: true, + message: "Close-all flag cleared (recovery mode will restore trading)".to_string(), + } + } + + fn name(&self) -> &str { "CloseAllPositions" } +} +``` + +**Step 4: Register module** + +In `risk/src/lib.rs`, add after `pub mod safety;` (line 158): +```rust +pub mod enforcement; +``` + +**Step 5: Run tests** + +Run: `SQLX_OFFLINE=true cargo test -p risk --lib enforcement::actions::tests -- --nocapture` +Expected: all 4 tests PASS + +**Step 6: Commit** + +```bash +git add risk/src/enforcement/ risk/src/lib.rs +git commit -m "feat(risk): add RiskAction trait and enforcement module with pluggable actions" +``` + +--- + +### Task 7: RiskEnforcer — Drawdown-to-Action Orchestrator + +**Files:** +- Create: `risk/src/enforcement/enforcer.rs` +- Test: inline `#[cfg(test)]` module + +**This implements the `RiskEnforcer` that maps drawdown levels to actions using the registry pattern. Key method: `fn on_drawdown(&mut self, drawdown_pct: f64) -> Vec` that evaluates the current drawdown against thresholds and executes the appropriate actions.** + +**Key types:** +```rust +pub struct EnforcerConfig { + pub warning_threshold: f64, // -0.05 (5%) + pub alert_threshold: f64, // -0.10 (10%) + pub emergency_threshold: f64, // -0.15 (15%) +} + +pub struct RiskEnforcer { + config: EnforcerConfig, + context: RiskContext, + actions: Vec>, + audit_log: Vec, + current_level: Option, +} +``` + +**Step 1–7:** Same TDD pattern. Tests verify: warning threshold triggers reduction, alert halts entries, emergency closes all, recovery restores. Each action gets an audit log entry. + +```bash +git commit -m "feat(risk): add RiskEnforcer drawdown-to-action orchestrator" +``` + +--- + +### Task 8: GraduatedRecovery — Post-Emergency Ramp + +**Files:** +- Create: `risk/src/enforcement/recovery.rs` +- Test: inline `#[cfg(test)]` module + +**`RecoveryMode` tracks days since emergency, computes position multiplier (Day 1 = 25%, Day 5 = 50%, Day 10 = 75%, Day 15 = 100%). If drawdown re-enters Warning during recovery, resets the ramp.** + +**Key types:** +```rust +pub struct RecoveryMode { + pub start_date: DateTime, + pub ramp_days: u32, // default 15 + pub current_multiplier: f64, // 0.25 → 1.0 +} +``` + +**Step 1–7:** Same TDD pattern. Tests verify: day-1 multiplier is 0.25, day-15 is 1.0, re-entering warning resets. + +```bash +git commit -m "feat(risk): add GraduatedRecovery for post-emergency position ramp" +``` + +--- + +### Task 9: CorrelationMonitor — Cross-Asset Exposure Limits + +**Files:** +- Create: `risk/src/correlation_monitor.rs` +- Modify: `risk/src/lib.rs` (add `pub mod correlation_monitor;`) +- Test: inline `#[cfg(test)]` module + +**Maintains a rolling correlation matrix across traded symbols. Pre-trade check computes effective exposure. Alerts on correlation breakdown.** + +**Key types:** +```rust +pub struct CorrelationMonitor { + returns: HashMap>, + lookback: usize, // default 60 + max_effective_exposure: f64, // default 3.0 + correlation_breakdown_threshold: f64, // default 0.5 +} +``` + +**Step 1–7:** Same TDD pattern. Tests verify: correlation between identical series is ~1.0, effective exposure computed correctly, breakdown detection works. + +```bash +git commit -m "feat(risk): add CorrelationMonitor for cross-asset exposure limits" +``` + +--- + +## Section 4: End-to-End Integration + +### Task 10: PipelineMessage and PipelineStage Traits + +**Files:** +- Create: `services/trading_service/src/pipeline/mod.rs` +- Create: `services/trading_service/src/pipeline/messages.rs` +- Create: `services/trading_service/src/pipeline/stages.rs` +- Test: inline tests + +**Defines the typed message enum and stage trait. This is the contract layer — no business logic yet.** + +```bash +git commit -m "feat(trading_service): add PipelineMessage and PipelineStage trait definitions" +``` + +--- + +### Task 11: RiskGate Pipeline Stage + +**Files:** +- Create: `services/trading_service/src/pipeline/risk_gate.rs` +- Test: inline tests + +**Implements `PipelineStage` by aggregating: kill switch check → drawdown state → correlation → drift → position limits. Supports `LogOnly` and `Enforcing` modes.** + +```bash +git commit -m "feat(trading_service): add RiskGate pipeline stage with enforcement modes" +``` + +--- + +### Task 12: Operating Mode State Machine + +**Files:** +- Create: `services/trading_service/src/pipeline/mode.rs` +- Test: inline tests + +**Four modes (Backtest, Paper, Shadow, Live) with validated transitions. Mode stored as enum, transition validation via `fn transition(from, to) -> Result<()>`.** + +```bash +git commit -m "feat(trading_service): add operating mode state machine with transition rules" +``` + +--- + +### Task 13: DriftResponder — Drift-to-Action Bridge + +**Files:** +- Create: `ml/src/safety/drift_responder.rs` +- Modify: `ml/src/safety/mod.rs` (add `pub mod drift_responder;`) +- Test: inline tests + +**Maps (drift_type, score) to risk actions using the response matrix from the design. Emits `RetrainingRequest` events.** + +```bash +git commit -m "feat(ml): add DriftResponder mapping drift detection to risk actions" +``` + +--- + +### Task 14: SystemState Observability Snapshot + +**Files:** +- Create: `services/trading_service/src/observability.rs` +- Test: inline tests + +**`SystemState` struct with `capture()` method aggregating: pipeline health, drawdown state, positions, drift scores, model versions. Serializable to JSON for Prometheus/Grafana.** + +```bash +git commit -m "feat(trading_service): add SystemState observability snapshot" +``` + +--- + +## Dependency Graph + +``` +Task 1 (TemporalGuard) ─── independent +Task 2 (SlippageModel) ─── independent +Task 3 (DegradationTracker) ─── independent +Task 4 (NoiseInjector) ─── independent +Task 5 (SensitivityAnalyzer) ─── independent +Task 6 (RiskAction + enforcement) ─── independent +Task 7 (RiskEnforcer) ─── depends on Task 6 +Task 8 (GraduatedRecovery) ─── depends on Task 7 +Task 9 (CorrelationMonitor) ─── independent +Task 10 (Pipeline traits) ─── independent +Task 11 (RiskGate) ─── depends on Tasks 6, 7, 9, 10 +Task 12 (OperatingMode) ─── depends on Task 10 +Task 13 (DriftResponder) ─── depends on Task 6 +Task 14 (SystemState) ─── depends on Tasks 7, 9, 10 +``` + +**Parallelizable groups:** +- **Group A** (all independent): Tasks 1, 2, 3, 4, 5, 6, 9, 10 +- **Group B** (after Task 6): Tasks 7, 13 +- **Group C** (after Tasks 7, 10): Tasks 8, 11, 12, 14