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    fn update(&mut self, candle: Candle) -> Option<AtrBandsOutput> {
84        let atr = self.atr.update(candle)?;
85        Some(AtrBandsOutput {
86            upper: candle.close + self.multiplier * atr,
87            middle: candle.close,
88            lower: candle.close - self.multiplier * atr,
89        })
90    }
91
92    fn reset(&mut self) {
93        self.atr.reset();
94    }
95
96    fn warmup_period(&self) -> usize {
97        self.atr.warmup_period()
98    }
99
100    fn is_ready(&self) -> bool {
101        self.atr.is_ready()
102    }
103
104    fn name(&self) -> &'static str {
105        "AtrBands"
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::traits::BatchExt;
113    use approx::assert_relative_eq;
114
115    fn c(h: f64, l: f64, cl: f64) -> Candle {
116        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
117    }
118
119    #[test]
120    fn rejects_zero_period() {
121        assert!(matches!(AtrBands::new(0, 3.0), Err(Error::PeriodZero)));
122    }
123
124    #[test]
125    fn rejects_non_positive_multiplier() {
126        assert!(matches!(
127            AtrBands::new(14, 0.0),
128            Err(Error::NonPositiveMultiplier)
129        ));
130        assert!(matches!(
131            AtrBands::new(14, -1.0),
132            Err(Error::NonPositiveMultiplier)
133        ));
134        assert!(matches!(
135            AtrBands::new(14, f64::INFINITY),
136            Err(Error::NonPositiveMultiplier)
137        ));
138    }
139
140    #[test]
141    fn accessors_and_metadata() {
142        let ab = AtrBands::new(14, 3.0).unwrap();
143        assert_eq!(ab.period(), 14);
144        assert_relative_eq!(ab.multiplier(), 3.0, epsilon = 1e-12);
145        assert_eq!(ab.warmup_period(), 14);
146        assert_eq!(ab.name(), "AtrBands");
147    }
148
149    #[test]
150    fn flat_market_collapses_bands() {
151        let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
152        let mut ab = AtrBands::new(5, 3.0).unwrap();
153        let last = ab.batch(&candles).into_iter().flatten().last().unwrap();
154        assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
155        assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
156        assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
157    }
158
159    #[test]
160    fn upper_above_middle_above_lower() {
161        let candles: Vec<Candle> = (0..50)
162            .map(|i| {
163                let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
164                c(m + 1.0, m - 1.0, m)
165            })
166            .collect();
167        let mut ab = AtrBands::new(14, 3.0).unwrap();
168        for o in ab.batch(&candles).into_iter().flatten() {
169            assert!(o.upper >= o.middle);
170            assert!(o.middle >= o.lower);
171        }
172    }
173
174    #[test]
175    fn batch_equals_streaming() {
176        let candles: Vec<Candle> = (0..40)
177            .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
178            .collect();
179        let mut a = AtrBands::new(10, 2.5).unwrap();
180        let mut b = AtrBands::new(10, 2.5).unwrap();
181        assert_eq!(
182            a.batch(&candles),
183            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
184        );
185    }
186
187    #[test]
188    fn reset_clears_state() {
189        let candles: Vec<Candle> = (0..20)
190            .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
191            .collect();
192        let mut ab = AtrBands::new(5, 3.0).unwrap();
193        ab.batch(&candles);
194        assert!(ab.is_ready());
195        ab.reset();
196        assert!(!ab.is_ready());
197        assert_eq!(ab.update(candles[0]), None);
198    }
199
200    /// Reference: with constant high-low spread of 2, ATR(period) converges to
201    /// 2 immediately; for multiplier 3 the bands are at `close ± 6`.
202    #[test]
203    fn reference_values_constant_spread() {
204        // Five identical candles with TR = 2 each: ATR seeds to 2 on bar 5.
205        let candles: Vec<Candle> = (0..5).map(|_| c(11.0, 9.0, 10.0)).collect();
206        let mut ab = AtrBands::new(5, 3.0).unwrap();
207        let out = ab.batch(&candles);
208        assert!(out[0].is_none() && out[3].is_none());
209        let v = out[4].unwrap();
210        assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9);
211        assert_relative_eq!(v.upper, 16.0, epsilon = 1e-9);
212        assert_relative_eq!(v.lower, 4.0, epsilon = 1e-9);
213    }
214}