Skip to main content

wickra_core/indicators/
td_open.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Open — open-vs-prior-range gap-reversal signal.
4//!
5//! TD Open flags bars whose open prints *outside* the prior bar's range
6//! but whose subsequent action recovers back inside it — a classic
7//! gap-and-fade reversal pattern.
8//!
9//! - **Buy signal** (`+1.0`) on bar `i` when:
10//!   1. `open[i] <  low[i - 1]`                  (gap-down open)
11//!   2. `high[i] >  low[i - 1]`                  (high recovers above the prior low)
12//! - **Sell signal** (`-1.0`) on bar `i` when:
13//!   1. `open[i] >  high[i - 1]`                 (gap-up open)
14//!   2. `low[i]  <  high[i - 1]`                 (low fades back under the prior high)
15//! - Otherwise the output is `0.0`.
16//!
17//! The one-bar lookback means the indicator emits its first value on the
18//! second input candle.
19
20use crate::ohlcv::Candle;
21use crate::traits::Indicator;
22
23/// TD Open — gap-and-fade reversal detector.
24/// # Example
25///
26/// ```
27/// use wickra_core::{TdOpen, Candle, Indicator};
28///
29/// let mut indicator = TdOpen::new();
30/// // `None` during warmup, then `Some(_)` once enough bars are seen.
31/// let mut out = None;
32/// for i in 0..40i64 {
33///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
34///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
35///     out = indicator.update(candle);
36/// }
37/// let _ = out;
38/// ```
39#[derive(Debug, Clone, Default)]
40pub struct TdOpen {
41    prev: Option<Candle>,
42    last_value: Option<f64>,
43}
44
45impl TdOpen {
46    /// Construct a new `TdOpen`.
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Latest emitted signal if available.
52    pub const fn value(&self) -> Option<f64> {
53        self.last_value
54    }
55}
56
57impl Indicator for TdOpen {
58    type Input = Candle;
59    type Output = f64;
60
61    #[inline]
62    fn update(&mut self, candle: Candle) -> Option<f64> {
63        let Some(prev) = self.prev else {
64            self.prev = Some(candle);
65            return None;
66        };
67        let v = if candle.open < prev.low && candle.high > prev.low {
68            1.0
69        } else if candle.open > prev.high && candle.low < prev.high {
70            -1.0
71        } else {
72            0.0
73        };
74        self.prev = Some(candle);
75        self.last_value = Some(v);
76        Some(v)
77    }
78
79    fn reset(&mut self) {
80        self.prev = None;
81        self.last_value = None;
82    }
83
84    #[inline]
85    fn warmup_period(&self) -> usize {
86        2
87    }
88
89    #[inline]
90    fn is_ready(&self) -> bool {
91        self.last_value.is_some()
92    }
93
94    #[inline]
95    fn name(&self) -> &'static str {
96        "TDOpen"
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::traits::BatchExt;
104
105    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
106        Candle::new_unchecked(open, high, low, close, 0.0, ts)
107    }
108
109    #[test]
110    fn buy_signal_on_gap_down_with_recovery() {
111        // Prev bar: low=10. Curr open=9 < 10, curr high=11 > 10 -> buy +1.
112        let mut td = TdOpen::new();
113        assert_eq!(td.update(c(10.0, 11.0, 10.0, 10.5, 0)), None);
114        assert_eq!(td.update(c(9.0, 11.0, 8.5, 9.5, 1)), Some(1.0));
115    }
116
117    #[test]
118    fn sell_signal_on_gap_up_with_fade() {
119        // Prev bar: high=12. Curr open=13 > 12, curr low=11 < 12 -> sell -1.
120        let mut td = TdOpen::new();
121        assert_eq!(td.update(c(10.0, 12.0, 9.0, 11.0, 0)), None);
122        assert_eq!(td.update(c(13.0, 13.5, 11.0, 11.5, 1)), Some(-1.0));
123    }
124
125    #[test]
126    fn no_signal_on_normal_open_within_range() {
127        // Open within previous range -> neither gap condition fires.
128        let mut td = TdOpen::new();
129        assert_eq!(td.update(c(10.0, 12.0, 9.0, 11.0, 0)), None);
130        assert_eq!(td.update(c(10.5, 11.5, 9.5, 11.0, 1)), Some(0.0));
131    }
132
133    #[test]
134    fn gap_down_without_recovery_is_zero() {
135        // Open below prev.low, but high stays below prev.low too -> no signal.
136        let mut td = TdOpen::new();
137        assert_eq!(td.update(c(10.0, 12.0, 10.0, 11.0, 0)), None);
138        // Curr open=9, curr high=9.5 -> high < prev.low (10) -> no buy.
139        assert_eq!(td.update(c(9.0, 9.5, 8.5, 9.0, 1)), Some(0.0));
140    }
141
142    #[test]
143    fn batch_equals_streaming() {
144        let candles: Vec<Candle> = (0..40)
145            .map(|i| {
146                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
147                c(m, m + 1.0, m - 1.0, m + 0.3, i64::from(i))
148            })
149            .collect();
150        let mut a = TdOpen::new();
151        let mut b = TdOpen::new();
152        assert_eq!(
153            a.batch(&candles),
154            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
155        );
156    }
157
158    #[test]
159    fn output_only_in_canonical_set() {
160        let candles: Vec<Candle> = (0..120)
161            .map(|i| {
162                let m = 100.0 + (f64::from(i) * 0.5).sin() * 5.0;
163                c(m, m + 1.0, m - 1.0, m + 0.3, i64::from(i))
164            })
165            .collect();
166        let mut td = TdOpen::new();
167        for v in td.batch(&candles).into_iter().flatten() {
168            assert!(v == -1.0 || v == 0.0 || v == 1.0, "unexpected value {v}");
169        }
170    }
171
172    #[test]
173    fn reset_clears_state() {
174        let mut td = TdOpen::new();
175        td.update(c(10.0, 11.0, 9.0, 10.0, 0));
176        td.update(c(10.5, 11.5, 9.5, 10.5, 1));
177        assert!(td.is_ready());
178        td.reset();
179        assert!(!td.is_ready());
180        assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0, 2)), None);
181        assert_eq!(td.value(), None);
182    }
183
184    #[test]
185    fn accessors_and_metadata() {
186        let td = TdOpen::new();
187        assert_eq!(td.warmup_period(), 2);
188        assert_eq!(td.name(), "TDOpen");
189        assert_eq!(td.value(), None);
190    }
191}