Skip to main content

finance_solution/stocks/ta/
moving_average.rs

1//! # Simple & exponential moving averages (SMA / EMA)
2//!
3//! Teaching + production building blocks for price smoothers. **Batch** APIs
4//! ([`sma`], [`ema`]) and **incremental** APIs ([`SmaState`], [`EmaState`]) share one
5//! implementation path: batch is “create state → [`push_bars`](SmaState::push_bars)”.
6//!
7//! ## Word problem
8//!
9//! > A stock closed at 10, 11, 12, 13, 14 over five days. What is the 3-day SMA on
10//! > day 5?
11//!
12//! Expect: `(12 + 13 + 14) / 3 = 13`.
13//!
14//! ```
15//! use finance_solution::stocks::ta::sma;
16//! let closes = [10.0, 11.0, 12.0, 13.0, 14.0];
17//! let s = sma(&closes, 3).unwrap();
18//! // period index:     0     1     2     3     4
19//! // warm-up:        None  None  Some  Some  Some
20//! assert_eq!(s[0], None);
21//! assert_eq!(s[1], None);
22//! assert!((s[2].unwrap() - 11.0).abs() < 1e-12); // (10+11+12)/3
23//! assert!((s[4].unwrap() - 13.0).abs() < 1e-12); // (12+13+14)/3
24//! ```
25//!
26//! ## Quant pattern — one pack, many symbols
27//!
28//! ```
29//! use finance_solution::stocks::ta::{SmaState, EmaState};
30//!
31//! // Live: hold state per symbol (your engine's HashMap)
32//! let mut sma20 = SmaState::new(20).unwrap();
33//! let mut ema20 = EmaState::new(20).unwrap();
34//! # let payload = [100.0, 100.5, 101.0];
35//! // One streaming payload with several bars:
36//! let _ = sma20.push_bars(&payload).unwrap();
37//! let _ = ema20.push_bars(&payload).unwrap();
38//! // Or single bar:
39//! let last_sma = sma20.push(101.2).unwrap(); // Option after warm-up
40//! ```
41//!
42//! ## Formulas
43//!
44//! **SMA** over window of length `n`:
45//!
46//! ```text
47//! SMA_t = (P_{t-n+1} + … + P_t) / n
48//! ```
49//!
50//! **EMA** with span `n` (α = 2/(n+1)), seed = SMA of first `n` closes:
51//!
52//! ```text
53//! EMA_seed = SMA(P_0..P_{n-1})
54//! EMA_t    = α * P_t + (1-α) * EMA_{t-1}
55//! ```
56//!
57//! ## Warm-up
58//!
59//! Output length = input length. Indices `0 .. n-2` are `None` until the window is full.
60//!
61//! ## Related
62//!
63//! - Incremental: [`SmaState`], [`EmaState`] in [`crate::stocks::ta::state`]
64//! - Used by: Bollinger (SMA mid), Keltner/MACD (EMA)
65
66use crate::stocks::ta::ring::RingF64;
67use crate::util::error::{require_finite, FinanceError, FinanceResult};
68use crate::util::primitives::PeriodLength;
69
70// ---------------------------------------------------------------------------
71// Incremental state (canonical math path for SMA/EMA)
72// ---------------------------------------------------------------------------
73
74/// Incremental SMA. After warm-up, each [`SmaState::push`] is O(1).
75///
76/// Batch [`sma`] is implemented as `SmaState::new` + [`SmaState::push_bars`].
77///
78/// # Examples
79/// ```
80/// use finance_solution::stocks::ta::SmaState;
81/// let mut s = SmaState::new(3).unwrap();
82/// assert_eq!(s.push(1.0).unwrap(), None);
83/// assert_eq!(s.push(2.0).unwrap(), None);
84/// assert!((s.push(3.0).unwrap().unwrap() - 2.0).abs() < 1e-12);
85/// assert!((s.push(6.0).unwrap().unwrap() - 3.666666666666).abs() < 1e-9);
86/// ```
87#[derive(Clone, Debug)]
88pub struct SmaState {
89    period: usize,
90    ring: RingF64,
91}
92
93impl SmaState {
94    /// Fallible constructor (`period >= 1`). Named `new` → [`FinanceResult`] (not `try_new`).
95    pub fn new(period: usize) -> FinanceResult<Self> {
96        let period = PeriodLength::new(period)?.get();
97        Ok(Self {
98            period,
99            ring: RingF64::with_capacity(period),
100        })
101    }
102
103    pub fn from_history(period: usize, closes: &[f64]) -> FinanceResult<Self> {
104        let mut s = Self::new(period)?;
105        s.push_bars(closes)?;
106        Ok(s)
107    }
108
109    pub fn period(&self) -> usize {
110        self.period
111    }
112
113    pub fn reset(&mut self) {
114        self.ring.clear();
115    }
116
117    /// Push one close. `None` until `period` samples seen.
118    pub fn push(&mut self, close: f64) -> FinanceResult<Option<f64>> {
119        require_finite("close", close)?;
120        self.ring.push(close);
121        if self.ring.is_full() {
122            Ok(Some(self.ring.sum() / self.period as f64))
123        } else {
124            Ok(None)
125        }
126    }
127
128    /// Push many closes (one streaming payload). One output per input.
129    pub fn push_bars(&mut self, closes: &[f64]) -> FinanceResult<Vec<Option<f64>>> {
130        let mut out = Vec::with_capacity(closes.len());
131        for &c in closes {
132            out.push(self.push(c)?);
133        }
134        Ok(out)
135    }
136
137    pub fn last(&self) -> Option<f64> {
138        if self.ring.is_full() {
139            Some(self.ring.sum() / self.period as f64)
140        } else {
141            None
142        }
143    }
144}
145
146/// Incremental EMA (α = 2/(period+1), seed = SMA of first `period` closes).
147///
148/// Batch [`ema`] uses this state end-to-end.
149///
150/// # Examples
151/// ```
152/// use finance_solution::stocks::ta::{EmaState, ema};
153/// let closes: Vec<f64> = (1..=20).map(|x| x as f64).collect();
154/// let batch = ema(&closes, 5).unwrap();
155/// let mut st = EmaState::new(5).unwrap();
156/// let mut last = None;
157/// for &c in &closes {
158///     last = st.push(c).unwrap();
159/// }
160/// assert!((last.unwrap() - batch[19].unwrap()).abs() < 1e-9);
161/// ```
162#[derive(Clone, Debug)]
163pub struct EmaState {
164    period: usize,
165    alpha: f64,
166    seed: RingF64,
167    value: Option<f64>,
168}
169
170impl EmaState {
171    pub fn new(period: usize) -> FinanceResult<Self> {
172        let period = PeriodLength::new(period)?.get();
173        Ok(Self {
174            period,
175            alpha: 2.0 / (period as f64 + 1.0),
176            seed: RingF64::with_capacity(period),
177            value: None,
178        })
179    }
180
181    pub fn from_history(period: usize, closes: &[f64]) -> FinanceResult<Self> {
182        let mut s = Self::new(period)?;
183        s.push_bars(closes)?;
184        Ok(s)
185    }
186
187    pub fn period(&self) -> usize {
188        self.period
189    }
190
191    pub fn reset(&mut self) {
192        self.seed.clear();
193        self.value = None;
194    }
195
196    pub fn push(&mut self, close: f64) -> FinanceResult<Option<f64>> {
197        require_finite("close", close)?;
198        if let Some(prev) = self.value {
199            let next = self.alpha * close + (1.0 - self.alpha) * prev;
200            self.value = Some(next);
201            return Ok(Some(next));
202        }
203        self.seed.push(close);
204        if self.seed.is_full() {
205            let seed = self.seed.sum() / self.period as f64;
206            self.value = Some(seed);
207            Ok(Some(seed))
208        } else {
209            Ok(None)
210        }
211    }
212
213    pub fn push_bars(&mut self, closes: &[f64]) -> FinanceResult<Vec<Option<f64>>> {
214        let mut out = Vec::with_capacity(closes.len());
215        for &c in closes {
216            out.push(self.push(c)?);
217        }
218        Ok(out)
219    }
220
221    pub fn last(&self) -> Option<f64> {
222        self.value
223    }
224}
225
226/// SMA of `period` closes. Leading `period - 1` values are `None`.
227///
228/// Implemented via [`SmaState::push_bars`] so batch and streaming stay bit-identical.
229///
230/// # Errors
231/// Empty input, non-finite values, or `period == 0`.
232///
233/// # Examples
234/// ```
235/// use finance_solution::stocks::ta::sma;
236/// let c = [1.0, 2.0, 3.0, 4.0, 5.0];
237/// let s = sma(&c, 3).unwrap();
238/// assert_eq!(s[0], None);
239/// assert_eq!(s[1], None);
240/// assert!((s[2].unwrap() - 2.0).abs() < 1e-12); // (1+2+3)/3
241/// assert!((s[4].unwrap() - 4.0).abs() < 1e-12); // (3+4+5)/3
242/// ```
243///
244/// Short series (shorter than period) — all `None`, still `Ok`:
245/// ```
246/// use finance_solution::stocks::ta::sma;
247/// let s = sma(&[1.0, 2.0], 5).unwrap();
248/// assert!(s.iter().all(|x| x.is_none()));
249/// ```
250pub fn sma(closes: &[f64], period: usize) -> FinanceResult<Vec<Option<f64>>> {
251    validate_closes(closes)?;
252    let mut st = SmaState::new(period)?;
253    st.push_bars(closes)
254}
255
256/// EMA with span `period` (α = 2 / (period + 1)). Seed = SMA of the first `period` closes.
257///
258/// Implemented via [`EmaState::push_bars`].
259///
260/// # Errors
261/// Same domain as [`sma`].
262///
263/// # Examples
264/// ```
265/// use finance_solution::stocks::ta::ema;
266/// let c: Vec<f64> = (1..=20).map(|x| x as f64).collect();
267/// let e = ema(&c, 5).unwrap();
268/// assert!(e[3].is_none());
269/// assert!(e[4].is_some());
270/// assert!(e[19].unwrap().is_finite());
271/// ```
272pub fn ema(closes: &[f64], period: usize) -> FinanceResult<Vec<Option<f64>>> {
273    validate_closes(closes)?;
274    let mut st = EmaState::new(period)?;
275    st.push_bars(closes)
276}
277
278/// Last defined SMA value, if any.
279///
280/// Uses [`SmaState`] end-to-end (no intermediate full `Vec` of options beyond the push loop).
281/// Prefer holding an [`SmaState`] across live bars instead of calling this on growing history.
282///
283/// # Examples
284/// ```
285/// use finance_solution::stocks::ta::sma_last;
286/// assert_eq!(sma_last(&[1.0, 2.0, 3.0], 3).unwrap(), Some(2.0));
287/// assert_eq!(sma_last(&[1.0, 2.0], 3).unwrap(), None);
288/// ```
289#[inline]
290pub fn sma_last(closes: &[f64], period: usize) -> FinanceResult<Option<f64>> {
291    validate_closes(closes)?;
292    let mut st = SmaState::new(period)?;
293    for &c in closes {
294        st.push(c)?;
295    }
296    Ok(st.last())
297}
298
299/// Last defined EMA value, if any (via [`EmaState`]).
300///
301/// # Examples
302/// ```
303/// use finance_solution::stocks::ta::{ema, ema_last};
304/// let c: Vec<f64> = (1..=15).map(|x| x as f64).collect();
305/// let series = ema(&c, 5).unwrap();
306/// let last = ema_last(&c, 5).unwrap();
307/// assert_eq!(last, series[14]);
308/// ```
309#[inline]
310pub fn ema_last(closes: &[f64], period: usize) -> FinanceResult<Option<f64>> {
311    validate_closes(closes)?;
312    let mut st = EmaState::new(period)?;
313    for &c in closes {
314        st.push(c)?;
315    }
316    Ok(st.last())
317}
318
319fn validate_closes(closes: &[f64]) -> FinanceResult<()> {
320    if closes.is_empty() {
321        return Err(FinanceError::EmptyInput { what: "closes" });
322    }
323    for &c in closes {
324        require_finite("close", c)?;
325    }
326    Ok(())
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn sma_constant() {
335        let c = [10.0; 5];
336        let s = sma(&c, 3).unwrap();
337        assert_eq!(s[2], Some(10.0));
338        assert_eq!(s[4], Some(10.0));
339    }
340
341    #[test]
342    fn ema_runs() {
343        let c: Vec<_> = (1..=30).map(|x| x as f64).collect();
344        let e = ema(&c, 10).unwrap();
345        assert!(e[8].is_none());
346        assert!(e[9].is_some());
347    }
348
349    #[test]
350    fn rejects_zero_period() {
351        assert!(sma(&[1.0, 2.0], 0).is_err());
352    }
353
354    #[test]
355    fn last_matches_series_tail() {
356        let c: Vec<_> = (1..=25).map(|x| x as f64 * 0.5).collect();
357        let s = sma(&c, 7).unwrap();
358        assert_eq!(sma_last(&c, 7).unwrap(), s[24]);
359        let e = ema(&c, 7).unwrap();
360        assert_eq!(ema_last(&c, 7).unwrap(), e[24]);
361    }
362}