Skip to main content

wickra_core/indicators/
atr.rs

1//! Average True Range (Wilder).
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Average True Range with Wilder smoothing.
8///
9/// The first emitted value, by convention, appears after `period` candles: the
10/// first `period − 1` true-range values seed the Wilder average alongside the
11/// `period`-th, then the smoothed update begins.
12///
13/// # Example
14///
15/// ```
16/// use wickra_core::{Candle, Indicator, Atr};
17///
18/// let mut indicator = Atr::new(5).unwrap();
19/// let mut last = None;
20/// for i in 0..80 {
21///     let base = 100.0 + f64::from(i);
22///     let candle =
23///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
24///     last = indicator.update(candle);
25/// }
26/// assert!(last.is_some());
27/// ```
28#[derive(Debug, Clone)]
29pub struct Atr {
30    period: usize,
31    /// `period - 1` as `f64`, precomputed for the Wilder smoothing step.
32    n_minus_1: f64,
33    /// `1 / period`, precomputed so the per-tick smoothing multiplies instead of
34    /// divides.
35    inv_period: f64,
36    prev_close: Option<f64>,
37    seed_buf: Vec<f64>,
38    /// Smoothed ATR, valid once `seeded` is set. Bare `f64` + flag rather than
39    /// `Option<f64>` so the hot recurrence avoids an enum-tag read per tick.
40    avg: f64,
41    seeded: bool,
42}
43
44impl Atr {
45    /// Construct an ATR with the given Wilder period.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`Error::PeriodZero`] if `period == 0`.
50    pub fn new(period: usize) -> Result<Self> {
51        if period == 0 {
52            return Err(Error::PeriodZero);
53        }
54        if period > crate::error::MAX_PERIOD {
55            return Err(Error::InvalidPeriod {
56                message: crate::error::PERIOD_ABOVE_MAX,
57            });
58        }
59        Ok(Self {
60            period,
61            n_minus_1: (period - 1) as f64,
62            inv_period: 1.0 / period as f64,
63            prev_close: None,
64            seed_buf: Vec::with_capacity(period),
65            avg: 0.0,
66            seeded: false,
67        })
68    }
69
70    /// Configured period.
71    pub const fn period(&self) -> usize {
72        self.period
73    }
74
75    /// Current value if available.
76    pub const fn value(&self) -> Option<f64> {
77        if self.seeded {
78            Some(self.avg)
79        } else {
80            None
81        }
82    }
83
84    /// Vectorized batch over raw high/low/close columns: one `f64` per bar
85    /// (`NaN` during warmup). The caller guarantees the three slices are equal
86    /// length and finite with valid OHLC ordering (the binding validates once up
87    /// front); ATR only reads high, low and the previous close.
88    ///
89    /// For a fresh indicator long enough to seed (`n >= period`) it runs the
90    /// true-range seed once and then the bare Wilder recurrence in a tight loop —
91    /// no per-bar `Candle` construction/validation, no `Option`, identical
92    /// division at the seed and `mul_add` afterwards, so the result is
93    /// *bit-for-bit* equal to replaying `update` over the same candles. Shorter
94    /// or non-fresh inputs defer to an exact `update` replay.
95    pub fn batch_atr(&mut self, high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
96        let p = self.period;
97        let n = high.len();
98        if self.seeded || !self.seed_buf.is_empty() || self.prev_close.is_some() || n < p {
99            let mut out = vec![f64::NAN; n];
100            for i in 0..n {
101                let candle = Candle::new_unchecked(close[i], high[i], low[i], close[i], 0.0, 0);
102                if let Some(v) = self.update(candle) {
103                    out[i] = v;
104                }
105            }
106            return out;
107        }
108
109        // Warmup `[0, p-1)` is `NaN`; the first ATR is emitted at index `p - 1`.
110        let mut out = vec![f64::NAN; p - 1];
111        out.reserve(n - (p - 1));
112        // Seed: mean of the first `period` true ranges. TRâ‚€ has no previous close.
113        let mut prev_close = close[0];
114        let mut sum_tr = high[0] - low[0];
115        self.seed_buf.push(sum_tr);
116        for i in 1..p {
117            let (h, l) = (high[i], low[i]);
118            let tr = (h - l)
119                .max((h - prev_close).abs())
120                .max((l - prev_close).abs());
121            prev_close = close[i];
122            self.seed_buf.push(tr);
123            sum_tr += tr;
124        }
125        let mut avg = sum_tr / p as f64;
126        out.push(avg);
127        // Steady state: Wilder smoothing, reciprocal hoisted out of the loop.
128        for i in p..n {
129            let (h, l) = (high[i], low[i]);
130            let tr = (h - l)
131                .max((h - prev_close).abs())
132                .max((l - prev_close).abs());
133            prev_close = close[i];
134            avg = avg.mul_add(self.n_minus_1, tr) * self.inv_period;
135            out.push(avg);
136        }
137
138        // Leave state where a full `update` replay would (seeded; seed_buf retained).
139        self.prev_close = Some(prev_close);
140        self.avg = avg;
141        self.seeded = true;
142        out
143    }
144}
145
146impl Indicator for Atr {
147    type Input = Candle;
148    type Output = f64;
149
150    #[inline]
151    fn update(&mut self, candle: Candle) -> Option<f64> {
152        let tr = candle.true_range(self.prev_close);
153        self.prev_close = Some(candle.close);
154
155        if self.seeded {
156            // Wilder smoothing with the reciprocal hoisted out of the hot path.
157            let new_avg = self.avg.mul_add(self.n_minus_1, tr) * self.inv_period;
158            self.avg = new_avg;
159            return Some(new_avg);
160        }
161
162        self.seed_buf.push(tr);
163        if self.seed_buf.len() == self.period {
164            let seed = self.seed_buf.iter().copied().sum::<f64>() / self.period as f64;
165            self.avg = seed;
166            self.seeded = true;
167            return Some(seed);
168        }
169        None
170    }
171
172    fn reset(&mut self) {
173        self.prev_close = None;
174        self.seed_buf.clear();
175        self.avg = 0.0;
176        self.seeded = false;
177    }
178
179    #[inline]
180    fn warmup_period(&self) -> usize {
181        self.period
182    }
183
184    #[inline]
185    fn is_ready(&self) -> bool {
186        self.seeded
187    }
188
189    #[inline]
190    fn name(&self) -> &'static str {
191        "ATR"
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::traits::BatchExt;
199    use approx::assert_relative_eq;
200
201    fn c(h: f64, l: f64, cl: f64) -> Candle {
202        // ts/open/volume don't affect ATR; use safe placeholders.
203        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
204    }
205
206    /// Independent reference: Wilder ATR computed straight from the definition.
207    fn atr_naive(hlc: &[(f64, f64, f64)], period: usize) -> Vec<Option<f64>> {
208        let n = period as f64;
209        let mut out = Vec::with_capacity(hlc.len());
210        let mut trs: Vec<f64> = Vec::new();
211        let mut avg: Option<f64> = None;
212        let mut prev_close: Option<f64> = None;
213        for &(h, l, cl) in hlc {
214            let tr = match prev_close {
215                None => h - l,
216                Some(pc) => (h - l).max((h - pc).abs()).max((l - pc).abs()),
217            };
218            prev_close = Some(cl);
219            if let Some(a) = avg {
220                let na = (a * (n - 1.0) + tr) / n;
221                avg = Some(na);
222                out.push(Some(na));
223            } else {
224                trs.push(tr);
225                if trs.len() == period {
226                    avg = Some(trs.iter().sum::<f64>() / n);
227                    out.push(avg);
228                } else {
229                    out.push(None);
230                }
231            }
232        }
233        out
234    }
235
236    #[test]
237    fn rejects_zero_period() {
238        assert!(matches!(Atr::new(0), Err(Error::PeriodZero)));
239    }
240
241    /// Cover the const accessors `period` / `value` (54-62) and the
242    /// Indicator-impl `name` body (103-105). Existing tests inspect
243    /// numeric ATR output but never query the metadata.
244    #[test]
245    fn accessors_and_metadata() {
246        let mut atr = Atr::new(14).unwrap();
247        assert_eq!(atr.period(), 14);
248        assert_eq!(atr.name(), "ATR");
249        assert_eq!(atr.value(), None);
250        for _ in 0..14 {
251            atr.update(c(11.0, 9.0, 10.0));
252        }
253        assert!(atr.value().is_some());
254    }
255
256    #[test]
257    fn warmup_emits_on_period_th_candle() {
258        let candles = vec![
259            c(2.0, 1.0, 1.5),
260            c(3.0, 2.0, 2.5),
261            c(4.0, 3.0, 3.5),
262            c(5.0, 4.0, 4.5),
263            c(6.0, 5.0, 5.5),
264        ];
265        let mut atr = Atr::new(3).unwrap();
266        let out = atr.batch(&candles);
267        assert!(out[0].is_none());
268        assert!(out[1].is_none());
269        assert!(out[2].is_some());
270        assert!(out[3].is_some());
271    }
272
273    #[test]
274    fn constant_range_yields_constant_atr() {
275        // Every candle has H=11, L=9, C=10 -> TR=2 (no gaps).
276        let candles: Vec<Candle> = (0..30).map(|_| c(11.0, 9.0, 10.0)).collect();
277        let mut atr = Atr::new(14).unwrap();
278        let out = atr.batch(&candles);
279        for v in out.iter().skip(13).flatten() {
280            assert_relative_eq!(*v, 2.0, epsilon = 1e-12);
281        }
282    }
283
284    #[test]
285    fn gap_up_uses_high_minus_prev_close() {
286        // Previous close 5, current candle H=10 L=9 C=9.5 -> TR = max(1, 5, 4) = 5.
287        let candles = vec![
288            c(6.0, 4.0, 5.0),  // prev close = 5
289            c(10.0, 9.0, 9.5), // TR = 5
290        ];
291        let mut atr = Atr::new(2).unwrap();
292        let out = atr.batch(&candles);
293        // Seed window covers TR_1 and TR_2. TR_1 = H1-L1 = 2 (no prev close). TR_2 = 5.
294        // Seed = (2+5)/2 = 3.5
295        assert_relative_eq!(out[1].unwrap(), 3.5, epsilon = 1e-12);
296    }
297
298    #[test]
299    fn batch_equals_streaming() {
300        let candles: Vec<Candle> = (0..40)
301            .map(|i| {
302                let mid = f64::from(i) + 10.0;
303                c(mid + 0.5, mid - 0.5, mid)
304            })
305            .collect();
306        let mut a = Atr::new(14).unwrap();
307        let mut b = Atr::new(14).unwrap();
308        assert_eq!(
309            a.batch(&candles),
310            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
311        );
312    }
313
314    #[test]
315    fn reset_clears_state() {
316        let candles: Vec<Candle> = (0..20).map(|_| c(11.0, 9.0, 10.0)).collect();
317        let mut atr = Atr::new(5).unwrap();
318        atr.batch(&candles);
319        assert!(atr.is_ready());
320        atr.reset();
321        assert!(!atr.is_ready());
322        assert_eq!(atr.update(candles[0]), None);
323    }
324
325    #[test]
326    fn never_negative() {
327        let candles: Vec<Candle> = (0..200)
328            .map(|i| {
329                let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
330                c(base + 1.0, base - 1.0, base)
331            })
332            .collect();
333        let mut atr = Atr::new(14).unwrap();
334        for v in atr.batch(&candles).into_iter().flatten() {
335            assert!(v >= 0.0, "ATR must be non-negative: {v}");
336        }
337    }
338
339    fn bits_eq(a: &[f64], b: &[f64]) -> bool {
340        a.len() == b.len()
341            && a.iter()
342                .zip(b)
343                .all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
344    }
345
346    fn atr_replay(period: usize, high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
347        let mut a = Atr::new(period).unwrap();
348        (0..high.len())
349            .map(|i| {
350                let candle = Candle::new_unchecked(close[i], high[i], low[i], close[i], 0.0, 0);
351                a.update(candle).unwrap_or(f64::NAN)
352            })
353            .collect()
354    }
355
356    /// Valid OHLC columns from a wandering base price.
357    fn columns(n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
358        let base: Vec<f64> = (0..n)
359            .map(|i| (f64::from(u32::try_from(i).unwrap()) * 0.3).sin() * 5.0 + 100.0)
360            .collect();
361        let high = base.iter().map(|b| b + 1.0).collect();
362        let low = base.iter().map(|b| b - 1.0).collect();
363        (high, low, base)
364    }
365
366    #[test]
367    fn batch_atr_fast_path_is_bit_identical() {
368        let (high, low, close) = columns(300);
369        let mut atr = Atr::new(14).unwrap();
370        let got = atr.batch_atr(&high, &low, &close);
371        assert!(bits_eq(&got, &atr_replay(14, &high, &low, &close)));
372        let mut ref_atr = Atr::new(14).unwrap();
373        for i in 0..high.len() {
374            ref_atr.update(Candle::new_unchecked(
375                close[i], high[i], low[i], close[i], 0.0, 0,
376            ));
377        }
378        let next = Candle::new_unchecked(101.0, 102.0, 100.0, 101.0, 0.0, 0);
379        assert_eq!(atr.update(next), ref_atr.update(next));
380    }
381
382    #[test]
383    fn batch_atr_falls_back_when_not_fresh() {
384        let (high, low, close) = columns(40);
385        let mut atr = Atr::new(14).unwrap();
386        atr.update(Candle::new_unchecked(
387            close[0], high[0], low[0], close[0], 0.0, 0,
388        ));
389        let mut ref_atr = Atr::new(14).unwrap();
390        ref_atr.update(Candle::new_unchecked(
391            close[0], high[0], low[0], close[0], 0.0, 0,
392        ));
393        let want: Vec<f64> = (0..high.len())
394            .map(|i| {
395                ref_atr
396                    .update(Candle::new_unchecked(
397                        close[i], high[i], low[i], close[i], 0.0, 0,
398                    ))
399                    .unwrap_or(f64::NAN)
400            })
401            .collect();
402        assert!(bits_eq(&atr.batch_atr(&high, &low, &close), &want));
403    }
404
405    #[test]
406    fn batch_atr_sub_period_slice_falls_back() {
407        let (high, low, close) = columns(5);
408        let mut atr = Atr::new(14).unwrap();
409        let got = atr.batch_atr(&high, &low, &close);
410        assert!(bits_eq(&got, &atr_replay(14, &high, &low, &close)));
411        assert!(got.iter().all(|x| x.is_nan()));
412    }
413
414    proptest::proptest! {
415        #![proptest_config(proptest::test_runner::Config::with_cases(48))]
416        #[test]
417        fn atr_matches_naive(
418            period in 1usize..15,
419            bars in proptest::collection::vec(
420                (10.0_f64..1000.0, 0.0_f64..50.0, 0.0_f64..1.0),
421                0..120,
422            ),
423        ) {
424            // bars: (low, range, close_fraction) -> a valid OHLC candle.
425            let hlc: Vec<(f64, f64, f64)> = bars
426                .iter()
427                .map(|&(low, range, frac)| (low + range, low, low + range * frac))
428                .collect();
429            let candles: Vec<Candle> = hlc.iter().map(|&(h, l, cl)| c(h, l, cl)).collect();
430            let mut atr = Atr::new(period).unwrap();
431            let got = atr.batch(&candles);
432            let want = atr_naive(&hlc, period);
433            proptest::prop_assert_eq!(got.len(), want.len());
434            for (g, w) in got.iter().zip(want.iter()) {
435                match (g, w) {
436                    (None, None) => {}
437                    (Some(a), Some(b)) => proptest::prop_assert!(
438                        (a - b).abs() <= 1e-9 * a.abs().max(1.0),
439                        "got={a} want={b}"
440                    ),
441                    _ => proptest::prop_assert!(false, "warmup mismatch"),
442                }
443            }
444        }
445    }
446}