Skip to main content

nautilus_indicators/momentum/
aroon.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::{
17    collections::VecDeque,
18    fmt::{Debug, Display},
19};
20
21use nautilus_core::correctness::FAILED;
22use nautilus_model::{
23    data::{Bar, QuoteTick, TradeTick},
24    enums::PriceType,
25};
26
27pub use crate::support::MAX_PERIOD;
28use crate::{indicator::Indicator, support::is_valid_high_low};
29
30/// The Aroon Oscillator calculates the Aroon Up and Aroon Down indicators to
31/// determine if an instrument is trending, and the strength of the trend.
32#[repr(C)]
33#[derive(Debug)]
34#[cfg_attr(
35    feature = "python",
36    pyo3::pyclass(module = "nautilus_trader.indicators")
37)]
38#[cfg_attr(
39    feature = "python",
40    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
41)]
42pub struct AroonOscillator {
43    pub period: usize,
44    pub aroon_up: f64,
45    pub aroon_down: f64,
46    pub value: f64,
47    pub count: usize,
48    pub initialized: bool,
49    has_inputs: bool,
50    high_inputs: VecDeque<f64>,
51    low_inputs: VecDeque<f64>,
52}
53
54impl Display for AroonOscillator {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        write!(f, "{}({})", self.name(), self.period)
57    }
58}
59
60impl Indicator for AroonOscillator {
61    fn name(&self) -> String {
62        stringify!(AroonOscillator).into()
63    }
64
65    fn has_inputs(&self) -> bool {
66        self.has_inputs
67    }
68
69    fn initialized(&self) -> bool {
70        self.initialized
71    }
72
73    fn handle_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
74        let price = quote.extract_price(PriceType::Mid)?.into();
75        self.update_raw(price, price);
76        Ok(())
77    }
78
79    fn handle_trade(&mut self, trade: &TradeTick) {
80        let price: f64 = trade.price.into();
81        self.update_raw(price, price);
82    }
83
84    fn handle_bar(&mut self, bar: &Bar) {
85        let high: f64 = (&bar.high).into();
86        let low: f64 = (&bar.low).into();
87        self.update_raw(high, low);
88    }
89
90    fn reset(&mut self) {
91        self.high_inputs.clear();
92        self.low_inputs.clear();
93        self.aroon_up = 0.0;
94        self.aroon_down = 0.0;
95        self.value = 0.0;
96        self.count = 0;
97        self.has_inputs = false;
98        self.initialized = false;
99    }
100}
101
102impl AroonOscillator {
103    /// Creates a new [`AroonOscillator`] instance.
104    ///
105    /// # Panics
106    ///
107    /// Panics if `period` is not positive (> 0).
108    #[must_use]
109    pub fn new(period: usize) -> Self {
110        Self::new_checked(period).expect(FAILED)
111    }
112
113    pub(crate) fn new_checked(period: usize) -> anyhow::Result<Self> {
114        anyhow::ensure!(
115            period > 0,
116            "AroonOscillator: period must be > 0 (received {period})"
117        );
118        anyhow::ensure!(
119            period <= MAX_PERIOD,
120            "AroonOscillator: period must be ≤ {MAX_PERIOD} (received {period})"
121        );
122
123        Ok(Self {
124            period,
125            aroon_up: 0.0,
126            aroon_down: 0.0,
127            value: 0.0,
128            count: 0,
129            has_inputs: false,
130            initialized: false,
131            high_inputs: VecDeque::with_capacity(period + 1),
132            low_inputs: VecDeque::with_capacity(period + 1),
133        })
134    }
135
136    pub fn update_raw(&mut self, high: f64, low: f64) {
137        if !is_valid_high_low(high, low) {
138            return;
139        }
140
141        if self.count == self.period + 1 {
142            let _ = self.high_inputs.pop_front();
143            let _ = self.low_inputs.pop_front();
144        } else {
145            self.count += 1;
146        }
147
148        self.high_inputs.push_back(high);
149        self.low_inputs.push_back(low);
150
151        let required = self.period + 1;
152        if !self.initialized && self.count >= required {
153            self.initialized = true;
154        }
155        self.has_inputs = true;
156
157        if self.initialized {
158            self.calculate_aroon();
159        }
160    }
161
162    // Both scans cover the full `period + 1` window, and ties keep the newest
163    // extreme (the standard Aroon convention), so both lines span [0, 100].
164    fn calculate_aroon(&mut self) {
165        debug_assert_eq!(self.high_inputs.len(), self.period + 1);
166
167        let mut max_idx = 0_usize;
168        let mut max_val = f64::MIN;
169        for (idx, &hi) in self.high_inputs.iter().enumerate() {
170            if hi >= max_val {
171                max_val = hi;
172                max_idx = idx;
173            }
174        }
175
176        let mut min_idx = 0_usize;
177        let mut min_val = f64::MAX;
178        for (idx, &lo) in self.low_inputs.iter().enumerate() {
179            if lo <= min_val {
180                min_val = lo;
181                min_idx = idx;
182            }
183        }
184
185        self.aroon_up = 100.0 * max_idx as f64 / self.period as f64;
186        self.aroon_down = 100.0 * min_idx as f64 / self.period as f64;
187        self.value = self.aroon_up - self.aroon_down;
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use rstest::rstest;
194
195    use super::*;
196    use crate::indicator::Indicator;
197
198    #[rstest]
199    fn test_name() {
200        let aroon = AroonOscillator::new(10);
201        assert_eq!(aroon.name(), "AroonOscillator");
202    }
203
204    #[rstest]
205    fn test_period() {
206        let aroon = AroonOscillator::new(10);
207        assert_eq!(aroon.period, 10);
208    }
209
210    #[rstest]
211    fn test_initialized_false() {
212        let aroon = AroonOscillator::new(10);
213        assert!(!aroon.initialized());
214    }
215
216    #[rstest]
217    fn test_initialized_true() {
218        let mut aroon = AroonOscillator::new(10);
219        for _ in 0..=10 {
220            aroon.update_raw(110.08, 109.61);
221        }
222        assert!(aroon.initialized());
223    }
224
225    #[rstest]
226    fn test_value_one_input() {
227        let mut aroon = AroonOscillator::new(1);
228        aroon.update_raw(110.08, 109.61);
229        assert_eq!(aroon.aroon_up, 0.0);
230        assert_eq!(aroon.aroon_down, 0.0);
231        assert_eq!(aroon.value, 0.0);
232        assert!(!aroon.initialized());
233        aroon.update_raw(110.10, 109.70);
234        assert!(aroon.initialized());
235        assert_eq!(aroon.aroon_up, 100.0);
236        assert_eq!(aroon.aroon_down, 0.0);
237        assert_eq!(aroon.value, 100.0);
238    }
239
240    #[rstest]
241    fn test_value_twenty_inputs() {
242        let mut aroon = AroonOscillator::new(20);
243        let inputs = [
244            (110.08, 109.61),
245            (110.15, 109.91),
246            (110.10, 109.73),
247            (110.06, 109.77),
248            (110.29, 109.88),
249            (110.53, 110.29),
250            (110.61, 110.26),
251            (110.28, 110.17),
252            (110.30, 110.00),
253            (110.25, 110.01),
254            (110.25, 109.81),
255            (109.92, 109.71),
256            (110.21, 109.84),
257            (110.08, 109.95),
258            (110.20, 109.96),
259            (110.16, 109.95),
260            (109.99, 109.75),
261            (110.20, 109.73),
262            (110.10, 109.81),
263            (110.04, 109.96),
264            (110.02, 109.90),
265        ];
266
267        for &(h, l) in &inputs {
268            aroon.update_raw(h, l);
269        }
270        assert!(aroon.initialized());
271        assert_eq!(aroon.aroon_up, 30.0);
272        assert_eq!(aroon.aroon_down, 0.0);
273        assert_eq!(aroon.value, 30.0);
274    }
275
276    #[rstest]
277    fn test_reset() {
278        let mut aroon = AroonOscillator::new(10);
279        for _ in 0..12 {
280            aroon.update_raw(110.08, 109.61);
281        }
282        aroon.reset();
283        assert!(!aroon.initialized());
284        assert_eq!(aroon.aroon_up, 0.0);
285        assert_eq!(aroon.aroon_down, 0.0);
286        assert_eq!(aroon.value, 0.0);
287    }
288
289    #[rstest]
290    fn test_initialized_boundary() {
291        let mut aroon = AroonOscillator::new(5);
292        for _ in 0..5 {
293            aroon.update_raw(1.0, 0.0);
294            assert!(!aroon.initialized());
295        }
296        aroon.update_raw(1.0, 0.0);
297        assert!(aroon.initialized());
298    }
299
300    #[rstest]
301    #[case(1, 0)]
302    #[case(5, 0)]
303    #[case(5, 2)]
304    #[case(10, 0)]
305    #[case(10, 9)]
306    fn test_formula_equivalence(#[case] period: usize, #[case] high_idx: usize) {
307        let mut aroon = AroonOscillator::new(period);
308        for idx in 0..=period {
309            let h = if idx == high_idx { 1_000.0 } else { idx as f64 };
310            aroon.update_raw(h, h);
311        }
312        assert!(aroon.initialized());
313        let expected = 100.0 * (high_idx as f64) / period as f64;
314        let diff = aroon.aroon_up - expected;
315        assert!(diff.abs() < 1e-6);
316    }
317
318    #[rstest]
319    fn test_window_size_period_plus_one() {
320        let period = 7;
321        let mut aroon = AroonOscillator::new(period);
322        for _ in 0..=period {
323            aroon.update_raw(1.0, 0.0);
324        }
325        assert_eq!(aroon.high_inputs.len(), period + 1);
326        assert_eq!(aroon.low_inputs.len(), period + 1);
327    }
328
329    #[rstest]
330    fn test_oldest_low_reaches_zero() {
331        // The low scan covers the full period + 1 window, so a lowest low at
332        // the oldest bar drives Aroon Down to exactly 0.
333        let mut aroon = AroonOscillator::new(5);
334        aroon.update_raw(10.0, 0.0);
335        let inputs = [
336            (11.0, 9.0),
337            (12.0, 9.5),
338            (13.0, 9.2),
339            (14.0, 9.3),
340            (15.0, 9.4),
341        ];
342
343        for &(h, l) in &inputs {
344            aroon.update_raw(h, l);
345        }
346        assert!(aroon.initialized());
347        assert_eq!(aroon.aroon_up, 100.0);
348        assert_eq!(aroon.aroon_down, 0.0);
349        assert_eq!(aroon.value, 100.0);
350    }
351
352    #[rstest]
353    fn test_flat_series_ties_keep_newest_extreme() {
354        let mut aroon = AroonOscillator::new(5);
355        for _ in 0..=5 {
356            aroon.update_raw(1.0, 0.5);
357        }
358        assert!(aroon.initialized());
359        assert_eq!(aroon.aroon_up, 100.0);
360        assert_eq!(aroon.aroon_down, 100.0);
361        assert_eq!(aroon.value, 0.0);
362    }
363}