Skip to main content

wickra_core/indicators/
thrusting.rs

1//! Thrusting candlestick pattern.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Thrusting — a 2-bar bearish continuation, deeper than In-Neck but short of a
7/// piercing reversal. A long black candle in a decline is followed by a white
8/// candle that opens below the black bar's low and closes well into the black
9/// body — but still below its midpoint, so the bounce is not yet a reversal.
10///
11/// ```text
12/// long body = |close − open| >= 0.5 * (high − low)
13/// bar1 black & long
14/// bar2 white, opens below bar1's low                   (open2 < low1)
15/// bar2 closes above the in-neck zone but below the body midpoint
16///      (close1 + 0.1·body1 < close2 < midpoint(open1, close1))
17/// ```
18///
19/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Thrusting is a
20/// single-direction (bearish-only) continuation, so it never emits `+1.0`. A close
21/// at or above the midpoint would be a piercing pattern instead. The first bar
22/// always returns `0.0` because the two-bar window is not yet filled. Body and
23/// neckline thresholds follow the geometric house style rather than TA-Lib's
24/// rolling averages. Pattern-shape check only — no trend filter is applied;
25/// combine with a trend indicator for actionable signals.
26///
27/// # Signed ±1 encoding
28///
29/// This detector emits the uniform candlestick sign convention shared across the
30/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into
31/// a machine-learning feature matrix as a single dimension.
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{Candle, Indicator, Thrusting};
37///
38/// let mut indicator = Thrusting::new();
39/// indicator.update(Candle::new(15.0, 15.1, 9.0, 10.0, 1.0, 0).unwrap());
40/// let out = indicator
41///     .update(Candle::new(7.0, 11.6, 6.9, 11.5, 1.0, 1).unwrap());
42/// assert_eq!(out, Some(-1.0));
43/// ```
44#[derive(Debug, Clone, Default)]
45pub struct Thrusting {
46    prev: Option<Candle>,
47    has_emitted: bool,
48}
49
50impl Thrusting {
51    /// Construct a new Thrusting detector.
52    pub const fn new() -> Self {
53        Self {
54            prev: None,
55            has_emitted: false,
56        }
57    }
58}
59
60impl Indicator for Thrusting {
61    type Input = Candle;
62    type Output = f64;
63
64    #[inline]
65    fn update(&mut self, candle: Candle) -> Option<f64> {
66        let prev = self.prev;
67        self.prev = Some(candle);
68        let bar1 = prev?;
69        self.has_emitted = true;
70        let range1 = bar1.high - bar1.low;
71        if range1 <= 0.0 {
72            return Some(0.0);
73        }
74        let body1 = bar1.open - bar1.close;
75        let mid1 = f64::midpoint(bar1.open, bar1.close);
76        if bar1.close < bar1.open
77            && body1 >= 0.5 * range1
78            && candle.close > candle.open
79            && candle.open < bar1.low
80            && candle.close > bar1.close + 0.1 * body1
81            && candle.close < mid1
82        {
83            return Some(-1.0);
84        }
85        Some(0.0)
86    }
87
88    fn reset(&mut self) {
89        self.prev = None;
90        self.has_emitted = false;
91    }
92
93    #[inline]
94    fn warmup_period(&self) -> usize {
95        2
96    }
97
98    #[inline]
99    fn is_ready(&self) -> bool {
100        self.has_emitted
101    }
102
103    #[inline]
104    fn name(&self) -> &'static str {
105        "Thrusting"
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::traits::BatchExt;
113
114    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
115        Candle::new(open, high, low, close, 1.0, ts).unwrap()
116    }
117
118    #[test]
119    fn accessors_and_metadata() {
120        let t = Thrusting::new();
121        assert_eq!(t.name(), "Thrusting");
122        assert_eq!(t.warmup_period(), 2);
123        assert!(!t.is_ready());
124    }
125
126    #[test]
127    fn thrusting_is_minus_one() {
128        let mut t = Thrusting::new();
129        assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), None);
130        assert_eq!(t.update(c(7.0, 11.6, 6.9, 11.5, 1)), Some(-1.0));
131    }
132
133    #[test]
134    fn shallow_close_yields_zero() {
135        let mut t = Thrusting::new();
136        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
137        // Closes barely into the body -> in-neck, not thrusting.
138        assert_eq!(t.update(c(7.0, 10.3, 6.9, 10.2, 1)), Some(0.0));
139    }
140
141    #[test]
142    fn close_past_midpoint_yields_zero() {
143        let mut t = Thrusting::new();
144        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
145        // Closes above the midpoint -> piercing, not thrusting.
146        assert_eq!(t.update(c(7.0, 13.1, 6.9, 13.0, 1)), Some(0.0));
147    }
148
149    #[test]
150    fn second_bar_black_yields_zero() {
151        let mut t = Thrusting::new();
152        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
153        assert_eq!(t.update(c(12.0, 12.1, 6.9, 11.5, 1)), Some(0.0));
154    }
155
156    #[test]
157    fn first_bar_returns_zero() {
158        let mut t = Thrusting::new();
159        assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), None);
160    }
161
162    #[test]
163    fn batch_equals_streaming() {
164        let candles: Vec<Candle> = (0..40)
165            .map(|i| {
166                let base = 100.0 + i as f64;
167                c(base + 5.0, base + 5.1, base - 1.0, base, i)
168            })
169            .collect();
170        let mut a = Thrusting::new();
171        let mut b = Thrusting::new();
172        assert_eq!(
173            a.batch(&candles),
174            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
175        );
176    }
177
178    #[test]
179    fn reset_clears_state() {
180        let mut t = Thrusting::new();
181        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
182        t.update(c(7.0, 11.6, 6.9, 11.5, 1));
183        assert!(t.is_ready());
184        t.reset();
185        assert!(!t.is_ready());
186        assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), None);
187    }
188
189    #[test]
190    fn zero_range_first_bar_yields_zero() {
191        let mut t = Thrusting::new();
192        // Flat first bar (range1 == 0) -> rejected.
193        t.update(c(10.0, 10.0, 10.0, 10.0, 0));
194        assert_eq!(t.update(c(9.0, 10.0, 8.0, 9.5, 1)), Some(0.0));
195    }
196}