Skip to main content

wickra_core/indicators/
td_rei.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark Range Expansion Index (TD REI).
4//!
5//! The TD REI is a `period`-bar bounded oscillator in `[-100, 100]` that
6//! detects exhaustion via comparisons of the current bar's range to the bars
7//! two and five-or-six bars earlier. The canonical TD REI uses a `period` of
8//! 5.
9//!
10//! Per bar `i` (requires history through `i - 7`):
11//!
12//! ```text
13//! cond1 = (high[i] >= low[i-5])  OR (high[i] >= low[i-6])
14//! cond2 = (low[i]  <= high[i-5]) OR (low[i]  <= high[i-6])
15//!
16//! if cond1 AND cond2:
17//!     numerator   = (high[i] - high[i-2]) + (low[i] - low[i-2])
18//! else:
19//!     numerator   = 0
20//!
21//! denominator = |high[i] - high[i-2]| + |low[i] - low[i-2]|
22//!
23//! REI(i) = 100 * sum(numerator, period) / sum(denominator, period)
24//! ```
25//!
26//! When the windowed denominator is zero the indicator falls back to `0` (the
27//! neutral midpoint). Readings above `+60` are typically considered
28//! overbought; below `-60` oversold.
29
30use std::collections::VecDeque;
31
32use crate::error::{Error, Result};
33use crate::ohlcv::Candle;
34use crate::traits::Indicator;
35
36/// TD Range Expansion Index oscillator.
37#[derive(Debug, Clone)]
38pub struct TdRei {
39    period: usize,
40    // Need at least the last 7 candles for the lookback comparisons; we keep a
41    // rolling window long enough for the rule plus enough numerator/
42    // denominator history.
43    candles: VecDeque<Candle>,
44    numerators: VecDeque<f64>,
45    denominators: VecDeque<f64>,
46    last_value: Option<f64>,
47}
48
49/// Minimum history required to evaluate the TD REI per-bar rule. The
50/// numerator and denominator both reference `bar[i-2]` and the long
51/// conditional references `bar[i-5]` and `bar[i-6]`, so we need the candle
52/// six bars before the current one to be available.
53const LOOKBACK: usize = 7;
54
55impl TdRei {
56    /// Construct a TD REI with the given averaging window. The classic
57    /// DeMark configuration is `period = 5`.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`Error::PeriodZero`] if `period == 0`.
62    pub fn new(period: usize) -> Result<Self> {
63        if period == 0 {
64            return Err(Error::PeriodZero);
65        }
66        if period > crate::error::MAX_PERIOD {
67            return Err(Error::InvalidPeriod {
68                message: crate::error::PERIOD_ABOVE_MAX,
69            });
70        }
71        Ok(Self {
72            period,
73            candles: VecDeque::with_capacity(LOOKBACK),
74            numerators: VecDeque::with_capacity(period),
75            denominators: VecDeque::with_capacity(period),
76            last_value: None,
77        })
78    }
79
80    /// DeMark's classic configuration: `period = 5`.
81    pub fn classic() -> Self {
82        Self::new(5).expect("classic TD REI parameters are valid")
83    }
84
85    /// Configured window.
86    pub const fn period(&self) -> usize {
87        self.period
88    }
89
90    /// Latest emitted value if available.
91    pub const fn value(&self) -> Option<f64> {
92        self.last_value
93    }
94}
95
96impl Indicator for TdRei {
97    type Input = Candle;
98    type Output = f64;
99
100    fn update(&mut self, candle: Candle) -> Option<f64> {
101        // Maintain a rolling window of the last `LOOKBACK` candles (front =
102        // 6 bars ago when full).
103        if self.candles.len() == LOOKBACK {
104            self.candles.pop_front();
105        }
106        if self.candles.len() < LOOKBACK - 1 {
107            // Need 6 previous candles before we can evaluate the rule on the
108            // current one.
109            self.candles.push_back(candle);
110            return None;
111        }
112        // candles currently holds the 6 most recent bars (in order); the new
113        // candle is the 7th. After the rule fires we push it onto the back.
114        // Indexing convention: index 0 is the oldest in the window (i.e. 6
115        // bars ago); index 5 is the bar just before the current one.
116        // For the rule we need:
117        //   bar[i-2] -> candles[len-2]  (here len == 6)
118        //   bar[i-5] -> candles[1]
119        //   bar[i-6] -> candles[0]
120        let prev2 = self.candles[self.candles.len() - 2];
121        let prev5 = self.candles[1];
122        let prev6 = self.candles[0];
123
124        let cond1 = candle.high >= prev5.low || candle.high >= prev6.low;
125        let cond2 = candle.low <= prev5.high || candle.low <= prev6.high;
126
127        let raw_num = (candle.high - prev2.high) + (candle.low - prev2.low);
128        let denominator = (candle.high - prev2.high).abs() + (candle.low - prev2.low).abs();
129        let numerator = if cond1 && cond2 { raw_num } else { 0.0 };
130
131        if self.numerators.len() == self.period {
132            self.numerators.pop_front();
133            self.denominators.pop_front();
134        }
135        self.numerators.push_back(numerator);
136        self.denominators.push_back(denominator);
137        self.candles.push_back(candle);
138
139        if self.numerators.len() < self.period {
140            return None;
141        }
142        let sum_num: f64 = self.numerators.iter().sum();
143        let sum_den: f64 = self.denominators.iter().sum();
144        let v = if sum_den == 0.0 {
145            0.0
146        } else {
147            100.0 * sum_num / sum_den
148        };
149        self.last_value = Some(v);
150        Some(v)
151    }
152
153    fn reset(&mut self) {
154        self.candles.clear();
155        self.numerators.clear();
156        self.denominators.clear();
157        self.last_value = None;
158    }
159
160    #[inline]
161    fn warmup_period(&self) -> usize {
162        // 6 bars to fill the lookback plus `period` updates to fill the
163        // numerator / denominator buffers.
164        (LOOKBACK - 1) + self.period
165    }
166
167    #[inline]
168    fn is_ready(&self) -> bool {
169        self.last_value.is_some()
170    }
171
172    #[inline]
173    fn name(&self) -> &'static str {
174        "TDREI"
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::traits::BatchExt;
182    use approx::assert_relative_eq;
183
184    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
185        Candle::new_unchecked(close, high, low, close, 0.0, ts)
186    }
187
188    #[test]
189    fn flat_market_yields_neutral_zero() {
190        // All highs and lows equal -> denominator is identically zero, so the
191        // indicator emits its neutral fallback of 0.
192        let candles: Vec<Candle> = (0..40).map(|i| c(11.0, 9.0, 10.0, i)).collect();
193        let mut rei = TdRei::classic();
194        let out = rei.batch(&candles);
195        for v in out.iter().skip(rei.warmup_period()).copied().flatten() {
196            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
197        }
198    }
199
200    #[test]
201    fn pure_uptrend_pegs_indicator_at_100() {
202        // Every bar makes strictly higher highs and lows. Both range-overlap
203        // conditions hold (current high > all previous lows; current low > all
204        // previous highs is false, but we need current low <= some prev
205        // high). For a slow steady uptrend cond2 still holds because
206        // current low < prev5/prev6 highs as long as the slope is moderate.
207        // With slope 1 and spread 2 (low to high), cond2 fails after ~3 bars.
208        // Use a smaller slope so cond2 holds throughout.
209        let candles: Vec<Candle> = (0..40)
210            .map(|i| {
211                let m = 100.0 + f64::from(i) * 0.1;
212                c(m + 1.0, m - 1.0, m, i64::from(i))
213            })
214            .collect();
215        let mut rei = TdRei::classic();
216        let last = rei.batch(&candles).into_iter().flatten().last().unwrap();
217        // Every numerator is positive (price moving up) and equals the
218        // denominator in magnitude (no sign flips), so REI saturates at 100.
219        assert_relative_eq!(last, 100.0, epsilon = 1e-9);
220    }
221
222    #[test]
223    fn pure_downtrend_pegs_indicator_at_minus_100() {
224        let candles: Vec<Candle> = (0..40)
225            .map(|i| {
226                let m = 100.0 - f64::from(i) * 0.1;
227                c(m + 1.0, m - 1.0, m, i64::from(i))
228            })
229            .collect();
230        let mut rei = TdRei::classic();
231        let last = rei.batch(&candles).into_iter().flatten().last().unwrap();
232        assert_relative_eq!(last, -100.0, epsilon = 1e-9);
233    }
234
235    #[test]
236    fn stays_in_minus_100_to_100() {
237        let candles: Vec<Candle> = (0..200)
238            .map(|i| {
239                let m = 50.0 + (f64::from(i) * 0.2).sin() * 5.0;
240                c(m + 1.0, m - 1.0, m, i64::from(i))
241            })
242            .collect();
243        let mut rei = TdRei::classic();
244        for v in rei.batch(&candles).into_iter().flatten() {
245            assert!((-100.0..=100.0).contains(&v), "out of range: {v}");
246        }
247    }
248
249    #[test]
250    fn batch_equals_streaming() {
251        let candles: Vec<Candle> = (0..80)
252            .map(|i| {
253                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
254                c(m + 1.0, m - 1.0, m, i64::from(i))
255            })
256            .collect();
257        let mut a = TdRei::classic();
258        let mut b = TdRei::classic();
259        assert_eq!(
260            a.batch(&candles),
261            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
262        );
263    }
264
265    #[test]
266    fn rejects_zero_period() {
267        assert!(matches!(TdRei::new(0), Err(Error::PeriodZero)));
268    }
269
270    #[test]
271    fn reset_clears_state() {
272        let candles: Vec<Candle> = (0..40)
273            .map(|i| {
274                let m = 100.0 + f64::from(i) * 0.1;
275                c(m + 1.0, m - 1.0, m, i64::from(i))
276            })
277            .collect();
278        let mut rei = TdRei::classic();
279        rei.batch(&candles);
280        assert!(rei.is_ready());
281        rei.reset();
282        assert!(!rei.is_ready());
283        assert_eq!(rei.update(candles[0]), None);
284        assert_eq!(rei.value(), None);
285    }
286
287    #[test]
288    fn accessors_and_metadata() {
289        let rei = TdRei::classic();
290        assert_eq!(rei.period(), 5);
291        assert_eq!(rei.warmup_period(), 6 + 5);
292        assert_eq!(rei.name(), "TDREI");
293    }
294}