Skip to main content

nautilus_indicators/momentum/
amat.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::{Debug, Display};
17
18use arraydeque::{ArrayDeque, Wrapping};
19use nautilus_core::correctness::FAILED;
20use nautilus_model::data::{Bar, QuoteTick, TradeTick};
21
22use crate::{
23    average::{MovingAverageFactory, MovingAverageType},
24    indicator::{Indicator, MovingAverage},
25    support::MAX_PERIOD,
26};
27
28const DEFAULT_MA_TYPE: MovingAverageType = MovingAverageType::Exponential;
29const MAX_SIGNAL: usize = 1_024;
30
31type SignalBuf = ArrayDeque<f64, { MAX_SIGNAL + 1 }, Wrapping>;
32
33#[repr(C)]
34#[derive(Debug)]
35#[cfg_attr(
36    feature = "python",
37    pyo3::pyclass(module = "nautilus_trader.indicators", unsendable)
38)]
39#[cfg_attr(
40    feature = "python",
41    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
42)]
43pub struct ArcherMovingAveragesTrends {
44    pub fast_period: usize,
45    pub slow_period: usize,
46    pub signal_period: usize,
47    pub ma_type: MovingAverageType,
48    pub long_run: bool,
49    pub short_run: bool,
50    pub initialized: bool,
51    fast_ma: Box<dyn MovingAverage + Send + 'static>,
52    slow_ma: Box<dyn MovingAverage + Send + 'static>,
53    fast_ma_price: SignalBuf,
54    slow_ma_price: SignalBuf,
55    has_inputs: bool,
56}
57
58impl Display for ArcherMovingAveragesTrends {
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        )
69    }
70}
71
72impl Indicator for ArcherMovingAveragesTrends {
73    fn name(&self) -> String {
74        stringify!(ArcherMovingAveragesTrends).into()
75    }
76
77    fn has_inputs(&self) -> bool {
78        self.has_inputs
79    }
80
81    fn initialized(&self) -> bool {
82        self.initialized
83    }
84
85    fn handle_quote(&mut self, _quote: &QuoteTick) -> anyhow::Result<()> {
86        Ok(())
87    }
88
89    fn handle_trade(&mut self, _trade: &TradeTick) {}
90
91    fn handle_bar(&mut self, bar: &Bar) {
92        self.update_raw(bar.close.into());
93    }
94
95    fn reset(&mut self) {
96        self.fast_ma.reset();
97        self.slow_ma.reset();
98        self.long_run = false;
99        self.short_run = false;
100        self.fast_ma_price.clear();
101        self.slow_ma_price.clear();
102        self.has_inputs = false;
103        self.initialized = false;
104    }
105}
106
107impl ArcherMovingAveragesTrends {
108    /// Creates a new [`ArcherMovingAveragesTrends`] instance.
109    ///
110    /// # Panics
111    ///
112    /// Panics if:
113    /// - Any period is zero.
114    /// - `slow_period` is not greater than `fast_period`.
115    /// - `signal_period` exceeds the fixed signal buffer limit.
116    /// - `slow_period` exceeds the supported indicator period limit.
117    #[must_use]
118    pub fn new(
119        fast_period: usize,
120        slow_period: usize,
121        signal_period: usize,
122        ma_type: Option<MovingAverageType>,
123    ) -> Self {
124        Self::new_checked(fast_period, slow_period, signal_period, ma_type).expect(FAILED)
125    }
126
127    pub(crate) fn new_checked(
128        fast_period: usize,
129        slow_period: usize,
130        signal_period: usize,
131        ma_type: Option<MovingAverageType>,
132    ) -> anyhow::Result<Self> {
133        anyhow::ensure!(
134            fast_period > 0,
135            "fast_period must be positive (received {fast_period})"
136        );
137        anyhow::ensure!(
138            slow_period > 0,
139            "slow_period must be positive (received {slow_period})"
140        );
141        anyhow::ensure!(
142            signal_period > 0,
143            "signal_period must be positive (received {signal_period})"
144        );
145        anyhow::ensure!(
146            slow_period > fast_period,
147            "slow_period ({slow_period}) must be greater than fast_period ({fast_period})"
148        );
149        anyhow::ensure!(
150            signal_period <= MAX_SIGNAL,
151            "signal_period ({signal_period}) must not exceed MAX_SIGNAL ({MAX_SIGNAL})"
152        );
153
154        anyhow::ensure!(
155            slow_period <= MAX_PERIOD,
156            "slow_period cannot exceed {MAX_PERIOD}"
157        );
158
159        let ma_type = ma_type.unwrap_or(DEFAULT_MA_TYPE);
160
161        Ok(Self {
162            fast_period,
163            slow_period,
164            signal_period,
165            ma_type,
166            long_run: false,
167            short_run: false,
168            fast_ma: MovingAverageFactory::create(ma_type, fast_period),
169            slow_ma: MovingAverageFactory::create(ma_type, slow_period),
170            fast_ma_price: SignalBuf::new(),
171            slow_ma_price: SignalBuf::new(),
172            has_inputs: false,
173            initialized: false,
174        })
175    }
176
177    /// Updates the indicator with a new raw price value.
178    ///
179    /// # Panics
180    /// Panics if the `slow_ma` is not initialized yet.
181    pub fn update_raw(&mut self, close: f64) {
182        self.fast_ma.update_raw(close);
183        self.slow_ma.update_raw(close);
184
185        if self.slow_ma.initialized() {
186            self.fast_ma_price.push_back(self.fast_ma.value());
187            self.slow_ma_price.push_back(self.slow_ma.value());
188
189            let max_len = self.signal_period + 1;
190            if self.fast_ma_price.len() > max_len {
191                self.fast_ma_price.pop_front();
192                self.slow_ma_price.pop_front();
193            }
194
195            let fast_back = self.fast_ma.value();
196            let fast_front = *self
197                .fast_ma_price
198                .front()
199                .expect("buffer has at least one element");
200
201            let fast_diff = fast_back - fast_front;
202            self.long_run = fast_diff > 0.0;
203            self.short_run = fast_diff < 0.0;
204        }
205
206        if !self.initialized {
207            self.has_inputs = true;
208            let max_len = self.signal_period + 1;
209            if self.slow_ma_price.len() == max_len && self.slow_ma.initialized() {
210                self.initialized = true;
211            }
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use rstest::rstest;
219
220    use super::*;
221    use crate::stubs::amat_345;
222
223    fn make(fast: usize, slow: usize, signal: usize) {
224        let _ = ArcherMovingAveragesTrends::new(fast, slow, signal, None);
225    }
226
227    #[rstest]
228    #[case(0, 2, 1, "fast_period must be positive (received 0)")]
229    #[case(1, 0, 1, "slow_period must be positive (received 0)")]
230    #[case(1, 2, 0, "signal_period must be positive (received 0)")]
231    #[case(2, 2, 1, "slow_period (2) must be greater than fast_period (2)")]
232    #[case(1, 2, MAX_SIGNAL + 1, "signal_period (1025) must not exceed MAX_SIGNAL (1024)")]
233    #[case(1, MAX_PERIOD + 1, 1, "slow_period cannot exceed 16777216")]
234    #[case(usize::MAX - 1, usize::MAX, 1, "slow_period cannot exceed 16777216")]
235    fn test_checked_constructor_rejects_invalid_periods(
236        #[case] fast: usize,
237        #[case] slow: usize,
238        #[case] signal: usize,
239        #[case] message: &str,
240        #[values(
241            MovingAverageType::Simple,
242            MovingAverageType::Exponential,
243            MovingAverageType::DoubleExponential,
244            MovingAverageType::Wilder,
245            MovingAverageType::Hull
246        )]
247        ma_type: MovingAverageType,
248    ) {
249        let error =
250            ArcherMovingAveragesTrends::new_checked(fast, slow, signal, Some(ma_type)).unwrap_err();
251        assert_eq!(error.to_string(), message);
252    }
253
254    #[rstest]
255    fn test_checked_constructor_accepts_maximum_periods() {
256        let ind =
257            ArcherMovingAveragesTrends::new_checked(MAX_PERIOD - 1, MAX_PERIOD, MAX_SIGNAL, None)
258                .unwrap();
259        assert_eq!(
260            (ind.fast_period, ind.slow_period, ind.signal_period),
261            (MAX_PERIOD - 1, MAX_PERIOD, MAX_SIGNAL)
262        );
263        assert_eq!(ind.ma_type, MovingAverageType::Exponential);
264        assert!(!ind.initialized());
265        assert!(!ind.has_inputs());
266        assert!(!ind.long_run);
267        assert!(!ind.short_run);
268    }
269
270    #[rstest]
271    fn default_ma_type_is_exponential() {
272        let ind = ArcherMovingAveragesTrends::new(3, 4, 5, None);
273        assert_eq!(ind.ma_type, MovingAverageType::Exponential);
274    }
275
276    #[rstest]
277    fn test_name_returns_expected_string(amat_345: ArcherMovingAveragesTrends) {
278        assert_eq!(amat_345.name(), "ArcherMovingAveragesTrends");
279    }
280
281    #[rstest]
282    fn test_str_repr_returns_expected_string(amat_345: ArcherMovingAveragesTrends) {
283        assert_eq!(
284            format!("{amat_345}"),
285            "ArcherMovingAveragesTrends(3,4,5,SIMPLE)"
286        );
287    }
288
289    #[rstest]
290    fn test_period_returns_expected_value(amat_345: ArcherMovingAveragesTrends) {
291        assert_eq!(amat_345.fast_period, 3);
292        assert_eq!(amat_345.slow_period, 4);
293        assert_eq!(amat_345.signal_period, 5);
294    }
295
296    #[rstest]
297    fn test_initialized_without_inputs_returns_false(amat_345: ArcherMovingAveragesTrends) {
298        assert!(!amat_345.initialized());
299    }
300
301    #[rstest]
302    #[should_panic(expected = "fast_period must be positive")]
303    fn new_panics_on_zero_fast_period() {
304        make(0, 4, 5);
305    }
306
307    #[rstest]
308    #[should_panic(expected = "slow_period must be positive")]
309    fn new_panics_on_zero_slow_period() {
310        make(3, 0, 5);
311    }
312
313    #[rstest]
314    #[should_panic(expected = "signal_period must be positive")]
315    fn new_panics_on_zero_signal_period() {
316        make(3, 5, 0);
317    }
318
319    #[rstest]
320    #[should_panic(expected = "slow_period (3) must be greater than fast_period (3)")]
321    fn new_panics_when_slow_not_greater_than_fast() {
322        make(3, 3, 5);
323    }
324
325    #[rstest]
326    #[should_panic(expected = "slow_period (2) must be greater than fast_period (3)")]
327    fn new_panics_when_slow_less_than_fast() {
328        make(3, 2, 5);
329    }
330
331    fn feed_sequence(ind: &mut ArcherMovingAveragesTrends, start: i64, count: usize, step: i64) {
332        (0..count).for_each(|i| ind.update_raw((start + i as i64 * step) as f64));
333    }
334
335    #[rstest]
336    fn buffer_len_never_exceeds_signal_plus_one() {
337        let mut ind = ArcherMovingAveragesTrends::new(3, 4, 5, None);
338        feed_sequence(&mut ind, 0, 100, 1);
339        assert_eq!(ind.fast_ma_price.len(), ind.signal_period + 1);
340        assert_eq!(ind.slow_ma_price.len(), ind.signal_period + 1);
341    }
342
343    #[rstest]
344    fn initialized_becomes_true_after_slow_ready_and_buffer_full() {
345        let mut ind = ArcherMovingAveragesTrends::new(3, 4, 5, None);
346        feed_sequence(&mut ind, 0, 11, 1); // 11 > 4+6
347        assert!(ind.initialized());
348    }
349
350    #[rstest]
351    fn long_run_flag_sets_on_bullish_trend() {
352        let mut ind = ArcherMovingAveragesTrends::new(3, 4, 5, None);
353        feed_sequence(&mut ind, 0, 60, 1);
354        assert!(ind.long_run, "Expected long_run=TRUE on up-trend");
355        assert!(!ind.short_run, "short_run should remain FALSE here");
356    }
357
358    #[rstest]
359    fn short_run_flag_sets_on_bearish_trend() {
360        let mut ind = ArcherMovingAveragesTrends::new(3, 4, 5, None);
361        feed_sequence(&mut ind, 100, 60, -1);
362        assert!(ind.short_run, "Expected short_run=TRUE on down-trend");
363        assert!(!ind.long_run, "long_run should remain FALSE here");
364    }
365
366    #[rstest]
367    fn long_run_clears_after_sustained_bearish_reversal() {
368        let mut ind = ArcherMovingAveragesTrends::new(3, 4, 5, Some(MovingAverageType::Simple));
369
370        for value in 0..60 {
371            ind.update_raw(f64::from(value));
372        }
373
374        assert!(ind.initialized());
375        assert!(ind.long_run);
376        assert!(!ind.short_run);
377
378        for value in (20..60).rev() {
379            ind.update_raw(f64::from(value));
380        }
381
382        assert!(!ind.long_run);
383        assert!(ind.short_run);
384    }
385
386    #[rstest]
387    fn short_run_clears_after_sustained_bullish_reversal() {
388        let mut ind = ArcherMovingAveragesTrends::new(3, 4, 5, Some(MovingAverageType::Simple));
389
390        for value in (0..60).rev() {
391            ind.update_raw(f64::from(value));
392        }
393
394        assert!(ind.initialized());
395        assert!(ind.short_run);
396        assert!(!ind.long_run);
397
398        for value in 20..60 {
399            ind.update_raw(f64::from(value));
400        }
401
402        assert!(ind.long_run);
403        assert!(!ind.short_run);
404    }
405
406    #[rstest]
407    fn reset_clears_internal_state() {
408        let mut ind = ArcherMovingAveragesTrends::new(3, 4, 5, None);
409        feed_sequence(&mut ind, 0, 50, 1);
410        assert!(ind.long_run || ind.short_run);
411        assert!(!ind.fast_ma_price.is_empty());
412
413        ind.reset();
414
415        assert!(!ind.long_run && !ind.short_run);
416        assert_eq!(ind.fast_ma_price.len(), 0);
417        assert_eq!(ind.slow_ma_price.len(), 0);
418        assert!(!ind.initialized());
419        assert!(!ind.has_inputs());
420    }
421
422    #[rstest]
423    #[should_panic(expected = "signal_period (1025) must not exceed MAX_SIGNAL (1024)")]
424    fn new_panics_when_signal_exceeds_max() {
425        let _ = ArcherMovingAveragesTrends::new(3, 4, MAX_SIGNAL + 1, None);
426    }
427
428    #[rstest]
429    fn ma_type_override_is_respected() {
430        let ind = ArcherMovingAveragesTrends::new(3, 4, 5, Some(MovingAverageType::Simple));
431        assert_eq!(ind.ma_type, MovingAverageType::Simple);
432    }
433}