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#[derive(Debug, Clone, Default)]
23pub struct TdTrap {
24    prev1: Option<Candle>,
25    prev2: Option<Candle>,
26    last_value: Option<f64>,
27}
28
29impl TdTrap {
30    /// Construct a new `TdTrap`.
31    #[must_use]
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Latest emitted signal if available.
37    pub const fn value(&self) -> Option<f64> {
38        self.last_value
39    }
40}
41
42impl Indicator for TdTrap {
43    type Input = Candle;
44    type Output = f64;
45
46    fn update(&mut self, candle: Candle) -> Option<f64> {
47        let (Some(trap), Some(before)) = (self.prev1, self.prev2) else {
48            // Not enough history yet: emit a neutral 0.0 while seeding.
49            self.prev2 = self.prev1;
50            self.prev1 = Some(candle);
51            self.last_value = Some(0.0);
52            return Some(0.0);
53        };
54        let is_inside = trap.high < before.high && trap.low > before.low;
55        let v = if is_inside && candle.close > trap.high {
56            1.0
57        } else if is_inside && candle.close < trap.low {
58            -1.0
59        } else {
60            0.0
61        };
62        self.prev2 = self.prev1;
63        self.prev1 = Some(candle);
64        self.last_value = Some(v);
65        Some(v)
66    }
67
68    fn reset(&mut self) {
69        self.prev1 = None;
70        self.prev2 = None;
71        self.last_value = None;
72    }
73
74    fn warmup_period(&self) -> usize {
75        3
76    }
77
78    fn is_ready(&self) -> bool {
79        self.last_value.is_some()
80    }
81
82    fn name(&self) -> &'static str {
83        "TDTrap"
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::traits::BatchExt;
91
92    fn c(high: f64, low: f64, close: f64) -> Candle {
93        Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 0.0, 0)
94    }
95
96    #[test]
97    fn accessors_and_metadata() {
98        let td = TdTrap::new();
99        assert_eq!(td.warmup_period(), 3);
100        assert_eq!(td.name(), "TDTrap");
101        assert!(!td.is_ready());
102        assert_eq!(td.value(), None);
103    }
104
105    #[test]
106    fn first_two_bars_seed_without_signal() {
107        let mut td = TdTrap::new();
108        assert_eq!(td.update(c(110.0, 90.0, 100.0)), Some(0.0));
109        assert_eq!(td.update(c(108.0, 95.0, 102.0)), Some(0.0));
110        assert!(td.update(c(112.0, 100.0, 110.0)).is_some());
111    }
112
113    #[test]
114    fn inside_then_breakout_up_buys() {
115        // bar0 wide [90,110]; bar1 inside [95,108]; bar2 close 109 > 108 -> +1.
116        let mut td = TdTrap::new();
117        td.update(c(110.0, 90.0, 100.0));
118        td.update(c(108.0, 95.0, 102.0)); // inside bar (high<110, low>90)
119        assert_eq!(td.update(c(112.0, 100.0, 109.0)), Some(1.0));
120    }
121
122    #[test]
123    fn inside_then_breakdown_sells() {
124        let mut td = TdTrap::new();
125        td.update(c(110.0, 90.0, 100.0));
126        td.update(c(108.0, 95.0, 102.0)); // inside bar
127        assert_eq!(td.update(c(100.0, 92.0, 94.0)), Some(-1.0)); // close 94 < 95
128    }
129
130    #[test]
131    fn no_inside_bar_is_zero() {
132        let mut td = TdTrap::new();
133        td.update(c(110.0, 90.0, 100.0));
134        td.update(c(115.0, 85.0, 100.0)); // outside bar, not inside
135        assert_eq!(td.update(c(120.0, 110.0, 118.0)), Some(0.0));
136    }
137
138    #[test]
139    fn inside_but_no_breakout_is_zero() {
140        let mut td = TdTrap::new();
141        td.update(c(110.0, 90.0, 100.0));
142        td.update(c(108.0, 95.0, 102.0)); // inside bar
143        assert_eq!(td.update(c(107.0, 96.0, 103.0)), Some(0.0)); // close 103 within [95,108]
144    }
145
146    #[test]
147    fn reset_clears_state() {
148        let mut td = TdTrap::new();
149        td.update(c(110.0, 90.0, 100.0));
150        td.update(c(108.0, 95.0, 102.0));
151        td.update(c(112.0, 100.0, 109.0));
152        assert!(td.is_ready());
153        td.reset();
154        assert!(!td.is_ready());
155        assert_eq!(td.update(c(110.0, 90.0, 100.0)), Some(0.0));
156    }
157
158    #[test]
159    fn batch_equals_streaming() {
160        let candles: Vec<Candle> = (0..40)
161            .map(|i| {
162                let b = 100.0 + (f64::from(i) * 0.4).sin() * 6.0;
163                c(b + 2.0, b - 2.0, b)
164            })
165            .collect();
166        let batch = TdTrap::new().batch(&candles);
167        let mut b = TdTrap::new();
168        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
169        assert_eq!(batch, streamed);
170    }
171}