Skip to main content

wickra_core/indicators/
acceleration_bands.rs

1//! Acceleration Bands (Price Headley).
2
3use crate::error::{Error, Result};
4use crate::indicators::sma::Sma;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// Acceleration Bands output: SMA of close with momentum-biased envelopes
9/// driven by the bar's high/low geometry.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct AccelerationBandsOutput {
12    /// Upper band: SMA of `high · (1 + factor · (high − low) / (high + low))`.
13    pub upper: f64,
14    /// Middle band: SMA of close.
15    pub middle: f64,
16    /// Lower band: SMA of `low · (1 − factor · (high − low) / (high + low))`.
17    pub lower: f64,
18}
19
20/// Acceleration Bands (Price Headley): SMA-smoothed bands that widen with each
21/// bar's relative range `(high − low) / (high + low)`.
22///
23/// ```text
24/// ratio  = (high − low) / (high + low)
25/// raw_up = high · (1 + factor · ratio)
26/// raw_lo = low  · (1 − factor · ratio)
27/// upper  = SMA(raw_up, period)
28/// middle = SMA(close,  period)
29/// lower  = SMA(raw_lo, period)
30/// ```
31///
32/// Headley's reference parameters are `period = 20`, `factor = 4` — in this
33/// form `raw_up = high · (1 + 4 · (high − low) / (high + low))`, the expression
34/// TA-Lib's `ACCBANDS` uses. (Headley's scaled spelling
35/// `2 · ((H − L) / ((H + L) / 2)) · 1000 · 0.001` is the same factor of 4.) The
36/// bands compress in
37/// quiet markets and flare on impulsive bars, making them a momentum-biased
38/// alternative to the volatility-driven Bollinger or Keltner envelopes.
39///
40/// # Example
41///
42/// ```
43/// use wickra_core::{AccelerationBands, Candle, Indicator};
44///
45/// let mut indicator = AccelerationBands::new(20, 4.0).unwrap();
46/// let mut last = None;
47/// for i in 0..40 {
48///     let base = 100.0 + f64::from(i);
49///     let candle =
50///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
51///     last = indicator.update(candle);
52/// }
53/// assert!(last.is_some());
54/// ```
55#[derive(Debug, Clone)]
56pub struct AccelerationBands {
57    upper_sma: Sma,
58    middle_sma: Sma,
59    lower_sma: Sma,
60    factor: f64,
61    period: usize,
62}
63
64impl AccelerationBands {
65    /// Construct a new Acceleration Bands indicator.
66    ///
67    /// # Errors
68    /// Returns [`Error::PeriodZero`] if `period == 0` and
69    /// [`Error::NonPositiveMultiplier`] if `factor` is not strictly positive
70    /// and finite.
71    pub fn new(period: usize, factor: f64) -> Result<Self> {
72        if !factor.is_finite() || factor <= 0.0 {
73            return Err(Error::NonPositiveMultiplier);
74        }
75        Ok(Self {
76            upper_sma: Sma::new(period)?,
77            middle_sma: Sma::new(period)?,
78            lower_sma: Sma::new(period)?,
79            factor,
80            period,
81        })
82    }
83
84    /// Headley's classic configuration: `period = 20`, `factor = 4`.
85    pub fn classic() -> Self {
86        Self::new(20, 4.0).expect("classic Acceleration Bands parameters are valid")
87    }
88
89    /// Configured `(period, factor)`.
90    pub const fn parameters(&self) -> (usize, f64) {
91        (self.period, self.factor)
92    }
93}
94
95impl Indicator for AccelerationBands {
96    type Input = Candle;
97    type Output = AccelerationBandsOutput;
98
99    #[inline]
100    fn update(&mut self, candle: Candle) -> Option<AccelerationBandsOutput> {
101        // (high + low) == 0 is geometrically impossible for valid OHLC
102        // (high >= low and a zero-sum requires both equal to 0, which would
103        // make the bar degenerate). Guard anyway so a hypothetical zero-price
104        // bar collapses the ratio to zero rather than emitting NaN.
105        let sum_hl = candle.high + candle.low;
106        let ratio = if sum_hl == 0.0 {
107            0.0
108        } else {
109            (candle.high - candle.low) / sum_hl
110        };
111        let raw_up = candle.high * self.factor.mul_add(ratio, 1.0);
112        let raw_lo = candle.low * (-self.factor).mul_add(ratio, 1.0);
113
114        // Feed all three SMAs unconditionally so they warm up in lock-step.
115        let upper = self.upper_sma.update(raw_up);
116        let middle = self.middle_sma.update(candle.close);
117        let lower = self.lower_sma.update(raw_lo);
118        let (upper, middle, lower) = (upper?, middle?, lower?);
119        Some(AccelerationBandsOutput {
120            upper,
121            middle,
122            lower,
123        })
124    }
125
126    fn reset(&mut self) {
127        self.upper_sma.reset();
128        self.middle_sma.reset();
129        self.lower_sma.reset();
130    }
131
132    #[inline]
133    fn warmup_period(&self) -> usize {
134        self.period
135    }
136
137    #[inline]
138    fn is_ready(&self) -> bool {
139        self.middle_sma.is_ready()
140    }
141
142    #[inline]
143    fn name(&self) -> &'static str {
144        "AccelerationBands"
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::traits::BatchExt;
152    use approx::assert_relative_eq;
153
154    fn c(h: f64, l: f64, cl: f64) -> Candle {
155        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
156    }
157
158    #[test]
159    fn rejects_zero_period() {
160        assert!(matches!(
161            AccelerationBands::new(0, 0.001),
162            Err(Error::PeriodZero)
163        ));
164    }
165
166    #[test]
167    fn rejects_non_positive_factor() {
168        assert!(matches!(
169            AccelerationBands::new(20, 0.0),
170            Err(Error::NonPositiveMultiplier)
171        ));
172        assert!(matches!(
173            AccelerationBands::new(20, -1.0),
174            Err(Error::NonPositiveMultiplier)
175        ));
176        assert!(matches!(
177            AccelerationBands::new(20, f64::NAN),
178            Err(Error::NonPositiveMultiplier)
179        ));
180    }
181
182    #[test]
183    fn accessors_and_metadata() {
184        let ab = AccelerationBands::classic();
185        let (p, f) = ab.parameters();
186        assert_eq!(p, 20);
187        assert_relative_eq!(f, 4.0, epsilon = 1e-12);
188        assert_eq!(ab.warmup_period(), 20);
189        assert_eq!(ab.name(), "AccelerationBands");
190    }
191
192    #[test]
193    fn flat_market_collapses_to_constant() {
194        // high == low so the ratio term is zero; all three SMAs converge to
195        // the same constant.
196        let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
197        let mut ab = AccelerationBands::new(5, 0.5).unwrap();
198        let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
199        assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
200        assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
201        assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
202    }
203
204    #[test]
205    fn warmup_returns_none() {
206        let mut ab = AccelerationBands::new(5, 0.001).unwrap();
207        for i in 0..4 {
208            let base = 100.0 + f64::from(i);
209            assert!(ab.update(c(base + 1.0, base - 1.0, base)).is_none());
210        }
211        assert!(ab.update(c(105.0, 103.0, 104.0)).is_some());
212    }
213
214    #[test]
215    fn upper_above_middle_above_lower() {
216        let candles: Vec<Candle> = (0..50)
217            .map(|i| {
218                let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
219                c(m + 1.0, m - 1.0, m)
220            })
221            .collect();
222        let mut ab = AccelerationBands::new(20, 0.5).unwrap();
223        for o in ab.batch(&candles).into_iter().flatten() {
224            assert!(o.upper >= o.middle, "{} < {}", o.upper, o.middle);
225            assert!(o.middle >= o.lower, "{} < {}", o.middle, o.lower);
226        }
227    }
228
229    #[test]
230    fn batch_equals_streaming() {
231        let candles: Vec<Candle> = (0..40)
232            .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
233            .collect();
234        let mut a = AccelerationBands::new(10, 0.5).unwrap();
235        let mut b = AccelerationBands::new(10, 0.5).unwrap();
236        assert_eq!(
237            a.batch(&candles),
238            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
239        );
240    }
241
242    #[test]
243    fn reset_clears_state() {
244        let candles: Vec<Candle> = (0..10)
245            .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
246            .collect();
247        let mut ab = AccelerationBands::new(5, 0.5).unwrap();
248        ab.batch(&candles);
249        assert!(ab.is_ready());
250        ab.reset();
251        assert!(!ab.is_ready());
252        assert_eq!(ab.update(candles[0]), None);
253    }
254
255    #[test]
256    fn zero_price_candle_collapses_ratio_to_zero() {
257        // `high + low == 0` is geometrically only reachable with a fully-zero
258        // bar (high >= low and both non-negative for a real market, but
259        // `Candle::new` accepts the degenerate `(0, 0, 0, 0)` case). The
260        // ratio guard must fire and the bands all collapse to zero.
261        let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 1.0, 0).unwrap();
262        let mut ab = AccelerationBands::new(1, 0.5).unwrap();
263        let v = ab.update(zero).unwrap();
264        assert_relative_eq!(v.upper, 0.0, epsilon = 1e-12);
265        assert_relative_eq!(v.middle, 0.0, epsilon = 1e-12);
266        assert_relative_eq!(v.lower, 0.0, epsilon = 1e-12);
267    }
268
269    /// Hand-computed reference. Single bar with `high = 12`, `low = 8`,
270    /// `close = 10`, `factor = 0.5`, `period = 1`.
271    /// `ratio  = (12 − 8) / (12 + 8) = 0.2`
272    /// `raw_up = 12 · (1 + 0.5 · 0.2) = 12 · 1.1 = 13.2`
273    /// `raw_lo = 8  · (1 − 0.5 · 0.2) = 8  · 0.9 = 7.2`
274    /// `middle = SMA(close, 1) = 10`
275    #[test]
276    fn reference_value_single_bar() {
277        let mut ab = AccelerationBands::new(1, 0.5).unwrap();
278        let v = ab.update(c(12.0, 8.0, 10.0)).unwrap();
279        assert_relative_eq!(v.upper, 13.2, epsilon = 1e-12);
280        assert_relative_eq!(v.middle, 10.0, epsilon = 1e-12);
281        assert_relative_eq!(v.lower, 7.2, epsilon = 1e-12);
282    }
283
284    #[test]
285    fn rejects_infinite_factor_and_oversized_period() {
286        assert!(matches!(
287            AccelerationBands::new(20, f64::INFINITY),
288            Err(Error::NonPositiveMultiplier)
289        ));
290        let too_long = crate::error::MAX_PERIOD + 1;
291        assert!(matches!(
292            AccelerationBands::new(too_long, 4.0),
293            Err(Error::InvalidPeriod { .. })
294        ));
295    }
296
297    fn wavy_candles(len: i32) -> Vec<Candle> {
298        (0..len)
299            .map(|i| {
300                let mid = 100.0 + (f64::from(i) * 0.4).sin() * 6.0;
301                let half = 0.5 + (f64::from(i) * 0.9).cos().abs() * 2.0;
302                c(mid + half, mid - half, mid + 0.3)
303            })
304            .collect()
305    }
306
307    #[test]
308    fn first_value_lands_exactly_at_warmup_index() {
309        let candles = wavy_candles(30);
310        let mut ab = AccelerationBands::new(7, 4.0).unwrap();
311        let warmup = ab.warmup_period();
312        let out = ab.batch(&candles);
313        assert!(out.iter().take(warmup - 1).all(Option::is_none));
314        assert!(out.iter().skip(warmup - 1).all(Option::is_some));
315    }
316
317    #[test]
318    fn reset_replays_identically_to_fresh_instance() {
319        let candles = wavy_candles(40);
320        let mut used = AccelerationBands::classic();
321        let first = used.batch(&candles);
322        used.reset();
323        let replay = used.batch(&candles);
324        let fresh = AccelerationBands::classic().batch(&candles);
325        assert_eq!(replay, fresh);
326        assert_eq!(first, fresh);
327    }
328
329    #[test]
330    fn batch_equals_streaming_bit_identical() {
331        let candles = wavy_candles(60);
332        let batch = AccelerationBands::new(9, 4.0).unwrap().batch(&candles);
333        let mut streamer = AccelerationBands::new(9, 4.0).unwrap();
334        let identical = candles.iter().zip(&batch).all(|(candle, b)| {
335            let s = streamer.update(*candle);
336            s.map(|o| (o.upper.to_bits(), o.middle.to_bits(), o.lower.to_bits()))
337                == b.map(|o| (o.upper.to_bits(), o.middle.to_bits(), o.lower.to_bits()))
338        });
339        assert!(identical);
340    }
341
342    /// Hand-computed two-bar reference with Headley's `factor = 4`, `period = 2`.
343    /// Bar 1: `H = 12, L = 8, C = 10` -> `ratio = 4 / 20 = 0.2`,
344    ///   `raw_up = 12 · (1 + 0.8) = 21.6`, `raw_lo = 8 · (1 − 0.8) = 1.6`.
345    /// Bar 2: `H = 22, L = 18, C = 20` -> `ratio = 4 / 40 = 0.1`,
346    ///   `raw_up = 22 · 1.4 = 30.8`, `raw_lo = 18 · 0.6 = 10.8`.
347    /// `upper = (21.6 + 30.8) / 2 = 26.2`, `middle = (10 + 20) / 2 = 15`,
348    /// `lower = (1.6 + 10.8) / 2 = 6.2`.
349    #[test]
350    fn reference_value_two_bars_headley_factor() {
351        let mut ab = AccelerationBands::new(2, 4.0).unwrap();
352        assert_eq!(ab.update(c(12.0, 8.0, 10.0)), None);
353        let v = ab.update(c(22.0, 18.0, 20.0)).unwrap();
354        assert_relative_eq!(v.upper, 26.2, epsilon = 1e-12);
355        assert_relative_eq!(v.middle, 15.0, epsilon = 1e-12);
356        assert_relative_eq!(v.lower, 6.2, epsilon = 1e-12);
357    }
358}