Skip to main content

wickra_core/indicators/
roc.rs

1//! Rate of Change (ROC).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rate of Change as a percentage: `(close - close[period]) / close[period] * 100`.
9///
10/// Non-finite inputs are ignored and leave the window untouched; the last
11/// computed value is returned instead, matching the SMA / EMA convention.
12///
13/// # Example
14///
15/// ```
16/// use wickra_core::{Indicator, Roc};
17///
18/// let mut indicator = Roc::new(3).unwrap();
19/// let mut last = None;
20/// for i in 0..80 {
21///     last = indicator.update(100.0 + f64::from(i));
22/// }
23/// assert!(last.is_some());
24/// ```
25#[derive(Debug, Clone)]
26pub struct Roc {
27    period: usize,
28    window: VecDeque<f64>,
29    last: Option<f64>,
30}
31
32impl Roc {
33    /// # Errors
34    /// Returns [`Error::PeriodZero`] if `period == 0`.
35    pub fn new(period: usize) -> Result<Self> {
36        if period == 0 {
37            return Err(Error::PeriodZero);
38        }
39        if period > crate::error::MAX_PERIOD {
40            return Err(Error::InvalidPeriod {
41                message: crate::error::PERIOD_ABOVE_MAX,
42            });
43        }
44        Ok(Self {
45            period,
46            window: VecDeque::with_capacity(period + 1),
47            last: None,
48        })
49    }
50
51    /// Configured period.
52    pub const fn period(&self) -> usize {
53        self.period
54    }
55}
56
57impl Indicator for Roc {
58    type Input = f64;
59    type Output = f64;
60
61    #[inline]
62    fn update(&mut self, input: f64) -> Option<f64> {
63        // Non-finite inputs are ignored: return the last value, leave state as is.
64        if !input.is_finite() {
65            return None;
66        }
67        if self.window.len() == self.period + 1 {
68            self.window.pop_front();
69        }
70        self.window.push_back(input);
71        if self.window.len() < self.period + 1 {
72            return None;
73        }
74        let prev = *self.window.front().expect("non-empty");
75        let roc = if prev == 0.0 {
76            0.0
77        } else {
78            (input - prev) / prev * 100.0
79        };
80        self.last = Some(roc);
81        Some(roc)
82    }
83
84    fn reset(&mut self) {
85        self.window.clear();
86        self.last = None;
87    }
88
89    #[inline]
90    fn warmup_period(&self) -> usize {
91        self.period + 1
92    }
93
94    #[inline]
95    fn is_ready(&self) -> bool {
96        self.window.len() == self.period + 1
97    }
98
99    #[inline]
100    fn name(&self) -> &'static str {
101        "ROC"
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::traits::BatchExt;
109    use approx::assert_relative_eq;
110
111    #[test]
112    fn constant_series_yields_zero() {
113        let mut roc = Roc::new(5).unwrap();
114        let out = roc.batch(&[10.0_f64; 20]);
115        for v in out.iter().skip(5).flatten() {
116            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
117        }
118    }
119
120    #[test]
121    fn known_value() {
122        // ROC(3) where prev = 100, now = 110 -> 10%
123        let mut roc = Roc::new(3).unwrap();
124        let out = roc.batch(&[100.0, 105.0, 108.0, 110.0]);
125        assert_relative_eq!(out[3].unwrap(), 10.0, epsilon = 1e-12);
126    }
127
128    #[test]
129    fn batch_equals_streaming() {
130        let prices: Vec<f64> = (1..=30).map(|i| f64::from(i) * 2.0).collect();
131        let mut a = Roc::new(5).unwrap();
132        let mut b = Roc::new(5).unwrap();
133        assert_eq!(
134            a.batch(&prices),
135            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
136        );
137    }
138
139    #[test]
140    fn reset_clears_state() {
141        let mut roc = Roc::new(5).unwrap();
142        roc.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
143        assert!(roc.is_ready());
144        roc.reset();
145        assert!(!roc.is_ready());
146    }
147
148    #[test]
149    fn rejects_zero_period() {
150        assert!(Roc::new(0).is_err());
151    }
152
153    /// Cover the const accessor `period` (47-49) and the Indicator-impl
154    /// `warmup_period` (83-85) + `name` (91-93). Existing tests never
155    /// inspect these metadata methods.
156    #[test]
157    fn accessors_and_metadata() {
158        let roc = Roc::new(5).unwrap();
159        assert_eq!(roc.period(), 5);
160        assert_eq!(roc.warmup_period(), 6);
161        assert_eq!(roc.name(), "ROC");
162    }
163
164    /// Cover the `prev == 0.0` defensive branch (line 70). All existing
165    /// tests use prices ≥ 1.0, so the divide-by-zero guard was never
166    /// triggered. Feed a leading zero followed by `period` more values
167    /// so the front of the window is exactly 0.0, then assert the next
168    /// emission is the flat-momentum fallback 0.0 (not NaN).
169    #[test]
170    fn zero_previous_price_yields_zero_roc() {
171        let mut roc = Roc::new(3).unwrap();
172        let out = roc.batch(&[0.0, 5.0, 7.0, 9.0]);
173        let v = out[3].expect("ready after period + 1 inputs");
174        assert_eq!(v, 0.0);
175    }
176
177    #[test]
178    fn ignores_non_finite_input() {
179        let mut roc = Roc::new(3).unwrap();
180        let out = roc.batch(&[100.0, 105.0, 108.0, 110.0]);
181        out[3].expect("ROC(3) ready after four inputs");
182        // Non-finite inputs return the last value without sliding the window.
183        assert_eq!(roc.update(f64::NAN), None);
184        assert_eq!(roc.update(f64::INFINITY), None);
185        // Window untouched: the next finite input still references prev = 105.
186        assert_relative_eq!(
187            roc.update(115.0).unwrap(),
188            (115.0 - 105.0) / 105.0 * 100.0,
189            epsilon = 1e-12
190        );
191    }
192}