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//! Batch [`macd`] / [`ValidatedMacd::compute`] uses [`MacdState`] end-to-end (same path as live).
28//!
29//! ```
30//! use finance_solution::stocks::ta::{MacdParams, ValidatedMacd, MacdState};
31//!
32//! const STD: MacdParams = MacdParams::standard();
33//! let eng = ValidatedMacd::new(STD).unwrap();
34//! # let closes: Vec<f64> = (1..=40).map(|x| 100.0 + x as f64 * 0.1).collect();
35//! let s = eng.compute(&closes).unwrap();
36//! let mut live = MacdState::new(STD).unwrap();
37//! let _ = live.push_bars(&closes).unwrap();
38//! assert_eq!(s.macd.len(), closes.len());
39//! ```
40//!
41//! ## Sample solution table
42//!
43//! ```text
44//! period  close   macd  signal  hist
45//! ------  ------  ----  ------  ----
46//!     25  102.50   n/a     n/a   n/a
47//!     26  102.60  0.12     n/a   n/a
48//!     34  103.40  0.18    0.15  0.03
49//! ```
50
51use crate::stocks::ta::common::{opt_cell, validate_series};
52use crate::stocks::ta::state::MacdState;
53use crate::util::error::{FinanceError, FinanceResult};
54use crate::util::primitives::PeriodLength;
55use crate::{columns_with_strings, print_table_locale_opt};
56
57/// MACD parameter pack: `fast < slow`, all periods ≥ 1.
58#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
59pub struct MacdParams {
60    pub fast: usize,
61    pub slow: usize,
62    pub signal: usize,
63}
64
65impl MacdParams {
66    /// Classic `(12, 26, 9)`.
67    pub const fn standard() -> Self {
68        Self {
69            fast: 12,
70            slow: 26,
71            signal: 9,
72        }
73    }
74
75    pub const fn new(fast: usize, slow: usize, signal: usize) -> Self {
76        Self { fast, slow, signal }
77    }
78}
79
80/// Validated MACD config for reuse across many close series.
81#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
82pub struct ValidatedMacd {
83    params: MacdParams,
84}
85
86impl ValidatedMacd {
87    pub fn new(params: MacdParams) -> FinanceResult<Self> {
88        PeriodLength::new(params.fast)?;
89        PeriodLength::new(params.slow)?;
90        PeriodLength::new(params.signal)?;
91        if params.fast >= params.slow {
92            return Err(FinanceError::Unsolvable {
93                message: "MACD requires fast period < slow period",
94            });
95        }
96        Ok(Self { params })
97    }
98
99    pub fn params(self) -> MacdParams {
100        self.params
101    }
102
103    pub fn compute(self, closes: &[f64]) -> FinanceResult<MacdSeries> {
104        macd_validated(closes, self)
105    }
106}
107
108/// Aligned MACD / signal / histogram series.
109#[derive(Clone, Debug, PartialEq)]
110pub struct MacdSeries {
111    pub macd: Vec<Option<f64>>,
112    pub signal: Vec<Option<f64>>,
113    pub histogram: Vec<Option<f64>>,
114    pub params: MacdParams,
115}
116
117impl MacdSeries {
118    pub fn last(&self) -> Option<(f64, f64, f64)> {
119        let m = self.macd.iter().rev().find_map(|x| *x)?;
120        let s = self.signal.iter().rev().find_map(|x| *x)?;
121        let h = self.histogram.iter().rev().find_map(|x| *x)?;
122        Some((m, s, h))
123    }
124}
125
126/// Teaching wrapper with formula strings and a printable table.
127#[derive(Clone, Debug)]
128pub struct MacdSolution {
129    series: MacdSeries,
130    closes: Vec<f64>,
131    formula: String,
132    symbolic_formula: String,
133}
134
135impl MacdSolution {
136    pub fn series(&self) -> &MacdSeries {
137        &self.series
138    }
139    pub fn formula(&self) -> &str {
140        &self.formula
141    }
142    pub fn symbolic_formula(&self) -> &str {
143        &self.symbolic_formula
144    }
145    pub fn params(&self) -> MacdParams {
146        self.series.params
147    }
148
149    /// Print period / close / macd / signal / hist (warm-up as `n/a`).
150    ///
151    /// # Sample output (truncated)
152    /// ```text
153    /// period   close    macd  signal    hist
154    /// ------  ------  ------  ------  ------
155    ///      0  100.10     n/a     n/a     n/a
156    ///     25  102.60  0.1234     n/a     n/a
157    ///     33  103.40  0.1800  0.1500  0.0300
158    /// ```
159    pub fn print_table(&self) {
160        self.print_table_locale_opt(None, None);
161    }
162
163    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
164        self.print_table_locale_opt(Some(locale), Some(precision));
165    }
166
167    fn print_table_locale_opt(
168        &self,
169        locale: Option<&num_format::Locale>,
170        precision: Option<usize>,
171    ) {
172        let columns = columns_with_strings(&[
173            ("period", "i", true),
174            ("close", "f", true),
175            ("macd", "f", true),
176            ("signal", "f", true),
177            ("hist", "f", true),
178        ]);
179        let data = self
180            .closes
181            .iter()
182            .enumerate()
183            .map(|(i, c)| {
184                vec![
185                    i.to_string(),
186                    c.to_string(),
187                    opt_cell(self.series.macd[i]),
188                    opt_cell(self.series.signal[i]),
189                    opt_cell(self.series.histogram[i]),
190                ]
191            })
192            .collect();
193        print_table_locale_opt(&columns, data, locale, precision);
194    }
195}
196
197/// Free function: validate params then compute.
198pub fn macd(closes: &[f64], params: MacdParams) -> FinanceResult<MacdSeries> {
199    ValidatedMacd::new(params)?.compute(closes)
200}
201
202/// Solution with formulas + table for teaching / audit.
203///
204/// # Examples
205/// ```
206/// use finance_solution::stocks::ta::{macd_solution, MacdParams};
207/// let closes: Vec<f64> = (1..=40).map(|x| 100.0 + x as f64).collect();
208/// let sol = macd_solution(&closes, MacdParams::standard()).unwrap();
209/// assert!(sol.formula().contains("12"));
210/// // sol.print_table(); // see module docs for sample layout
211/// ```
212pub fn macd_solution(closes: &[f64], params: MacdParams) -> FinanceResult<MacdSolution> {
213    let series = macd(closes, params)?;
214    let formula = format!(
215        "macd = EMA({}) - EMA({}); signal = EMA({})(macd); hist = macd - signal",
216        params.fast, params.slow, params.signal
217    );
218    let symbolic =
219        "macd = ema_fast(close) - ema_slow(close); signal = ema_signal(macd); hist = macd - signal"
220            .to_string();
221    Ok(MacdSolution {
222        series,
223        closes: closes.to_vec(),
224        formula,
225        symbolic_formula: symbolic,
226    })
227}
228
229fn macd_validated(closes: &[f64], v: ValidatedMacd) -> FinanceResult<MacdSeries> {
230    validate_series("close", closes)?;
231    let p = v.params;
232    let mut st = MacdState::new(p)?;
233    let bars = st.push_bars_detail(closes)?;
234    let n = bars.len();
235    let mut macd_line = vec![None; n];
236    let mut signal = vec![None; n];
237    let mut histogram = vec![None; n];
238    for (i, b) in bars.into_iter().enumerate() {
239        macd_line[i] = b.macd;
240        signal[i] = b.signal;
241        histogram[i] = b.histogram;
242    }
243    Ok(MacdSeries {
244        macd: macd_line,
245        signal,
246        histogram,
247        params: p,
248    })
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn standard_runs() {
257        let c: Vec<_> = (1..=50).map(|x| 100.0 + x as f64 * 0.2).collect();
258        let s = macd(&c, MacdParams::standard()).unwrap();
259        assert_eq!(s.macd.len(), 50);
260        assert!(s.last().is_some());
261    }
262
263    #[test]
264    fn fast_not_lt_slow_err() {
265        assert!(ValidatedMacd::new(MacdParams::new(26, 12, 9)).is_err());
266    }
267
268    #[test]
269    fn histogram_is_macd_minus_signal() {
270        let c: Vec<_> = (1..=60).map(|x| 100.0 + x as f64 * 0.15).collect();
271        let s = macd(&c, MacdParams::standard()).unwrap();
272        for i in 0..s.macd.len() {
273            if let (Some(m), Some(sig), Some(h)) = (s.macd[i], s.signal[i], s.histogram[i]) {
274                assert!((h - (m - sig)).abs() < 1e-12);
275            }
276        }
277    }
278
279    #[test]
280    fn empty_err() {
281        assert!(macd(&[], MacdParams::standard()).is_err());
282    }
283}