Skip to main content

egui_charts/studies/builtin/
vw_macd.rs

1//! Volume Weighted MACD (VW-MACD) indicator.
2//!
3//! A variation of MACD built from volume-weighted moving averages of the close,
4//! giving more weight to price movements backed by higher volume.
5//! This can reduce false signals during low-volume price moves.
6//!
7//! # Components
8//!
9//! - **VW-MACD line** = VWMA(close, fast) - VWMA(close, slow), where
10//!   `VWMA(n) = sum(close * volume, n) / sum(volume, n)`.
11//! - **Signal line** = EMA(VW-MACD line, signal).
12//! - **Histogram** = VW-MACD line - signal line.
13//!
14//! The line starts on bar `max(fast, slow) - 1`; earlier bars have no value.
15//! A window whose volume sums to 0 takes the simple average of its closes, as
16//! [`VWMA`](super::VWMA) does.
17//!
18//! # Default parameters
19//!
20//! `VolumeWeightedMACD::new(12, 26, 9)` -- same periods as classic MACD.
21//!
22//! # Example
23//!
24//! ```rust
25//! # 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();
26//! use egui_charts::studies::{VolumeWeightedMACD, Indicator};
27//!
28//! let mut vw = VolumeWeightedMACD::new(12, 26, 9);
29//! vw.calculate(&bars);
30//! ```
31
32use crate::model::Bar;
33use crate::studies::palette;
34use crate::studies::stepper::Stepper;
35use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
36use egui::Color32;
37
38/// Volume Weighted MACD indicator
39///
40/// The difference of a fast and a slow volume-weighted moving average of the
41/// close, with an EMA signal line seeded with the first line value, as the
42/// signal of [`MACD`](super::MACD) is.
43#[derive(Clone)]
44pub struct VolumeWeightedMACD {
45    fast_period: usize,
46    slow_period: usize,
47    signal_period: usize,
48    values: IndicatorValues,
49    stepper: Stepper<(std::collections::VecDeque<[f64; 2]>, Option<f64>)>,
50    colors: Vec<Color32>,
51    visible: bool,
52}
53
54impl Default for VolumeWeightedMACD {
55    fn default() -> Self {
56        Self::new(12, 26, 9)
57    }
58}
59
60impl VolumeWeightedMACD {
61    /// Create a new VW-MACD with specified periods
62    pub fn new(fast_period: usize, slow_period: usize, signal_period: usize) -> Self {
63        Self {
64            fast_period: fast_period.max(1),
65            slow_period: slow_period.max(1),
66            signal_period: signal_period.max(1),
67            values: IndicatorValues::new(),
68            stepper: Stepper::default(),
69            colors: vec![
70                palette::BLUE,   // MACD line
71                palette::ORANGE, // Signal line
72                palette::GREEN,  // Histogram positive
73            ],
74            visible: true,
75        }
76    }
77}
78
79impl Indicator for VolumeWeightedMACD {
80    fn source(&self) -> Option<crate::studies::StudySource> {
81        Some(crate::studies::StudySource::Price(
82            crate::model::PriceSource::Close,
83        ))
84    }
85
86    crate::studies::inputs::study_inputs!(self;
87        fast_period: length("Fast period", 1, usize::MAX),
88        slow_period: length("Slow period", 1, usize::MAX),
89        signal_period: length("Signal period", 1, usize::MAX)
90    ; {});
91
92    fn value_format(&self) -> crate::scales::StudyFormat {
93        crate::scales::StudyFormat::Price
94    }
95
96    fn name(&self) -> &str {
97        "VW-MACD"
98    }
99
100    fn desc(&self) -> &str {
101        "Volume Weighted MACD - MACD using volume-weighted prices"
102    }
103
104    fn calculate(&mut self, data: &[Bar]) {
105        self.update(data, 0);
106    }
107
108    fn update(&mut self, data: &[Bar], from: usize) {
109        self.stepper
110            .run(data, from, &mut self.values, |state, bar| {
111                if !bar.close.is_finite() || !bar.volume.is_finite() {
112                    return IndicatorValue::None;
113                }
114                state.0.push_back([bar.close, bar.volume]);
115                let length = self.fast_period.max(self.slow_period);
116                if state.0.len() > length {
117                    state.0.pop_front();
118                }
119                if state.0.len() < length {
120                    return IndicatorValue::None;
121                }
122                let average = |period| {
123                    let start = state.0.len() - period;
124                    let price_volume: f64 = state.0.iter().skip(start).map(|v| v[0] * v[1]).sum();
125                    let volume: f64 = state.0.iter().skip(start).map(|v| v[1]).sum();
126                    if volume > 0.0 {
127                        price_volume / volume
128                    } else {
129                        state.0.iter().skip(start).map(|v| v[0]).sum::<f64>() / period as f64
130                    }
131                };
132                let (fast, slow) = (average(self.fast_period), average(self.slow_period));
133                if !fast.is_finite() || !slow.is_finite() {
134                    return IndicatorValue::None;
135                }
136                let line = fast - slow;
137                let signal = state.1.map_or(line, |previous| {
138                    (line - previous) * (2.0 / (self.signal_period as f64 + 1.0)) + previous
139                });
140                state.1 = Some(signal);
141                IndicatorValue::Multiple(vec![line, signal, line - signal])
142            });
143    }
144
145    fn is_incremental(&self) -> bool {
146        true
147    }
148
149    fn values(&self) -> &IndicatorValues {
150        &self.values
151    }
152
153    fn colors(&self) -> Vec<Color32> {
154        self.colors.clone()
155    }
156
157    fn set_colors(&mut self, colors: Vec<Color32>) {
158        if colors.len() >= 3 {
159            self.colors = colors;
160        } else if !colors.is_empty() {
161            self.colors = vec![colors[0], colors[0], colors[0]];
162        }
163    }
164
165    fn is_overlay(&self) -> bool {
166        false // VW-MACD is drawn in separate pane
167    }
168
169    fn line_count(&self) -> usize {
170        3
171    }
172
173    fn line_names(&self) -> Vec<String> {
174        vec![
175            format!(
176                "VW-MACD({},{},{})",
177                self.fast_period, self.slow_period, self.signal_period
178            ),
179            "Signal".to_string(),
180            "Histogram".to_string(),
181        ]
182    }
183
184    fn is_visible(&self) -> bool {
185        self.visible
186    }
187
188    fn set_visible(&mut self, visible: bool) {
189        self.visible = visible;
190    }
191
192    fn clone_box(&self) -> Box<dyn Indicator> {
193        Box::new(self.clone())
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn bars(closes: &[f64], volumes: &[f64]) -> Vec<Bar> {
202        closes
203            .iter()
204            .zip(volumes)
205            .map(|(&c, &v)| Bar::new(chrono::Utc::now(), c, c, c, c, v))
206            .collect()
207    }
208
209    fn lines(study: &VolumeWeightedMACD, bar: usize) -> Option<Vec<f64>> {
210        match &study.values().to_rows()[bar] {
211            IndicatorValue::Multiple(v) => Some(v.clone()),
212            _ => None,
213        }
214    }
215
216    /// Reference values from the published definition (Buff Pelz Dormeier, "Investing with
217    /// Volume Analysis", 2011; John Bollinger, "Bollinger on Bollinger Bands", 2002):
218    /// VWMA(close, fast) - VWMA(close, slow), a signal EMA seeded with the first line value,
219    /// and their difference, computed independently in Python.
220    #[test]
221    fn matches_published_definition() {
222        let closes = [
223            102.0, 107.0, 108.0, 110.0, 114.0, 112.0, 117.0, 119.0, 121.0, 124.0,
224        ];
225        let volumes = [
226            1000.0, 1500.0, 1200.0, 2000.0, 1800.0, 1400.0, 2500.0, 1600.0, 1300.0, 1700.0,
227        ];
228        let expected: [Option<[f64; 3]>; 10] = [
229            None,
230            None,
231            None,
232            None,
233            Some([1.9866666666666646, 1.9866666666666646, 0.0]),
234            Some([1.5306718597857838, 1.7586692632262242, -0.2279974034404404]),
235            Some([2.0043366844076473, 1.8815029738169358, 0.12283371059071158]),
236            Some([
237                1.8037145650048956,
238                1.8426087694109157,
239                -0.038894204406020094,
240            ]),
241            Some([2.0206718346253183, 1.931640302018117, 0.08903153260720131]),
242            Some([2.8483375959079353, 2.389988948963026, 0.4583486469449092]),
243        ];
244        let mut study = VolumeWeightedMACD::new(3, 5, 3);
245        study.calculate(&bars(&closes, &volumes));
246        assert_eq!(study.values().len(), expected.len());
247        for (bar, want) in expected.iter().enumerate() {
248            let got = lines(&study, bar);
249            match want {
250                None => assert!(got.is_none(), "bar {bar}: {got:?}"),
251                Some(want) => {
252                    let got = got.unwrap_or_else(|| panic!("bar {bar}: no value"));
253                    for (g, w) in got.iter().zip(want) {
254                        assert!((g - w).abs() < 1e-12, "bar {bar}: {got:?} != {want:?}");
255                    }
256                }
257            }
258        }
259
260        // With constant volume the line is SMA(12) - SMA(26), 7 on a trend rising by 1 per bar,
261        // whatever the length of the history.
262        let closes: Vec<f64> = (0..200).map(|i| 100.0 + i as f64).collect();
263        let mut study = VolumeWeightedMACD::default();
264        study.calculate(&bars(&closes, &[1000.0; 200]));
265        assert!(lines(&study, 24).is_none());
266        for bar in 25..200 {
267            let line = lines(&study, bar).map(|v| v[0]);
268            assert!(
269                line.is_some_and(|l| (l - 7.0).abs() < 1e-9),
270                "bar {bar}: {line:?}"
271            );
272        }
273    }
274}