Skip to main content

wickra_core/indicators/
ad_oscillator.rs

1//! Williams Accumulation/Distribution.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Larry Williams' Accumulation/Distribution — a cumulative volume-less price
7/// flow that classifies each bar as accumulation or distribution based on its
8/// close relative to the previous close, then sums the directional component.
9///
10/// Williams' definition (1972) uses a *true* high/low that includes the prior
11/// close as an anchor — the same idea that motivates true range:
12///
13/// ```text
14/// TR_h_t = max(close_{t−1}, high_t)
15/// TR_l_t = min(close_{t−1}, low_t)
16/// AD_t   = AD_{t−1} + (close_t − TR_l_t)   if close_t > close_{t−1}   (accumulation)
17/// AD_t   = AD_{t−1} + (close_t − TR_h_t)   if close_t < close_{t−1}   (distribution)
18/// AD_t   = AD_{t−1}                        if close_t == close_{t−1}  (no change)
19/// ```
20///
21/// Unlike Chaikin's Accumulation/Distribution Line, the Williams A/D ignores
22/// volume entirely — Williams argued that the relative position of the close
23/// already encodes the day's "true" buying or selling pressure. The series is
24/// unbounded and used primarily for divergence analysis. The first candle only
25/// seeds the previous close; the first emission lands at bar 2.
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Candle, Indicator, AdOscillator};
31///
32/// let mut indicator = AdOscillator::new();
33/// let mut last = None;
34/// for i in 0..80 {
35///     let base = 100.0 + f64::from(i);
36///     let candle =
37///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
38///     last = indicator.update(candle);
39/// }
40/// assert!(last.is_some());
41/// ```
42#[derive(Debug, Clone, Default)]
43pub struct AdOscillator {
44    prev_close: Option<f64>,
45    total: f64,
46    has_emitted: bool,
47}
48
49impl AdOscillator {
50    /// Construct a new Williams A/D starting at zero.
51    pub const fn new() -> Self {
52        Self {
53            prev_close: None,
54            total: 0.0,
55            has_emitted: false,
56        }
57    }
58
59    /// Current cumulative value if at least one emission has happened.
60    pub const fn value(&self) -> Option<f64> {
61        if self.has_emitted {
62            Some(self.total)
63        } else {
64            None
65        }
66    }
67}
68
69impl Indicator for AdOscillator {
70    type Input = Candle;
71    type Output = f64;
72
73    fn update(&mut self, candle: Candle) -> Option<f64> {
74        let Some(prev) = self.prev_close else {
75            // The first bar only establishes the previous close anchor.
76            self.prev_close = Some(candle.close);
77            return None;
78        };
79        let delta = if candle.close > prev {
80            // Accumulation: distance from the true low.
81            let tr_l = prev.min(candle.low);
82            candle.close - tr_l
83        } else if candle.close < prev {
84            // Distribution: distance from the true high (negative).
85            let tr_h = prev.max(candle.high);
86            candle.close - tr_h
87        } else {
88            // Unchanged close contributes nothing.
89            0.0
90        };
91        self.total += delta;
92        self.prev_close = Some(candle.close);
93        self.has_emitted = true;
94        Some(self.total)
95    }
96
97    fn reset(&mut self) {
98        self.prev_close = None;
99        self.total = 0.0;
100        self.has_emitted = false;
101    }
102
103    fn warmup_period(&self) -> usize {
104        // One seed bar; the second bar is the first emission.
105        2
106    }
107
108    fn is_ready(&self) -> bool {
109        self.has_emitted
110    }
111
112    fn name(&self) -> &'static str {
113        "WilliamsAD"
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::traits::BatchExt;
121    use approx::assert_relative_eq;
122
123    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
124        Candle::new(open, high, low, close, 100.0, ts).unwrap()
125    }
126
127    #[test]
128    fn accessors_and_metadata() {
129        let ad = AdOscillator::new();
130        assert_eq!(ad.name(), "WilliamsAD");
131        assert_eq!(ad.warmup_period(), 2);
132        assert_eq!(ad.value(), None);
133    }
134
135    #[test]
136    fn value_returns_total_after_first_emission() {
137        let mut ad = AdOscillator::new();
138        ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
139        let v = ad.update(c(11.0, 13.0, 8.0, 12.0, 1)).unwrap();
140        assert_relative_eq!(ad.value().unwrap(), v, epsilon = 1e-12);
141    }
142
143    #[test]
144    fn first_bar_only_seeds() {
145        let mut ad = AdOscillator::new();
146        assert_eq!(ad.update(c(10.0, 11.0, 9.0, 10.0, 0)), None);
147        assert!(!ad.is_ready());
148    }
149
150    #[test]
151    fn accumulation_adds_distance_from_true_low() {
152        // prev close = 10, today low = 8, today close = 12 (up day).
153        //   TR_l = min(10, 8) = 8, delta = 12 - 8 = 4. AD = 0 + 4 = 4.
154        let mut ad = AdOscillator::new();
155        ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
156        let v = ad.update(c(11.0, 13.0, 8.0, 12.0, 1)).unwrap();
157        assert_relative_eq!(v, 4.0, epsilon = 1e-12);
158    }
159
160    #[test]
161    fn distribution_adds_distance_from_true_high() {
162        // prev close = 10, today high = 11, today close = 7 (down day).
163        //   TR_h = max(10, 11) = 11, delta = 7 - 11 = -4. AD = -4.
164        let mut ad = AdOscillator::new();
165        ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
166        let v = ad.update(c(10.0, 11.0, 7.0, 7.0, 1)).unwrap();
167        assert_relative_eq!(v, -4.0, epsilon = 1e-12);
168    }
169
170    #[test]
171    fn unchanged_close_keeps_total() {
172        // close equals prev close -> no contribution.
173        let mut ad = AdOscillator::new();
174        ad.update(c(10.0, 11.0, 9.0, 10.0, 0));
175        let v = ad.update(c(10.0, 12.0, 8.0, 10.0, 1)).unwrap();
176        assert_relative_eq!(v, 0.0, epsilon = 1e-12);
177    }
178
179    #[test]
180    fn constant_series_yields_zero() {
181        // Every close equals the previous -> AD stays at zero forever.
182        let candles: Vec<Candle> = (0..40).map(|i| c(10.0, 11.0, 9.0, 10.0, i)).collect();
183        let mut ad = AdOscillator::new();
184        for v in ad.batch(&candles).into_iter().flatten() {
185            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
186        }
187    }
188
189    #[test]
190    fn batch_equals_streaming() {
191        let candles: Vec<Candle> = (0..80i64)
192            .map(|i| {
193                let f = i as f64;
194                let mid = 100.0 + (f * 0.3).sin() * 5.0;
195                c(mid, mid + 2.0, mid - 2.0, mid + 0.5, i)
196            })
197            .collect();
198        let mut a = AdOscillator::new();
199        let mut b = AdOscillator::new();
200        assert_eq!(
201            a.batch(&candles),
202            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
203        );
204    }
205
206    #[test]
207    fn reset_clears_state() {
208        let mut ad = AdOscillator::new();
209        ad.batch(&[
210            c(10.0, 11.0, 9.0, 10.0, 0),
211            c(10.0, 12.0, 9.0, 11.0, 1),
212            c(11.0, 13.0, 10.0, 12.0, 2),
213        ]);
214        assert!(ad.is_ready());
215        ad.reset();
216        assert!(!ad.is_ready());
217        assert_eq!(ad.value(), None);
218        assert_eq!(ad.update(c(10.0, 11.0, 9.0, 10.0, 3)), None);
219    }
220}