Skip to main content

egui_charts/studies/builtin/
macd.rs

1//! Moving Average Convergence Divergence (MACD) indicator.
2//!
3//! MACD is a trend-following momentum indicator that shows the
4//! relationship between two moving averages of closing prices,
5//! exponential by default. It consists of three output lines plotted in
6//! a separate sub-pane.
7//!
8//! # Components
9//!
10//! 1. **MACD line** = EMA(fast) - EMA(slow)
11//! 2. **Signal line** = EMA(MACD line, signal_period)
12//! 3. **Histogram** = MACD line - Signal line
13//!
14//! # Default parameters
15//!
16//! `MACD::new(12, 26, 9)` -- the classic (12, 26, 9) setting.
17//!
18//! # Interpretation
19//!
20//! - MACD crossing above signal: bullish momentum.
21//! - MACD crossing below signal: bearish momentum.
22//! - Histogram shrinking toward zero: momentum slowing.
23//! - Divergence between MACD and price: potential reversal.
24//!
25//! # Averages
26//!
27//! Each exponential average starts at its first value. With simple averages the MACD line starts
28//! once both have a full window; a simple signal is the mean of the last `signal_period` MACD
29//! values, and an exponential one starts at the first MACD value.
30//!
31//! # Default colours
32//!
33//! - MACD line: blue ([`palette::BLUE`](crate::studies::palette::BLUE))
34//! - Signal line: orange ([`palette::ORANGE`](crate::studies::palette::ORANGE))
35//! - Histogram: teal above zero and red below, with lighter tints toward zero.
36//!
37//! # Example
38//!
39//! ```rust
40//! # let bars: Vec<egui_charts::model::Bar> = (0..60).map(|i| { let p = 100.0 + (i as f64 * 0.3).sin() * 5.0; egui_charts::model::Bar::new(chrono::Utc::now(), p, p + 1.0, p - 1.0, p + 0.5, 1_000.0) }).collect();
41//! use egui_charts::studies::{MACD, Indicator, IndicatorValue};
42//!
43//! let mut macd = MACD::new(12, 26, 9);
44//! macd.calculate(&bars);
45//!
46//! if let IndicatorValue::Multiple(vals) = &macd.values().to_rows()[50] {
47//!     let (macd_line, signal, histogram) = (vals[0], vals[1], vals[2]);
48//! }
49//! ```
50
51use super::price_oscillator::{Mean, OscillatorAverage};
52use crate::model::Bar;
53use crate::studies::palette;
54use crate::studies::stepper::Stepper;
55use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
56use egui::Color32;
57
58/// Moving Average Convergence Divergence (MACD) indicator.
59///
60/// A three-line momentum oscillator: MACD line, signal line, and histogram.
61/// Drawn in a separate sub-pane (`is_overlay = false`).
62#[derive(Clone)]
63pub struct MACD {
64    fast_period: usize,
65    slow_period: usize,
66    signal_period: usize,
67    /// Formula of the fast and slow averages.
68    oscillator_average: OscillatorAverage,
69    /// Formula of the signal line.
70    signal_average: OscillatorAverage,
71    values: IndicatorValues,
72    stepper: Stepper<State>,
73    colors: Vec<Color32>,
74    visible: bool,
75}
76
77#[derive(Clone, Default)]
78struct State {
79    usable: usize,
80    /// Bars with a MACD value.
81    defined: usize,
82    fast: f64,
83    slow: f64,
84    signal: f64,
85    /// The fast, slow and signal simple averages.
86    means: [Mean; 3],
87}
88
89impl MACD {
90    /// Create a new MACD indicator.
91    ///
92    /// # Arguments
93    /// * `fast_period` -- Period for the fast EMA (default: 12).
94    /// * `slow_period` -- Period for the slow EMA (default: 26).
95    /// * `signal_period` -- Period for the signal-line EMA (default: 9).
96    pub fn new(fast_period: usize, slow_period: usize, signal_period: usize) -> Self {
97        Self {
98            fast_period: fast_period.max(1),
99            slow_period: slow_period.max(1),
100            signal_period: signal_period.max(1),
101            oscillator_average: OscillatorAverage::Exponential,
102            signal_average: OscillatorAverage::Exponential,
103            values: IndicatorValues::new(),
104            stepper: Stepper::default(),
105            colors: vec![
106                palette::BLUE,           // MACD line
107                palette::ORANGE,         // Signal line
108                palette::HISTOGRAM_TEAL, // Histogram: positive and rising
109                palette::LIGHT_TEAL,
110                palette::HISTOGRAM_RED,
111                palette::LIGHT_RED,
112            ],
113            visible: true,
114        }
115    }
116
117    /// Choose the formula of the fast and slow averages and of the signal line; both exponential
118    /// by default.
119    pub fn with_averages(
120        mut self,
121        oscillator: OscillatorAverage,
122        signal: OscillatorAverage,
123    ) -> Self {
124        self.oscillator_average = oscillator;
125        self.signal_average = signal;
126        self
127    }
128}
129
130/// Construct with the conventional default parameters.
131impl Default for MACD {
132    fn default() -> Self {
133        Self::new(12, 26, 9)
134    }
135}
136
137impl Indicator for MACD {
138    fn source(&self) -> Option<crate::studies::StudySource> {
139        Some(crate::studies::StudySource::Price(
140            crate::model::PriceSource::Close,
141        ))
142    }
143
144    crate::studies::inputs::study_inputs!(self;
145        fast_period: length("Fast period", 1, usize::MAX),
146        slow_period: length("Slow period", 1, usize::MAX),
147        signal_period: length("Signal period", 1, usize::MAX),
148        oscillator_average: choice("Oscillator MA", [OscillatorAverage::Exponential, OscillatorAverage::Simple]),
149        signal_average: choice("Signal MA", [OscillatorAverage::Exponential, OscillatorAverage::Simple])
150    ; {});
151
152    fn plot_style(&self, line: usize) -> crate::studies::PlotStyle {
153        crate::studies::PlotStyle {
154            color: self.colors.get(line).copied().unwrap_or(Color32::WHITE),
155            width: 1.0,
156            pattern: crate::model::LineStyle::Solid,
157            plot_type: if line == 2 {
158                crate::studies::PlotType::Columns
159            } else {
160                crate::studies::PlotType::Line
161            },
162            visible: true,
163        }
164    }
165
166    fn plot_color(&self, line: usize, bar: usize) -> Option<Color32> {
167        if line != 2 {
168            return None;
169        }
170        histogram_color(&self.values, bar, &self.colors[2..])
171    }
172
173    fn value_format(&self) -> crate::scales::StudyFormat {
174        crate::scales::StudyFormat::Price
175    }
176
177    fn name(&self) -> &str {
178        "MACD"
179    }
180
181    fn desc(&self) -> &str {
182        "MACD - Trend-following momentum indicator"
183    }
184
185    fn calculate(&mut self, data: &[Bar]) {
186        self.update(data, 0);
187    }
188
189    fn update(&mut self, data: &[Bar], from: usize) {
190        let threshold = self.slow_period.max(self.signal_period);
191        // Crossing the whole-series threshold reveals values on earlier bars too.
192        let from = if self.stepper.state().usable < threshold {
193            0
194        } else {
195            from
196        };
197        let fast_mult = 2.0 / (self.fast_period as f64 + 1.0);
198        let slow_mult = 2.0 / (self.slow_period as f64 + 1.0);
199        let signal_mult = 2.0 / (self.signal_period as f64 + 1.0);
200        self.stepper
201            .run(data, from, &mut self.values, |state, bar| {
202                if !bar.close.is_finite() {
203                    return IndicatorValue::None;
204                }
205                let (fast, slow) = match self.oscillator_average {
206                    OscillatorAverage::Exponential => {
207                        if state.usable == 0 {
208                            state.fast = bar.close;
209                            state.slow = bar.close;
210                        } else {
211                            state.fast = (bar.close - state.fast) * fast_mult + state.fast;
212                            state.slow = (bar.close - state.slow) * slow_mult + state.slow;
213                        }
214                        (state.fast, state.slow)
215                    }
216                    OscillatorAverage::Simple => (
217                        state.means[0].next(OscillatorAverage::Simple, bar.close, self.fast_period),
218                        state.means[1].next(OscillatorAverage::Simple, bar.close, self.slow_period),
219                    ),
220                };
221                state.usable += 1;
222                let macd = fast - slow;
223                // A simple average still filling its window.
224                if !macd.is_finite() {
225                    return IndicatorValue::None;
226                }
227                let signal = match self.signal_average {
228                    OscillatorAverage::Exponential => {
229                        state.signal = if state.defined == 0 {
230                            macd
231                        } else {
232                            (macd - state.signal) * signal_mult + state.signal
233                        };
234                        state.signal
235                    }
236                    OscillatorAverage::Simple => {
237                        state.means[2].next(OscillatorAverage::Simple, macd, self.signal_period)
238                    }
239                };
240                state.defined += 1;
241                IndicatorValue::Multiple(vec![macd, signal, macd - signal])
242            });
243        if self.stepper.state().usable < threshold {
244            self.values.fill(IndicatorValue::None);
245        }
246    }
247
248    fn is_incremental(&self) -> bool {
249        true
250    }
251
252    fn values(&self) -> &IndicatorValues {
253        &self.values
254    }
255
256    fn colors(&self) -> Vec<Color32> {
257        self.colors.clone()
258    }
259
260    fn set_colors(&mut self, colors: Vec<Color32>) {
261        if colors.len() >= 3 {
262            self.colors = colors;
263            if self.colors.len() < 6 {
264                self.colors.truncate(3);
265            }
266            self.colors.resize(6, self.colors[2]);
267        } else if !colors.is_empty() {
268            self.colors = vec![colors[0]; 6];
269        }
270    }
271
272    fn set_color(&mut self, line: usize, color: Color32) {
273        match line {
274            0 | 1 => self.colors[line] = color,
275            2 => self.colors[2..].fill(color),
276            _ => {}
277        }
278    }
279
280    fn pane(&self) -> crate::studies::StudyPane {
281        crate::studies::StudyPane {
282            height: None,
283            range: None,
284            levels: vec![(0.0, palette::MUTED.gamma_multiply_u8(128), "Zero Line")],
285            fill_between_levels: false,
286        }
287    }
288
289    fn is_overlay(&self) -> bool {
290        false // MACD is drawn in separate pane
291    }
292
293    fn line_count(&self) -> usize {
294        3
295    }
296
297    fn line_names(&self) -> Vec<String> {
298        vec![
299            format!(
300                "MACD({},{},{})",
301                self.fast_period, self.slow_period, self.signal_period
302            ),
303            "Signal".to_string(),
304            "Histogram".to_string(),
305        ]
306    }
307
308    fn is_visible(&self) -> bool {
309        self.visible
310    }
311
312    fn set_visible(&mut self, visible: bool) {
313        self.visible = visible;
314    }
315
316    fn clone_box(&self) -> Box<dyn Indicator> {
317        Box::new(self.clone())
318    }
319}
320
321/// Column colour of a histogram in output line 2 from `shades`: rising and easing above zero,
322/// then falling and recovering below it.
323pub(super) fn histogram_color(
324    values: &IndicatorValues,
325    bar: usize,
326    shades: &[Color32],
327) -> Option<Color32> {
328    let value = values.value(2, bar)?;
329    let previous = (0..bar).rev().find_map(|bar| values.value(2, bar));
330    let slot = if value >= 0.0 {
331        usize::from(!previous.is_some_and(|p| value > p))
332    } else {
333        2 + usize::from(previous.is_some_and(|p| value > p))
334    };
335    shades.get(slot).copied()
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::studies::ConfiguredStudy;
342
343    #[test]
344    fn histogram_palette_survives_edits_and_saved_explicit_colours() {
345        let study = MACD {
346            values: [1.0, 2.0, 1.5, f64::NAN, -1.0, -2.0, -1.5, 0.0]
347                .into_iter()
348                .map(|value| IndicatorValue::Multiple(vec![0.0, 0.0, value]))
349                .collect(),
350            ..MACD::default()
351        };
352        let expected = [
353            Some(palette::LIGHT_TEAL),
354            Some(palette::HISTOGRAM_TEAL),
355            Some(palette::LIGHT_TEAL),
356            None,
357            Some(palette::HISTOGRAM_RED),
358            Some(palette::HISTOGRAM_RED),
359            Some(palette::LIGHT_RED),
360            Some(palette::HISTOGRAM_TEAL),
361        ];
362        let colours = |study: &dyn Indicator| -> Vec<_> {
363            (0..8).map(|bar| study.plot_color(2, bar)).collect()
364        };
365        assert_eq!(colours(&study), expected);
366        assert_eq!(study.plot_color(0, 1), None);
367        let mut configured = ConfiguredStudy::new(Box::new(study.clone()));
368        configured
369            .apply_settings(&[], configured.settings())
370            .unwrap();
371        assert_eq!(colours(&configured), expected);
372        let mut settings = configured.settings();
373        settings.plots[0].color = Color32::YELLOW;
374        configured.apply_settings(&[], settings).unwrap();
375        assert_eq!(colours(&configured), expected);
376        let snapshot = configured.snapshot_settings();
377        configured.set_color(2, Color32::WHITE);
378        assert_eq!(configured.plot_color(2, 1), Some(Color32::WHITE));
379        configured.restore_settings(&snapshot).unwrap();
380        assert_eq!(colours(&configured), expected);
381        // A saved single histogram colour stays solid even when it equals the new up colour.
382        for color in [palette::UP, palette::HISTOGRAM_TEAL, Color32::YELLOW] {
383            let mut legacy = study.clone();
384            legacy.set_colors(vec![palette::BLUE, palette::ORANGE, color]);
385            assert!(
386                (0..8)
387                    .filter(|&bar| bar != 3)
388                    .all(|bar| legacy.plot_color(2, bar) == Some(color))
389            );
390            let snapshot = legacy.snapshot_settings();
391            configured.restore_settings(&snapshot).unwrap();
392            assert_eq!(colours(&configured), colours(&legacy));
393        }
394    }
395}