Skip to main content

wickra_core/indicators/
td_differential.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Differential — 2-bar momentum-divergence reversal pattern.
4//!
5//! TD Differential flags an exhaustion-and-reversal candle whose buying or
6//! selling pressure has shifted from the prior bar. The rules use the
7//! current bar's close vs the prior bar's close (direction filter), the
8//! buying pressure `close - low` and the selling pressure `high - close`.
9//!
10//! - **Buy signal** (`+1.0`) on bar `i` when:
11//!   1. `close[i]   <  close[i - 1]`                  (down day)
12//!   2. `close[i]   -  low[i]   >  close[i - 1] - low[i - 1]`   (more buying pressure than the prior bar)
13//!   3. `high[i]    -  close[i] <  high[i - 1] - close[i - 1]`  (less selling pressure than the prior bar)
14//! - **Sell signal** (`-1.0`) on bar `i` when:
15//!   1. `close[i]   >  close[i - 1]`
16//!   2. `high[i]    -  close[i] >  high[i - 1] - close[i - 1]`
17//!   3. `close[i]   -  low[i]   <  close[i - 1] - low[i - 1]`
18//! - Otherwise the output is `0.0`.
19//!
20//! The two-bar lookback means the indicator emits its first value on the
21//! second input candle.
22
23use crate::ohlcv::Candle;
24use crate::traits::Indicator;
25
26/// TD Differential — 2-bar reversal pattern detector.
27/// # Example
28///
29/// ```
30/// use wickra_core::{TdDifferential, Candle, Indicator};
31///
32/// let mut indicator = TdDifferential::new();
33/// // `None` during warmup, then `Some(_)` once enough bars are seen.
34/// let mut out = None;
35/// for i in 0..40i64 {
36///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
37///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
38///     out = indicator.update(candle);
39/// }
40/// let _ = out;
41/// ```
42#[derive(Debug, Clone, Default)]
43pub struct TdDifferential {
44    prev: Option<Candle>,
45    last_value: Option<f64>,
46}
47
48impl TdDifferential {
49    /// Construct a new `TdDifferential`.
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Latest emitted signal if available.
55    pub const fn value(&self) -> Option<f64> {
56        self.last_value
57    }
58}
59
60impl Indicator for TdDifferential {
61    type Input = Candle;
62    type Output = f64;
63
64    #[inline]
65    fn update(&mut self, candle: Candle) -> Option<f64> {
66        let Some(prev) = self.prev else {
67            self.prev = Some(candle);
68            return None;
69        };
70        let buying_now = candle.close - candle.low;
71        let buying_prev = prev.close - prev.low;
72        let selling_now = candle.high - candle.close;
73        let selling_prev = prev.high - prev.close;
74
75        let v = if candle.close < prev.close
76            && buying_now > buying_prev
77            && selling_now < selling_prev
78        {
79            1.0
80        } else if candle.close > prev.close
81            && selling_now > selling_prev
82            && buying_now < buying_prev
83        {
84            -1.0
85        } else {
86            0.0
87        };
88
89        self.prev = Some(candle);
90        self.last_value = Some(v);
91        Some(v)
92    }
93
94    fn reset(&mut self) {
95        self.prev = None;
96        self.last_value = None;
97    }
98
99    #[inline]
100    fn warmup_period(&self) -> usize {
101        2
102    }
103
104    #[inline]
105    fn is_ready(&self) -> bool {
106        self.last_value.is_some()
107    }
108
109    #[inline]
110    fn name(&self) -> &'static str {
111        "TDDifferential"
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::traits::BatchExt;
119    use approx::assert_relative_eq;
120
121    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
122        Candle::new_unchecked(close, high, low, close, 0.0, ts)
123    }
124
125    #[test]
126    fn buy_signal_on_strong_down_close_with_more_buying_pressure() {
127        // Prev bar: high=10, low=8, close=9 -> buying=1, selling=1.
128        // Curr bar: high=9, low=7, close=8.5 -> close<prev.close (8.5<9),
129        // buying=1.5 > 1, selling=0.5 < 1 -> buy signal +1.
130        let mut td = TdDifferential::new();
131        assert_eq!(td.update(c(10.0, 8.0, 9.0, 0)), None);
132        assert_eq!(td.update(c(9.0, 7.0, 8.5, 1)), Some(1.0));
133    }
134
135    #[test]
136    fn sell_signal_on_strong_up_close_with_more_selling_pressure() {
137        // Prev bar: high=10, low=8, close=9 -> buying=1, selling=1.
138        // Curr bar: high=12, low=9, close=10.5 -> close>prev.close (10.5>9),
139        // selling=1.5 > 1, buying=1.5 > 1 -> condition 3 fails -> no signal.
140        // Build a real sell case:
141        // Curr bar: high=12, low=9.5, close=10.5 ->
142        //   close>prev.close: 10.5>9 ✓
143        //   selling = 12 - 10.5 = 1.5 > prev.selling 1 ✓
144        //   buying  = 10.5 - 9.5 = 1.0 < prev.buying 1 → NO (need strict <).
145        // Curr bar: high=12, low=9.8, close=10.5 ->
146        //   buying = 0.7 < 1 ✓; selling = 1.5 > 1 ✓; close>prev ✓ -> sell.
147        let mut td = TdDifferential::new();
148        assert_eq!(td.update(c(10.0, 8.0, 9.0, 0)), None);
149        assert_relative_eq!(td.update(c(12.0, 9.8, 10.5, 1)).unwrap(), -1.0);
150    }
151
152    #[test]
153    fn no_signal_on_neutral_bar() {
154        // Identical bars -> equality everywhere -> zero.
155        let mut td = TdDifferential::new();
156        assert_eq!(td.update(c(10.0, 8.0, 9.0, 0)), None);
157        assert_eq!(td.update(c(10.0, 8.0, 9.0, 1)), Some(0.0));
158    }
159
160    #[test]
161    fn batch_equals_streaming() {
162        let candles: Vec<Candle> = (0..40)
163            .map(|i| {
164                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
165                c(m + 1.0, m - 1.0, m, i64::from(i))
166            })
167            .collect();
168        let mut a = TdDifferential::new();
169        let mut b = TdDifferential::new();
170        assert_eq!(
171            a.batch(&candles),
172            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
173        );
174    }
175
176    #[test]
177    fn output_only_in_canonical_set() {
178        // Every emitted value is in {-1, 0, +1}.
179        let candles: Vec<Candle> = (0..120)
180            .map(|i| {
181                let m = 100.0 + (f64::from(i) * 0.5).sin() * 5.0;
182                c(m + 1.0, m - 1.0, m, i64::from(i))
183            })
184            .collect();
185        let mut td = TdDifferential::new();
186        for v in td.batch(&candles).into_iter().flatten() {
187            assert!(v == -1.0 || v == 0.0 || v == 1.0, "unexpected value {v}");
188        }
189    }
190
191    #[test]
192    fn reset_clears_state() {
193        let mut td = TdDifferential::new();
194        td.update(c(10.0, 8.0, 9.0, 0));
195        td.update(c(11.0, 9.0, 10.0, 1));
196        assert!(td.is_ready());
197        td.reset();
198        assert!(!td.is_ready());
199        assert_eq!(td.update(c(10.0, 8.0, 9.0, 2)), None);
200        assert_eq!(td.value(), None);
201    }
202
203    #[test]
204    fn accessors_and_metadata() {
205        let td = TdDifferential::new();
206        assert_eq!(td.warmup_period(), 2);
207        assert_eq!(td.name(), "TDDifferential");
208        assert_eq!(td.value(), None);
209    }
210}