Skip to main content

quantwave_core/indicators/
volatility.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::smoothing::{EMA, WarmupPolicy};
3use crate::traits::Next;
4use serde::{Deserialize, Serialize};
5
6pub use crate::indicators::incremental::ta_atr::{AtrSeed, TaATR};
7impl From<usize> for TaATR {
8    fn from(p: usize) -> Self {
9        Self::new(p)
10    }
11}
12pub use crate::indicators::incremental::trange::{TaNATR, TaTRANGE};
13impl From<usize> for TaNATR {
14    fn from(p: usize) -> Self {
15        Self::new(p)
16    }
17}
18
19/// True Range (TR)
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21pub struct TrueRange {
22    prev_close: Option<f64>,
23}
24
25impl TrueRange {
26    pub fn new() -> Self {
27        Self::default()
28    }
29}
30
31impl Next<(f64, f64, f64)> for TrueRange {
32    type Output = f64;
33
34    fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
35        if high.is_nan() || low.is_nan() || close.is_nan() {
36            return f64::NAN;
37        }
38        let tr = match self.prev_close {
39            Some(pc) => {
40                let h_l = high - low;
41                let h_pc = (high - pc).abs();
42                let l_pc = (low - pc).abs();
43                h_l.max(h_pc).max(l_pc)
44            }
45            None => high - low,
46        };
47        self.prev_close = Some(close);
48        tr
49    }
50}
51
52/// Average True Range (ATR) — **EMA-smoothed variant, not Wilder's RMA**.
53///
54/// This smooths true range with a standard EMA (`alpha = 2/(period+1)`), seeded
55/// from the first bar's `high - low` and emitting a value from bar 0 with no NaN
56/// warmup. It is **not** the ATR that Wilder defined in 1978 and that TA-Lib,
57/// TradingView Pine `ta.atr`, and `pandas.ewm(alpha=1/period, adjust=False)`
58/// compute — for that, use [`TaATR`], which is proptest-verified against
59/// `talib_rs::volatility::atr`.
60///
61/// No authoritative source has been recorded for this EMA-smoothed variant; the
62/// divergence and its blast radius (SuperTrend, Keltner, ATR trailing stop, TTM
63/// Squeeze, VPN, S/R monitor, volatility-clustering regime) are tracked in
64/// `quantwave-xnaf` and recorded as data in
65/// [`crate::indicators::conventions::CONVENTION_NOTES`].
66///
67/// Note that the Polars plugin `pl.col(close).ta.atr(high, low)` and
68/// `quantwave.talib.ATR` are backed by [`TaATR`] and *are* Wilder; only this
69/// struct and the `atr(period, high, low, close)` PyO3 batch function are not.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ATR {
72    tr: TrueRange,
73    smoothing: EMA,
74}
75
76impl ATR {
77    pub fn new(period: usize) -> Self {
78        Self {
79            tr: TrueRange::default(),
80            smoothing: EMA::new(period),
81        }
82    }
83
84    pub fn with_warmup(period: usize, warmup: WarmupPolicy) -> Self {
85        Self {
86            tr: TrueRange::default(),
87            smoothing: EMA::with_warmup(period, warmup),
88        }
89    }
90}
91
92impl Next<(f64, f64, f64)> for ATR {
93    type Output = f64;
94
95    fn next(&mut self, input: (f64, f64, f64)) -> Self::Output {
96        let tr = self.tr.next(input);
97        self.smoothing.next(tr)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::traits::Next;
105    use proptest::prelude::*;
106
107    proptest! {
108        #[test]
109        fn test_ta_atr_parity(
110            h in prop::collection::vec(1.0..100.0, 1..100),
111            l in prop::collection::vec(1.0..100.0, 1..100),
112            c in prop::collection::vec(1.0..100.0, 1..100)
113        ) {
114            let len = h.len().min(l.len()).min(c.len());
115            if len == 0 { return Ok(()); }
116            let mut high = Vec::with_capacity(len);
117            let mut low = Vec::with_capacity(len);
118            let mut close = Vec::with_capacity(len);
119            for i in 0..len {
120                let v_h: f64 = h[i];
121                let v_l: f64 = l[i];
122                let v_c: f64 = c[i];
123                high.push(v_h.max(v_l).max(v_c));
124                low.push(v_h.min(v_l).min(v_c));
125                close.push(v_c);
126            }
127
128            let period = 14;
129            let mut ta_atr = TaATR::new(period);
130            let streaming_results: Vec<f64> = (0..len).map(|i| ta_atr.next((high[i], low[i], close[i]))).collect();
131            let batch_results = talib_rs::volatility::atr(&high, &low, &close, period).unwrap_or_else(|_| vec![f64::NAN; len]);
132
133            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
134                if s.is_nan() {
135                    assert!(b.is_nan());
136                } else {
137                    approx::assert_relative_eq!(s, b, epsilon = 1e-6);
138                }
139            }
140        }
141
142        #[test]
143        fn test_ta_trange_parity(
144            h in prop::collection::vec(1.0..100.0, 1..100),
145            l in prop::collection::vec(1.0..100.0, 1..100),
146            c in prop::collection::vec(1.0..100.0, 1..100)
147        ) {
148            let len = h.len().min(l.len()).min(c.len());
149            if len == 0 { return Ok(()); }
150            let mut high = Vec::with_capacity(len);
151            let mut low = Vec::with_capacity(len);
152            let mut close = Vec::with_capacity(len);
153            for i in 0..len {
154                let v_h: f64 = h[i];
155                let v_l: f64 = l[i];
156                let v_c: f64 = c[i];
157                high.push(v_h.max(v_l).max(v_c));
158                low.push(v_h.min(v_l).min(v_c));
159                close.push(v_c);
160            }
161
162            let mut ta_tr = TaTRANGE::new();
163            let streaming_results: Vec<f64> = (0..len).map(|i| ta_tr.next((high[i], low[i], close[i]))).collect();
164            let batch_results = talib_rs::volatility::trange(&high, &low, &close).unwrap_or_else(|_| vec![f64::NAN; len]);
165
166            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
167                if s.is_nan() {
168                    assert!(b.is_nan());
169                } else {
170                    approx::assert_relative_eq!(s, b, epsilon = 1e-6);
171                }
172            }
173        }
174    }
175}
176
177pub const TRUE_RANGE_METADATA: IndicatorMetadata = IndicatorMetadata {
178    name: "True Range",
179    description: "True Range measures daily volatility.",
180    usage: "Use as the foundational volatility module providing ATR, True Range, and related volatility measures used by higher-level indicators such as SuperTrend and Keltner Channels.",
181    keywords: &["volatility", "atr", "classic", "range"],
182    ehlers_summary: "Average True Range, developed by J. Welles Wilder in New Concepts in Technical Trading Systems (1978), measures the average of the true range over N bars. True Range accounts for overnight gaps by taking the maximum of: current high minus low, current high minus prior close, prior close minus current low. It remains the industry standard raw volatility measure.",
183    params: &[],
184    formula_source: "https://www.investopedia.com/terms/a/atr.asp",
185    formula_latex: r#"
186\[
187TR = \max(H - L, |H - C_{t-1}|, |L - C_{t-1}|)
188\]
189"#,
190    gold_standard_file: "true_range.json",
191    category: "Classic",
192};
193
194pub const ATR_METADATA: IndicatorMetadata = IndicatorMetadata {
195    name: "Average True Range",
196    description: "ATR represents the average of true ranges over a specified period. Standalone `atr()` and `Atr` default to Wilder's RMA (alpha = 1/period, TA-Lib standard); an EMA-smoothed variant (alpha = 2/(period+1), matching SuperTrend's default) is selectable via `smoothing='ema'`. Seeding defaults to TA-Lib standard (`seed='talib'`), with Wilder's 1978 textbook formula supported via `seed='wilder1978'` (`'classic'`).",
197    usage: "Use as the foundational volatility module providing ATR, True Range, and related volatility measures used by higher-level indicators such as SuperTrend and Keltner Channels. Check `quantwave.conventions(\"atr\")` before reconciling values against TA-Lib, TradingView, or pandas.",
198    keywords: &[
199        "volatility",
200        "atr",
201        "classic",
202        "range",
203        "convention-divergence",
204    ],
205    ehlers_summary: "Average True Range, developed by J. Welles Wilder in New Concepts in Technical Trading Systems (1978), measures the average of the true range over N bars. True Range accounts for overnight gaps by taking the maximum of: current high minus low, current high minus prior close, prior close minus current low. It remains the industry standard raw volatility measure.",
206    params: &[
207        ParamDef {
208            name: "period",
209            default: "14",
210            description: "Smoothing period",
211        },
212        ParamDef {
213            name: "smoothing",
214            default: "wilder",
215            description: "Smoothing algorithm: 'wilder' (default, TA-Lib standard RMA) or 'ema'",
216        },
217        ParamDef {
218            name: "seed",
219            default: "talib",
220            description: "Seed/warmup convention: 'talib' (default, seeds at bar period from TR[1..period], period NaNs) or 'wilder1978'/'classic' (Wilder 1978 book formula, TR[0]=H-L, seeds at bar period-1 from TR[0..period-1], period-1 NaNs)",
221        },
222    ],
223    // NOTE (quantwave-xnaf): this URL documents Wilder's RMA, which is what `ta_atr`
224    // implements. No source has been established for the EMA-smoothed variant that
225    // `Atr` / `atr()` actually compute; per AGENTS.md it is recorded as unsourced in
226    // `conventions::CONVENTION_NOTES` rather than having a source assumed for it.
227    formula_source: "https://www.investopedia.com/terms/a/atr.asp (documents Wilder's RMA — implemented by `ta_atr`; no source recorded for the EMA-smoothed `Atr`)",
228    formula_latex: r#"
229\[
230TR_t = \max(H_t - L_t, |H_t - C_{t-1}|, |L_t - C_{t-1}|)
231\]
232\[
233\text{Atr / atr() (EMA variant): } ATR_t = \alpha TR_t + (1-\alpha) ATR_{t-1}, \quad \alpha = \frac{2}{n+1}
234\]
235\[
236\text{ta\_atr (Wilder RMA, TA-Lib): } ATR_t = \frac{ATR_{t-1} \times (n-1) + TR_t}{n}
237\]
238"#,
239    gold_standard_file: "atr.json",
240    category: "Classic",
241};
242
243pub const NATR_METADATA: IndicatorMetadata = IndicatorMetadata {
244    name: "Normalized Average True Range (NATR)",
245    description: "A normalized version of ATR that represents volatility as a percentage of price.",
246    usage: "Use to compare volatility across different securities with varying price levels. NATR allows for normalized risk assessment and position sizing.",
247    keywords: &["volatility", "atr", "normalization", "classic"],
248    ehlers_summary: "Normalized ATR (NATR) was developed to allow traders to compare the volatility of high-priced stocks with low-priced stocks. By dividing the ATR by the closing price and multiplying by 100, the result is a percentage that can be used consistently across all assets. — TA-Lib Documentation",
249    params: &[ParamDef {
250        name: "timeperiod",
251        default: "14",
252        description: "Smoothing period",
253    }],
254    formula_source: "https://www.tradingtechnologies.com/help/x-study/technical-indicator-definitions/normalized-average-true-range-natr/",
255    formula_latex: r#"
256\[
257NATR = \frac{ATR(n)}{Close} \times 100
258\]
259"#,
260    gold_standard_file: "natr.json",
261    category: "Classic",
262};