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 = 0.001` for
33/// intraday equity markets — the geometric `ratio` term tends to scale on
34/// fractional moves, so the literal `factor` is small. The bands compress in
35/// quiet markets and flare on impulsive bars, making them a momentum-biased
36/// alternative to the volatility-driven Bollinger or Keltner envelopes.
37///
38/// # Example
39///
40/// ```
41/// use wickra_core::{AccelerationBands, Candle, Indicator};
42///
43/// let mut indicator = AccelerationBands::new(20, 0.001).unwrap();
44/// let mut last = None;
45/// for i in 0..40 {
46///     let base = 100.0 + f64::from(i);
47///     let candle =
48///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
49///     last = indicator.update(candle);
50/// }
51/// assert!(last.is_some());
52/// ```
53#[derive(Debug, Clone)]
54pub struct AccelerationBands {
55    upper_sma: Sma,
56    middle_sma: Sma,
57    lower_sma: Sma,
58    factor: f64,
59    period: usize,
60}
61
62impl AccelerationBands {
63    /// Construct a new Acceleration Bands indicator.
64    ///
65    /// # Errors
66    /// Returns [`Error::PeriodZero`] if `period == 0` and
67    /// [`Error::NonPositiveMultiplier`] if `factor` is not strictly positive
68    /// and finite.
69    pub fn new(period: usize, factor: f64) -> Result<Self> {
70        if !factor.is_finite() || factor <= 0.0 {
71            return Err(Error::NonPositiveMultiplier);
72        }
73        Ok(Self {
74            upper_sma: Sma::new(period)?,
75            middle_sma: Sma::new(period)?,
76            lower_sma: Sma::new(period)?,
77            factor,
78            period,
79        })
80    }
81
82    /// Headley's classic configuration: `period = 20`, `factor = 0.001`.
83    pub fn classic() -> Self {
84        Self::new(20, 0.001).expect("classic Acceleration Bands parameters are valid")
85    }
86
87    /// Configured `(period, factor)`.
88    pub const fn parameters(&self) -> (usize, f64) {
89        (self.period, self.factor)
90    }
91}
92
93impl Indicator for AccelerationBands {
94    type Input = Candle;
95    type Output = AccelerationBandsOutput;
96
97    fn update(&mut self, candle: Candle) -> Option<AccelerationBandsOutput> {
98        // (high + low) == 0 is geometrically impossible for valid OHLC
99        // (high >= low and a zero-sum requires both equal to 0, which would
100        // make the bar degenerate). Guard anyway so a hypothetical zero-price
101        // bar collapses the ratio to zero rather than emitting NaN.
102        let sum_hl = candle.high + candle.low;
103        let ratio = if sum_hl == 0.0 {
104            0.0
105        } else {
106            (candle.high - candle.low) / sum_hl
107        };
108        let raw_up = candle.high * self.factor.mul_add(ratio, 1.0);
109        let raw_lo = candle.low * (-self.factor).mul_add(ratio, 1.0);
110
111        // Feed all three SMAs unconditionally so they warm up in lock-step.
112        let upper = self.upper_sma.update(raw_up);
113        let middle = self.middle_sma.update(candle.close);
114        let lower = self.lower_sma.update(raw_lo);
115        let (upper, middle, lower) = (upper?, middle?, lower?);
116        Some(AccelerationBandsOutput {
117            upper,
118            middle,
119            lower,
120        })
121    }
122
123    fn reset(&mut self) {
124        self.upper_sma.reset();
125        self.middle_sma.reset();
126        self.lower_sma.reset();
127    }
128
129    fn warmup_period(&self) -> usize {
130        self.period
131    }
132
133    fn is_ready(&self) -> bool {
134        self.middle_sma.is_ready()
135    }
136
137    fn name(&self) -> &'static str {
138        "AccelerationBands"
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::traits::BatchExt;
146    use approx::assert_relative_eq;
147
148    fn c(h: f64, l: f64, cl: f64) -> Candle {
149        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
150    }
151
152    #[test]
153    fn rejects_zero_period() {
154        assert!(matches!(
155            AccelerationBands::new(0, 0.001),
156            Err(Error::PeriodZero)
157        ));
158    }
159
160    #[test]
161    fn rejects_non_positive_factor() {
162        assert!(matches!(
163            AccelerationBands::new(20, 0.0),
164            Err(Error::NonPositiveMultiplier)
165        ));
166        assert!(matches!(
167            AccelerationBands::new(20, -1.0),
168            Err(Error::NonPositiveMultiplier)
169        ));
170        assert!(matches!(
171            AccelerationBands::new(20, f64::NAN),
172            Err(Error::NonPositiveMultiplier)
173        ));
174    }
175
176    #[test]
177    fn accessors_and_metadata() {
178        let ab = AccelerationBands::classic();
179        let (p, f) = ab.parameters();
180        assert_eq!(p, 20);
181        assert_relative_eq!(f, 0.001, epsilon = 1e-12);
182        assert_eq!(ab.warmup_period(), 20);
183        assert_eq!(ab.name(), "AccelerationBands");
184    }
185
186    #[test]
187    fn flat_market_collapses_to_constant() {
188        // high == low so the ratio term is zero; all three SMAs converge to
189        // the same constant.
190        let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
191        let mut ab = AccelerationBands::new(5, 0.5).unwrap();
192        let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
193        assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
194        assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
195        assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
196    }
197
198    #[test]
199    fn warmup_returns_none() {
200        let mut ab = AccelerationBands::new(5, 0.001).unwrap();
201        for i in 0..4 {
202            let base = 100.0 + f64::from(i);
203            assert!(ab.update(c(base + 1.0, base - 1.0, base)).is_none());
204        }
205        assert!(ab.update(c(105.0, 103.0, 104.0)).is_some());
206    }
207
208    #[test]
209    fn upper_above_middle_above_lower() {
210        let candles: Vec<Candle> = (0..50)
211            .map(|i| {
212                let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
213                c(m + 1.0, m - 1.0, m)
214            })
215            .collect();
216        let mut ab = AccelerationBands::new(20, 0.5).unwrap();
217        for o in ab.batch(&candles).into_iter().flatten() {
218            assert!(o.upper >= o.middle, "{} < {}", o.upper, o.middle);
219            assert!(o.middle >= o.lower, "{} < {}", o.middle, o.lower);
220        }
221    }
222
223    #[test]
224    fn batch_equals_streaming() {
225        let candles: Vec<Candle> = (0..40)
226            .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
227            .collect();
228        let mut a = AccelerationBands::new(10, 0.5).unwrap();
229        let mut b = AccelerationBands::new(10, 0.5).unwrap();
230        assert_eq!(
231            a.batch(&candles),
232            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
233        );
234    }
235
236    #[test]
237    fn reset_clears_state() {
238        let candles: Vec<Candle> = (0..10)
239            .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
240            .collect();
241        let mut ab = AccelerationBands::new(5, 0.5).unwrap();
242        ab.batch(&candles);
243        assert!(ab.is_ready());
244        ab.reset();
245        assert!(!ab.is_ready());
246        assert_eq!(ab.update(candles[0]), None);
247    }
248
249    #[test]
250    fn zero_price_candle_collapses_ratio_to_zero() {
251        // `high + low == 0` is geometrically only reachable with a fully-zero
252        // bar (high >= low and both non-negative for a real market, but
253        // `Candle::new` accepts the degenerate `(0, 0, 0, 0)` case). The
254        // ratio guard must fire and the bands all collapse to zero.
255        let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 1.0, 0).unwrap();
256        let mut ab = AccelerationBands::new(1, 0.5).unwrap();
257        let v = ab.update(zero).unwrap();
258        assert_relative_eq!(v.upper, 0.0, epsilon = 1e-12);
259        assert_relative_eq!(v.middle, 0.0, epsilon = 1e-12);
260        assert_relative_eq!(v.lower, 0.0, epsilon = 1e-12);
261    }
262
263    /// Hand-computed reference. Single bar with `high = 12`, `low = 8`,
264    /// `close = 10`, `factor = 0.5`, `period = 1`.
265    /// `ratio  = (12 − 8) / (12 + 8) = 0.2`
266    /// `raw_up = 12 · (1 + 0.5 · 0.2) = 12 · 1.1 = 13.2`
267    /// `raw_lo = 8  · (1 − 0.5 · 0.2) = 8  · 0.9 = 7.2`
268    /// `middle = SMA(close, 1) = 10`
269    #[test]
270    fn reference_value_single_bar() {
271        let mut ab = AccelerationBands::new(1, 0.5).unwrap();
272        let v = ab.update(c(12.0, 8.0, 10.0)).unwrap();
273        assert_relative_eq!(v.upper, 13.2, epsilon = 1e-12);
274        assert_relative_eq!(v.middle, 10.0, epsilon = 1e-12);
275        assert_relative_eq!(v.lower, 7.2, epsilon = 1e-12);
276    }
277}