Skip to main content

wickra_core/indicators/
adl.rs

1//! Accumulation/Distribution Line.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Accumulation/Distribution Line — Marc Chaikin's cumulative volume-flow
7/// indicator.
8///
9/// Each bar contributes a *money-flow volume*: the bar's volume weighted by
10/// where the close fell within the bar's range.
11///
12/// ```text
13/// MFM_t = ((close − low) − (high − close)) / (high − low)   (the money-flow multiplier, −1..+1)
14/// MFV_t = MFM_t · volume_t
15/// ADL_t = ADL_{t−1} + MFV_t
16/// ```
17///
18/// A close near the high makes the multiplier near `+1` (accumulation), near
19/// the low near `−1` (distribution). The running total is unbounded and drifts
20/// with cumulative volume — what matters is its slope and its divergence from
21/// price. A bar with `high == low` contributes `0`.
22///
23/// # Example
24///
25/// ```
26/// use wickra_core::{Candle, Indicator, Adl};
27///
28/// let mut indicator = Adl::new();
29/// let mut last = None;
30/// for i in 0..80 {
31///     let base = 100.0 + f64::from(i);
32///     let candle =
33///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
34///     last = indicator.update(candle);
35/// }
36/// assert!(last.is_some());
37/// ```
38#[derive(Debug, Clone, Default)]
39pub struct Adl {
40    total: f64,
41    has_emitted: bool,
42}
43
44impl Adl {
45    /// Construct a new Accumulation/Distribution Line starting at zero.
46    pub const fn new() -> Self {
47        Self {
48            total: 0.0,
49            has_emitted: false,
50        }
51    }
52
53    /// Current cumulative value if at least one candle has been ingested.
54    pub const fn value(&self) -> Option<f64> {
55        if self.has_emitted {
56            Some(self.total)
57        } else {
58            None
59        }
60    }
61}
62
63impl Indicator for Adl {
64    type Input = Candle;
65    type Output = f64;
66
67    fn update(&mut self, candle: Candle) -> Option<f64> {
68        let range = candle.high - candle.low;
69        let mfv = if range == 0.0 {
70            // A zero-range bar carries no positional information.
71            0.0
72        } else {
73            let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range;
74            mfm * candle.volume
75        };
76        self.total += mfv;
77        self.has_emitted = true;
78        Some(self.total)
79    }
80
81    fn reset(&mut self) {
82        self.total = 0.0;
83        self.has_emitted = false;
84    }
85
86    fn warmup_period(&self) -> usize {
87        1
88    }
89
90    fn is_ready(&self) -> bool {
91        self.has_emitted
92    }
93
94    fn name(&self) -> &'static str {
95        "ADL"
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(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
106        Candle::new(open, high, low, close, volume, ts).unwrap()
107    }
108
109    #[test]
110    fn reference_values() {
111        // bar 1: close at high -> MFM = +1 -> MFV = +100; ADL = 100.
112        // bar 2: h=12 l=8 c=9  -> MFM = ((9-8)-(12-9))/4 = -0.5 -> MFV = -100;
113        //        ADL = 100 - 100 = 0.
114        let mut adl = Adl::new();
115        let out = adl.batch(&[
116            candle(8.0, 10.0, 8.0, 10.0, 100.0, 0),
117            candle(10.0, 12.0, 8.0, 9.0, 200.0, 1),
118        ]);
119        assert_relative_eq!(out[0].unwrap(), 100.0, epsilon = 1e-12);
120        assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
121    }
122
123    #[test]
124    fn emits_from_first_candle() {
125        let mut adl = Adl::new();
126        assert_eq!(adl.warmup_period(), 1);
127        assert!(adl.update(candle(8.0, 10.0, 8.0, 9.0, 50.0, 0)).is_some());
128    }
129
130    /// Cover the Indicator-impl `name` body (94-96). The other accessors
131    /// are exercised by existing tests; `name` was never queried.
132    #[test]
133    fn accessors_and_metadata() {
134        let adl = Adl::new();
135        assert_eq!(adl.name(), "ADL");
136    }
137
138    #[test]
139    fn close_at_high_accumulates_full_volume() {
140        // Every bar closes at its high: MFM = +1, so ADL grows by `volume`.
141        let mut adl = Adl::new();
142        let mut expected = 0.0;
143        for i in 0..10 {
144            let c = candle(8.0, 10.0, 8.0, 10.0, 25.0, i);
145            expected += 25.0;
146            assert_relative_eq!(adl.update(c).unwrap(), expected, epsilon = 1e-9);
147        }
148    }
149
150    #[test]
151    fn zero_range_bar_contributes_nothing() {
152        let mut adl = Adl::new();
153        adl.update(candle(8.0, 10.0, 8.0, 10.0, 100.0, 0));
154        let before = adl.value().unwrap();
155        // A flat candle (high == low) adds zero.
156        let after = adl.update(candle(9.0, 9.0, 9.0, 9.0, 999.0, 1)).unwrap();
157        assert_relative_eq!(after, before, epsilon = 1e-12);
158    }
159
160    #[test]
161    fn reset_clears_state() {
162        let mut adl = Adl::new();
163        adl.batch(&[
164            candle(8.0, 10.0, 8.0, 9.0, 100.0, 0),
165            candle(9.0, 11.0, 9.0, 10.0, 100.0, 1),
166        ]);
167        assert!(adl.is_ready());
168        adl.reset();
169        assert!(!adl.is_ready());
170        assert_eq!(adl.value(), None);
171    }
172
173    #[test]
174    fn batch_equals_streaming() {
175        let candles: Vec<Candle> = (0..60)
176            .map(|i| {
177                let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
178                candle(
179                    mid,
180                    mid + 2.0,
181                    mid - 2.0,
182                    mid + 0.5,
183                    10.0 + (i % 5) as f64,
184                    i,
185                )
186            })
187            .collect();
188        let batch = Adl::new().batch(&candles);
189        let mut b = Adl::new();
190        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
191        assert_eq!(batch, streamed);
192    }
193}