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        Ok(Self {
49            period,
50            window: VecDeque::with_capacity(period + 1),
51            last: None,
52        })
53    }
54
55    /// Configured lookback period.
56    pub const fn period(&self) -> usize {
57        self.period
58    }
59
60    /// Current value if available.
61    pub const fn value(&self) -> Option<f64> {
62        self.last
63    }
64}
65
66impl Indicator for Mom {
67    type Input = f64;
68    type Output = f64;
69
70    fn update(&mut self, input: f64) -> Option<f64> {
71        if !input.is_finite() {
72            // Non-finite input is ignored; the window is left untouched.
73            return self.last;
74        }
75        if self.window.len() == self.period + 1 {
76            self.window.pop_front();
77        }
78        self.window.push_back(input);
79        if self.window.len() < self.period + 1 {
80            return None;
81        }
82        let prev = *self.window.front().expect("window is non-empty");
83        let mom = input - prev;
84        self.last = Some(mom);
85        Some(mom)
86    }
87
88    fn reset(&mut self) {
89        self.window.clear();
90        self.last = None;
91    }
92
93    fn warmup_period(&self) -> usize {
94        self.period + 1
95    }
96
97    fn is_ready(&self) -> bool {
98        self.window.len() == self.period + 1
99    }
100
101    fn name(&self) -> &'static str {
102        "MOM"
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::traits::BatchExt;
110    use approx::assert_relative_eq;
111
112    #[test]
113    fn new_rejects_zero_period() {
114        assert!(matches!(Mom::new(0), Err(Error::PeriodZero)));
115    }
116
117    /// Cover the const accessors `period` / `value` (56-63) and the
118    /// Indicator-impl `name` body (101-103). Existing tests inspect
119    /// momentum output but never query the metadata.
120    #[test]
121    fn accessors_and_metadata() {
122        let mut m = Mom::new(5).unwrap();
123        assert_eq!(m.period(), 5);
124        assert_eq!(m.name(), "MOM");
125        assert_eq!(m.value(), None);
126        for i in 1..=6 {
127            m.update(f64::from(i));
128        }
129        assert!(m.value().is_some());
130    }
131
132    #[test]
133    fn reference_values() {
134        // MOM(3): price_t − price_{t-3}.
135        let mut mom = Mom::new(3).unwrap();
136        let out = mom.batch(&[1.0, 2.0, 3.0, 4.0, 7.0]);
137        assert_eq!(mom.warmup_period(), 4);
138        assert_eq!(out[0], None);
139        assert_eq!(out[2], None);
140        assert_relative_eq!(out[3].unwrap(), 4.0 - 1.0, epsilon = 1e-12);
141        assert_relative_eq!(out[4].unwrap(), 7.0 - 2.0, epsilon = 1e-12);
142    }
143
144    #[test]
145    fn constant_series_yields_zero() {
146        let mut mom = Mom::new(5).unwrap();
147        let out = mom.batch(&[10.0; 20]);
148        for v in out.iter().skip(5).flatten() {
149            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
150        }
151    }
152
153    #[test]
154    fn ignores_non_finite_input() {
155        let mut mom = Mom::new(3).unwrap();
156        let out = mom.batch(&[1.0, 2.0, 3.0, 4.0]);
157        let ready = out[3].expect("MOM(3) ready after four inputs");
158        assert_eq!(mom.update(f64::NAN), Some(ready));
159        assert_eq!(mom.update(f64::INFINITY), Some(ready));
160        // Window untouched: the next finite input still references price 2.
161        assert_relative_eq!(mom.update(10.0).unwrap(), 10.0 - 2.0, epsilon = 1e-12);
162    }
163
164    #[test]
165    fn reset_clears_state() {
166        let mut mom = Mom::new(3).unwrap();
167        mom.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
168        assert!(mom.is_ready());
169        mom.reset();
170        assert!(!mom.is_ready());
171        assert_eq!(mom.update(1.0), None);
172    }
173
174    #[test]
175    fn batch_equals_streaming() {
176        let prices: Vec<f64> = (1..=40).map(|i| f64::from(i) * 1.5).collect();
177        let batch = Mom::new(7).unwrap().batch(&prices);
178        let mut b = Mom::new(7).unwrap();
179        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
180        assert_eq!(batch, streamed);
181    }
182}