Skip to main content

kestrel_chartkit/indicator/
macd.rs

1use std::collections::HashMap;
2
3use crate::model::Bar;
4
5use super::smoothing::{crossed_over, crossed_under, Ema};
6use super::{Indicator, IndicatorAlert, IndicatorOutput};
7
8/// Moving Average Convergence Divergence.
9///
10/// `MACD = Ema(fast_len)(close) - Ema(slow_len)(close)`, both the shared [`Ema`] with its
11/// first-sample seed, running from the first bar; the line is published from the `slow_len`-th bar
12/// on. `extra["signal"]` is an `Ema(signal_len)` over the published MACD values, seeded with the
13/// first of them, and `extra["hist"]` is `MACD - signal`. Alerts fire on signal and zero crosses.
14///
15/// First output: with the `slow_len`-th bar. [`Indicator::reset`] clears all three averages.
16pub struct Macd {
17    fast_ema: Ema,
18    slow_ema: Ema,
19    signal_ema: Ema,
20    slow_len: usize,
21
22    prev_macd: Option<f64>,
23    prev_signal: Option<f64>,
24    bars_seen: usize,
25
26    alerts: MacdAlerts,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Default)]
30pub struct MacdAlerts {
31    pub bull_cross: bool,
32    pub bear_cross: bool,
33    pub bull_zero_cross: bool,
34    pub bear_zero_cross: bool,
35}
36
37impl Macd {
38    pub fn new(fast_len: usize, slow_len: usize, signal_len: usize) -> Self {
39        Self {
40            fast_ema: Ema::new(fast_len),
41            slow_ema: Ema::new(slow_len),
42            signal_ema: Ema::new(signal_len),
43            slow_len,
44            prev_macd: None,
45            prev_signal: None,
46            bars_seen: 0,
47            alerts: MacdAlerts::default(),
48        }
49    }
50
51    pub fn with_defaults() -> Self {
52        Self::new(12, 26, 9)
53    }
54}
55
56impl Indicator for Macd {
57    fn name(&self) -> &str {
58        "macd"
59    }
60
61    fn warmup_period(&self) -> usize {
62        self.slow_len
63    }
64
65    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
66        self.alerts = MacdAlerts::default();
67        self.bars_seen += 1;
68
69        let close = bar.close;
70        let fast = self.fast_ema.update(close)?;
71        let slow = self.slow_ema.update(close)?;
72
73        if self.bars_seen < self.slow_len {
74            return None;
75        }
76
77        let macd_line = fast - slow;
78        let signal_line = self.signal_ema.update(macd_line)?;
79        let hist = macd_line - signal_line;
80
81        if let (Some(prev_m), Some(prev_s)) = (self.prev_macd, self.prev_signal) {
82            self.alerts.bull_cross = crossed_over(prev_m, prev_s, macd_line, signal_line);
83            self.alerts.bear_cross = crossed_under(prev_m, prev_s, macd_line, signal_line);
84            self.alerts.bull_zero_cross = crossed_over(prev_m, 0.0, macd_line, 0.0);
85            self.alerts.bear_zero_cross = crossed_under(prev_m, 0.0, macd_line, 0.0);
86        }
87
88        self.prev_macd = Some(macd_line);
89        self.prev_signal = Some(signal_line);
90
91        let mut extra = HashMap::new();
92        extra.insert("signal".to_string(), signal_line);
93        extra.insert("hist".to_string(), hist);
94
95        Some(IndicatorOutput::with_extra(macd_line, extra))
96    }
97
98    fn reset(&mut self) {
99        self.fast_ema.reset();
100        self.slow_ema.reset();
101        self.signal_ema.reset();
102        self.prev_macd = None;
103        self.prev_signal = None;
104        self.bars_seen = 0;
105        self.alerts = MacdAlerts::default();
106    }
107
108    fn alerts(&self) -> Vec<IndicatorAlert> {
109        let a = self.alerts;
110        let mut out = Vec::new();
111        if a.bull_cross {
112            out.push(IndicatorAlert {
113                kind: "bull_cross".to_string(),
114                note: "MACD · BULL CROSS SIGNAL".to_string(),
115                strength: 1.0,
116            });
117        }
118        if a.bear_cross {
119            out.push(IndicatorAlert {
120                kind: "bear_cross".to_string(),
121                note: "MACD · BEAR CROSS SIGNAL".to_string(),
122                strength: 1.0,
123            });
124        }
125        if a.bull_zero_cross {
126            out.push(IndicatorAlert {
127                kind: "bull_zero_cross".to_string(),
128                note: "MACD · CROSS ABOVE ZERO".to_string(),
129                strength: 1.0,
130            });
131        }
132        if a.bear_zero_cross {
133            out.push(IndicatorAlert {
134                kind: "bear_zero_cross".to_string(),
135                note: "MACD · CROSS BELOW ZERO".to_string(),
136                strength: 1.0,
137            });
138        }
139        out
140    }
141}