Skip to main content

wickra_core/indicators/
natr.rs

1//! Normalized Average True Range.
2
3use crate::error::Result;
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7use super::Atr;
8
9/// Normalized Average True Range — [`Atr`] expressed as a percentage of price.
10///
11/// `Atr` reports volatility in raw price units, which makes its readings
12/// impossible to compare across instruments at different price levels. NATR
13/// fixes that by dividing by the current close:
14///
15/// ```text
16/// NATR = 100 · ATR / close
17/// ```
18///
19/// A NATR of `2.0` always means "the average true range is 2 % of price",
20/// whether the instrument trades at $10 or $10 000 — so NATR values are
21/// directly comparable, and stop distances or position sizes expressed as a
22/// NATR multiple behave consistently across a portfolio.
23///
24/// # Example
25///
26/// ```
27/// use wickra_core::{Candle, Indicator, Natr};
28///
29/// let mut indicator = Natr::new(14).unwrap();
30/// let mut last = None;
31/// for i in 0..80 {
32///     let base = 100.0 + f64::from(i);
33///     let candle =
34///         Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap();
35///     last = indicator.update(candle);
36/// }
37/// assert!(last.is_some());
38/// ```
39#[derive(Debug, Clone)]
40pub struct Natr {
41    atr: Atr,
42    last: Option<f64>,
43}
44
45impl Natr {
46    /// Construct a new NATR with the given ATR period.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`crate::Error::PeriodZero`] if `period == 0`.
51    pub fn new(period: usize) -> Result<Self> {
52        Ok(Self {
53            atr: Atr::new(period)?,
54            last: None,
55        })
56    }
57
58    /// Configured period.
59    pub const fn period(&self) -> usize {
60        self.atr.period()
61    }
62
63    /// Current value if available.
64    pub const fn value(&self) -> Option<f64> {
65        self.last
66    }
67}
68
69impl Indicator for Natr {
70    type Input = Candle;
71    type Output = f64;
72
73    #[inline]
74    fn update(&mut self, candle: Candle) -> Option<f64> {
75        let atr = self.atr.update(candle)?;
76        let natr = if candle.close == 0.0 {
77            // NATR is undefined against a zero close.
78            0.0
79        } else {
80            100.0 * atr / candle.close
81        };
82        self.last = Some(natr);
83        Some(natr)
84    }
85
86    fn reset(&mut self) {
87        self.atr.reset();
88        self.last = None;
89    }
90
91    #[inline]
92    fn warmup_period(&self) -> usize {
93        self.atr.warmup_period()
94    }
95
96    #[inline]
97    fn is_ready(&self) -> bool {
98        self.last.is_some()
99    }
100
101    #[inline]
102    fn name(&self) -> &'static str {
103        "NATR"
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::traits::BatchExt;
111    use approx::assert_relative_eq;
112
113    fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
114        Candle::new(open, high, low, close, 1.0, ts).unwrap()
115    }
116
117    #[test]
118    fn new_rejects_zero_period() {
119        assert!(Natr::new(0).is_err());
120    }
121
122    #[test]
123    fn warmup_period_matches_atr() {
124        let natr = Natr::new(14).unwrap();
125        assert_eq!(natr.warmup_period(), 14);
126    }
127
128    /// Cover the const accessors `period` / `value` (lines 59-66) and the
129    /// Indicator-impl `name` body (98-100). `warmup_period` is covered
130    /// already by `warmup_period_matches_atr`.
131    #[test]
132    fn accessors_and_metadata() {
133        let mut natr = Natr::new(14).unwrap();
134        assert_eq!(natr.period(), 14);
135        assert_eq!(natr.name(), "NATR");
136        assert_eq!(natr.value(), None);
137        let candles: Vec<Candle> = (0..14)
138            .map(|i| candle(100.0, 102.0, 98.0, 101.0, i))
139            .collect();
140        for c in &candles {
141            natr.update(*c);
142        }
143        assert!(natr.value().is_some());
144    }
145
146    /// Cover the `candle.close == 0.0` defensive branch (line 77). All
147    /// other tests feed candles with close ≈ 100, so the zero-close
148    /// fallback never fired. Feed an all-zero candle series — the Candle
149    /// validator accepts open == high == low == close == 0 with positive
150    /// volume, and ATR is 0 each bar, so the indicator must emit exactly
151    /// 0.0 rather than computing 100 * 0 / 0 = NaN.
152    #[test]
153    fn zero_close_yields_zero_natr() {
154        let candles: Vec<Candle> = (0..15).map(|i| candle(0.0, 0.0, 0.0, 0.0, i)).collect();
155        let mut natr = Natr::new(5).unwrap();
156        let out = natr.batch(&candles);
157        let last = out.into_iter().flatten().last().expect("emits");
158        assert_eq!(last, 0.0);
159    }
160
161    #[test]
162    fn natr_is_atr_over_close_as_percent() {
163        // NATR must equal 100 * ATR / close, bar for bar.
164        let candles: Vec<Candle> = (0..60)
165            .map(|i| {
166                let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0;
167                candle(mid, mid + 3.0, mid - 3.0, mid + 1.0, i)
168            })
169            .collect();
170        let natr_out = Natr::new(14).unwrap().batch(&candles);
171        let atr_out = Atr::new(14).unwrap().batch(&candles);
172        for (i, (n, a)) in natr_out.iter().zip(atr_out.iter()).enumerate() {
173            // Same warmup period — emission shape must agree at every index.
174            assert_eq!(n.is_some(), a.is_some(), "warmup mismatch at index {i}");
175            if let (Some(nv), Some(av)) = (n, a) {
176                let want = 100.0 * av / candles[i].close;
177                assert_relative_eq!(*nv, want, epsilon = 1e-9);
178            }
179        }
180    }
181
182    #[test]
183    fn flat_market_yields_zero() {
184        // No range -> ATR is 0 -> NATR is 0.
185        let mut natr = Natr::new(5).unwrap();
186        let candles: Vec<Candle> = (0..30)
187            .map(|i| candle(100.0, 100.0, 100.0, 100.0, i))
188            .collect();
189        for v in natr.batch(&candles).into_iter().flatten() {
190            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
191        }
192    }
193
194    #[test]
195    fn reset_clears_state() {
196        let mut natr = Natr::new(5).unwrap();
197        let candles: Vec<Candle> = (0..20)
198            .map(|i| candle(100.0, 102.0, 98.0, 101.0, i))
199            .collect();
200        natr.batch(&candles);
201        assert!(natr.is_ready());
202        natr.reset();
203        assert!(!natr.is_ready());
204        assert_eq!(natr.update(candles[0]), None);
205    }
206
207    #[test]
208    fn batch_equals_streaming() {
209        let candles: Vec<Candle> = (0..80)
210            .map(|i| {
211                let mid = 100.0 + (i as f64 * 0.35).sin() * 9.0;
212                candle(mid, mid + 2.5, mid - 2.5, mid + 0.5, i)
213            })
214            .collect();
215        let batch = Natr::new(14).unwrap().batch(&candles);
216        let mut b = Natr::new(14).unwrap();
217        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
218        assert_eq!(batch, streamed);
219    }
220}