//! PAGES Test for Variance Changepoint Detection //! //! Implementation of Page's Test (one-sided CUSUM) for detecting changes in variance. //! This test monitors the cumulative sum of log-likelihood ratios to detect shifts in //! the variance of a time series. Particularly useful for regime detection in financial markets. //! //! ## Algorithm //! - Page's statistic: Pₜ = max(0, Pₜ₋₁ + log(σ²ₜ/σ²₀) - k) //! - Detection when: Pₜ > h //! - k: drift allowance (reduces false positives) //! - h: detection threshold (higher = fewer false alarms) //! //! ## Performance Target //! - <80μs per update operation //! - Memory efficient with rolling variance computation //! //! ## Usage //! ```rust //! use ml::regime::pages_test::PAGESTest; //! //! let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); //! //! // Update with new values //! if let Some(change) = pages.update(1.5)? { //! println!("Variance change detected at index {}", change.detection_index); //! } //! ``` use anyhow::Result; use serde::{Deserialize, Serialize}; use std::collections::VecDeque; /// Result of PAGES variance changepoint detection #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct VarianceChange { /// Index where variance change was detected pub detection_index: usize, /// Current Page's cumulative sum value (exceeds threshold) pub pages_statistic: f64, /// Current variance estimate pub current_variance: f64, /// Target variance being monitored against pub target_variance: f64, /// Variance ratio (current/target) pub variance_ratio: f64, } /// PAGES Test for detecting variance changes in time series /// /// Uses one-sided CUSUM to monitor cumulative deviations from target variance. /// Efficient online algorithm with O(1) update complexity. #[derive(Debug)] pub struct PAGESTest { /// Target variance (σ²₀) - baseline to compare against target_variance: f64, /// Drift allowance (k) - reduces false positives drift_allowance: f64, /// Detection threshold (h) - triggers alarm when exceeded detection_threshold: f64, /// Current Page's cumulative sum cumulative_sum: f64, /// Rolling window size for variance estimation window_size: usize, /// Recent values for rolling variance computation recent_values: VecDeque, /// Running sum for efficient mean computation running_sum: f64, /// Running sum of squares for efficient variance computation running_sum_squares: f64, /// Number of updates processed (for indexing) update_count: usize, } impl PAGESTest { /// Create a new PAGES test detector /// /// # Arguments /// - `target_variance`: Expected baseline variance (σ²₀) /// - `drift_allowance`: Drift parameter k (typical: 0.25 to 1.0) /// - `detection_threshold`: Alarm threshold h (typical: 4.0 to 8.0) /// - `window_size`: Rolling window for variance estimation (typical: 20-50) /// /// # Recommendations /// - For high-frequency trading: k=0.5, h=5.0, window=20 /// - For daily data: k=1.0, h=8.0, window=50 /// - Smaller k = more sensitive to small changes /// - Larger h = fewer false alarms pub fn new( target_variance: f64, drift_allowance: f64, detection_threshold: f64, window_size: usize, ) -> Self { assert!(target_variance > 0.0, "Target variance must be positive"); assert!( drift_allowance >= 0.0, "Drift allowance must be non-negative" ); assert!( detection_threshold > 0.0, "Detection threshold must be positive" ); assert!(window_size >= 2, "Window size must be at least 2"); Self { target_variance, drift_allowance, detection_threshold, cumulative_sum: 0.0, window_size, recent_values: VecDeque::with_capacity(window_size), running_sum: 0.0, running_sum_squares: 0.0, update_count: 0, } } /// Update PAGES test with new observation /// /// # Arguments /// - `value`: New observation /// /// # Returns /// - `Ok(Some(VarianceChange))` if variance change detected /// - `Ok(None)` if no change detected /// - `Err` if value is non-finite /// /// # Performance /// - Target: <80μs per call /// - O(1) time complexity with rolling statistics pub fn update(&mut self, value: f64) -> Result> { if !value.is_finite() { anyhow::bail!("PAGES test received non-finite value: {}", value); } self.update_count += 1; // Add new value to rolling window self.recent_values.push_back(value); self.running_sum += value; self.running_sum_squares += value * value; // Remove oldest value if window full if self.recent_values.len() > self.window_size { if let Some(old_value) = self.recent_values.pop_front() { self.running_sum -= old_value; self.running_sum_squares -= old_value * old_value; } } // Need at least 2 values to compute variance if self.recent_values.len() < 2 { return Ok(None); } // Compute current variance using Welford's online algorithm let current_variance = self.get_current_variance(); // Protect against division by zero or negative variance if current_variance <= 1e-10 { // Reset cumulative sum when variance is negligible self.cumulative_sum = 0.0; return Ok(None); } // Page's statistic: Pₜ = max(0, Pₜ₋₁ + log(σ²ₜ/σ²₀) - k) let variance_ratio = current_variance / self.target_variance; let log_likelihood_ratio = variance_ratio.ln(); self.cumulative_sum = (self.cumulative_sum + log_likelihood_ratio - self.drift_allowance).max(0.0); // Check if alarm threshold exceeded if self.cumulative_sum > self.detection_threshold { let change = VarianceChange { detection_index: self.update_count, pages_statistic: self.cumulative_sum, current_variance, target_variance: self.target_variance, variance_ratio, }; // Reset cumulative sum after detection self.cumulative_sum = 0.0; Ok(Some(change)) } else { Ok(None) } } /// Reset PAGES test state /// /// Clears all accumulated state while preserving configuration parameters. /// Useful for starting fresh analysis on a new time series segment. pub fn reset(&mut self) { self.cumulative_sum = 0.0; self.recent_values.clear(); self.running_sum = 0.0; self.running_sum_squares = 0.0; self.update_count = 0; } /// Get current variance estimate from rolling window /// /// Uses efficient online computation with running statistics. /// Formula: σ² = (Σx² - (Σx)²/n) / (n-1) pub fn get_current_variance(&self) -> f64 { let n = self.recent_values.len(); if n < 2 { return 0.0; } let n_f64 = n as f64; let mean = self.running_sum / n_f64; // Variance = E[X²] - (E[X])² let variance = (self.running_sum_squares / n_f64) - (mean * mean); // Apply Bessel's correction (n-1 instead of n) let corrected_variance = variance * n_f64 / (n_f64 - 1.0); corrected_variance.max(0.0) // Protect against numerical errors } /// Get current Page's cumulative sum (for monitoring) pub fn get_cumulative_sum(&self) -> f64 { self.cumulative_sum } /// Get number of values in current rolling window pub fn get_window_fill(&self) -> usize { self.recent_values.len() } /// Get total number of updates processed pub fn get_update_count(&self) -> usize { self.update_count } /// Get target variance pub fn get_target_variance(&self) -> f64 { self.target_variance } /// Get detection threshold pub fn get_detection_threshold(&self) -> f64 { self.detection_threshold } /// Get drift allowance pub fn get_drift_allowance(&self) -> f64 { self.drift_allowance } } impl Default for PAGESTest { /// Create PAGES test with default parameters for HFT applications /// /// - target_variance: 1.0 (normalized) /// - drift_allowance: 0.5 (balanced sensitivity) /// - detection_threshold: 5.0 (moderate false alarm rate) /// - window_size: 20 (suitable for minute bars) fn default() -> Self { Self::new(1.0, 0.5, 5.0, 20) } } #[cfg(test)] #[allow(clippy::assertions_on_result_states)] mod tests { use super::*; #[test] fn test_pages_new_initialization() { let pages = PAGESTest::new(1.0, 0.5, 5.0, 20); assert_eq!(pages.get_target_variance(), 1.0); assert_eq!(pages.get_drift_allowance(), 0.5); assert_eq!(pages.get_detection_threshold(), 5.0); assert_eq!(pages.get_cumulative_sum(), 0.0); assert_eq!(pages.get_window_fill(), 0); assert_eq!(pages.get_update_count(), 0); } #[test] fn test_pages_stable_variance_no_detection() { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); // Feed values with variance ≈ 1.0 (stable) for _ in 0..50 { let value = rand::random::() * 2.0 - 1.0; // uniform [-1, 1], variance ≈ 1/3 let result = pages.update(value).unwrap(); assert!( result.is_none(), "Should not detect change in stable variance" ); } } #[test] fn test_pages_variance_increase_detection() { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); // Phase 1: Stable variance ≈ 1.0 for i in 0..30 { let value = if i % 2 == 0 { 1.0 } else { -1.0 }; pages.update(value).unwrap(); } // Phase 2: Increased variance (3x larger swings) let mut detected = false; for i in 0..50 { let value = if i % 2 == 0 { 3.0 } else { -3.0 }; if let Some(change) = pages.update(value).unwrap() { detected = true; assert!( change.variance_ratio > 1.0, "Should detect variance increase" ); assert!(change.pages_statistic > pages.get_detection_threshold()); break; } } assert!( detected, "Should detect variance increase within 50 samples" ); } #[test] fn test_pages_reset() { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); // Add some values for i in 0..10 { pages.update(i as f64).unwrap(); } assert!(pages.get_window_fill() > 0); assert!(pages.get_update_count() > 0); // Reset pages.reset(); assert_eq!(pages.get_window_fill(), 0); assert_eq!(pages.get_update_count(), 0); assert_eq!(pages.get_cumulative_sum(), 0.0); } #[test] fn test_pages_non_finite_value() { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); assert!(pages.update(f64::NAN).is_err()); assert!(pages.update(f64::INFINITY).is_err()); assert!(pages.update(f64::NEG_INFINITY).is_err()); } }