Skip to main content

quant_indicators/
hull.rs

1//! Hull Moving Average (HullMA) indicator.
2
3use quant_primitives::Candle;
4use rust_decimal::Decimal;
5
6use crate::error::IndicatorError;
7use crate::indicator::Indicator;
8use crate::series::Series;
9use crate::wma::Wma;
10
11/// Hull Moving Average indicator.
12///
13/// A responsive moving average that reduces lag while maintaining smoothness.
14/// Developed by Alan Hull.
15///
16/// # Formula
17///
18/// HullMA = WMA(2 * WMA(n/2) - WMA(n), sqrt(n))
19///
20/// # Example
21///
22/// ```
23/// use quant_indicators::{Indicator, HullMa};
24/// use quant_primitives::Candle;
25/// use chrono::Utc;
26/// use rust_decimal_macros::dec;
27///
28/// let ts = Utc::now();
29/// let candles: Vec<Candle> = (0..25).map(|i| {
30///     Candle::new(dec!(100), dec!(110), dec!(90), dec!(100) + rust_decimal::Decimal::from(i), dec!(1000), ts).unwrap()
31/// }).collect();
32/// let hull = HullMa::new(20).unwrap();
33/// let series = hull.compute(&candles).unwrap();
34/// ```
35#[derive(Debug, Clone)]
36pub struct HullMa {
37    period: usize,
38    half_period: usize,
39    sqrt_period: usize,
40    name: String,
41}
42
43impl HullMa {
44    /// Create a new Hull MA indicator with the specified period.
45    ///
46    /// # Errors
47    ///
48    /// Returns `InvalidParameter` if period < 2.
49    pub fn new(period: usize) -> Result<Self, IndicatorError> {
50        if period < 2 {
51            return Err(IndicatorError::InvalidParameter {
52                message: "HullMA period must be >= 2".to_string(),
53            });
54        }
55
56        let half_period = period / 2;
57        let sqrt_period = (period as f64).sqrt().round() as usize;
58
59        Ok(Self {
60            period,
61            half_period,
62            sqrt_period,
63            name: format!("HullMA({})", period),
64        })
65    }
66}
67
68impl Indicator for HullMa {
69    fn name(&self) -> &str {
70        &self.name
71    }
72
73    fn warmup_period(&self) -> usize {
74        // Need enough data for WMA(n) plus WMA(sqrt(n)) on the result
75        self.period + self.sqrt_period - 1
76    }
77
78    fn compute(&self, candles: &[Candle]) -> Result<Series, IndicatorError> {
79        let required = self.warmup_period();
80        if candles.len() < required {
81            return Err(IndicatorError::InsufficientData {
82                required,
83                actual: candles.len(),
84            });
85        }
86
87        // Step 1: Compute WMA(n/2)
88        let wma_half = Wma::new(self.half_period)?;
89        let half_series = wma_half.compute(candles)?;
90
91        // Step 2: Compute WMA(n)
92        let wma_full = Wma::new(self.period)?;
93        let full_series = wma_full.compute(candles)?;
94
95        // Step 3: Calculate 2 * WMA(n/2) - WMA(n) for overlapping region
96        // The full_series starts later than half_series, so we need to align them
97        let offset = self.period - self.half_period;
98        let half_values = half_series.values();
99        let full_values = full_series.values();
100
101        // Create intermediate values: 2 * WMA(n/2) - WMA(n)
102        let mut intermediate = Vec::with_capacity(full_values.len());
103        for (i, (ts, full_val)) in full_values.iter().enumerate() {
104            let half_val = half_values[i + offset].1;
105            let raw = Decimal::TWO * half_val - *full_val;
106            intermediate.push((*ts, raw));
107        }
108
109        // Step 4: Apply WMA(sqrt(n)) to the intermediate values
110        if intermediate.len() < self.sqrt_period {
111            return Err(IndicatorError::InsufficientData {
112                required,
113                actual: candles.len(),
114            });
115        }
116
117        let mut values = Vec::with_capacity(intermediate.len() - self.sqrt_period + 1);
118        let wma_sqrt = Wma::new(self.sqrt_period)?;
119
120        // Manual WMA calculation on intermediate values
121        let weight_sum = Decimal::from(self.sqrt_period as u64)
122            * Decimal::from(self.sqrt_period as u64 + 1)
123            / Decimal::TWO;
124
125        for window in intermediate.windows(self.sqrt_period) {
126            let weighted_sum: Decimal = window
127                .iter()
128                .enumerate()
129                .map(|(i, (_, v))| *v * Decimal::from((i + 1) as u64))
130                .sum();
131
132            let hull = weighted_sum / weight_sum;
133            // Safe: windows(n) always yields slices of length n when n > 0
134            let ts = window[self.sqrt_period - 1].0;
135            values.push((ts, hull));
136        }
137
138        // Suppress unused warning since we constructed it for clarity
139        let _ = wma_sqrt;
140
141        Ok(Series::new(values))
142    }
143}
144
145#[cfg(test)]
146#[path = "hull_tests.rs"]
147mod tests;