Skip to main content

wickra_core/indicators/
aroon_oscillator.rs

1//! Aroon Oscillator.
2
3use crate::error::Result;
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7use super::Aroon;
8
9/// Aroon Oscillator — the single-line difference `AroonUp − AroonDown`.
10///
11/// The [`Aroon`] indicator reports two `[0, 100]` lines; the Aroon Oscillator
12/// collapses them into one value in `[−100, 100]`:
13///
14/// ```text
15/// AroonOscillator = AroonUp − AroonDown
16/// ```
17///
18/// Strongly positive means the most recent high is much fresher than the most
19/// recent low (an up-trend); strongly negative is the mirror image. Readings
20/// near zero mean neither extreme is recent — a range.
21///
22/// # Example
23///
24/// ```
25/// use wickra_core::{Candle, Indicator, AroonOscillator};
26///
27/// let mut indicator = AroonOscillator::new(5).unwrap();
28/// let mut last = None;
29/// for i in 0..80 {
30///     let base = 100.0 + i as f64;
31///     let candle =
32///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
33///     last = indicator.update(candle);
34/// }
35/// assert_eq!(last, Some(100.0)); // pure uptrend
36/// ```
37#[derive(Debug, Clone)]
38pub struct AroonOscillator {
39    aroon: Aroon,
40    last: Option<f64>,
41}
42
43impl AroonOscillator {
44    /// Construct a new Aroon Oscillator with the given period.
45    ///
46    /// # Errors
47    ///
48    /// Returns [`crate::Error::PeriodZero`] if `period == 0`.
49    pub fn new(period: usize) -> Result<Self> {
50        Ok(Self {
51            aroon: Aroon::new(period)?,
52            last: None,
53        })
54    }
55
56    /// Configured period.
57    pub const fn period(&self) -> usize {
58        self.aroon.period()
59    }
60
61    /// Current value if available.
62    pub const fn value(&self) -> Option<f64> {
63        self.last
64    }
65}
66
67impl Indicator for AroonOscillator {
68    type Input = Candle;
69    type Output = f64;
70
71    fn update(&mut self, candle: Candle) -> Option<f64> {
72        let osc = self.aroon.update(candle).map(|o| o.up - o.down)?;
73        self.last = Some(osc);
74        Some(osc)
75    }
76
77    fn reset(&mut self) {
78        self.aroon.reset();
79        self.last = None;
80    }
81
82    fn warmup_period(&self) -> usize {
83        self.aroon.warmup_period()
84    }
85
86    fn is_ready(&self) -> bool {
87        self.last.is_some()
88    }
89
90    fn name(&self) -> &'static str {
91        "AroonOscillator"
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::traits::BatchExt;
99    use approx::assert_relative_eq;
100
101    fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
102        Candle::new(close, high, low, close, 1.0, ts).unwrap()
103    }
104
105    #[test]
106    fn new_rejects_zero_period() {
107        assert!(AroonOscillator::new(0).is_err());
108    }
109
110    /// Cover the const accessors `period` / `value` (57-64) and the
111    /// Indicator-impl `name` body (90-92). `warmup_period` is covered
112    /// already by `warmup_period_matches_aroon`.
113    #[test]
114    fn accessors_and_metadata() {
115        let mut osc = AroonOscillator::new(7).unwrap();
116        assert_eq!(osc.period(), 7);
117        assert_eq!(osc.name(), "AroonOscillator");
118        assert_eq!(osc.value(), None);
119        for i in 0..8 {
120            osc.update(candle(100.0 + f64::from(i), 90.0, 95.0, i64::from(i)));
121        }
122        assert!(osc.value().is_some());
123    }
124
125    #[test]
126    fn pure_uptrend_yields_plus_100() {
127        // Every bar a fresh high, no fresh low: AroonUp = 100, AroonDown = 0.
128        let mut osc = AroonOscillator::new(5).unwrap();
129        let candles: Vec<Candle> = (0..30)
130            .map(|i| {
131                let p = 100.0 + i as f64;
132                candle(p + 1.0, p - 1.0, p, i)
133            })
134            .collect();
135        for v in osc.batch(&candles).into_iter().flatten() {
136            assert_relative_eq!(v, 100.0, epsilon = 1e-12);
137        }
138    }
139
140    #[test]
141    fn pure_downtrend_yields_minus_100() {
142        let mut osc = AroonOscillator::new(5).unwrap();
143        let candles: Vec<Candle> = (0..30)
144            .map(|i| {
145                let p = 100.0 - i as f64;
146                candle(p + 1.0, p - 1.0, p, i)
147            })
148            .collect();
149        for v in osc.batch(&candles).into_iter().flatten() {
150            assert_relative_eq!(v, -100.0, epsilon = 1e-12);
151        }
152    }
153
154    #[test]
155    fn output_stays_within_minus_100_and_100() {
156        let mut osc = AroonOscillator::new(14).unwrap();
157        let candles: Vec<Candle> = (0..200)
158            .map(|i| {
159                let mid = 100.0 + (i as f64 * 0.25).sin() * 12.0;
160                candle(mid + 2.0, mid - 2.0, mid, i)
161            })
162            .collect();
163        for v in osc.batch(&candles).into_iter().flatten() {
164            assert!((-100.0..=100.0).contains(&v), "out of range: {v}");
165        }
166    }
167
168    #[test]
169    fn warmup_period_matches_aroon() {
170        let osc = AroonOscillator::new(7).unwrap();
171        assert_eq!(osc.warmup_period(), 8);
172    }
173
174    #[test]
175    fn reset_clears_state() {
176        let mut osc = AroonOscillator::new(5).unwrap();
177        let candles: Vec<Candle> = (0..20)
178            .map(|i| candle(100.0 + i as f64, 90.0, 95.0, i))
179            .collect();
180        osc.batch(&candles);
181        assert!(osc.is_ready());
182        osc.reset();
183        assert!(!osc.is_ready());
184        assert_eq!(osc.update(candles[0]), None);
185    }
186
187    #[test]
188    fn batch_equals_streaming() {
189        let candles: Vec<Candle> = (0..60)
190            .map(|i| {
191                let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
192                candle(mid + 2.0, mid - 2.0, mid, i)
193            })
194            .collect();
195        let batch = AroonOscillator::new(14).unwrap().batch(&candles);
196        let mut b = AroonOscillator::new(14).unwrap();
197        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
198        assert_eq!(batch, streamed);
199    }
200}