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>
375 lines
10 KiB
Rust
375 lines
10 KiB
Rust
//! 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);
|
||
}
|
||
}
|