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    #[inline]
59    fn update(&mut self, candle: Candle) -> Option<f64> {
60        let Some(prev) = self.prev else {
61            self.prev = Some(candle);
62            self.last_value = None;
63            return None;
64        };
65        let v = if candle.open >= prev.close && candle.close > prev.high {
66            1.0
67        } else if candle.open <= prev.close && candle.close < prev.low {
68            -1.0
69        } else {
70            0.0
71        };
72        self.prev = Some(candle);
73        self.last_value = Some(v);
74        Some(v)
75    }
76
77    fn reset(&mut self) {
78        self.prev = None;
79        self.last_value = None;
80    }
81
82    #[inline]
83    fn warmup_period(&self) -> usize {
84        2
85    }
86
87    #[inline]
88    fn is_ready(&self) -> bool {
89        self.last_value.is_some()
90    }
91
92    #[inline]
93    fn name(&self) -> &'static str {
94        "TDPropulsion"
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::traits::BatchExt;
102
103    fn c(open: f64, high: f64, low: f64, close: f64) -> Candle {
104        Candle::new_unchecked(open, high, low, close, 0.0, 0)
105    }
106
107    #[test]
108    fn accessors_and_metadata() {
109        let td = TdPropulsion::new();
110        assert_eq!(td.warmup_period(), 2);
111        assert_eq!(td.name(), "TDPropulsion");
112        assert!(!td.is_ready());
113        assert_eq!(td.value(), None);
114    }
115
116    #[test]
117    fn first_bar_seeds_without_signal() {
118        let mut td = TdPropulsion::new();
119        assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0)), None);
120        assert!(td.update(c(10.5, 12.0, 10.0, 11.5)).is_some());
121    }
122
123    #[test]
124    fn propulsion_up() {
125        // prev close 10, high 11. Current open 10.5 >= 10, close 11.5 > 11 -> +1.
126        let mut td = TdPropulsion::new();
127        td.update(c(9.5, 11.0, 9.0, 10.0));
128        assert_eq!(td.update(c(10.5, 12.0, 10.0, 11.5)), Some(1.0));
129    }
130
131    #[test]
132    fn propulsion_down() {
133        // prev close 10, low 9. Current open 9.5 <= 10, close 8.5 < 9 -> -1.
134        let mut td = TdPropulsion::new();
135        td.update(c(10.5, 11.0, 9.0, 10.0));
136        assert_eq!(td.update(c(9.5, 10.0, 8.0, 8.5)), Some(-1.0));
137    }
138
139    #[test]
140    fn no_thrust_is_zero() {
141        let mut td = TdPropulsion::new();
142        td.update(c(9.5, 11.0, 9.0, 10.0));
143        // close 10.5 not above prior high 11 -> 0.
144        assert_eq!(td.update(c(10.5, 10.8, 10.0, 10.5)), Some(0.0));
145    }
146
147    #[test]
148    fn reset_clears_state() {
149        let mut td = TdPropulsion::new();
150        td.update(c(9.5, 11.0, 9.0, 10.0));
151        td.update(c(10.5, 12.0, 10.0, 11.5));
152        assert!(td.is_ready());
153        td.reset();
154        assert!(!td.is_ready());
155        assert_eq!(td.update(c(9.5, 11.0, 9.0, 10.0)), None);
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() * 5.0;
163                c(b, b + 1.0, b - 1.0, b + 0.3)
164            })
165            .collect();
166        let batch = TdPropulsion::new().batch(&candles);
167        let mut b = TdPropulsion::new();
168        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
169        assert_eq!(batch, streamed);
170    }
171}