Skip to main content

wickra_core/indicators/
atr_bands.rs

1//! ATR Bands.
2
3use crate::error::{Error, Result};
4use crate::indicators::atr::Atr;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// ATR Bands output.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct AtrBandsOutput {
11    /// Upper band: `close + multiplier · ATR`.
12    pub upper: f64,
13    /// Middle band: the current close.
14    pub middle: f64,
15    /// Lower band: `close − multiplier · ATR`.
16    pub lower: f64,
17}
18
19/// ATR Bands: a close-anchored envelope of width `multiplier · ATR`.
20///
21/// ```text
22/// upper = close + multiplier · ATR(period)
23/// lower = close − multiplier · ATR(period)
24/// ```
25///
26/// Unlike [`Keltner`](crate::Keltner) or [`StarcBands`](crate::StarcBands), the
27/// centerline is the *raw close* rather than a smoothed average — the band
28/// rides the price tick-for-tick. This is the standard volatility-targeting
29/// envelope traders use to set initial stop-loss and profit targets: an entry
30/// at the close sets a `multiplier · ATR` stop and the symmetric target
31/// without ever needing to wait for a moving average to warm up.
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{AtrBands, Candle, Indicator};
37///
38/// let mut indicator = AtrBands::new(14, 3.0).unwrap();
39/// let mut last = None;
40/// for i in 0..30 {
41///     let base = 100.0 + f64::from(i);
42///     let candle =
43///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
44///     last = indicator.update(candle);
45/// }
46/// assert!(last.is_some());
47/// ```
48#[derive(Debug, Clone)]
49pub struct AtrBands {
50    atr: Atr,
51    multiplier: f64,
52}
53
54impl AtrBands {
55    /// # Errors
56    /// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on
57    /// invalid inputs.
58    pub fn new(period: usize, multiplier: f64) -> Result<Self> {
59        if !multiplier.is_finite() || multiplier <= 0.0 {
60            return Err(Error::NonPositiveMultiplier);
61        }
62        Ok(Self {
63            atr: Atr::new(period)?,
64            multiplier,
65        })
66    }
67
68    /// Configured ATR period.
69    pub const fn period(&self) -> usize {
70        self.atr.period()
71    }
72
73    /// Configured ATR multiplier.
74    pub const fn multiplier(&self) -> f64 {
75        self.multiplier
76    }
77}
78
79impl Indicator for AtrBands {
80    type Input = Candle;
81    type Output = AtrBandsOutput;
82
83    #[inline]
84    fn update(&mut self, candle: Candle) -> Option<AtrBandsOutput> {
85        let atr = self.atr.update(candle)?;
86        Some(AtrBandsOutput {
87            upper: candle.close + self.multiplier * atr,
88            middle: candle.close,
89            lower: candle.close - self.multiplier * atr,
90        })
91    }
92
93    fn reset(&mut self) {
94        self.atr.reset();
95    }
96
97    #[inline]
98    fn warmup_period(&self) -> usize {
99        self.atr.warmup_period()
100    }
101
102    #[inline]
103    fn is_ready(&self) -> bool {
104        self.atr.is_ready()
105    }
106
107    #[inline]
108    fn name(&self) -> &'static str {
109        "AtrBands"
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::traits::BatchExt;
117    use approx::assert_relative_eq;
118
119    fn c(h: f64, l: f64, cl: f64) -> Candle {
120        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
121    }
122
123    #[test]
124    fn rejects_zero_period() {
125        assert!(matches!(AtrBands::new(0, 3.0), Err(Error::PeriodZero)));
126    }
127
128    #[test]
129    fn rejects_non_positive_multiplier() {
130        assert!(matches!(
131            AtrBands::new(14, 0.0),
132            Err(Error::NonPositiveMultiplier)
133        ));
134        assert!(matches!(
135            AtrBands::new(14, -1.0),
136            Err(Error::NonPositiveMultiplier)
137        ));
138        assert!(matches!(
139            AtrBands::new(14, f64::INFINITY),
140            Err(Error::NonPositiveMultiplier)
141        ));
142    }
143
144    #[test]
145    fn accessors_and_metadata() {
146        let ab = AtrBands::new(14, 3.0).unwrap();
147        assert_eq!(ab.period(), 14);
148        assert_relative_eq!(ab.multiplier(), 3.0, epsilon = 1e-12);
149        assert_eq!(ab.warmup_period(), 14);
150        assert_eq!(ab.name(), "AtrBands");
151    }
152
153    #[test]
154    fn flat_market_collapses_bands() {
155        let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
156        let mut ab = AtrBands::new(5, 3.0).unwrap();
157        let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
158        assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
159        assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
160        assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
161    }
162
163    #[test]
164    fn upper_above_middle_above_lower() {
165        let candles: Vec<Candle> = (0..50)
166            .map(|i| {
167                let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
168                c(m + 1.0, m - 1.0, m)
169            })
170            .collect();
171        let mut ab = AtrBands::new(14, 3.0).unwrap();
172        for o in ab.batch(&candles).into_iter().flatten() {
173            assert!(o.upper >= o.middle);
174            assert!(o.middle >= o.lower);
175        }
176    }
177
178    #[test]
179    fn batch_equals_streaming() {
180        let candles: Vec<Candle> = (0..40)
181            .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
182            .collect();
183        let mut a = AtrBands::new(10, 2.5).unwrap();
184        let mut b = AtrBands::new(10, 2.5).unwrap();
185        assert_eq!(
186            a.batch(&candles),
187            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
188        );
189    }
190
191    #[test]
192    fn reset_clears_state() {
193        let candles: Vec<Candle> = (0..20)
194            .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
195            .collect();
196        let mut ab = AtrBands::new(5, 3.0).unwrap();
197        ab.batch(&candles);
198        assert!(ab.is_ready());
199        ab.reset();
200        assert!(!ab.is_ready());
201        assert_eq!(ab.update(candles[0]), None);
202    }
203
204    /// Reference: with constant high-low spread of 2, ATR(period) converges to
205    /// 2 immediately; for multiplier 3 the bands are at `close ± 6`.
206    #[test]
207    fn reference_values_constant_spread() {
208        // Five identical candles with TR = 2 each: ATR seeds to 2 on bar 5.
209        let candles: Vec<Candle> = (0..5).map(|_| c(11.0, 9.0, 10.0)).collect();
210        let mut ab = AtrBands::new(5, 3.0).unwrap();
211        let out = ab.batch(&candles);
212        assert!(out[0].is_none() && out[3].is_none());
213        let v = out[4].unwrap();
214        assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9);
215        assert_relative_eq!(v.upper, 16.0, epsilon = 1e-9);
216        assert_relative_eq!(v.lower, 4.0, epsilon = 1e-9);
217    }
218}