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    fn update(&mut self, candle: Candle) -> Option<f64> {
74        let atr = self.atr.update(candle)?;
75        let natr = if candle.close == 0.0 {
76            // NATR is undefined against a zero close.
77            0.0
78        } else {
79            100.0 * atr / candle.close
80        };
81        self.last = Some(natr);
82        Some(natr)
83    }
84
85    fn reset(&mut self) {
86        self.atr.reset();
87        self.last = None;
88    }
89
90    fn warmup_period(&self) -> usize {
91        self.atr.warmup_period()
92    }
93
94    fn is_ready(&self) -> bool {
95        self.last.is_some()
96    }
97
98    fn name(&self) -> &'static str {
99        "NATR"
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::traits::BatchExt;
107    use approx::assert_relative_eq;
108
109    fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
110        Candle::new(open, high, low, close, 1.0, ts).unwrap()
111    }
112
113    #[test]
114    fn new_rejects_zero_period() {
115        assert!(Natr::new(0).is_err());
116    }
117
118    #[test]
119    fn warmup_period_matches_atr() {
120        let natr = Natr::new(14).unwrap();
121        assert_eq!(natr.warmup_period(), 14);
122    }
123
124    /// Cover the const accessors `period` / `value` (lines 59-66) and the
125    /// Indicator-impl `name` body (98-100). `warmup_period` is covered
126    /// already by `warmup_period_matches_atr`.
127    #[test]
128    fn accessors_and_metadata() {
129        let mut natr = Natr::new(14).unwrap();
130        assert_eq!(natr.period(), 14);
131        assert_eq!(natr.name(), "NATR");
132        assert_eq!(natr.value(), None);
133        let candles: Vec<Candle> = (0..14)
134            .map(|i| candle(100.0, 102.0, 98.0, 101.0, i))
135            .collect();
136        for c in &candles {
137            natr.update(*c);
138        }
139        assert!(natr.value().is_some());
140    }
141
142    /// Cover the `candle.close == 0.0` defensive branch (line 77). All
143    /// other tests feed candles with close ≈ 100, so the zero-close
144    /// fallback never fired. Feed an all-zero candle series — the Candle
145    /// validator accepts open == high == low == close == 0 with positive
146    /// volume, and ATR is 0 each bar, so the indicator must emit exactly
147    /// 0.0 rather than computing 100 * 0 / 0 = NaN.
148    #[test]
149    fn zero_close_yields_zero_natr() {
150        let candles: Vec<Candle> = (0..15).map(|i| candle(0.0, 0.0, 0.0, 0.0, i)).collect();
151        let mut natr = Natr::new(5).unwrap();
152        let out = natr.batch(&candles);
153        let last = out.into_iter().flatten().last().expect("emits");
154        assert_eq!(last, 0.0);
155    }
156
157    #[test]
158    fn natr_is_atr_over_close_as_percent() {
159        // NATR must equal 100 * ATR / close, bar for bar.
160        let candles: Vec<Candle> = (0..60)
161            .map(|i| {
162                let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0;
163                candle(mid, mid + 3.0, mid - 3.0, mid + 1.0, i)
164            })
165            .collect();
166        let natr_out = Natr::new(14).unwrap().batch(&candles);
167        let atr_out = Atr::new(14).unwrap().batch(&candles);
168        for (i, (n, a)) in natr_out.iter().zip(atr_out.iter()).enumerate() {
169            // Same warmup period — emission shape must agree at every index.
170            assert_eq!(n.is_some(), a.is_some(), "warmup mismatch at index {i}");
171            if let (Some(nv), Some(av)) = (n, a) {
172                let want = 100.0 * av / candles[i].close;
173                assert_relative_eq!(*nv, want, epsilon = 1e-9);
174            }
175        }
176    }
177
178    #[test]
179    fn flat_market_yields_zero() {
180        // No range -> ATR is 0 -> NATR is 0.
181        let mut natr = Natr::new(5).unwrap();
182        let candles: Vec<Candle> = (0..30)
183            .map(|i| candle(100.0, 100.0, 100.0, 100.0, i))
184            .collect();
185        for v in natr.batch(&candles).into_iter().flatten() {
186            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
187        }
188    }
189
190    #[test]
191    fn reset_clears_state() {
192        let mut natr = Natr::new(5).unwrap();
193        let candles: Vec<Candle> = (0..20)
194            .map(|i| candle(100.0, 102.0, 98.0, 101.0, i))
195            .collect();
196        natr.batch(&candles);
197        assert!(natr.is_ready());
198        natr.reset();
199        assert!(!natr.is_ready());
200        assert_eq!(natr.update(candles[0]), None);
201    }
202
203    #[test]
204    fn batch_equals_streaming() {
205        let candles: Vec<Candle> = (0..80)
206            .map(|i| {
207                let mid = 100.0 + (i as f64 * 0.35).sin() * 9.0;
208                candle(mid, mid + 2.5, mid - 2.5, mid + 0.5, i)
209            })
210            .collect();
211        let batch = Natr::new(14).unwrap().batch(&candles);
212        let mut b = Natr::new(14).unwrap();
213        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
214        assert_eq!(batch, streamed);
215    }
216}