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    #[inline]
72    fn update(&mut self, candle: Candle) -> Option<f64> {
73        let osc = self.aroon.update(candle).map(|o| o.up - o.down)?;
74        self.last = Some(osc);
75        Some(osc)
76    }
77
78    fn reset(&mut self) {
79        self.aroon.reset();
80        self.last = None;
81    }
82
83    #[inline]
84    fn warmup_period(&self) -> usize {
85        self.aroon.warmup_period()
86    }
87
88    #[inline]
89    fn is_ready(&self) -> bool {
90        self.last.is_some()
91    }
92
93    #[inline]
94    fn name(&self) -> &'static str {
95        "AroonOscillator"
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::traits::BatchExt;
103    use approx::assert_relative_eq;
104
105    fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
106        Candle::new(close, high, low, close, 1.0, ts).unwrap()
107    }
108
109    #[test]
110    fn new_rejects_zero_period() {
111        assert!(AroonOscillator::new(0).is_err());
112    }
113
114    /// Cover the const accessors `period` / `value` (57-64) and the
115    /// Indicator-impl `name` body (90-92). `warmup_period` is covered
116    /// already by `warmup_period_matches_aroon`.
117    #[test]
118    fn accessors_and_metadata() {
119        let mut osc = AroonOscillator::new(7).unwrap();
120        assert_eq!(osc.period(), 7);
121        assert_eq!(osc.name(), "AroonOscillator");
122        assert_eq!(osc.value(), None);
123        for i in 0..8 {
124            osc.update(candle(100.0 + f64::from(i), 90.0, 95.0, i64::from(i)));
125        }
126        assert!(osc.value().is_some());
127    }
128
129    #[test]
130    fn pure_uptrend_yields_plus_100() {
131        // Every bar a fresh high, no fresh low: AroonUp = 100, AroonDown = 0.
132        let mut osc = AroonOscillator::new(5).unwrap();
133        let candles: Vec<Candle> = (0..30)
134            .map(|i| {
135                let p = 100.0 + i as f64;
136                candle(p + 1.0, p - 1.0, p, i)
137            })
138            .collect();
139        for v in osc.batch(&candles).into_iter().flatten() {
140            assert_relative_eq!(v, 100.0, epsilon = 1e-12);
141        }
142    }
143
144    #[test]
145    fn pure_downtrend_yields_minus_100() {
146        let mut osc = AroonOscillator::new(5).unwrap();
147        let candles: Vec<Candle> = (0..30)
148            .map(|i| {
149                let p = 100.0 - i as f64;
150                candle(p + 1.0, p - 1.0, p, i)
151            })
152            .collect();
153        for v in osc.batch(&candles).into_iter().flatten() {
154            assert_relative_eq!(v, -100.0, epsilon = 1e-12);
155        }
156    }
157
158    #[test]
159    fn output_stays_within_minus_100_and_100() {
160        let mut osc = AroonOscillator::new(14).unwrap();
161        let candles: Vec<Candle> = (0..200)
162            .map(|i| {
163                let mid = 100.0 + (i as f64 * 0.25).sin() * 12.0;
164                candle(mid + 2.0, mid - 2.0, mid, i)
165            })
166            .collect();
167        for v in osc.batch(&candles).into_iter().flatten() {
168            assert!((-100.0..=100.0).contains(&v), "out of range: {v}");
169        }
170    }
171
172    #[test]
173    fn warmup_period_matches_aroon() {
174        let osc = AroonOscillator::new(7).unwrap();
175        assert_eq!(osc.warmup_period(), 8);
176    }
177
178    #[test]
179    fn reset_clears_state() {
180        let mut osc = AroonOscillator::new(5).unwrap();
181        let candles: Vec<Candle> = (0..20)
182            .map(|i| candle(100.0 + i as f64, 90.0, 95.0, i))
183            .collect();
184        osc.batch(&candles);
185        assert!(osc.is_ready());
186        osc.reset();
187        assert!(!osc.is_ready());
188        assert_eq!(osc.update(candles[0]), None);
189    }
190
191    #[test]
192    fn batch_equals_streaming() {
193        let candles: Vec<Candle> = (0..60)
194            .map(|i| {
195                let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
196                candle(mid + 2.0, mid - 2.0, mid, i)
197            })
198            .collect();
199        let batch = AroonOscillator::new(14).unwrap().batch(&candles);
200        let mut b = AroonOscillator::new(14).unwrap();
201        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
202        assert_eq!(batch, streamed);
203    }
204}