Skip to main content

wickra_core/indicators/
td_trap.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Trap — an inside-bar ("trap") followed by a range breakout.
4//!
5//! A TD Trap forms when one bar is an **inside bar** (its high below and low above
6//! the prior bar's), coiling the market; the next bar that closes beyond the trap
7//! bar's high or low triggers the directional signal.
8//!
9//! - **Buy signal** (`+1.0`): the prior bar was an inside bar and the current
10//!   `close` is above that inside bar's `high`.
11//! - **Sell signal** (`-1.0`): the prior bar was an inside bar and the current
12//!   `close` is below that inside bar's `low`.
13//! - Otherwise the output is `0.0`.
14//!
15//! The two-bar lookback (one to set the inside bar, one before it) means the first
16//! value lands on the third candle.
17
18use crate::ohlcv::Candle;
19use crate::traits::Indicator;
20
21/// TD Trap — inside-bar breakout signal detector.
22/// # Example
23///
24/// ```
25/// use wickra_core::{TdTrap, Candle, Indicator};
26///
27/// let mut indicator = TdTrap::new();
28/// // `None` during warmup, then `Some(_)` once enough bars are seen.
29/// let mut out = None;
30/// for i in 0..40i64 {
31///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
32///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
33///     out = indicator.update(candle);
34/// }
35/// let _ = out;
36/// ```
37#[derive(Debug, Clone, Default)]
38pub struct TdTrap {
39    prev1: Option<Candle>,
40    prev2: Option<Candle>,
41    last_value: Option<f64>,
42}
43
44impl TdTrap {
45    /// Construct a new `TdTrap`.
46    #[must_use]
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 TdTrap {
58    type Input = Candle;
59    type Output = f64;
60
61    #[inline]
62    fn update(&mut self, candle: Candle) -> Option<f64> {
63        let (Some(trap), Some(before)) = (self.prev1, self.prev2) else {
64            // Not enough history yet: emit a neutral 0.0 while seeding.
65            self.prev2 = self.prev1;
66            self.prev1 = Some(candle);
67            self.last_value = None;
68            return None;
69        };
70        let is_inside = trap.high < before.high && trap.low > before.low;
71        let v = if is_inside && candle.close > trap.high {
72            1.0
73        } else if is_inside && candle.close < trap.low {
74            -1.0
75        } else {
76            0.0
77        };
78        self.prev2 = self.prev1;
79        self.prev1 = Some(candle);
80        self.last_value = Some(v);
81        Some(v)
82    }
83
84    fn reset(&mut self) {
85        self.prev1 = None;
86        self.prev2 = None;
87        self.last_value = None;
88    }
89
90    #[inline]
91    fn warmup_period(&self) -> usize {
92        3
93    }
94
95    #[inline]
96    fn is_ready(&self) -> bool {
97        self.last_value.is_some()
98    }
99
100    #[inline]
101    fn name(&self) -> &'static str {
102        "TDTrap"
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::traits::BatchExt;
110
111    fn c(high: f64, low: f64, close: f64) -> Candle {
112        Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 0.0, 0)
113    }
114
115    #[test]
116    fn accessors_and_metadata() {
117        let td = TdTrap::new();
118        assert_eq!(td.warmup_period(), 3);
119        assert_eq!(td.name(), "TDTrap");
120        assert!(!td.is_ready());
121        assert_eq!(td.value(), None);
122    }
123
124    #[test]
125    fn first_two_bars_seed_without_signal() {
126        let mut td = TdTrap::new();
127        assert_eq!(td.update(c(110.0, 90.0, 100.0)), None);
128        assert_eq!(td.update(c(108.0, 95.0, 102.0)), None);
129        assert!(td.update(c(112.0, 100.0, 110.0)).is_some());
130    }
131
132    #[test]
133    fn inside_then_breakout_up_buys() {
134        // bar0 wide [90,110]; bar1 inside [95,108]; bar2 close 109 > 108 -> +1.
135        let mut td = TdTrap::new();
136        td.update(c(110.0, 90.0, 100.0));
137        td.update(c(108.0, 95.0, 102.0)); // inside bar (high<110, low>90)
138        assert_eq!(td.update(c(112.0, 100.0, 109.0)), Some(1.0));
139    }
140
141    #[test]
142    fn inside_then_breakdown_sells() {
143        let mut td = TdTrap::new();
144        td.update(c(110.0, 90.0, 100.0));
145        td.update(c(108.0, 95.0, 102.0)); // inside bar
146        assert_eq!(td.update(c(100.0, 92.0, 94.0)), Some(-1.0)); // close 94 < 95
147    }
148
149    #[test]
150    fn no_inside_bar_is_zero() {
151        let mut td = TdTrap::new();
152        td.update(c(110.0, 90.0, 100.0));
153        td.update(c(115.0, 85.0, 100.0)); // outside bar, not inside
154        assert_eq!(td.update(c(120.0, 110.0, 118.0)), Some(0.0));
155    }
156
157    #[test]
158    fn inside_but_no_breakout_is_zero() {
159        let mut td = TdTrap::new();
160        td.update(c(110.0, 90.0, 100.0));
161        td.update(c(108.0, 95.0, 102.0)); // inside bar
162        assert_eq!(td.update(c(107.0, 96.0, 103.0)), Some(0.0)); // close 103 within [95,108]
163    }
164
165    #[test]
166    fn reset_clears_state() {
167        let mut td = TdTrap::new();
168        td.update(c(110.0, 90.0, 100.0));
169        td.update(c(108.0, 95.0, 102.0));
170        td.update(c(112.0, 100.0, 109.0));
171        assert!(td.is_ready());
172        td.reset();
173        assert!(!td.is_ready());
174        assert_eq!(td.update(c(110.0, 90.0, 100.0)), None);
175    }
176
177    #[test]
178    fn batch_equals_streaming() {
179        let candles: Vec<Candle> = (0..40)
180            .map(|i| {
181                let b = 100.0 + (f64::from(i) * 0.4).sin() * 6.0;
182                c(b + 2.0, b - 2.0, b)
183            })
184            .collect();
185        let batch = TdTrap::new().batch(&candles);
186        let mut b = TdTrap::new();
187        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
188        assert_eq!(batch, streamed);
189    }
190}