Skip to main content

wickra_core/indicators/
qqe.rs

1//! QQE — Quantitative Qualitative Estimation.
2
3use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::indicators::rsi::Rsi;
6use crate::traits::Indicator;
7
8/// One QQE reading: the smoothed RSI and its volatility-trailing line.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct QqeOutput {
11    /// The EMA-smoothed RSI (the fast QQE line).
12    pub rsi_ma: f64,
13    /// The trailing line (the slow QQE line): an ATR-of-RSI trailing stop that
14    /// the smoothed RSI rides above in an uptrend and below in a downtrend.
15    pub trailing_line: f64,
16}
17
18/// QQE — Quantitative Qualitative Estimation (Igor Livshin).
19///
20/// QQE smooths the RSI, then builds an "ATR of the RSI" trailing stop around it.
21/// Crossovers of the smoothed RSI and that trailing line give cleaner momentum
22/// signals than the raw RSI:
23///
24/// ```text
25/// rsi_ma   = EMA(RSI(price, rsi_period), smoothing)
26/// atr_rsi  = |rsi_ma − rsi_ma_prev|
27/// ma_atr   = EMA(atr_rsi, 2·rsi_period − 1)        // Wilder length
28/// dar      = EMA(ma_atr, 2·rsi_period − 1) · factor // smoothed band width
29///
30/// long_band  = (rsi_ma_prev > long_band_prev  && rsi_ma > long_band_prev)
31///              ? max(long_band_prev,  rsi_ma − dar) : rsi_ma − dar
32/// short_band = (rsi_ma_prev < short_band_prev && rsi_ma < short_band_prev)
33///              ? min(short_band_prev, rsi_ma + dar) : rsi_ma + dar
34/// trend      = cross-up of short_band → +1, cross-down of long_band → −1, else hold
35/// trailing   = trend == +1 ? long_band : short_band
36/// ```
37///
38/// The trailing line ratchets in the trend direction (only ever tightening until
39/// the smoothed RSI crosses it), exactly like a [`SuperTrend`](crate::SuperTrend)
40/// on the RSI. Livshin's defaults are `rsi_period = 14`, `smoothing = 5`,
41/// `factor = 4.236`.
42///
43/// # Example
44///
45/// ```
46/// use wickra_core::{Indicator, Qqe};
47///
48/// let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
49/// let mut last = None;
50/// for i in 0..200 {
51///     last = qqe.update(100.0 + (f64::from(i) * 0.1).sin() * 8.0);
52/// }
53/// assert!(last.is_some());
54/// ```
55#[derive(Debug, Clone)]
56pub struct Qqe {
57    rsi: Rsi,
58    rsi_ma: Ema,
59    ma_atr: Ema,
60    dar_ema: Ema,
61    factor: f64,
62    prev_rsi_ma: Option<f64>,
63    bands: Option<(f64, f64, i8)>, // (long_band, short_band, trend)
64    last_value: Option<QqeOutput>,
65}
66
67impl Qqe {
68    /// Construct a QQE with the RSI period, RSI smoothing, and band `factor`.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`Error::PeriodZero`] if `rsi_period` or `smoothing` is `0`, or
73    /// [`Error::InvalidPeriod`] if `factor` is non-finite or not positive.
74    pub fn new(rsi_period: usize, smoothing: usize, factor: f64) -> Result<Self> {
75        if rsi_period == 0 || smoothing == 0 {
76            return Err(Error::PeriodZero);
77        }
78        if !factor.is_finite() || factor <= 0.0 {
79            return Err(Error::InvalidPeriod {
80                message: "QQE factor must be a finite positive value",
81            });
82        }
83        let wilders = 2 * rsi_period - 1;
84        Ok(Self {
85            rsi: Rsi::new(rsi_period)?,
86            rsi_ma: Ema::new(smoothing)?,
87            ma_atr: Ema::new(wilders)?,
88            dar_ema: Ema::new(wilders)?,
89            factor,
90            prev_rsi_ma: None,
91            bands: None,
92            last_value: None,
93        })
94    }
95
96    /// Configured band factor.
97    pub const fn factor(&self) -> f64 {
98        self.factor
99    }
100
101    /// Current value if available.
102    pub const fn value(&self) -> Option<QqeOutput> {
103        self.last_value
104    }
105}
106
107impl Indicator for Qqe {
108    type Input = f64;
109    type Output = QqeOutput;
110
111    fn update(&mut self, price: f64) -> Option<QqeOutput> {
112        let rsi = self.rsi.update(price)?;
113        let rsi_ma = self.rsi_ma.update(rsi)?;
114
115        let Some(prev_ma) = self.prev_rsi_ma else {
116            self.prev_rsi_ma = Some(rsi_ma);
117            return None;
118        };
119        let atr_rsi = (rsi_ma - prev_ma).abs();
120        self.prev_rsi_ma = Some(rsi_ma);
121
122        let ma_atr = self.ma_atr.update(atr_rsi)?;
123        let dar = self.dar_ema.update(ma_atr)? * self.factor;
124
125        let new_long = rsi_ma - dar;
126        let new_short = rsi_ma + dar;
127
128        let (long_band, short_band, trend) = match self.bands {
129            Some((lb_prev, sb_prev, tr_prev)) => {
130                let lb = if prev_ma > lb_prev && rsi_ma > lb_prev {
131                    lb_prev.max(new_long)
132                } else {
133                    new_long
134                };
135                let sb = if prev_ma < sb_prev && rsi_ma < sb_prev {
136                    sb_prev.min(new_short)
137                } else {
138                    new_short
139                };
140                let tr = if prev_ma <= sb_prev && rsi_ma > sb_prev {
141                    1
142                } else if prev_ma >= lb_prev && rsi_ma < lb_prev {
143                    -1
144                } else {
145                    tr_prev
146                };
147                (lb, sb, tr)
148            }
149            None => (new_long, new_short, 1),
150        };
151        self.bands = Some((long_band, short_band, trend));
152
153        let trailing_line = if trend == 1 { long_band } else { short_band };
154        let out = QqeOutput {
155            rsi_ma,
156            trailing_line,
157        };
158        self.last_value = Some(out);
159        Some(out)
160    }
161
162    fn reset(&mut self) {
163        self.rsi.reset();
164        self.rsi_ma.reset();
165        self.ma_atr.reset();
166        self.dar_ema.reset();
167        self.prev_rsi_ma = None;
168        self.bands = None;
169        self.last_value = None;
170    }
171
172    #[inline]
173    fn warmup_period(&self) -> usize {
174        // RSI (rsi_period + 1) -> rsi_ma EMA -> one bar for the first atr_rsi ->
175        // ma_atr EMA -> dar EMA. Expressed via the component warmups so it stays
176        // correct if those change.
177        self.rsi.warmup_period()
178            + self.rsi_ma.warmup_period()
179            + self.ma_atr.warmup_period()
180            + self.dar_ema.warmup_period()
181            - 2
182    }
183
184    #[inline]
185    fn is_ready(&self) -> bool {
186        self.last_value.is_some()
187    }
188
189    #[inline]
190    fn name(&self) -> &'static str {
191        "QQE"
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::traits::BatchExt;
199    use approx::assert_relative_eq;
200
201    /// Independent reference replaying the full QQE recurrence.
202    fn naive(
203        prices: &[f64],
204        rsi_period: usize,
205        smoothing: usize,
206        factor: f64,
207    ) -> Vec<Option<QqeOutput>> {
208        let mut rsi = Rsi::new(rsi_period).unwrap();
209        let mut rsi_ma = Ema::new(smoothing).unwrap();
210        let wilders = 2 * rsi_period - 1;
211        let mut ma_atr = Ema::new(wilders).unwrap();
212        let mut dar_ema = Ema::new(wilders).unwrap();
213        let mut prev_ma: Option<f64> = None;
214        let mut bands: Option<(f64, f64, i8)> = None;
215        let mut out = Vec::with_capacity(prices.len());
216        for &p in prices {
217            let v = (|| {
218                let r = rsi.update(p)?;
219                let m = rsi_ma.update(r)?;
220                let Some(pm) = prev_ma else {
221                    prev_ma = Some(m);
222                    return None;
223                };
224                let atr = (m - pm).abs();
225                prev_ma = Some(m);
226                let ma = ma_atr.update(atr)?;
227                let dar = dar_ema.update(ma)? * factor;
228                let nl = m - dar;
229                let ns = m + dar;
230                let (lb, sb, tr) = match bands {
231                    Some((lbp, sbp, trp)) => {
232                        let lb = if pm > lbp && m > lbp { lbp.max(nl) } else { nl };
233                        let sb = if pm < sbp && m < sbp { sbp.min(ns) } else { ns };
234                        let tr = if pm <= sbp && m > sbp {
235                            1
236                        } else if pm >= lbp && m < lbp {
237                            -1
238                        } else {
239                            trp
240                        };
241                        (lb, sb, tr)
242                    }
243                    None => (nl, ns, 1),
244                };
245                bands = Some((lb, sb, tr));
246                Some(QqeOutput {
247                    rsi_ma: m,
248                    trailing_line: if tr == 1 { lb } else { sb },
249                })
250            })();
251            out.push(v);
252        }
253        out
254    }
255
256    #[test]
257    fn rejects_bad_params() {
258        assert!(matches!(Qqe::new(0, 5, 4.236), Err(Error::PeriodZero)));
259        assert!(matches!(Qqe::new(14, 0, 4.236), Err(Error::PeriodZero)));
260        assert!(matches!(
261            Qqe::new(14, 5, 0.0),
262            Err(Error::InvalidPeriod { .. })
263        ));
264        assert!(matches!(
265            Qqe::new(14, 5, f64::NAN),
266            Err(Error::InvalidPeriod { .. })
267        ));
268    }
269
270    /// Cover the const accessors `factor` + `value` and the Indicator-impl
271    /// `name`. `warmup_period` is covered by `first_emission_matches_warmup`.
272    #[test]
273    fn accessors_and_metadata() {
274        let qqe = Qqe::new(14, 5, 4.236).unwrap();
275        assert_relative_eq!(qqe.factor(), 4.236, epsilon = 1e-12);
276        assert_eq!(qqe.value(), None);
277        assert_eq!(qqe.name(), "QQE");
278    }
279
280    #[test]
281    fn first_emission_matches_warmup() {
282        // A long trend-up-then-down series exercises both trend flips and the
283        // band tighten/reset branches.
284        let prices: Vec<f64> = (0..200)
285            .map(|i| 100.0 + (f64::from(i) * 0.06).sin() * 20.0)
286            .collect();
287        let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
288        let out = qqe.batch(&prices);
289        let warmup = qqe.warmup_period();
290        for (i, v) in out.iter().enumerate().take(warmup - 1) {
291            assert!(v.is_none(), "index {i} must be None during warmup");
292        }
293        assert!(
294            out[warmup - 1].is_some(),
295            "first value at warmup_period - 1"
296        );
297    }
298
299    #[test]
300    fn matches_naive_over_full_cycle() {
301        // Up, range, and down phases so every band/trend branch is traversed.
302        let prices: Vec<f64> = (0..220)
303            .map(|i| {
304                let t = f64::from(i);
305                100.0 + (t * 0.05).sin() * 18.0 + (t * 0.2).cos() * 4.0
306            })
307            .collect();
308        let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
309        let got = qqe.batch(&prices);
310        let want = naive(&prices, 14, 5, 4.236);
311        for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
312            assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}");
313            if let (Some(a), Some(b)) = (g, w) {
314                assert_relative_eq!(a.rsi_ma, b.rsi_ma, epsilon = 1e-9);
315                assert_relative_eq!(a.trailing_line, b.trailing_line, epsilon = 1e-9);
316            }
317        }
318    }
319
320    #[test]
321    fn trailing_line_below_rsi_ma_in_uptrend() {
322        // Sustained rise: trend resolves to +1 and the trailing (long) band sits
323        // below the smoothed RSI.
324        let prices: Vec<f64> = (1..=120).map(f64::from).collect();
325        let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
326        let last = qqe.batch(&prices).into_iter().flatten().last().unwrap();
327        assert!(
328            last.trailing_line <= last.rsi_ma,
329            "uptrend trailing {} should sit at/below rsi_ma {}",
330            last.trailing_line,
331            last.rsi_ma
332        );
333    }
334
335    #[test]
336    fn reset_clears_state() {
337        let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
338        qqe.batch(
339            &(0..120)
340                .map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 8.0)
341                .collect::<Vec<_>>(),
342        );
343        assert!(qqe.is_ready());
344        qqe.reset();
345        assert!(!qqe.is_ready());
346        assert_eq!(qqe.update(1.0), None);
347    }
348
349    #[test]
350    fn batch_equals_streaming() {
351        let prices: Vec<f64> = (0..150)
352            .map(|i| 50.0 + (f64::from(i) * 0.12).sin() * 12.0)
353            .collect();
354        let mut a = Qqe::new(14, 5, 4.236).unwrap();
355        let mut b = Qqe::new(14, 5, 4.236).unwrap();
356        assert_eq!(
357            a.batch(&prices),
358            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
359        );
360    }
361}