Files
foxhunt/crates/ml-features/src/ewma.rs
jgrusewski 7ef92983f9 fix(clippy): apply cargo clippy --fix across workspace
Mechanical auto-fixes: redundant borrows, clone on Copy, or_insert_with,
single-char push_str, get(0) → first(), needless borrow, let_and_return.
150 files, no behavior changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:17:51 +01:00

375 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! EWMA (Exponentially Weighted Moving Average) Calculator
//!
//! Implements adaptive threshold calculation using exponentially weighted moving averages.
//! EWMA provides smooth tracking of trends while being responsive to recent changes.
//!
//! # Formula
//!
//! `EWMA_t` = α * `value_t` + (1 - α) * EWMA_{t-1}
//!
//! where α = 2 / (span + 1)
//!
//! # Usage
//!
//! ```rust
//! use ml::features::ewma::EWMACalculator;
//!
//! let mut calculator = EWMACalculator::new(100);
//!
//! // Update with new values
//! let ewma1 = calculator.update(100.0);
//! let ewma2 = calculator.update(105.0);
//! let ewma3 = calculator.update(102.0);
//!
//! // Get current EWMA
//! if let Some(current) = calculator.current() {
//! println!("Current EWMA: {}", current);
//! }
//! ```
//!
//! # Span Selection
//!
//! - **Small span (10-20)**: High responsiveness, tracks recent changes closely
//! - **Medium span (50-100)**: Balanced smoothing and responsiveness
//! - **Large span (200+)**: Heavy smoothing, slower to respond to changes
//!
//! Common spans:
//! - **12**: Very responsive for short-term trends
//! - **26**: Medium-term trends (common in MACD)
//! - **50**: Balanced for most use cases
//! - **100**: Longer-term smoothing
//! - **200**: Very smooth, for long-term trends
use serde::{Deserialize, Serialize};
/// EWMA calculator for adaptive thresholds
///
/// Tracks exponentially weighted moving average of a time series.
/// The span parameter controls how much weight is given to recent vs historical values.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EWMACalculator {
/// Number of periods for EWMA calculation (e.g., 100)
span: usize,
/// Smoothing factor: α = 2 / (span + 1)
alpha: f64,
/// Current EWMA value
ewma: Option<f64>,
}
impl EWMACalculator {
/// Create a new EWMA calculator with specified span
///
/// # Arguments
///
/// * `span` - Number of periods for EWMA calculation (e.g., 100)
///
/// # Formula
///
/// α = 2 / (span + 1)
///
/// # Examples
///
/// ```rust
/// use ml::features::ewma::EWMACalculator;
///
/// // Create calculator with 100-period span
/// let calculator = EWMACalculator::new(100);
/// ```
pub fn new(span: usize) -> Self {
let alpha = 2.0 / (span as f64 + 1.0);
Self {
span,
alpha,
ewma: None,
}
}
/// Update EWMA with a new value
///
/// # Arguments
///
/// * `value` - New value to incorporate into EWMA
///
/// # Returns
///
/// Updated EWMA value
///
/// # Formula
///
/// First update: EWMA = value (initialization)
/// Subsequent updates: EWMA = α * value + (1 - α) * `EWMA_prev`
///
/// # Examples
///
/// ```rust
/// use ml::features::ewma::EWMACalculator;
///
/// let mut calculator = EWMACalculator::new(100);
/// let ewma1 = calculator.update(100.0); // Initialize
/// let ewma2 = calculator.update(105.0); // Update
/// ```
pub fn update(&mut self, value: f64) -> f64 {
self.ewma = Some(match self.ewma {
Some(prev) => self.alpha * value + (1.0 - self.alpha) * prev,
None => value, // Initialize on first value
});
// ewma is guaranteed to be Some after the match above sets it
self.ewma.unwrap_or(value)
}
/// Get current EWMA value
///
/// # Returns
///
/// Current EWMA if initialized, None otherwise
///
/// # Examples
///
/// ```rust
/// use ml::features::ewma::EWMACalculator;
///
/// let mut calculator = EWMACalculator::new(100);
/// assert!(calculator.current().is_none()); // Not initialized
///
/// calculator.update(100.0);
/// assert!(calculator.current().is_some()); // Initialized
/// ```
pub const fn current(&self) -> Option<f64> {
self.ewma
}
/// Get the span parameter
///
/// # Returns
///
/// Number of periods for EWMA calculation
pub const fn span(&self) -> usize {
self.span
}
/// Get the alpha (smoothing factor)
///
/// # Returns
///
/// Smoothing factor: α = 2 / (span + 1)
pub const fn alpha(&self) -> f64 {
self.alpha
}
/// Reset EWMA to uninitialized state
///
/// # Examples
///
/// ```rust
/// use ml::features::ewma::EWMACalculator;
///
/// let mut calculator = EWMACalculator::new(100);
/// calculator.update(100.0);
/// assert!(calculator.current().is_some());
///
/// calculator.reset();
/// assert!(calculator.current().is_none());
/// ```
pub const fn reset(&mut self) {
self.ewma = None;
}
/// Check if EWMA is initialized
///
/// # Returns
///
/// true if at least one value has been added, false otherwise
pub const fn is_initialized(&self) -> bool {
self.ewma.is_some()
}
}
/// Adaptive threshold using EWMA
///
/// Provides dynamic threshold calculation based on EWMA and standard deviation.
/// Useful for detecting anomalies or regime changes in time series data.
#[derive(Debug, Clone)]
pub struct AdaptiveThreshold {
/// EWMA calculator for mean tracking
ewma: EWMACalculator,
/// EWMA calculator for variance tracking
variance_ewma: EWMACalculator,
/// Number of standard deviations for threshold
num_std: f64,
}
impl AdaptiveThreshold {
/// Create a new adaptive threshold calculator
///
/// # Arguments
///
/// * `span` - Number of periods for EWMA calculation
/// * `num_std` - Number of standard deviations for threshold (e.g., 2.0 for 95% confidence)
///
/// # Examples
///
/// ```rust
/// use ml::features::ewma::AdaptiveThreshold;
///
/// // Create threshold with 100-period span and 2 standard deviations
/// let threshold = AdaptiveThreshold::new(100, 2.0);
/// ```
pub fn new(span: usize, num_std: f64) -> Self {
Self {
ewma: EWMACalculator::new(span),
variance_ewma: EWMACalculator::new(span),
num_std,
}
}
/// Update threshold with new value and return (`lower_bound`, `upper_bound`)
///
/// # Arguments
///
/// * `value` - New value to incorporate
///
/// # Returns
///
/// Tuple of (`lower_bound`, `upper_bound`) for adaptive threshold.
/// Returns (value, value) if not initialized.
///
/// # Examples
///
/// ```rust
/// use ml::features::ewma::AdaptiveThreshold;
///
/// let mut threshold = AdaptiveThreshold::new(100, 2.0);
/// let (lower, upper) = threshold.update(100.0);
/// ```
pub fn update(&mut self, value: f64) -> (f64, f64) {
let mean = self.ewma.update(value);
// Update variance EWMA with squared deviation
let deviation = value - mean;
let variance = self.variance_ewma.update(deviation * deviation);
let std_dev = variance.sqrt();
// Calculate bounds
let lower_bound = mean - self.num_std * std_dev;
let upper_bound = mean + self.num_std * std_dev;
(lower_bound, upper_bound)
}
/// Get current mean (EWMA)
pub const fn mean(&self) -> Option<f64> {
self.ewma.current()
}
/// Get current standard deviation
pub fn std_dev(&self) -> Option<f64> {
self.variance_ewma.current().map(|v| v.sqrt())
}
/// Reset threshold to uninitialized state
pub const fn reset(&mut self) {
self.ewma.reset();
self.variance_ewma.reset();
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_ewma_initialization() {
let calculator = EWMACalculator::new(100);
assert_eq!(calculator.span(), 100);
assert_relative_eq!(calculator.alpha(), 2.0 / 101.0, epsilon = 1e-10);
assert!(!calculator.is_initialized());
assert!(calculator.current().is_none());
}
#[test]
fn test_ewma_first_value() {
let mut calculator = EWMACalculator::new(100);
let first_value = 100.0;
let result = calculator.update(first_value);
assert_relative_eq!(result, first_value, epsilon = 1e-10);
assert!(calculator.is_initialized());
assert_relative_eq!(calculator.current().unwrap(), first_value, epsilon = 1e-10);
}
#[test]
fn test_ewma_formula() {
let span = 10;
let alpha = 2.0 / 11.0;
let mut calculator = EWMACalculator::new(span);
let v1 = 100.0;
let ewma1 = calculator.update(v1);
assert_relative_eq!(ewma1, v1, epsilon = 1e-10);
let v2 = 110.0;
let expected_ewma2 = alpha * v2 + (1.0 - alpha) * ewma1;
let ewma2 = calculator.update(v2);
assert_relative_eq!(ewma2, expected_ewma2, epsilon = 1e-10);
}
#[test]
fn test_ewma_reset() {
let mut calculator = EWMACalculator::new(100);
calculator.update(100.0);
assert!(calculator.is_initialized());
calculator.reset();
assert!(!calculator.is_initialized());
assert!(calculator.current().is_none());
}
#[test]
fn test_adaptive_threshold_basic() {
let mut threshold = AdaptiveThreshold::new(100, 2.0);
// Initialize with first value
let (lower1, upper1) = threshold.update(100.0);
assert_eq!(lower1, upper1); // First value has zero variance
// Add more values
threshold.update(105.0);
threshold.update(102.0);
let (lower, upper) = threshold.update(98.0);
// Bounds should be different and mean should be between them
assert!(lower < upper);
if let Some(mean) = threshold.mean() {
assert!(lower < mean && mean < upper);
}
}
#[test]
fn test_adaptive_threshold_volatility() {
let mut threshold = AdaptiveThreshold::new(50, 2.0);
// Low volatility period
for _ in 0..20 {
threshold.update(100.0);
}
let (low_vol_lower, low_vol_upper) = threshold.update(100.0);
let low_vol_range = low_vol_upper - low_vol_lower;
// High volatility period
let high_vol_values = vec![100.0, 120.0, 90.0, 130.0, 80.0];
for value in high_vol_values {
threshold.update(value);
}
let (high_vol_lower, high_vol_upper) = threshold.update(100.0);
let high_vol_range = high_vol_upper - high_vol_lower;
// High volatility should produce wider bounds
assert!(high_vol_range > low_vol_range);
}
}