Skip to main content

wickra_core/indicators/
ad_oscillator.rs

1//! Williams A/D Oscillator (ADOSC).
2
3use crate::indicators::sma::Sma;
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Smoothing window applied to the Williams A/D line to form the oscillator.
8const SIGNAL_PERIOD: usize = 13;
9
10/// Williams **A/D Oscillator** — the volume-free Williams Accumulation/
11/// Distribution line measured against its own moving average, so it oscillates
12/// around zero instead of drifting like the cumulative line.
13///
14/// The underlying line is Larry Williams' volume-less A/D (1972), which uses a
15/// *true* high/low anchored on the prior close; the oscillator subtracts its
16/// 13-bar simple moving average:
17///
18/// ```text
19/// TR_h_t = max(close_{t−1}, high_t)
20/// TR_l_t = min(close_{t−1}, low_t)
21/// WAD_t  = WAD_{t−1} + (close_t − TR_l_t)   if close_t > close_{t−1}
22/// WAD_t  = WAD_{t−1} + (close_t − TR_h_t)   if close_t < close_{t−1}
23/// WAD_t  = WAD_{t−1}                          if close_t == close_{t−1}
24/// ADOSC_t = WAD_t − SMA(WAD, 13)_t
25/// ```
26///
27/// This is distinct from the raw cumulative line, which Wickra ships as
28/// [`Wad`](crate::Wad): `Wad` is the drifting line for divergence analysis,
29/// while this oscillator is its zero-centred, mean-reverting form (positive
30/// when accumulation is running ahead of its recent average, negative when
31/// distribution is). The first bar only seeds the previous close; the first
32/// oscillator value lands once the 13-bar average of the line is full.
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{Candle, Indicator, AdOscillator};
38///
39/// let mut indicator = AdOscillator::new();
40/// let mut last = None;
41/// for i in 0..80 {
42///     let base = 100.0 + f64::from(i);
43///     let candle =
44///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
45///     last = indicator.update(candle);
46/// }
47/// assert!(last.is_some());
48/// ```
49#[derive(Debug, Clone)]
50pub struct AdOscillator {
51    prev_close: Option<f64>,
52    line: f64,
53    signal: Sma,
54    last: Option<f64>,
55}
56
57impl Default for AdOscillator {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl AdOscillator {
64    /// Construct a new Williams A/D Oscillator.
65    #[must_use]
66    pub fn new() -> Self {
67        Self {
68            prev_close: None,
69            line: 0.0,
70            signal: Sma::new(SIGNAL_PERIOD).expect("SIGNAL_PERIOD is non-zero"),
71            last: None,
72        }
73    }
74
75    /// Current oscillator value if available.
76    pub const fn value(&self) -> Option<f64> {
77        self.last
78    }
79}
80
81impl Indicator for AdOscillator {
82    type Input = Candle;
83    type Output = f64;
84
85    #[inline]
86    fn update(&mut self, candle: Candle) -> Option<f64> {
87        let Some(prev) = self.prev_close else {
88            // The first bar only establishes the previous close anchor.
89            self.prev_close = Some(candle.close);
90            return None;
91        };
92        let delta = if candle.close > prev {
93            // Accumulation: distance from the true low.
94            candle.close - prev.min(candle.low)
95        } else if candle.close < prev {
96            // Distribution: distance from the true high (negative).
97            candle.close - prev.max(candle.high)
98        } else {
99            0.0
100        };
101        self.line += delta;
102        self.prev_close = Some(candle.close);
103        let signal = self.signal.update(self.line)?;
104        let osc = self.line - signal;
105        self.last = Some(osc);
106        Some(osc)
107    }
108
109    fn reset(&mut self) {
110        self.prev_close = None;
111        self.line = 0.0;
112        self.signal.reset();
113        self.last = None;
114    }
115
116    #[inline]
117    fn warmup_period(&self) -> usize {
118        // One seed bar establishes the prior close; the line then feeds the
119        // 13-bar signal SMA, which is full after `SIGNAL_PERIOD` line values.
120        1 + SIGNAL_PERIOD
121    }
122
123    #[inline]
124    fn is_ready(&self) -> bool {
125        self.last.is_some()
126    }
127
128    #[inline]
129    fn name(&self) -> &'static str {
130        "ADOSC"
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::indicators::wad::Wad;
138    use crate::traits::BatchExt;
139    use approx::assert_relative_eq;
140
141    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
142        Candle::new(open, high, low, close, 100.0, ts).unwrap()
143    }
144
145    #[test]
146    fn accessors_and_metadata() {
147        let ad = AdOscillator::new();
148        assert_eq!(ad.name(), "ADOSC");
149        assert_eq!(ad.warmup_period(), 14);
150        assert!(!ad.is_ready());
151        assert_eq!(ad.value(), None);
152        // `Default` matches `new`.
153        assert_eq!(AdOscillator::default().warmup_period(), 14);
154    }
155
156    #[test]
157    fn seed_bar_returns_none() {
158        let mut ad = AdOscillator::new();
159        assert_eq!(ad.update(c(100.0, 101.0, 99.0, 100.0, 0)), None);
160    }
161
162    #[test]
163    fn equals_wad_line_minus_its_sma() {
164        // The oscillator is exactly the Williams A/D line minus its 13-SMA, so
165        // it must match the standalone `Wad` line passed through an SMA(13).
166        let candles: Vec<Candle> = (0..80_i64)
167            .map(|i| {
168                let base = 100.0 + (i as f64 * 0.3).sin() * 6.0;
169                c(
170                    base,
171                    base + 2.0,
172                    base - 2.0,
173                    base + (i as f64 * 0.5).cos(),
174                    i,
175                )
176            })
177            .collect();
178        let osc = AdOscillator::new().batch(&candles);
179        // Reconstruct: Wad line, then line − SMA(line, 13).
180        let line = Wad::new().batch(&candles);
181        let mut sma = Sma::new(SIGNAL_PERIOD).unwrap();
182        let expected: Vec<Option<f64>> = line
183            .iter()
184            .map(|v| v.and_then(|l| sma.update(l).map(|s| l - s)))
185            .collect();
186        assert_eq!(osc, expected);
187    }
188
189    #[test]
190    fn flat_market_oscillates_at_zero() {
191        // A flat market never accumulates or distributes, so the line is
192        // constant and the oscillator sits at zero once warm.
193        let mut ad = AdOscillator::new();
194        let candles: Vec<Candle> = (0..40).map(|i| c(50.0, 50.0, 50.0, 50.0, i)).collect();
195        let out = ad.batch(&candles);
196        for v in out.iter().skip(ad.warmup_period() - 1).flatten() {
197            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
198        }
199    }
200
201    #[test]
202    fn warmup_emits_at_warmup_period() {
203        let mut ad = AdOscillator::new();
204        let candles: Vec<Candle> = (0..20)
205            .map(|i| {
206                let close = 100.0 + f64::from(i);
207                c(close, close + 2.0, close - 2.0, close, i64::from(i))
208            })
209            .collect();
210        let out = ad.batch(&candles);
211        assert_eq!(ad.warmup_period(), 14);
212        for v in out.iter().take(13) {
213            assert!(v.is_none());
214        }
215        assert!(out[13].is_some());
216    }
217
218    #[test]
219    fn reset_clears_state() {
220        let mut ad = AdOscillator::new();
221        let candles: Vec<Candle> = (0..30)
222            .map(|i| {
223                let close = 100.0 + f64::from(i);
224                c(close, close + 2.0, close - 2.0, close, i64::from(i))
225            })
226            .collect();
227        ad.batch(&candles);
228        assert!(ad.is_ready());
229        ad.reset();
230        assert!(!ad.is_ready());
231        assert_eq!(ad.value(), None);
232    }
233
234    #[test]
235    fn batch_equals_streaming() {
236        let candles: Vec<Candle> = (0..100_i64)
237            .map(|i| {
238                let base = 100.0 + (i as f64 * 0.2).sin() * 5.0;
239                c(base, base + 1.5, base - 1.5, base + 0.4, i)
240            })
241            .collect();
242        let batch = AdOscillator::new().batch(&candles);
243        let mut s = AdOscillator::new();
244        let streamed: Vec<_> = candles.iter().map(|x| s.update(*x)).collect();
245        assert_eq!(batch, streamed);
246    }
247}