nautilus_indicators/momentum/
aroon.rs

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