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_model::data::{Bar, QuoteTick, TradeTick};
20
21use crate::{
22    average::{MovingAverageFactory, MovingAverageType},
23    indicator::{Indicator, MovingAverage},
24};
25
26const DEFAULT_MA_TYPE: MovingAverageType = MovingAverageType::Exponential;
27const MAX_SIGNAL: usize = 1_024;
28
29type SignalBuf = ArrayDeque<f64, { MAX_SIGNAL + 1 }, Wrapping>;
30
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 ArcherMovingAveragesTrends {
42    pub fast_period: usize,
43    pub slow_period: usize,
44    pub signal_period: usize,
45    pub ma_type: MovingAverageType,
46    pub long_run: bool,
47    pub short_run: bool,
48    pub initialized: bool,
49    fast_ma: Box<dyn MovingAverage + Send + 'static>,
50    slow_ma: Box<dyn MovingAverage + Send + 'static>,
51    fast_ma_price: SignalBuf,
52    slow_ma_price: SignalBuf,
53    has_inputs: bool,
54}
55
56impl Display for ArcherMovingAveragesTrends {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        write!(
59            f,
60            "{}({},{},{},{})",
61            self.name(),
62            self.fast_period,
63            self.slow_period,
64            self.signal_period,
65            self.ma_type,
66        )
67    }
68}
69
70impl Indicator for ArcherMovingAveragesTrends {
71    fn name(&self) -> String {
72        stringify!(ArcherMovingAveragesTrends).into()
73    }
74
75    fn has_inputs(&self) -> bool {
76        self.has_inputs
77    }
78
79    fn initialized(&self) -> bool {
80        self.initialized
81    }
82
83    fn handle_quote(&mut self, _quote: &QuoteTick) -> anyhow::Result<()> {
84        Ok(())
85    }
86
87    fn handle_trade(&mut self, _trade: &TradeTick) {}
88
89    fn handle_bar(&mut self, bar: &Bar) {
90        self.update_raw(bar.close.into());
91    }
92
93    fn reset(&mut self) {
94        self.fast_ma.reset();
95        self.slow_ma.reset();
96        self.long_run = false;
97        self.short_run = false;
98        self.fast_ma_price.clear();
99        self.slow_ma_price.clear();
100        self.has_inputs = false;
101        self.initialized = false;
102    }
103}
104
105impl ArcherMovingAveragesTrends {
106    /// Creates a new [`ArcherMovingAveragesTrends`] instance.
107    ///
108    /// # Panics
109    ///
110    /// This function panics if:
111    /// `fast_period`*, *`slow_period`* or *`signal_period`* is 0.
112    /// `slow_period`* ≤ *`fast_period`*.
113    /// `signal_period`* > `MAX_SIGNAL`.
114    #[must_use]
115    pub fn new(
116        fast_period: usize,
117        slow_period: usize,
118        signal_period: usize,
119        ma_type: Option<MovingAverageType>,
120    ) -> Self {
121        assert!(
122            fast_period > 0,
123            "fast_period must be positive (received {fast_period})"
124        );
125        assert!(
126            slow_period > 0,
127            "slow_period must be positive (received {slow_period})"
128        );
129        assert!(
130            signal_period > 0,
131            "signal_period must be positive (received {signal_period})"
132        );
133        assert!(
134            slow_period > fast_period,
135            "slow_period ({slow_period}) must be greater than fast_period ({fast_period})"
136        );
137        assert!(
138            signal_period <= MAX_SIGNAL,
139            "signal_period ({signal_period}) must not exceed MAX_SIGNAL ({MAX_SIGNAL})"
140        );
141
142        let ma_type = ma_type.unwrap_or(DEFAULT_MA_TYPE);
143
144        Self {
145            fast_period,
146            slow_period,
147            signal_period,
148            ma_type,
149            long_run: false,
150            short_run: false,
151            fast_ma: MovingAverageFactory::create(ma_type, fast_period),
152            slow_ma: MovingAverageFactory::create(ma_type, slow_period),
153            fast_ma_price: SignalBuf::new(),
154            slow_ma_price: SignalBuf::new(),
155            has_inputs: false,
156            initialized: false,
157        }
158    }
159
160    /// Updates the indicator with a new raw price value.
161    ///
162    /// # Panics
163    /// This method will panic if the `slow_ma` is not initialized yet.
164    pub fn update_raw(&mut self, close: f64) {
165        self.fast_ma.update_raw(close);
166        self.slow_ma.update_raw(close);
167
168        if self.slow_ma.initialized() {
169            self.fast_ma_price.push_back(self.fast_ma.value());
170            self.slow_ma_price.push_back(self.slow_ma.value());
171
172            let max_len = self.signal_period + 1;
173            if self.fast_ma_price.len() > max_len {
174                self.fast_ma_price.pop_front();
175                self.slow_ma_price.pop_front();
176            }
177
178            let fast_back = self.fast_ma.value();
179            let fast_front = *self
180                .fast_ma_price
181                .front()
182                .expect("buffer has at least one element");
183
184            let fast_diff = fast_back - fast_front;
185            self.long_run = fast_diff > 0.0 || self.long_run;
186            self.short_run = fast_diff < 0.0 || self.short_run;
187        }
188
189        if !self.initialized {
190            self.has_inputs = true;
191            let max_len = self.signal_period + 1;
192            if self.slow_ma_price.len() == max_len && self.slow_ma.initialized() {
193                self.initialized = true;
194            }
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use rstest::rstest;
202
203    use super::*;
204    use crate::stubs::amat_345;
205
206    fn make(fast: usize, slow: usize, signal: usize) {
207        let _ = ArcherMovingAveragesTrends::new(fast, slow, signal, None);
208    }
209
210    #[rstest]
211    fn default_ma_type_is_exponential() {
212        let ind = ArcherMovingAveragesTrends::new(3, 4, 5, None);
213        assert_eq!(ind.ma_type, MovingAverageType::Exponential);
214    }
215
216    #[rstest]
217    fn test_name_returns_expected_string(amat_345: ArcherMovingAveragesTrends) {
218        assert_eq!(amat_345.name(), "ArcherMovingAveragesTrends");
219    }
220
221    #[rstest]
222    fn test_str_repr_returns_expected_string(amat_345: ArcherMovingAveragesTrends) {
223        assert_eq!(
224            format!("{amat_345}"),
225            "ArcherMovingAveragesTrends(3,4,5,SIMPLE)"
226        );
227    }
228
229    #[rstest]
230    fn test_period_returns_expected_value(amat_345: ArcherMovingAveragesTrends) {
231        assert_eq!(amat_345.fast_period, 3);
232        assert_eq!(amat_345.slow_period, 4);
233        assert_eq!(amat_345.signal_period, 5);
234    }
235
236    #[rstest]
237    fn test_initialized_without_inputs_returns_false(amat_345: ArcherMovingAveragesTrends) {
238        assert!(!amat_345.initialized());
239    }
240
241    #[rstest]
242    #[should_panic(expected = "fast_period must be positive")]
243    fn new_panics_on_zero_fast_period() {
244        make(0, 4, 5);
245    }
246
247    #[rstest]
248    #[should_panic(expected = "slow_period must be positive")]
249    fn new_panics_on_zero_slow_period() {
250        make(3, 0, 5);
251    }
252
253    #[rstest]
254    #[should_panic(expected = "signal_period must be positive")]
255    fn new_panics_on_zero_signal_period() {
256        make(3, 5, 0);
257    }
258
259    #[rstest]
260    #[should_panic(expected = "slow_period (3) must be greater than fast_period (3)")]
261    fn new_panics_when_slow_not_greater_than_fast() {
262        make(3, 3, 5);
263    }
264
265    #[rstest]
266    #[should_panic(expected = "slow_period (2) must be greater than fast_period (3)")]
267    fn new_panics_when_slow_less_than_fast() {
268        make(3, 2, 5);
269    }
270
271    fn feed_sequence(ind: &mut ArcherMovingAveragesTrends, start: i64, count: usize, step: i64) {
272        (0..count).for_each(|i| ind.update_raw((start + i as i64 * step) as f64));
273    }
274
275    #[rstest]
276    fn buffer_len_never_exceeds_signal_plus_one() {
277        let mut ind = ArcherMovingAveragesTrends::new(3, 4, 5, None);
278        feed_sequence(&mut ind, 0, 100, 1);
279        assert_eq!(ind.fast_ma_price.len(), ind.signal_period + 1);
280        assert_eq!(ind.slow_ma_price.len(), ind.signal_period + 1);
281    }
282
283    #[rstest]
284    fn initialized_becomes_true_after_slow_ready_and_buffer_full() {
285        let mut ind = ArcherMovingAveragesTrends::new(3, 4, 5, None);
286        feed_sequence(&mut ind, 0, 11, 1); // 11 > 4+6
287        assert!(ind.initialized());
288    }
289
290    #[rstest]
291    fn long_run_flag_sets_on_bullish_trend() {
292        let mut ind = ArcherMovingAveragesTrends::new(3, 4, 5, None);
293        feed_sequence(&mut ind, 0, 60, 1);
294        assert!(ind.long_run, "Expected long_run=TRUE on up-trend");
295        assert!(!ind.short_run, "short_run should remain FALSE here");
296    }
297
298    #[rstest]
299    fn short_run_flag_sets_on_bearish_trend() {
300        let mut ind = ArcherMovingAveragesTrends::new(3, 4, 5, None);
301        feed_sequence(&mut ind, 100, 60, -1);
302        assert!(ind.short_run, "Expected short_run=TRUE on down-trend");
303        assert!(!ind.long_run, "long_run should remain FALSE here");
304    }
305
306    #[rstest]
307    fn reset_clears_internal_state() {
308        let mut ind = ArcherMovingAveragesTrends::new(3, 4, 5, None);
309        feed_sequence(&mut ind, 0, 50, 1);
310        assert!(ind.long_run || ind.short_run);
311        assert!(!ind.fast_ma_price.is_empty());
312
313        ind.reset();
314
315        assert!(!ind.long_run && !ind.short_run);
316        assert_eq!(ind.fast_ma_price.len(), 0);
317        assert_eq!(ind.slow_ma_price.len(), 0);
318        assert!(!ind.initialized());
319        assert!(!ind.has_inputs());
320    }
321
322    #[rstest]
323    #[should_panic(expected = "signal_period (1025) must not exceed MAX_SIGNAL (1024)")]
324    fn new_panics_when_signal_exceeds_max() {
325        let _ = ArcherMovingAveragesTrends::new(3, 4, MAX_SIGNAL + 1, None);
326    }
327
328    #[rstest]
329    fn ma_type_override_is_respected() {
330        let ind = ArcherMovingAveragesTrends::new(3, 4, 5, Some(MovingAverageType::Simple));
331        assert_eq!(ind.ma_type, MovingAverageType::Simple);
332    }
333}