Skip to main content

quantwave_core/indicators/
volatility.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::smoothing::EMA;
3use crate::traits::Next;
4use serde::{Deserialize, Serialize};
5
6pub use crate::indicators::incremental::ta_atr::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 Next<(f64, f64, f64)> for TrueRange {
26    type Output = f64;
27
28    fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
29        let tr = match self.prev_close {
30            Some(pc) => {
31                let h_l = high - low;
32                let h_pc = (high - pc).abs();
33                let l_pc = (low - pc).abs();
34                h_l.max(h_pc).max(l_pc)
35            }
36            None => high - low,
37        };
38        self.prev_close = Some(close);
39        tr
40    }
41}
42
43/// Average True Range (ATR) — **EMA-smoothed variant, not Wilder's RMA**.
44///
45/// This smooths true range with a standard EMA (`alpha = 2/(period+1)`), seeded
46/// from the first bar's `high - low` and emitting a value from bar 0 with no NaN
47/// warmup. It is **not** the ATR that Wilder defined in 1978 and that TA-Lib,
48/// TradingView Pine `ta.atr`, and `pandas.ewm(alpha=1/period, adjust=False)`
49/// compute — for that, use [`TaATR`], which is proptest-verified against
50/// `talib_rs::volatility::atr`.
51///
52/// No authoritative source has been recorded for this EMA-smoothed variant; the
53/// divergence and its blast radius (SuperTrend, Keltner, ATR trailing stop, TTM
54/// Squeeze, VPN, S/R monitor, volatility-clustering regime) are tracked in
55/// `quantwave-xnaf` and recorded as data in
56/// [`crate::indicators::conventions::CONVENTION_NOTES`].
57///
58/// Note that the Polars plugin `pl.col(close).ta.atr(high, low)` and
59/// `quantwave.talib.ATR` are backed by [`TaATR`] and *are* Wilder; only this
60/// struct and the `atr(period, high, low, close)` PyO3 batch function are not.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ATR {
63    tr: TrueRange,
64    smoothing: EMA,
65}
66
67impl ATR {
68    pub fn new(period: usize) -> Self {
69        Self {
70            tr: TrueRange::default(),
71            smoothing: EMA::new(period),
72        }
73    }
74}
75
76impl Next<(f64, f64, f64)> for ATR {
77    type Output = f64;
78
79    fn next(&mut self, input: (f64, f64, f64)) -> Self::Output {
80        let tr = self.tr.next(input);
81        self.smoothing.next(tr)
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::traits::Next;
89    use proptest::prelude::*;
90
91    proptest! {
92        #[test]
93        fn test_ta_atr_parity(
94            h in prop::collection::vec(1.0..100.0, 1..100),
95            l in prop::collection::vec(1.0..100.0, 1..100),
96            c in prop::collection::vec(1.0..100.0, 1..100)
97        ) {
98            let len = h.len().min(l.len()).min(c.len());
99            if len == 0 { return Ok(()); }
100            let mut high = Vec::with_capacity(len);
101            let mut low = Vec::with_capacity(len);
102            let mut close = Vec::with_capacity(len);
103            for i in 0..len {
104                let v_h: f64 = h[i];
105                let v_l: f64 = l[i];
106                let v_c: f64 = c[i];
107                high.push(v_h.max(v_l).max(v_c));
108                low.push(v_h.min(v_l).min(v_c));
109                close.push(v_c);
110            }
111
112            let period = 14;
113            let mut ta_atr = TaATR::new(period);
114            let streaming_results: Vec<f64> = (0..len).map(|i| ta_atr.next((high[i], low[i], close[i]))).collect();
115            let batch_results = talib_rs::volatility::atr(&high, &low, &close, period).unwrap_or_else(|_| vec![f64::NAN; len]);
116
117            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
118                if s.is_nan() {
119                    assert!(b.is_nan());
120                } else {
121                    approx::assert_relative_eq!(s, b, epsilon = 1e-6);
122                }
123            }
124        }
125
126        #[test]
127        fn test_ta_trange_parity(
128            h in prop::collection::vec(1.0..100.0, 1..100),
129            l in prop::collection::vec(1.0..100.0, 1..100),
130            c in prop::collection::vec(1.0..100.0, 1..100)
131        ) {
132            let len = h.len().min(l.len()).min(c.len());
133            if len == 0 { return Ok(()); }
134            let mut high = Vec::with_capacity(len);
135            let mut low = Vec::with_capacity(len);
136            let mut close = Vec::with_capacity(len);
137            for i in 0..len {
138                let v_h: f64 = h[i];
139                let v_l: f64 = l[i];
140                let v_c: f64 = c[i];
141                high.push(v_h.max(v_l).max(v_c));
142                low.push(v_h.min(v_l).min(v_c));
143                close.push(v_c);
144            }
145
146            let mut ta_tr = TaTRANGE::new();
147            let streaming_results: Vec<f64> = (0..len).map(|i| ta_tr.next((high[i], low[i], close[i]))).collect();
148            let batch_results = talib_rs::volatility::trange(&high, &low, &close).unwrap_or_else(|_| vec![f64::NAN; len]);
149
150            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
151                if s.is_nan() {
152                    assert!(b.is_nan());
153                } else {
154                    approx::assert_relative_eq!(s, b, epsilon = 1e-6);
155                }
156            }
157        }
158    }
159}
160
161pub const TRUE_RANGE_METADATA: IndicatorMetadata = IndicatorMetadata {
162    name: "True Range",
163    description: "True Range measures daily volatility.",
164    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.",
165    keywords: &["volatility", "atr", "classic", "range"],
166    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.",
167    params: &[],
168    formula_source: "https://www.investopedia.com/terms/a/atr.asp",
169    formula_latex: r#"
170\[
171TR = \max(H - L, |H - C_{t-1}|, |L - C_{t-1}|)
172\]
173"#,
174    gold_standard_file: "true_range.json",
175    category: "Classic",
176};
177
178pub const ATR_METADATA: IndicatorMetadata = IndicatorMetadata {
179    name: "Average True Range",
180    description: "ATR represents the average of true ranges over a specified period. CONVENTION: the `Atr` streaming class and the `atr(period, high, low, close)` batch function smooth true range with an EMA (alpha = 2/(period+1)), NOT Wilder's RMA. For TA-Lib/TradingView-identical ATR use `ta_atr`. See the Average True Range indicator guide for the full surface-by-surface breakdown.",
181    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.",
182    keywords: &[
183        "volatility",
184        "atr",
185        "classic",
186        "range",
187        "convention-divergence",
188    ],
189    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.",
190    params: &[ParamDef {
191        name: "period",
192        default: "14",
193        description: "Smoothing period",
194    }],
195    // NOTE (quantwave-xnaf): this URL documents Wilder's RMA, which is what `ta_atr`
196    // implements. No source has been established for the EMA-smoothed variant that
197    // `Atr` / `atr()` actually compute; per AGENTS.md it is recorded as unsourced in
198    // `conventions::CONVENTION_NOTES` rather than having a source assumed for it.
199    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`)",
200    formula_latex: r#"
201\[
202TR_t = \max(H_t - L_t, |H_t - C_{t-1}|, |L_t - C_{t-1}|)
203\]
204\[
205\text{Atr / atr() (EMA variant): } ATR_t = \alpha TR_t + (1-\alpha) ATR_{t-1}, \quad \alpha = \frac{2}{n+1}
206\]
207\[
208\text{ta\_atr (Wilder RMA, TA-Lib): } ATR_t = \frac{ATR_{t-1} \times (n-1) + TR_t}{n}
209\]
210"#,
211    gold_standard_file: "atr.json",
212    category: "Classic",
213};
214
215pub const NATR_METADATA: IndicatorMetadata = IndicatorMetadata {
216    name: "Normalized Average True Range (NATR)",
217    description: "A normalized version of ATR that represents volatility as a percentage of price.",
218    usage: "Use to compare volatility across different securities with varying price levels. NATR allows for normalized risk assessment and position sizing.",
219    keywords: &["volatility", "atr", "normalization", "classic"],
220    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",
221    params: &[ParamDef {
222        name: "timeperiod",
223        default: "14",
224        description: "Smoothing period",
225    }],
226    formula_source: "https://www.tradingtechnologies.com/help/x-study/technical-indicator-definitions/normalized-average-true-range-natr/",
227    formula_latex: r#"
228\[
229NATR = \frac{ATR(n)}{Close} \times 100
230\]
231"#,
232    gold_standard_file: "natr.json",
233    category: "Classic",
234};