Skip to main content

wickra_core/indicators/
mom.rs

1//! Momentum (absolute price change over a fixed lookback).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Momentum: the raw price change over `period` bars, `price_t − price_{t−period}`.
9///
10/// Unlike [`Roc`](crate::Roc), which divides by the old price to give a
11/// percentage, `Mom` reports the change in absolute price units. It is the
12/// simplest momentum primitive: positive values mean price is higher than it
13/// was `period` bars ago, negative values mean lower.
14///
15/// Non-finite inputs are ignored and leave the window untouched; the last
16/// computed value is returned instead.
17///
18/// # Example
19///
20/// ```
21/// use wickra_core::{Indicator, Mom};
22///
23/// let mut indicator = Mom::new(3).unwrap();
24/// let mut last = None;
25/// for i in 0..80 {
26///     last = indicator.update(100.0 + f64::from(i));
27/// }
28/// assert!(last.is_some());
29/// ```
30#[derive(Debug, Clone)]
31pub struct Mom {
32    period: usize,
33    /// Rolling buffer of the last `period + 1` inputs, oldest at the front.
34    window: VecDeque<f64>,
35    last: Option<f64>,
36}
37
38impl Mom {
39    /// Construct a new momentum indicator with the given lookback period.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`Error::PeriodZero`] if `period == 0`.
44    pub fn new(period: usize) -> Result<Self> {
45        if period == 0 {
46            return Err(Error::PeriodZero);
47        }
48        if period > crate::error::MAX_PERIOD {
49            return Err(Error::InvalidPeriod {
50                message: crate::error::PERIOD_ABOVE_MAX,
51            });
52        }
53        Ok(Self {
54            period,
55            window: VecDeque::with_capacity(period + 1),
56            last: None,
57        })
58    }
59
60    /// Configured lookback period.
61    pub const fn period(&self) -> usize {
62        self.period
63    }
64
65    /// Current value if available.
66    pub const fn value(&self) -> Option<f64> {
67        self.last
68    }
69}
70
71impl Indicator for Mom {
72    type Input = f64;
73    type Output = f64;
74
75    #[inline]
76    fn update(&mut self, input: f64) -> Option<f64> {
77        if !input.is_finite() {
78            // Non-finite input is ignored; the window is left untouched.
79            return None;
80        }
81        if self.window.len() == self.period + 1 {
82            self.window.pop_front();
83        }
84        self.window.push_back(input);
85        if self.window.len() < self.period + 1 {
86            return None;
87        }
88        let prev = *self.window.front().expect("window is non-empty");
89        let mom = input - prev;
90        self.last = Some(mom);
91        Some(mom)
92    }
93
94    fn reset(&mut self) {
95        self.window.clear();
96        self.last = None;
97    }
98
99    #[inline]
100    fn warmup_period(&self) -> usize {
101        self.period + 1
102    }
103
104    #[inline]
105    fn is_ready(&self) -> bool {
106        self.window.len() == self.period + 1
107    }
108
109    #[inline]
110    fn name(&self) -> &'static str {
111        "MOM"
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::traits::BatchExt;
119    use approx::assert_relative_eq;
120
121    #[test]
122    fn new_rejects_zero_period() {
123        assert!(matches!(Mom::new(0), Err(Error::PeriodZero)));
124    }
125
126    /// Cover the const accessors `period` / `value` (56-63) and the
127    /// Indicator-impl `name` body (101-103). Existing tests inspect
128    /// momentum output but never query the metadata.
129    #[test]
130    fn accessors_and_metadata() {
131        let mut m = Mom::new(5).unwrap();
132        assert_eq!(m.period(), 5);
133        assert_eq!(m.name(), "MOM");
134        assert_eq!(m.value(), None);
135        for i in 1..=6 {
136            m.update(f64::from(i));
137        }
138        assert!(m.value().is_some());
139    }
140
141    #[test]
142    fn reference_values() {
143        // MOM(3): price_t − price_{t-3}.
144        let mut mom = Mom::new(3).unwrap();
145        let out = mom.batch(&[1.0, 2.0, 3.0, 4.0, 7.0]);
146        assert_eq!(mom.warmup_period(), 4);
147        assert_eq!(out[0], None);
148        assert_eq!(out[2], None);
149        assert_relative_eq!(out[3].unwrap(), 4.0 - 1.0, epsilon = 1e-12);
150        assert_relative_eq!(out[4].unwrap(), 7.0 - 2.0, epsilon = 1e-12);
151    }
152
153    #[test]
154    fn constant_series_yields_zero() {
155        let mut mom = Mom::new(5).unwrap();
156        let out = mom.batch(&[10.0; 20]);
157        for v in out.iter().skip(5).flatten() {
158            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
159        }
160    }
161
162    #[test]
163    fn ignores_non_finite_input() {
164        let mut mom = Mom::new(3).unwrap();
165        let out = mom.batch(&[1.0, 2.0, 3.0, 4.0]);
166        out[3].expect("MOM(3) ready after four inputs");
167        assert_eq!(mom.update(f64::NAN), None);
168        assert_eq!(mom.update(f64::INFINITY), None);
169        // Window untouched: the next finite input still references price 2.
170        assert_relative_eq!(mom.update(10.0).unwrap(), 10.0 - 2.0, epsilon = 1e-12);
171    }
172
173    #[test]
174    fn reset_clears_state() {
175        let mut mom = Mom::new(3).unwrap();
176        mom.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
177        assert!(mom.is_ready());
178        mom.reset();
179        assert!(!mom.is_ready());
180        assert_eq!(mom.update(1.0), None);
181    }
182
183    #[test]
184    fn batch_equals_streaming() {
185        let prices: Vec<f64> = (1..=40).map(|i| f64::from(i) * 1.5).collect();
186        let batch = Mom::new(7).unwrap().batch(&prices);
187        let mut b = Mom::new(7).unwrap();
188        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
189        assert_eq!(batch, streamed);
190    }
191}