quant-indicators 0.7.0

Pure indicator math library for trading — MA, RSI, Bollinger, MACD, ATR, HRP
Documentation
//! Hull Moving Average (HullMA) indicator.

use quant_primitives::Candle;
use rust_decimal::Decimal;

use crate::error::IndicatorError;
use crate::indicator::Indicator;
use crate::series::Series;
use crate::wma::Wma;

/// Hull Moving Average indicator.
///
/// A responsive moving average that reduces lag while maintaining smoothness.
/// Developed by Alan Hull.
///
/// # Formula
///
/// HullMA = WMA(2 * WMA(n/2) - WMA(n), sqrt(n))
///
/// # Example
///
/// ```
/// use quant_indicators::{Indicator, HullMa};
/// use quant_primitives::Candle;
/// use chrono::Utc;
/// use rust_decimal_macros::dec;
///
/// let ts = Utc::now();
/// let candles: Vec<Candle> = (0..25).map(|i| {
///     Candle::new(dec!(100), dec!(110), dec!(90), dec!(100) + rust_decimal::Decimal::from(i), dec!(1000), ts).unwrap()
/// }).collect();
/// let hull = HullMa::new(20).unwrap();
/// let series = hull.compute(&candles).unwrap();
/// ```
#[derive(Debug, Clone)]
pub struct HullMa {
    period: usize,
    half_period: usize,
    sqrt_period: usize,
    name: String,
}

impl HullMa {
    /// Create a new Hull MA indicator with the specified period.
    ///
    /// # Errors
    ///
    /// Returns `InvalidParameter` if period < 2.
    pub fn new(period: usize) -> Result<Self, IndicatorError> {
        if period < 2 {
            return Err(IndicatorError::InvalidParameter {
                message: "HullMA period must be >= 2".to_string(),
            });
        }

        let half_period = period / 2;
        let sqrt_period = (period as f64).sqrt().round() as usize;

        Ok(Self {
            period,
            half_period,
            sqrt_period,
            name: format!("HullMA({})", period),
        })
    }
}

impl Indicator for HullMa {
    fn name(&self) -> &str {
        &self.name
    }

    fn warmup_period(&self) -> usize {
        // Need enough data for WMA(n) plus WMA(sqrt(n)) on the result
        self.period + self.sqrt_period - 1
    }

    fn compute(&self, candles: &[Candle]) -> Result<Series, IndicatorError> {
        let required = self.warmup_period();
        if candles.len() < required {
            return Err(IndicatorError::InsufficientData {
                required,
                actual: candles.len(),
            });
        }

        // Step 1: Compute WMA(n/2)
        let wma_half = Wma::new(self.half_period)?;
        let half_series = wma_half.compute(candles)?;

        // Step 2: Compute WMA(n)
        let wma_full = Wma::new(self.period)?;
        let full_series = wma_full.compute(candles)?;

        // Step 3: Calculate 2 * WMA(n/2) - WMA(n) for overlapping region
        // The full_series starts later than half_series, so we need to align them
        let offset = self.period - self.half_period;
        let half_values = half_series.values();
        let full_values = full_series.values();

        // Create intermediate values: 2 * WMA(n/2) - WMA(n)
        let mut intermediate = Vec::with_capacity(full_values.len());
        for (i, (ts, full_val)) in full_values.iter().enumerate() {
            let half_val = half_values[i + offset].1;
            let raw = Decimal::TWO * half_val - *full_val;
            intermediate.push((*ts, raw));
        }

        // Step 4: Apply WMA(sqrt(n)) to the intermediate values
        if intermediate.len() < self.sqrt_period {
            return Err(IndicatorError::InsufficientData {
                required,
                actual: candles.len(),
            });
        }

        let mut values = Vec::with_capacity(intermediate.len() - self.sqrt_period + 1);
        let wma_sqrt = Wma::new(self.sqrt_period)?;

        // Manual WMA calculation on intermediate values
        let weight_sum = Decimal::from(self.sqrt_period as u64)
            * Decimal::from(self.sqrt_period as u64 + 1)
            / Decimal::TWO;

        for window in intermediate.windows(self.sqrt_period) {
            let weighted_sum: Decimal = window
                .iter()
                .enumerate()
                .map(|(i, (_, v))| *v * Decimal::from((i + 1) as u64))
                .sum();

            let hull = weighted_sum / weight_sum;
            // Safe: windows(n) always yields slices of length n when n > 0
            let ts = window[self.sqrt_period - 1].0;
            values.push((ts, hull));
        }

        // Suppress unused warning since we constructed it for clarity
        let _ = wma_sqrt;

        Ok(Series::new(values))
    }
}

#[cfg(test)]
#[path = "hull_tests.rs"]
mod tests;