Skip to main content

wickra_core/indicators/
holt_winters.rs

1//! Holt's linear (double exponential) smoothing.
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6/// Holt's linear method — double exponential smoothing with a level and a
7/// trend component.
8///
9/// A single [`Ema`](crate::Ema) tracks only a *level* and therefore lags any
10/// sustained trend. Holt's method adds a second smoothed state, the trend, and
11/// reports the one-step-ahead forecast `level + trend`, which removes that lag
12/// on trending data while still smoothing noise.
13///
14/// ```text
15/// level_t = α · price_t        + (1 − α) · (level_{t-1} + trend_{t-1})
16/// trend_t = β · (level_t − level_{t-1}) + (1 − β) · trend_{t-1}
17/// output  = level_t + trend_t          (one-step-ahead forecast)
18/// ```
19///
20/// `α ∈ (0, 1]` is the level smoothing constant and `β ∈ (0, 1]` the trend
21/// smoothing constant. The state is seeded from the first two inputs
22/// (`level = price_1`, `trend = price_1 − price_0`), so the first output lands
23/// on the **second** input.
24///
25/// On a perfectly linear series the forecast is exact from the second bar
26/// onward (for any `α`, `β`): if the level equals the current value and the
27/// trend equals the slope, both invariants are preserved and `level + trend`
28/// equals the next value.
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{HoltWinters, Indicator};
34///
35/// let mut indicator = HoltWinters::new(0.2, 0.1).unwrap();
36/// let mut last = None;
37/// for i in 0..80 {
38///     last = indicator.update(100.0 + f64::from(i));
39/// }
40/// assert!(last.is_some());
41/// ```
42#[derive(Debug, Clone)]
43pub struct HoltWinters {
44    alpha: f64,
45    beta: f64,
46    /// `(level, trend)` once seeded.
47    state: Option<(f64, f64)>,
48    /// First input, held until the second arrives to seed the trend.
49    prev_price: Option<f64>,
50}
51
52impl HoltWinters {
53    /// Construct Holt's linear smoother with level constant `alpha` and trend
54    /// constant `beta`.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`Error::InvalidPeriod`] if either constant is non-finite or
59    /// outside `(0.0, 1.0]`.
60    pub fn new(alpha: f64, beta: f64) -> Result<Self> {
61        if !alpha.is_finite() || alpha <= 0.0 || alpha > 1.0 {
62            return Err(Error::InvalidPeriod {
63                message: "HoltWinters alpha must be in (0.0, 1.0]",
64            });
65        }
66        if !beta.is_finite() || beta <= 0.0 || beta > 1.0 {
67            return Err(Error::InvalidPeriod {
68                message: "HoltWinters beta must be in (0.0, 1.0]",
69            });
70        }
71        Ok(Self {
72            alpha,
73            beta,
74            state: None,
75            prev_price: None,
76        })
77    }
78
79    /// Level smoothing constant `alpha`.
80    pub const fn alpha(&self) -> f64 {
81        self.alpha
82    }
83
84    /// Trend smoothing constant `beta`.
85    pub const fn beta(&self) -> f64 {
86        self.beta
87    }
88
89    /// Current smoothed level, if seeded.
90    pub fn level(&self) -> Option<f64> {
91        self.state.map(|(level, _)| level)
92    }
93
94    /// Current smoothed trend, if seeded.
95    pub fn trend(&self) -> Option<f64> {
96        self.state.map(|(_, trend)| trend)
97    }
98
99    /// Current one-step-ahead forecast `level + trend`, if seeded.
100    pub fn value(&self) -> Option<f64> {
101        self.state.map(|(level, trend)| level + trend)
102    }
103}
104
105impl Indicator for HoltWinters {
106    type Input = f64;
107    type Output = f64;
108
109    #[inline]
110    fn update(&mut self, price: f64) -> Option<f64> {
111        if !price.is_finite() {
112            return None;
113        }
114        match self.state {
115            None => {
116                if let Some(prev) = self.prev_price {
117                    // Second input: seed level and trend.
118                    let level = price;
119                    let trend = price - prev;
120                    self.state = Some((level, trend));
121                    Some(level + trend)
122                } else {
123                    // First input: hold it to seed the trend next time.
124                    self.prev_price = Some(price);
125                    None
126                }
127            }
128            Some((level, trend)) => {
129                let level_new = self.alpha * price + (1.0 - self.alpha) * (level + trend);
130                let trend_new = self.beta * (level_new - level) + (1.0 - self.beta) * trend;
131                self.state = Some((level_new, trend_new));
132                Some(level_new + trend_new)
133            }
134        }
135    }
136
137    fn reset(&mut self) {
138        self.state = None;
139        self.prev_price = None;
140    }
141
142    #[inline]
143    fn warmup_period(&self) -> usize {
144        // Two inputs are needed to seed the level and the trend.
145        2
146    }
147
148    #[inline]
149    fn is_ready(&self) -> bool {
150        self.state.is_some()
151    }
152
153    #[inline]
154    fn name(&self) -> &'static str {
155        "HoltWinters"
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::traits::BatchExt;
163    use approx::assert_relative_eq;
164
165    /// Independent reference for the steady-state recurrence.
166    fn naive(prices: &[f64], alpha: f64, beta: f64) -> Vec<Option<f64>> {
167        let mut state: Option<(f64, f64)> = None;
168        let mut prev: Option<f64> = None;
169        let mut out = Vec::with_capacity(prices.len());
170        for &price in prices {
171            let v = match state {
172                None => {
173                    if let Some(p0) = prev {
174                        let level = price;
175                        let trend = price - p0;
176                        state = Some((level, trend));
177                        Some(level + trend)
178                    } else {
179                        prev = Some(price);
180                        None
181                    }
182                }
183                Some((level, trend)) => {
184                    let ln = alpha * price + (1.0 - alpha) * (level + trend);
185                    let tn = beta * (ln - level) + (1.0 - beta) * trend;
186                    state = Some((ln, tn));
187                    Some(ln + tn)
188                }
189            };
190            out.push(v);
191        }
192        out
193    }
194
195    #[test]
196    fn rejects_invalid_alpha() {
197        assert!(matches!(
198            HoltWinters::new(0.0, 0.1),
199            Err(Error::InvalidPeriod { .. })
200        ));
201        assert!(matches!(
202            HoltWinters::new(1.5, 0.1),
203            Err(Error::InvalidPeriod { .. })
204        ));
205        assert!(matches!(
206            HoltWinters::new(f64::NAN, 0.1),
207            Err(Error::InvalidPeriod { .. })
208        ));
209    }
210
211    #[test]
212    fn rejects_invalid_beta() {
213        assert!(matches!(
214            HoltWinters::new(0.2, 0.0),
215            Err(Error::InvalidPeriod { .. })
216        ));
217        assert!(matches!(
218            HoltWinters::new(0.2, 1.5),
219            Err(Error::InvalidPeriod { .. })
220        ));
221        assert!(matches!(
222            HoltWinters::new(0.2, f64::INFINITY),
223            Err(Error::InvalidPeriod { .. })
224        ));
225    }
226
227    /// Cover the const accessors `alpha` + `beta` and the Indicator-impl
228    /// `warmup_period` + `name`.
229    #[test]
230    fn accessors_and_metadata() {
231        let hw = HoltWinters::new(0.2, 0.1).unwrap();
232        assert_relative_eq!(hw.alpha(), 0.2, epsilon = 1e-12);
233        assert_relative_eq!(hw.beta(), 0.1, epsilon = 1e-12);
234        assert_eq!(hw.warmup_period(), 2);
235        assert_eq!(hw.name(), "HoltWinters");
236    }
237
238    #[test]
239    fn warmup_then_seed_on_second_input() {
240        let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
241        assert_eq!(hw.update(10.0), None);
242        // Second input seeds level = 12, trend = 12 - 10 = 2 -> forecast 14.
243        assert_relative_eq!(hw.update(12.0).unwrap(), 14.0, epsilon = 1e-12);
244        assert_relative_eq!(hw.level().unwrap(), 12.0, epsilon = 1e-12);
245        assert_relative_eq!(hw.trend().unwrap(), 2.0, epsilon = 1e-12);
246    }
247
248    #[test]
249    fn linear_series_forecasts_exactly() {
250        // On a perfect ramp the one-step forecast equals the next value, for
251        // any alpha/beta, from the second bar onward.
252        let prices: Vec<f64> = (1..=20).map(f64::from).collect();
253        let mut hw = HoltWinters::new(0.3, 0.4).unwrap();
254        let out = hw.batch(&prices);
255        assert!(out[0].is_none());
256        for (i, v) in out.iter().enumerate().skip(1) {
257            // forecast at index i is the price at index i + 1 = (i + 2).
258            assert_relative_eq!(v.unwrap(), (i + 2) as f64, epsilon = 1e-9);
259        }
260    }
261
262    #[test]
263    fn constant_series_yields_constant() {
264        let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
265        let out = hw.batch(&[42.0_f64; 30]);
266        for v in out.into_iter().skip(1).flatten() {
267            assert_relative_eq!(v, 42.0, epsilon = 1e-9);
268        }
269    }
270
271    #[test]
272    fn matches_naive_recurrence() {
273        let prices: Vec<f64> = (0..60)
274            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + f64::from(i) * 0.2)
275            .collect();
276        let mut hw = HoltWinters::new(0.25, 0.15).unwrap();
277        let got = hw.batch(&prices);
278        let want = naive(&prices, 0.25, 0.15);
279        for (g, w) in got.iter().zip(want.iter()) {
280            assert_eq!(g.is_some(), w.is_some());
281            if let (Some(a), Some(b)) = (g, w) {
282                assert_relative_eq!(a, b, epsilon = 1e-9);
283            }
284        }
285    }
286
287    #[test]
288    fn reset_clears_state() {
289        let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
290        hw.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
291        assert!(hw.is_ready());
292        hw.reset();
293        assert!(!hw.is_ready());
294        assert_eq!(hw.update(1.0), None);
295    }
296
297    #[test]
298    fn batch_equals_streaming() {
299        let prices: Vec<f64> = (1..=30).map(|i| f64::from(i) * 0.5).collect();
300        let mut a = HoltWinters::new(0.3, 0.2).unwrap();
301        let mut b = HoltWinters::new(0.3, 0.2).unwrap();
302        assert_eq!(
303            a.batch(&prices),
304            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
305        );
306    }
307
308    #[test]
309    fn ignores_non_finite_input() {
310        let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
311        // Non-finite before any state returns None.
312        assert_eq!(hw.update(f64::NAN), None);
313        hw.update(10.0);
314        hw.update(12.0).expect("seeded on second finite input");
315        // Non-finite after seeding returns the current forecast unchanged.
316        assert_eq!(hw.update(f64::NAN), None);
317        assert_eq!(hw.update(f64::INFINITY), None);
318    }
319}