Skip to main content

finance_solution/stocks/ta/
macd.rs

1//! # MACD (Moving Average Convergence Divergence)
2//!
3//! ```text
4//! MACD   = EMA(fast) − EMA(slow)     // default 12 and 26
5//! Signal = EMA(signal) of MACD line  // default 9
6//! Hist   = MACD − Signal
7//! ```
8//!
9//! ## Word problem
10//!
11//! > Using classic (12, 26, 9) settings on daily closes, when is the histogram first defined?
12//!
13//! After the slow EMA seeds (26 bars) and then the signal EMA seeds on 9 MACD points —
14//! teaching tables show `n/a` until then. Live code: hold [`MacdState`] and read
15//! [`MacdState::last`] after warm-up.
16//!
17//! ## Variations (same core)
18//!
19//! | Pack | Typical use |
20//! |------|-------------|
21//! | `MacdParams::standard()` → (12,26,9) | Default charting |
22//! | `MacdParams::new(8, 17, 9)` | Faster reaction |
23//! | `MacdParams::new(5, 35, 5)` | Custom desk setting |
24//!
25//! ## Quant pattern
26//!
27//! ```
28//! use finance_solution::stocks::ta::{MacdParams, ValidatedMacd, MacdState};
29//!
30//! const STD: MacdParams = MacdParams::standard();
31//! let eng = ValidatedMacd::new(STD).unwrap();
32//! # let closes: Vec<f64> = (1..=40).map(|x| 100.0 + x as f64 * 0.1).collect();
33//! let s = eng.compute(&closes).unwrap();
34//! let mut live = MacdState::new(STD).unwrap();
35//! let _ = live.push_bars(&closes).unwrap();
36//! assert_eq!(s.macd.len(), closes.len());
37//! ```
38//!
39//! ## Sample solution table
40//!
41//! ```text
42//! period  close   macd  signal  hist
43//! ------  ------  ----  ------  ----
44//!     25  102.50   n/a     n/a   n/a
45//!     26  102.60  0.12     n/a   n/a
46//!     34  103.40  0.18    0.15  0.03
47//! ```
48
49use crate::stocks::ta::common::{opt_cell, validate_series};
50use crate::stocks::ta::moving_average::ema;
51use crate::util::error::{FinanceError, FinanceResult};
52use crate::util::primitives::PeriodLength;
53use crate::{columns_with_strings, print_table_locale_opt};
54
55/// MACD parameter pack: `fast < slow`, all periods ≥ 1.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
57pub struct MacdParams {
58    pub fast: usize,
59    pub slow: usize,
60    pub signal: usize,
61}
62
63impl MacdParams {
64    /// Classic `(12, 26, 9)`.
65    pub const fn standard() -> Self {
66        Self {
67            fast: 12,
68            slow: 26,
69            signal: 9,
70        }
71    }
72
73    pub const fn new(fast: usize, slow: usize, signal: usize) -> Self {
74        Self { fast, slow, signal }
75    }
76}
77
78/// Validated MACD config for reuse across many close series.
79#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
80pub struct ValidatedMacd {
81    params: MacdParams,
82}
83
84impl ValidatedMacd {
85    pub fn new(params: MacdParams) -> FinanceResult<Self> {
86        PeriodLength::new(params.fast)?;
87        PeriodLength::new(params.slow)?;
88        PeriodLength::new(params.signal)?;
89        if params.fast >= params.slow {
90            return Err(FinanceError::Unsolvable {
91                message: "MACD requires fast period < slow period",
92            });
93        }
94        Ok(Self { params })
95    }
96
97    pub fn params(self) -> MacdParams {
98        self.params
99    }
100
101    pub fn compute(self, closes: &[f64]) -> FinanceResult<MacdSeries> {
102        macd_validated(closes, self)
103    }
104}
105
106/// Aligned MACD / signal / histogram series.
107#[derive(Clone, Debug, PartialEq)]
108pub struct MacdSeries {
109    pub macd: Vec<Option<f64>>,
110    pub signal: Vec<Option<f64>>,
111    pub histogram: Vec<Option<f64>>,
112    pub params: MacdParams,
113}
114
115impl MacdSeries {
116    pub fn last(&self) -> Option<(f64, f64, f64)> {
117        let m = self.macd.iter().rev().find_map(|x| *x)?;
118        let s = self.signal.iter().rev().find_map(|x| *x)?;
119        let h = self.histogram.iter().rev().find_map(|x| *x)?;
120        Some((m, s, h))
121    }
122}
123
124/// Teaching wrapper with formula strings and a printable table.
125#[derive(Clone, Debug)]
126pub struct MacdSolution {
127    series: MacdSeries,
128    closes: Vec<f64>,
129    formula: String,
130    symbolic_formula: String,
131}
132
133impl MacdSolution {
134    pub fn series(&self) -> &MacdSeries {
135        &self.series
136    }
137    pub fn formula(&self) -> &str {
138        &self.formula
139    }
140    pub fn symbolic_formula(&self) -> &str {
141        &self.symbolic_formula
142    }
143    pub fn params(&self) -> MacdParams {
144        self.series.params
145    }
146
147    /// Print period / close / macd / signal / hist (warm-up as `n/a`).
148    ///
149    /// # Sample output (truncated)
150    /// ```text
151    /// period   close    macd  signal    hist
152    /// ------  ------  ------  ------  ------
153    ///      0  100.10     n/a     n/a     n/a
154    ///     25  102.60  0.1234     n/a     n/a
155    ///     33  103.40  0.1800  0.1500  0.0300
156    /// ```
157    pub fn print_table(&self) {
158        self.print_table_locale_opt(None, None);
159    }
160
161    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
162        self.print_table_locale_opt(Some(locale), Some(precision));
163    }
164
165    fn print_table_locale_opt(
166        &self,
167        locale: Option<&num_format::Locale>,
168        precision: Option<usize>,
169    ) {
170        let columns = columns_with_strings(&[
171            ("period", "i", true),
172            ("close", "f", true),
173            ("macd", "f", true),
174            ("signal", "f", true),
175            ("hist", "f", true),
176        ]);
177        let data = self
178            .closes
179            .iter()
180            .enumerate()
181            .map(|(i, c)| {
182                vec![
183                    i.to_string(),
184                    c.to_string(),
185                    opt_cell(self.series.macd[i]),
186                    opt_cell(self.series.signal[i]),
187                    opt_cell(self.series.histogram[i]),
188                ]
189            })
190            .collect();
191        print_table_locale_opt(&columns, data, locale, precision);
192    }
193}
194
195/// Free function: validate params then compute.
196pub fn macd(closes: &[f64], params: MacdParams) -> FinanceResult<MacdSeries> {
197    ValidatedMacd::new(params)?.compute(closes)
198}
199
200/// Solution with formulas + table for teaching / audit.
201///
202/// # Examples
203/// ```
204/// use finance_solution::stocks::ta::{macd_solution, MacdParams};
205/// let closes: Vec<f64> = (1..=40).map(|x| 100.0 + x as f64).collect();
206/// let sol = macd_solution(&closes, MacdParams::standard()).unwrap();
207/// assert!(sol.formula().contains("12"));
208/// // sol.print_table(); // see module docs for sample layout
209/// ```
210pub fn macd_solution(closes: &[f64], params: MacdParams) -> FinanceResult<MacdSolution> {
211    let series = macd(closes, params)?;
212    let formula = format!(
213        "macd = EMA({}) - EMA({}); signal = EMA({})(macd); hist = macd - signal",
214        params.fast, params.slow, params.signal
215    );
216    let symbolic =
217        "macd = ema_fast(close) - ema_slow(close); signal = ema_signal(macd); hist = macd - signal"
218            .to_string();
219    Ok(MacdSolution {
220        series,
221        closes: closes.to_vec(),
222        formula,
223        symbolic_formula: symbolic,
224    })
225}
226
227fn macd_validated(closes: &[f64], v: ValidatedMacd) -> FinanceResult<MacdSeries> {
228    validate_series("close", closes)?;
229    let p = v.params;
230    let fast = ema(closes, p.fast)?;
231    let slow = ema(closes, p.slow)?;
232    let n = closes.len();
233    let mut macd_line = vec![None; n];
234    for i in 0..n {
235        match (fast[i], slow[i]) {
236            (Some(f), Some(s)) => macd_line[i] = Some(f - s),
237            _ => {}
238        }
239    }
240    let signal = ema_on_option_series(&macd_line, p.signal);
241    let mut histogram = vec![None; n];
242    for i in 0..n {
243        match (macd_line[i], signal[i]) {
244            (Some(m), Some(s)) => histogram[i] = Some(m - s),
245            _ => {}
246        }
247    }
248    Ok(MacdSeries {
249        macd: macd_line,
250        signal,
251        histogram,
252        params: p,
253    })
254}
255
256/// EMA over a series with warm-up `None`s: seed with SMA of first `period` defined values
257/// in order of first availability is awkward — instead: collect contiguous once enough
258/// `Some` values exist from the left after first Some.
259///
260/// Practical approach: build dense vector of macd values with index mapping; run EMA on dense
261/// then scatter back. First seed = SMA of first `period` dense points.
262fn ema_on_option_series(data: &[Option<f64>], period: usize) -> Vec<Option<f64>> {
263    let n = data.len();
264    let mut out = vec![None; n];
265    if period == 0 {
266        return out;
267    }
268    // Gather (index, value) for defined macd points
269    let pts: Vec<(usize, f64)> = data
270        .iter()
271        .enumerate()
272        .filter_map(|(i, v)| v.map(|x| (i, x)))
273        .collect();
274    if pts.len() < period {
275        return out;
276    }
277    let mut sum = 0.0;
278    for j in 0..period {
279        sum += pts[j].1;
280    }
281    let mut prev = sum / period as f64;
282    let seed_idx = pts[period - 1].0;
283    out[seed_idx] = Some(prev);
284    let alpha = 2.0 / (period as f64 + 1.0);
285    for j in period..pts.len() {
286        prev = alpha * pts[j].1 + (1.0 - alpha) * prev;
287        out[pts[j].0] = Some(prev);
288    }
289    out
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn standard_runs() {
298        let c: Vec<_> = (1..=50).map(|x| 100.0 + x as f64 * 0.2).collect();
299        let s = macd(&c, MacdParams::standard()).unwrap();
300        assert_eq!(s.macd.len(), 50);
301        assert!(s.last().is_some());
302    }
303
304    #[test]
305    fn fast_not_lt_slow_err() {
306        assert!(ValidatedMacd::new(MacdParams::new(26, 12, 9)).is_err());
307    }
308
309    #[test]
310    fn histogram_is_macd_minus_signal() {
311        let c: Vec<_> = (1..=60).map(|x| 100.0 + x as f64 * 0.15).collect();
312        let s = macd(&c, MacdParams::standard()).unwrap();
313        for i in 0..s.macd.len() {
314            if let (Some(m), Some(sig), Some(h)) = (s.macd[i], s.signal[i], s.histogram[i]) {
315                assert!((h - (m - sig)).abs() < 1e-12);
316            }
317        }
318    }
319
320    #[test]
321    fn empty_err() {
322        assert!(macd(&[], MacdParams::standard()).is_err());
323    }
324}