Skip to main content

wickra_core/indicators/
ad_oscillator.rs

1//! Williams A/D Oscillator.
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/// WADOSC_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        "WilliamsAdOscillator"
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(), "WilliamsAdOscillator");
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
248    fn wavy(len: i64) -> Vec<Candle> {
249        (0..len)
250            .map(|i| {
251                let t = f64::from(i32::try_from(i).unwrap());
252                let base = 100.0 + (t * 0.35).sin() * 5.0;
253                let close = base + (t * 0.8).cos() * 1.2;
254                c(base, base.max(close) + 1.0, base.min(close) - 1.0, close, i)
255            })
256            .collect()
257    }
258
259    #[test]
260    fn first_value_lands_exactly_at_warmup_index() {
261        let candles = wavy(40);
262        let mut ad = AdOscillator::new();
263        let warmup = ad.warmup_period();
264        let out = ad.batch(&candles);
265        assert!(out.iter().take(warmup - 1).all(Option::is_none));
266        assert!(out.iter().skip(warmup - 1).all(Option::is_some));
267    }
268
269    #[test]
270    fn reset_replays_identically_to_fresh_instance() {
271        let candles = wavy(60);
272        let mut used = AdOscillator::new();
273        used.batch(&candles);
274        used.reset();
275        let replay = used.batch(&candles);
276        assert_eq!(replay, AdOscillator::new().batch(&candles));
277    }
278
279    #[test]
280    fn batch_nan_into_matches_streaming_bits() {
281        let candles = wavy(80);
282        let mut nan_out = vec![0.0; candles.len()];
283        AdOscillator::new().batch_nan_into(&candles, &mut nan_out);
284        let mut streamer = AdOscillator::new();
285        let identical = candles.iter().zip(&nan_out).all(|(candle, v)| {
286            streamer.update(*candle).unwrap_or(f64::NAN).to_bits() == v.to_bits()
287        });
288        assert!(identical);
289    }
290
291    /// Hand-computed reference. Bar 0 seeds `prev_close = 100`. Bars `i = 1..=14`
292    /// close at `100 + i` with `high = close + 2`, `low = close − 2`, so every bar
293    /// is an up bar: `TR_l = min(99 + i, 98 + i) = 98 + i` and
294    /// `delta = (100 + i) − (98 + i) = 2`, giving `WAD_i = 2i`.
295    /// At bar 13: SMA(13) of `2, 4, …, 26` is `14`, so `osc = 26 − 14 = 12`.
296    /// At bar 14: SMA of `4, …, 28` is `16`, so `osc = 28 − 16 = 12`.
297    /// Bar 15 is a down bar: close `113` after `114`, high `115`, low `111`:
298    /// `TR_h = max(114, 115) = 115`, `delta = 113 − 115 = −2`, `WAD = 26`.
299    /// SMA of `6, 8, …, 28, 26` = `(6 + 28) · 12 / 2 + 26 = 230`, `/ 13`;
300    /// `osc = 26 − 230 / 13 = 108 / 13`.
301    /// Bar 16 repeats the close `113`: `delta = 0`, `WAD = 26`;
302    /// SMA of `8, …, 28, 26, 26` = `(8 + 28) · 11 / 2 + 52 = 250`, `/ 13`;
303    /// `osc = 26 − 250 / 13 = 88 / 13`.
304    #[test]
305    fn reference_values_up_down_and_unchanged_bars() {
306        let mut ad = AdOscillator::new();
307        assert_eq!(ad.update(c(100.0, 102.0, 98.0, 100.0, 0)), None);
308        let mut out = Vec::new();
309        for i in 1..=14_i32 {
310            let close = 100.0 + f64::from(i);
311            out.push(ad.update(c(close, close + 2.0, close - 2.0, close, i64::from(i))));
312        }
313        assert!(out.iter().take(12).all(Option::is_none));
314        assert_relative_eq!(out[12].unwrap(), 12.0, epsilon = 1e-9);
315        assert_relative_eq!(out[13].unwrap(), 12.0, epsilon = 1e-9);
316        let down = ad.update(c(114.0, 115.0, 111.0, 113.0, 15)).unwrap();
317        assert_relative_eq!(down, 108.0 / 13.0, epsilon = 1e-9);
318        let flat = ad.update(c(113.0, 114.0, 112.0, 113.0, 16)).unwrap();
319        assert_relative_eq!(flat, 88.0 / 13.0, epsilon = 1e-9);
320        assert_eq!(ad.value(), Some(flat));
321    }
322}