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    #[inline]
98    fn update(&mut self, candle: Candle) -> Option<AccelerationBandsOutput> {
99        // (high + low) == 0 is geometrically impossible for valid OHLC
100        // (high >= low and a zero-sum requires both equal to 0, which would
101        // make the bar degenerate). Guard anyway so a hypothetical zero-price
102        // bar collapses the ratio to zero rather than emitting NaN.
103        let sum_hl = candle.high + candle.low;
104        let ratio = if sum_hl == 0.0 {
105            0.0
106        } else {
107            (candle.high - candle.low) / sum_hl
108        };
109        let raw_up = candle.high * self.factor.mul_add(ratio, 1.0);
110        let raw_lo = candle.low * (-self.factor).mul_add(ratio, 1.0);
111
112        // Feed all three SMAs unconditionally so they warm up in lock-step.
113        let upper = self.upper_sma.update(raw_up);
114        let middle = self.middle_sma.update(candle.close);
115        let lower = self.lower_sma.update(raw_lo);
116        let (upper, middle, lower) = (upper?, middle?, lower?);
117        Some(AccelerationBandsOutput {
118            upper,
119            middle,
120            lower,
121        })
122    }
123
124    fn reset(&mut self) {
125        self.upper_sma.reset();
126        self.middle_sma.reset();
127        self.lower_sma.reset();
128    }
129
130    #[inline]
131    fn warmup_period(&self) -> usize {
132        self.period
133    }
134
135    #[inline]
136    fn is_ready(&self) -> bool {
137        self.middle_sma.is_ready()
138    }
139
140    #[inline]
141    fn name(&self) -> &'static str {
142        "AccelerationBands"
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::traits::BatchExt;
150    use approx::assert_relative_eq;
151
152    fn c(h: f64, l: f64, cl: f64) -> Candle {
153        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
154    }
155
156    #[test]
157    fn rejects_zero_period() {
158        assert!(matches!(
159            AccelerationBands::new(0, 0.001),
160            Err(Error::PeriodZero)
161        ));
162    }
163
164    #[test]
165    fn rejects_non_positive_factor() {
166        assert!(matches!(
167            AccelerationBands::new(20, 0.0),
168            Err(Error::NonPositiveMultiplier)
169        ));
170        assert!(matches!(
171            AccelerationBands::new(20, -1.0),
172            Err(Error::NonPositiveMultiplier)
173        ));
174        assert!(matches!(
175            AccelerationBands::new(20, f64::NAN),
176            Err(Error::NonPositiveMultiplier)
177        ));
178    }
179
180    #[test]
181    fn accessors_and_metadata() {
182        let ab = AccelerationBands::classic();
183        let (p, f) = ab.parameters();
184        assert_eq!(p, 20);
185        assert_relative_eq!(f, 0.001, epsilon = 1e-12);
186        assert_eq!(ab.warmup_period(), 20);
187        assert_eq!(ab.name(), "AccelerationBands");
188    }
189
190    #[test]
191    fn flat_market_collapses_to_constant() {
192        // high == low so the ratio term is zero; all three SMAs converge to
193        // the same constant.
194        let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
195        let mut ab = AccelerationBands::new(5, 0.5).unwrap();
196        let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
197        assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
198        assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
199        assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
200    }
201
202    #[test]
203    fn warmup_returns_none() {
204        let mut ab = AccelerationBands::new(5, 0.001).unwrap();
205        for i in 0..4 {
206            let base = 100.0 + f64::from(i);
207            assert!(ab.update(c(base + 1.0, base - 1.0, base)).is_none());
208        }
209        assert!(ab.update(c(105.0, 103.0, 104.0)).is_some());
210    }
211
212    #[test]
213    fn upper_above_middle_above_lower() {
214        let candles: Vec<Candle> = (0..50)
215            .map(|i| {
216                let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
217                c(m + 1.0, m - 1.0, m)
218            })
219            .collect();
220        let mut ab = AccelerationBands::new(20, 0.5).unwrap();
221        for o in ab.batch(&candles).into_iter().flatten() {
222            assert!(o.upper >= o.middle, "{} < {}", o.upper, o.middle);
223            assert!(o.middle >= o.lower, "{} < {}", o.middle, o.lower);
224        }
225    }
226
227    #[test]
228    fn batch_equals_streaming() {
229        let candles: Vec<Candle> = (0..40)
230            .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
231            .collect();
232        let mut a = AccelerationBands::new(10, 0.5).unwrap();
233        let mut b = AccelerationBands::new(10, 0.5).unwrap();
234        assert_eq!(
235            a.batch(&candles),
236            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
237        );
238    }
239
240    #[test]
241    fn reset_clears_state() {
242        let candles: Vec<Candle> = (0..10)
243            .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
244            .collect();
245        let mut ab = AccelerationBands::new(5, 0.5).unwrap();
246        ab.batch(&candles);
247        assert!(ab.is_ready());
248        ab.reset();
249        assert!(!ab.is_ready());
250        assert_eq!(ab.update(candles[0]), None);
251    }
252
253    #[test]
254    fn zero_price_candle_collapses_ratio_to_zero() {
255        // `high + low == 0` is geometrically only reachable with a fully-zero
256        // bar (high >= low and both non-negative for a real market, but
257        // `Candle::new` accepts the degenerate `(0, 0, 0, 0)` case). The
258        // ratio guard must fire and the bands all collapse to zero.
259        let zero = Candle::new(0.0, 0.0, 0.0, 0.0, 1.0, 0).unwrap();
260        let mut ab = AccelerationBands::new(1, 0.5).unwrap();
261        let v = ab.update(zero).unwrap();
262        assert_relative_eq!(v.upper, 0.0, epsilon = 1e-12);
263        assert_relative_eq!(v.middle, 0.0, epsilon = 1e-12);
264        assert_relative_eq!(v.lower, 0.0, epsilon = 1e-12);
265    }
266
267    /// Hand-computed reference. Single bar with `high = 12`, `low = 8`,
268    /// `close = 10`, `factor = 0.5`, `period = 1`.
269    /// `ratio  = (12 − 8) / (12 + 8) = 0.2`
270    /// `raw_up = 12 · (1 + 0.5 · 0.2) = 12 · 1.1 = 13.2`
271    /// `raw_lo = 8  · (1 − 0.5 · 0.2) = 8  · 0.9 = 7.2`
272    /// `middle = SMA(close, 1) = 10`
273    #[test]
274    fn reference_value_single_bar() {
275        let mut ab = AccelerationBands::new(1, 0.5).unwrap();
276        let v = ab.update(c(12.0, 8.0, 10.0)).unwrap();
277        assert_relative_eq!(v.upper, 13.2, epsilon = 1e-12);
278        assert_relative_eq!(v.middle, 10.0, epsilon = 1e-12);
279        assert_relative_eq!(v.lower, 7.2, epsilon = 1e-12);
280    }
281}