Skip to main content

nautilus_indicators/momentum/
macd.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::fmt::Display;
17
18use nautilus_core::correctness::FAILED;
19use nautilus_model::{
20    data::{Bar, QuoteTick, TradeTick},
21    enums::PriceType,
22};
23
24use crate::{
25    average::{MovingAverageFactory, MovingAverageType},
26    indicator::{Indicator, MovingAverage},
27    support::MAX_PERIOD,
28};
29
30/// Moving average convergence/divergence, signal, and histogram.
31#[repr(C)]
32#[derive(Debug)]
33#[cfg_attr(
34    feature = "python",
35    pyo3::pyclass(module = "nautilus_trader.indicators", unsendable)
36)]
37#[cfg_attr(
38    feature = "python",
39    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
40)]
41pub struct MovingAverageConvergenceDivergence {
42    pub fast_period: usize,
43    pub slow_period: usize,
44    pub signal_period: usize,
45    pub ma_type: MovingAverageType,
46    pub count: usize,
47    pub price_type: PriceType,
48    pub value: f64,
49    pub signal: f64,
50    pub histogram: f64,
51    pub initialized: bool,
52    has_inputs: bool,
53    fast_ma: Box<dyn MovingAverage + Send + 'static>,
54    slow_ma: Box<dyn MovingAverage + Send + 'static>,
55    signal_ma: Box<dyn MovingAverage + Send + 'static>,
56}
57
58impl Display for MovingAverageConvergenceDivergence {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        write!(
61            f,
62            "{}({},{},{},{},{})",
63            self.name(),
64            self.fast_period,
65            self.slow_period,
66            self.signal_period,
67            self.ma_type,
68            self.price_type
69        )
70    }
71}
72
73impl Indicator for MovingAverageConvergenceDivergence {
74    fn name(&self) -> String {
75        stringify!(MovingAverageConvergenceDivergence).to_string()
76    }
77
78    fn has_inputs(&self) -> bool {
79        self.has_inputs
80    }
81
82    fn initialized(&self) -> bool {
83        self.initialized
84    }
85
86    fn handle_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
87        self.update_raw(quote.extract_price(self.price_type)?.into());
88        Ok(())
89    }
90
91    fn handle_trade(&mut self, trade: &TradeTick) {
92        self.update_raw((&trade.price).into());
93    }
94
95    fn handle_bar(&mut self, bar: &Bar) {
96        self.update_raw((&bar.close).into());
97    }
98
99    fn reset(&mut self) {
100        self.value = 0.0;
101        self.signal = 0.0;
102        self.histogram = 0.0;
103        self.count = 0;
104        self.fast_ma.reset();
105        self.slow_ma.reset();
106        self.signal_ma.reset();
107        self.has_inputs = false;
108        self.initialized = false;
109    }
110}
111
112impl MovingAverageConvergenceDivergence {
113    /// Creates a new [`MovingAverageConvergenceDivergence`] instance.
114    ///
115    /// The defaults are the standard MACD convention: exponential smoothing
116    /// with a 9 period signal line.
117    ///
118    /// # Panics
119    ///
120    /// Panics if `fast_period` is zero or not less than `slow_period`, or if
121    /// `signal_period` is zero.
122    #[must_use]
123    pub fn new(
124        fast_period: usize,
125        slow_period: usize,
126        signal_period: Option<usize>,
127        ma_type: Option<MovingAverageType>,
128        price_type: Option<PriceType>,
129    ) -> Self {
130        Self::new_checked(fast_period, slow_period, signal_period, ma_type, price_type)
131            .expect(FAILED)
132    }
133
134    pub(crate) fn new_checked(
135        fast_period: usize,
136        slow_period: usize,
137        signal_period: Option<usize>,
138        ma_type: Option<MovingAverageType>,
139        price_type: Option<PriceType>,
140    ) -> anyhow::Result<Self> {
141        anyhow::ensure!(
142            fast_period <= MAX_PERIOD,
143            "fast_period cannot exceed {MAX_PERIOD}"
144        );
145        anyhow::ensure!(
146            slow_period <= MAX_PERIOD,
147            "slow_period cannot exceed {MAX_PERIOD}"
148        );
149        anyhow::ensure!(
150            fast_period > 0 && fast_period < slow_period,
151            "MovingAverageConvergenceDivergence: fast_period must be > 0 and < slow_period (received fast {fast_period}, slow {slow_period})"
152        );
153        let signal_period = signal_period.unwrap_or(9);
154        anyhow::ensure!(
155            signal_period <= MAX_PERIOD,
156            "signal_period cannot exceed {MAX_PERIOD}"
157        );
158        anyhow::ensure!(
159            signal_period > 0,
160            "MovingAverageConvergenceDivergence: signal_period must be > 0 (received {signal_period})"
161        );
162        let ma_type = ma_type.unwrap_or(MovingAverageType::Exponential);
163
164        Ok(Self {
165            fast_period,
166            slow_period,
167            signal_period,
168            ma_type,
169            price_type: price_type.unwrap_or(PriceType::Last),
170            value: 0.0,
171            signal: 0.0,
172            histogram: 0.0,
173            count: 0,
174            initialized: false,
175            has_inputs: false,
176            fast_ma: MovingAverageFactory::create(ma_type, fast_period),
177            slow_ma: MovingAverageFactory::create(ma_type, slow_period),
178            signal_ma: MovingAverageFactory::create(ma_type, signal_period),
179        })
180    }
181}
182
183impl MovingAverage for MovingAverageConvergenceDivergence {
184    fn value(&self) -> f64 {
185        self.value
186    }
187
188    fn count(&self) -> usize {
189        self.count
190    }
191
192    fn update_raw(&mut self, close: f64) {
193        if !close.is_finite() {
194            return;
195        }
196        self.fast_ma.update_raw(close);
197        self.slow_ma.update_raw(close);
198        self.count += 1;
199
200        self.has_inputs = true;
201
202        if self.fast_ma.initialized() && self.slow_ma.initialized() {
203            let value = self.fast_ma.value() - self.slow_ma.value();
204            self.signal_ma.update_raw(value);
205            self.initialized = self.signal_ma.initialized();
206            if self.initialized {
207                self.value = value;
208                self.signal = self.signal_ma.value();
209                self.histogram = self.value - self.signal;
210            }
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use nautilus_model::data::{Bar, QuoteTick, TradeTick};
218    use rstest::rstest;
219
220    use crate::{
221        average::MovingAverageType,
222        indicator::{Indicator, MovingAverage},
223        momentum::macd::MovingAverageConvergenceDivergence,
224        stubs::*,
225    };
226
227    #[rstest]
228    fn test_macd_initialized(macd_10: MovingAverageConvergenceDivergence) {
229        let display_st = format!("{macd_10}");
230        assert_eq!(
231            display_st,
232            "MovingAverageConvergenceDivergence(8,10,9,SIMPLE,BID)"
233        );
234        assert_eq!(macd_10.fast_period, 8);
235        assert_eq!(macd_10.slow_period, 10);
236        assert_eq!(macd_10.signal_period, 9);
237        assert!(!macd_10.initialized());
238        assert!(!macd_10.has_inputs());
239    }
240
241    #[rstest]
242    fn test_initialized_with_required_input(mut macd_10: MovingAverageConvergenceDivergence) {
243        // Composite warmup is slow_period + signal_period - 1 = 18 inputs
244        for i in 1..18 {
245            macd_10.update_raw(f64::from(i));
246            assert!(!macd_10.initialized);
247        }
248        macd_10.update_raw(18.0);
249        assert!(macd_10.initialized);
250    }
251
252    #[rstest]
253    #[case(MovingAverageType::Simple, 3)]
254    #[case(MovingAverageType::Exponential, 3)]
255    #[case(MovingAverageType::DoubleExponential, 5)]
256    #[case(MovingAverageType::Wilder, 3)]
257    #[case(MovingAverageType::Hull, 3)]
258    fn test_composite_warmup_uses_selected_ma(
259        #[case] ma_type: MovingAverageType,
260        #[case] warmup: usize,
261    ) {
262        let mut macd = MovingAverageConvergenceDivergence::new(1, 2, Some(2), Some(ma_type), None);
263
264        for i in 1..warmup {
265            macd.update_raw(i as f64);
266            assert!(!macd.initialized(), "initialized at input {i}");
267        }
268
269        macd.update_raw(warmup as f64);
270        assert!(macd.initialized());
271    }
272
273    #[rstest]
274    fn test_hull_signal_waits_for_full_warmup() {
275        let mut macd = MovingAverageConvergenceDivergence::new(
276            1,
277            2,
278            Some(20),
279            Some(MovingAverageType::Hull),
280            None,
281        );
282
283        for i in 1..24 {
284            macd.update_raw(f64::from(i));
285            assert!(!macd.initialized(), "initialized at input {i}");
286        }
287
288        macd.update_raw(24.0);
289        assert!(macd.initialized());
290    }
291
292    #[rstest]
293    fn test_value_with_one_input(mut macd_10: MovingAverageConvergenceDivergence) {
294        macd_10.update_raw(1.0);
295        assert_eq!(macd_10.value, 0.0);
296    }
297
298    #[rstest]
299    fn test_value_with_three_inputs(mut macd_10: MovingAverageConvergenceDivergence) {
300        macd_10.update_raw(1.0);
301        macd_10.update_raw(2.0);
302        macd_10.update_raw(3.0);
303        assert_eq!(macd_10.value, 0.0);
304    }
305
306    #[rstest]
307    fn test_value_before_signal_ready(mut macd_10: MovingAverageConvergenceDivergence) {
308        macd_10.update_raw(1.00000);
309        macd_10.update_raw(1.00010);
310        macd_10.update_raw(1.00020);
311        macd_10.update_raw(1.00030);
312        macd_10.update_raw(1.00040);
313        macd_10.update_raw(1.00050);
314        macd_10.update_raw(1.00040);
315        macd_10.update_raw(1.00030);
316        macd_10.update_raw(1.00020);
317        macd_10.update_raw(1.00010);
318        macd_10.update_raw(1.00000);
319        assert_eq!(
320            (macd_10.value, macd_10.signal, macd_10.histogram),
321            (0.0, 0.0, 0.0)
322        );
323        assert!(!macd_10.initialized);
324    }
325
326    #[rstest]
327    fn test_handle_quote_tick(
328        mut macd_10: MovingAverageConvergenceDivergence,
329        stub_quote: QuoteTick,
330    ) {
331        macd_10.handle_quote(&stub_quote).unwrap();
332        assert_eq!(macd_10.value, 0.0);
333    }
334
335    #[rstest]
336    fn test_handle_trade_tick(
337        mut macd_10: MovingAverageConvergenceDivergence,
338        stub_trade: TradeTick,
339    ) {
340        macd_10.handle_trade(&stub_trade);
341        assert_eq!(macd_10.value, 0.0);
342    }
343
344    #[rstest]
345    fn test_handle_bar(
346        mut macd_10: MovingAverageConvergenceDivergence,
347        bar_ethusdt_binance_minute_bid: Bar,
348    ) {
349        macd_10.handle_bar(&bar_ethusdt_binance_minute_bid);
350        assert_eq!(macd_10.value, 0.0);
351        assert!(!macd_10.initialized);
352    }
353
354    #[rstest]
355    fn test_reset(mut macd_10: MovingAverageConvergenceDivergence) {
356        macd_10.update_raw(1.0);
357        macd_10.reset();
358        assert_eq!(macd_10.value, 0.0);
359        assert_eq!(macd_10.signal, 0.0);
360        assert_eq!(macd_10.histogram, 0.0);
361        assert_eq!(macd_10.count, 0);
362        assert_eq!(macd_10.fast_ma.value(), 0.0);
363        assert_eq!(macd_10.slow_ma.value(), 0.0);
364        assert!(!macd_10.has_inputs);
365        assert!(!macd_10.initialized);
366    }
367
368    #[rstest]
369    fn count_matches_inputs(mut macd_10: MovingAverageConvergenceDivergence) {
370        assert_eq!(macd_10.count(), 0);
371
372        for i in 1..=12 {
373            macd_10.update_raw(f64::from(i));
374            assert_eq!(macd_10.count(), i as usize);
375        }
376    }
377}