Skip to main content

wickra_core/indicators/
td_propulsion.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Propulsion — a 2-bar trend-continuation thrust signal.
4//!
5//! TD Propulsion qualifies a continuation thrust: the bar opens on the trend side
6//! of the prior close and then closes beyond the prior bar's extreme, "propelling"
7//! the move forward.
8//!
9//! - **Propulsion up** (`+1.0`): `open >= close[-1]` (opens at or above the prior
10//!   close) AND `close > high[-1]` (closes above the prior high).
11//! - **Propulsion down** (`-1.0`): `open <= close[-1]` AND `close < low[-1]`.
12//! - Otherwise the output is `0.0`.
13//!
14//! The one-bar lookback means the first value lands on the second candle.
15
16use crate::ohlcv::Candle;
17use crate::traits::Indicator;
18
19/// TD Propulsion — 2-bar trend-continuation thrust detector.
20/// # Example
21///
22/// ```
23/// use wickra_core::{TdPropulsion, Candle, Indicator};
24///
25/// let mut indicator = TdPropulsion::new();
26/// // `None` during warmup, then `Some(_)` once enough bars are seen.
27/// let mut out = None;
28/// for i in 0..40i64 {
29///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
30///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
31///     out = indicator.update(candle);
32/// }
33/// let _ = out;
34/// ```
35#[derive(Debug, Clone, Default)]
36pub struct TdPropulsion {
37    prev: Option<Candle>,
38    last_value: Option<f64>,
39}
40
41impl TdPropulsion {
42    /// Construct a new `TdPropulsion`.
43    #[must_use]
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Latest emitted signal if available.
49    pub const fn value(&self) -> Option<f64> {
50        self.last_value
51    }
52}
53
54impl Indicator for TdPropulsion {
55    type Input = Candle;
56    type Output = f64;
57
58    fn update(&mut self, candle: Candle) -> Option<f64> {
59        let Some(prev) = self.prev else {
60            self.prev = Some(candle);
61            self.last_value = Some(0.0);
62            return Some(0.0);
63        };
64        let v = if candle.open >= prev.close && candle.close > prev.high {
65            1.0
66        } else if candle.open <= prev.close && candle.close < prev.low {
67            -1.0
68        } else {
69            0.0
70        };
71        self.prev = Some(candle);
72        self.last_value = Some(v);
73        Some(v)
74    }
75
76    fn reset(&mut self) {
77        self.prev = None;
78        self.last_value = None;
79    }
80
81    fn warmup_period(&self) -> usize {
82        2
83    }
84
85    fn is_ready(&self) -> bool {
86        self.last_value.is_some()
87    }
88
89    fn name(&self) -> &'static str {
90        "TDPropulsion"
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::traits::BatchExt;
98
99    fn c(open: f64, high: f64, low: f64, close: f64) -> Candle {
100        Candle::new_unchecked(open, high, low, close, 0.0, 0)
101    }
102
103    #[test]
104    fn accessors_and_metadata() {
105        let td = TdPropulsion::new();
106        assert_eq!(td.warmup_period(), 2);
107        assert_eq!(td.name(), "TDPropulsion");
108        assert!(!td.is_ready());
109        assert_eq!(td.value(), None);
110    }
111
112    #[test]
113    fn first_bar_seeds_without_signal() {
114        let mut td = TdPropulsion::new();
115        assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0)), Some(0.0));
116        assert!(td.update(c(10.5, 12.0, 10.0, 11.5)).is_some());
117    }
118
119    #[test]
120    fn propulsion_up() {
121        // prev close 10, high 11. Current open 10.5 >= 10, close 11.5 > 11 -> +1.
122        let mut td = TdPropulsion::new();
123        td.update(c(9.5, 11.0, 9.0, 10.0));
124        assert_eq!(td.update(c(10.5, 12.0, 10.0, 11.5)), Some(1.0));
125    }
126
127    #[test]
128    fn propulsion_down() {
129        // prev close 10, low 9. Current open 9.5 <= 10, close 8.5 < 9 -> -1.
130        let mut td = TdPropulsion::new();
131        td.update(c(10.5, 11.0, 9.0, 10.0));
132        assert_eq!(td.update(c(9.5, 10.0, 8.0, 8.5)), Some(-1.0));
133    }
134
135    #[test]
136    fn no_thrust_is_zero() {
137        let mut td = TdPropulsion::new();
138        td.update(c(9.5, 11.0, 9.0, 10.0));
139        // close 10.5 not above prior high 11 -> 0.
140        assert_eq!(td.update(c(10.5, 10.8, 10.0, 10.5)), Some(0.0));
141    }
142
143    #[test]
144    fn reset_clears_state() {
145        let mut td = TdPropulsion::new();
146        td.update(c(9.5, 11.0, 9.0, 10.0));
147        td.update(c(10.5, 12.0, 10.0, 11.5));
148        assert!(td.is_ready());
149        td.reset();
150        assert!(!td.is_ready());
151        assert_eq!(td.update(c(9.5, 11.0, 9.0, 10.0)), Some(0.0));
152    }
153
154    #[test]
155    fn batch_equals_streaming() {
156        let candles: Vec<Candle> = (0..40)
157            .map(|i| {
158                let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
159                c(b, b + 1.0, b - 1.0, b + 0.3)
160            })
161            .collect();
162        let batch = TdPropulsion::new().batch(&candles);
163        let mut b = TdPropulsion::new();
164        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
165        assert_eq!(batch, streamed);
166    }
167}