nautilus_indicators/momentum/
aroon.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Posei Systems Pty Ltd. All rights reserved.
3//  https://poseitrader.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 = "posei_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////////////////////////////////////////////////////////////////////////////////
198// Tests
199////////////////////////////////////////////////////////////////////////////////
200#[cfg(test)]
201mod tests {
202    use rstest::rstest;
203
204    use super::*;
205    use crate::indicator::Indicator;
206
207    #[rstest]
208    fn test_name() {
209        let aroon = AroonOscillator::new(10);
210        assert_eq!(aroon.name(), "AroonOscillator");
211    }
212
213    #[rstest]
214    fn test_period() {
215        let aroon = AroonOscillator::new(10);
216        assert_eq!(aroon.period, 10);
217    }
218
219    #[rstest]
220    fn test_initialized_false() {
221        let aroon = AroonOscillator::new(10);
222        assert!(!aroon.initialized());
223    }
224
225    #[rstest]
226    fn test_initialized_true() {
227        let mut aroon = AroonOscillator::new(10);
228        for _ in 0..=10 {
229            aroon.update_raw(110.08, 109.61);
230        }
231        assert!(aroon.initialized());
232    }
233
234    #[rstest]
235    fn test_value_one_input() {
236        let mut aroon = AroonOscillator::new(1);
237        aroon.update_raw(110.08, 109.61);
238        assert_eq!(aroon.aroon_up, 0.0);
239        assert_eq!(aroon.aroon_down, 0.0);
240        assert_eq!(aroon.value, 0.0);
241        assert!(!aroon.initialized());
242        aroon.update_raw(110.10, 109.70);
243        assert!(aroon.initialized());
244        assert_eq!(aroon.aroon_up, 100.0);
245        assert_eq!(aroon.aroon_down, 100.0);
246        assert_eq!(aroon.value, 0.0);
247    }
248
249    #[rstest]
250    fn test_value_twenty_inputs() {
251        let mut aroon = AroonOscillator::new(20);
252        let inputs = [
253            (110.08, 109.61),
254            (110.15, 109.91),
255            (110.10, 109.73),
256            (110.06, 109.77),
257            (110.29, 109.88),
258            (110.53, 110.29),
259            (110.61, 110.26),
260            (110.28, 110.17),
261            (110.30, 110.00),
262            (110.25, 110.01),
263            (110.25, 109.81),
264            (109.92, 109.71),
265            (110.21, 109.84),
266            (110.08, 109.95),
267            (110.20, 109.96),
268            (110.16, 109.95),
269            (109.99, 109.75),
270            (110.20, 109.73),
271            (110.10, 109.81),
272            (110.04, 109.96),
273            (110.02, 109.90),
274        ];
275        for &(h, l) in &inputs {
276            aroon.update_raw(h, l);
277        }
278        assert!(aroon.initialized());
279        assert_eq!(aroon.aroon_up, 30.0);
280        assert_eq!(aroon.value, -25.0);
281    }
282
283    #[rstest]
284    fn test_reset() {
285        let mut aroon = AroonOscillator::new(10);
286        for _ in 0..12 {
287            aroon.update_raw(110.08, 109.61);
288        }
289        aroon.reset();
290        assert!(!aroon.initialized());
291        assert_eq!(aroon.aroon_up, 0.0);
292        assert_eq!(aroon.aroon_down, 0.0);
293        assert_eq!(aroon.value, 0.0);
294    }
295
296    #[rstest]
297    fn test_initialized_boundary() {
298        let mut aroon = AroonOscillator::new(5);
299        for _ in 0..5 {
300            aroon.update_raw(1.0, 0.0);
301            assert!(!aroon.initialized());
302        }
303        aroon.update_raw(1.0, 0.0);
304        assert!(aroon.initialized());
305    }
306
307    #[rstest]
308    #[case(1, 0)]
309    #[case(5, 0)]
310    #[case(5, 2)]
311    #[case(10, 0)]
312    #[case(10, 9)]
313    fn test_formula_equivalence(#[case] period: usize, #[case] high_idx: usize) {
314        let mut aroon = AroonOscillator::new(period);
315        for idx in 0..=period {
316            let h = if idx == high_idx { 1_000.0 } else { idx as f64 };
317            aroon.update_raw(h, h);
318        }
319        assert!(aroon.initialized());
320        let expected = 100.0 * (high_idx as f64) / period as f64;
321        let diff = aroon.aroon_up - expected;
322        assert!(diff.abs() < 1e-6);
323    }
324
325    #[rstest]
326    fn test_window_size_period_plus_one() {
327        let period = 7;
328        let mut aroon = AroonOscillator::new(period);
329        for _ in 0..=period {
330            aroon.update_raw(1.0, 0.0);
331        }
332        assert_eq!(aroon.high_inputs.len(), period + 1);
333        assert_eq!(aroon.low_inputs.len(), period + 1);
334    }
335
336    #[rstest]
337    fn test_ignore_oldest_low() {
338        let mut aroon = AroonOscillator::new(5);
339        aroon.update_raw(10.0, 0.0);
340        let inputs = [
341            (11.0, 9.0),
342            (12.0, 9.5),
343            (13.0, 9.2),
344            (14.0, 9.3),
345            (15.0, 9.4),
346        ];
347        for &(h, l) in &inputs {
348            aroon.update_raw(h, l);
349        }
350        assert!(aroon.initialized());
351        assert_eq!(aroon.aroon_up, 100.0);
352        assert_eq!(aroon.aroon_down, 20.0);
353        assert_eq!(aroon.value, 80.0);
354    }
355}