Skip to main content

finance_solution/stocks/ta/
momentum.rs

1//! # Rate of change / momentum (ROC, ROCP, MOM)
2//!
3//! On a close series with lookback \(N\):
4//!
5//! ```text
6//! MOM[i]  = close[i] − close[i−N]
7//! ROC[i]  = 100 * (close[i] − close[i−N]) / close[i−N]   // percent
8//! ROCP[i] = (close[i] − close[i−N]) / close[i−N]         // fraction
9//! ```
10//!
11//! Warm-up: first \(N\) bars are `None`. If `close[i−N] == 0`, ROC/ROCP are `None`
12//! (MOM can still be defined).
13//!
14//! Defaults: **period 10** ([`MomParams::period_10`]).
15//!
16//! ---
17//!
18//! ## Trading perspective
19//!
20//! | Reading | Habit (classic) |
21//! |---------|-----------------|
22//! | ROC \> 0 | Price above N-bar-ago close (up momentum) |
23//! | ROC zero cross | Momentum flip screen |
24//! | Rising ROC while price flat | Acceleration narrative |
25//!
26//! **MOM** is absolute points (scale depends on price level). **ROC/ROCP** are scale-free —
27//! prefer them when comparing names or eras.
28//!
29//! ## vs RSI / MACD
30//!
31//! | | MOM/ROC | RSI | MACD |
32//! |--|---------|-----|------|
33//! | Lag / smooth | None (raw N-bar change) | Wilder smooth | Dual EMA smooth |
34//! | Best for | Simple impulse / ranking | Bounded OB/OS | Crossover systems |
35//!
36//! ## Pairs well with
37//!
38//! - **SMA/EMA** — only long when ROC \> 0 and price above MA.
39//! - **ADX** — strong ROC moves with rising ADX = trend impulse.
40//! - **Bollinger** — ROC spike into band tag for exhaustion screens.
41//!
42//! ---
43//!
44//! ## Engineering
45//!
46//! [`MomParams`] → [`mom`] / [`roc`] / [`rocp`] / [`MomState`] → [`mom_solution`].  
47//! Batch uses [`MomState`]. After warm-up each push is **O(1)** (ring of lag closes).
48//! One state computes all three outputs per bar ([`MomBarOutput`]).
49
50use crate::stocks::ta::common::{opt_cell, validate_series};
51use crate::stocks::ta::ring::RingF64;
52use crate::util::error::{require_finite, FinanceResult};
53use crate::util::primitives::PeriodLength;
54use crate::{columns_with_strings, print_table_locale_opt};
55
56/// Lookback for MOM / ROC / ROCP.
57#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
58pub struct MomParams {
59    pub period: usize,
60}
61
62impl MomParams {
63    pub const fn new(period: usize) -> Self {
64        Self { period }
65    }
66
67    pub const fn period_10() -> Self {
68        Self { period: 10 }
69    }
70
71    pub const fn period_12() -> Self {
72        Self { period: 12 }
73    }
74}
75
76#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
77pub struct ValidatedMom {
78    params: MomParams,
79}
80
81impl ValidatedMom {
82    pub fn new(params: MomParams) -> FinanceResult<Self> {
83        PeriodLength::new(params.period)?;
84        Ok(Self { params })
85    }
86
87    pub fn params(self) -> MomParams {
88        self.params
89    }
90
91    pub fn compute_mom(self, closes: &[f64]) -> FinanceResult<MomSeries> {
92        mom_validated(closes, self)
93    }
94
95    pub fn compute_roc(self, closes: &[f64]) -> FinanceResult<RocSeries> {
96        roc_validated(closes, self)
97    }
98
99    pub fn compute_rocp(self, closes: &[f64]) -> FinanceResult<RocpSeries> {
100        rocp_validated(closes, self)
101    }
102}
103
104#[derive(Clone, Debug, PartialEq)]
105pub struct MomSeries {
106    pub mom: Vec<Option<f64>>,
107    pub params: MomParams,
108}
109
110#[derive(Clone, Debug, PartialEq)]
111pub struct RocSeries {
112    pub roc: Vec<Option<f64>>,
113    pub params: MomParams,
114}
115
116#[derive(Clone, Debug, PartialEq)]
117pub struct RocpSeries {
118    pub rocp: Vec<Option<f64>>,
119    pub params: MomParams,
120}
121
122/// One-bar momentum pack.
123#[derive(Clone, Copy, Debug, PartialEq)]
124pub struct MomBarOutput {
125    pub mom: Option<f64>,
126    pub roc: Option<f64>,
127    pub rocp: Option<f64>,
128}
129
130/// Incremental MOM/ROC/ROCP (shared ring of last `period` closes + current).
131///
132/// Ring capacity is `period` holding the **lag** close (oldest = close\[i−period\] when full).
133#[derive(Clone, Debug)]
134pub struct MomState {
135    params: MomParams,
136    /// Holds the last `period` closes (oldest is lag for current after push of new).
137    lag: RingF64,
138    last: Option<MomBarOutput>,
139}
140
141impl MomState {
142    pub fn new(params: MomParams) -> FinanceResult<Self> {
143        let _ = ValidatedMom::new(params)?;
144        Ok(Self {
145            params,
146            lag: RingF64::with_capacity(params.period),
147            last: None,
148        })
149    }
150
151    pub fn from_history(params: MomParams, closes: &[f64]) -> FinanceResult<Self> {
152        let mut s = Self::new(params)?;
153        let _ = s.push_bars(closes)?;
154        Ok(s)
155    }
156
157    pub fn params(&self) -> MomParams {
158        self.params
159    }
160
161    pub fn reset(&mut self) {
162        self.lag.clear();
163        self.last = None;
164    }
165
166    pub fn push(&mut self, close: f64) -> FinanceResult<Option<MomBarOutput>> {
167        require_finite("close", close)?;
168        let out = if self.lag.is_full() {
169            let lag = self.lag.oldest().unwrap();
170            let mom = close - lag;
171            let (roc, rocp) = if lag == 0.0 {
172                (None, None)
173            } else {
174                let frac = (close - lag) / lag;
175                (Some(100.0 * frac), Some(frac))
176            };
177            let bar = MomBarOutput {
178                mom: Some(mom),
179                roc,
180                rocp,
181            };
182            self.last = Some(bar);
183            Some(bar)
184        } else {
185            self.last = None;
186            None
187        };
188        let _ = self.lag.push(close);
189        Ok(out)
190    }
191
192    pub fn push_bars(&mut self, closes: &[f64]) -> FinanceResult<Vec<Option<MomBarOutput>>> {
193        validate_series("close", closes)?;
194        let mut out = Vec::with_capacity(closes.len());
195        for &c in closes {
196            out.push(self.push(c)?);
197        }
198        Ok(out)
199    }
200
201    pub fn last(&self) -> Option<MomBarOutput> {
202        self.last
203    }
204}
205
206pub fn mom(closes: &[f64], params: MomParams) -> FinanceResult<MomSeries> {
207    ValidatedMom::new(params)?.compute_mom(closes)
208}
209
210pub fn roc(closes: &[f64], params: MomParams) -> FinanceResult<RocSeries> {
211    ValidatedMom::new(params)?.compute_roc(closes)
212}
213
214pub fn rocp(closes: &[f64], params: MomParams) -> FinanceResult<RocpSeries> {
215    ValidatedMom::new(params)?.compute_rocp(closes)
216}
217
218fn mom_validated(closes: &[f64], eng: ValidatedMom) -> FinanceResult<MomSeries> {
219    let mut st = MomState::new(eng.params)?;
220    let bars = st.push_bars(closes)?;
221    Ok(MomSeries {
222        mom: bars.iter().map(|b| b.and_then(|x| x.mom)).collect(),
223        params: eng.params,
224    })
225}
226
227fn roc_validated(closes: &[f64], eng: ValidatedMom) -> FinanceResult<RocSeries> {
228    let mut st = MomState::new(eng.params)?;
229    let bars = st.push_bars(closes)?;
230    Ok(RocSeries {
231        roc: bars.iter().map(|b| b.and_then(|x| x.roc)).collect(),
232        params: eng.params,
233    })
234}
235
236fn rocp_validated(closes: &[f64], eng: ValidatedMom) -> FinanceResult<RocpSeries> {
237    let mut st = MomState::new(eng.params)?;
238    let bars = st.push_bars(closes)?;
239    Ok(RocpSeries {
240        rocp: bars.iter().map(|b| b.and_then(|x| x.rocp)).collect(),
241        params: eng.params,
242    })
243}
244
245#[derive(Clone, Debug)]
246pub struct MomSolution {
247    series: MomSeries,
248    roc: RocSeries,
249    close: Vec<f64>,
250    formula: String,
251}
252
253impl MomSolution {
254    pub fn series(&self) -> &MomSeries {
255        &self.series
256    }
257    pub fn roc_series(&self) -> &RocSeries {
258        &self.roc
259    }
260    pub fn formula(&self) -> &str {
261        &self.formula
262    }
263
264    pub fn print_table(&self) {
265        self.print_table_locale_opt(None, None);
266    }
267
268    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
269        self.print_table_locale_opt(Some(locale), Some(precision));
270    }
271
272    fn print_table_locale_opt(
273        &self,
274        locale: Option<&num_format::Locale>,
275        precision: Option<usize>,
276    ) {
277        let columns = columns_with_strings(&[
278            ("period", "i", true),
279            ("close", "f", true),
280            ("mom", "f", true),
281            ("roc", "f", true),
282        ]);
283        let data = self
284            .close
285            .iter()
286            .enumerate()
287            .map(|(i, c)| {
288                vec![
289                    i.to_string(),
290                    c.to_string(),
291                    opt_cell(self.series.mom[i]),
292                    opt_cell(self.roc.roc[i]),
293                ]
294            })
295            .collect();
296        print_table_locale_opt(&columns, data, locale, precision);
297    }
298}
299
300/// # Examples
301/// ```
302/// use finance_solution::stocks::ta::{mom_solution, MomParams};
303/// let c: Vec<_> = (0..20).map(|i| 100.0 + i as f64).collect();
304/// let sol = mom_solution(&c, MomParams::period_10()).unwrap();
305/// assert!(sol.formula().contains("10"));
306/// ```
307pub fn mom_solution(closes: &[f64], params: MomParams) -> FinanceResult<MomSolution> {
308    let series = mom(closes, params)?;
309    let roc = roc(closes, params)?;
310    Ok(MomSolution {
311        series,
312        roc,
313        close: closes.to_vec(),
314        formula: format!(
315            "MOM = C - C[n-{}]; ROC% = 100*(C-C_lag)/C_lag",
316            params.period
317        ),
318    })
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn mom_word_problem() {
327        let c = [1.0, 2.0, 3.0, 4.0, 10.0];
328        let s = mom(&c, MomParams::new(2)).unwrap();
329        assert!(s.mom[0].is_none());
330        assert!(s.mom[1].is_none());
331        assert!((s.mom[2].unwrap() - 2.0).abs() < 1e-12); // 3-1
332        assert!((s.mom[4].unwrap() - 7.0).abs() < 1e-12); // 10-3 (lag = i−period)
333    }
334
335    #[test]
336    fn roc_percent() {
337        let c = [100.0, 100.0, 110.0];
338        let s = roc(&c, MomParams::new(2)).unwrap();
339        assert!((s.roc[2].unwrap() - 10.0).abs() < 1e-12);
340    }
341
342    #[test]
343    fn zero_lag_roc_none() {
344        let c = [0.0, 1.0, 2.0];
345        let s = roc(&c, MomParams::new(2)).unwrap();
346        assert!(s.roc[2].is_none());
347    }
348
349    #[test]
350    fn state_parity() {
351        let c: Vec<_> = (0..30).map(|i| 100.0 + i as f64 * 0.5).collect();
352        let p = MomParams::period_10();
353        let batch = mom(&c, p).unwrap();
354        let mut st = MomState::new(p).unwrap();
355        for i in 0..c.len() {
356            let o = st.push(c[i]).unwrap();
357            match (o.and_then(|b| b.mom), batch.mom[i]) {
358                (None, None) => {}
359                (Some(a), Some(b)) => assert!((a - b).abs() < 1e-12),
360                other => panic!("{other:?}"),
361            }
362        }
363    }
364}