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    fn update(&mut self, candle: Candle) -> Option<f64> {
65        self.has_emitted = true;
66        let prev = self.prev;
67        self.prev = Some(candle);
68        let Some(bar1) = prev else {
69            return Some(0.0);
70        };
71        let range1 = bar1.high - bar1.low;
72        if range1 <= 0.0 {
73            return Some(0.0);
74        }
75        let body1 = bar1.open - bar1.close;
76        let mid1 = f64::midpoint(bar1.open, bar1.close);
77        if bar1.close < bar1.open
78            && body1 >= 0.5 * range1
79            && candle.close > candle.open
80            && candle.open < bar1.low
81            && candle.close > bar1.close + 0.1 * body1
82            && candle.close < mid1
83        {
84            return Some(-1.0);
85        }
86        Some(0.0)
87    }
88
89    fn reset(&mut self) {
90        self.prev = None;
91        self.has_emitted = false;
92    }
93
94    fn warmup_period(&self) -> usize {
95        2
96    }
97
98    fn is_ready(&self) -> bool {
99        self.has_emitted
100    }
101
102    fn name(&self) -> &'static str {
103        "Thrusting"
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::traits::BatchExt;
111
112    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
113        Candle::new(open, high, low, close, 1.0, ts).unwrap()
114    }
115
116    #[test]
117    fn accessors_and_metadata() {
118        let t = Thrusting::new();
119        assert_eq!(t.name(), "Thrusting");
120        assert_eq!(t.warmup_period(), 2);
121        assert!(!t.is_ready());
122    }
123
124    #[test]
125    fn thrusting_is_minus_one() {
126        let mut t = Thrusting::new();
127        assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0));
128        assert_eq!(t.update(c(7.0, 11.6, 6.9, 11.5, 1)), Some(-1.0));
129    }
130
131    #[test]
132    fn shallow_close_yields_zero() {
133        let mut t = Thrusting::new();
134        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
135        // Closes barely into the body -> in-neck, not thrusting.
136        assert_eq!(t.update(c(7.0, 10.3, 6.9, 10.2, 1)), Some(0.0));
137    }
138
139    #[test]
140    fn close_past_midpoint_yields_zero() {
141        let mut t = Thrusting::new();
142        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
143        // Closes above the midpoint -> piercing, not thrusting.
144        assert_eq!(t.update(c(7.0, 13.1, 6.9, 13.0, 1)), Some(0.0));
145    }
146
147    #[test]
148    fn second_bar_black_yields_zero() {
149        let mut t = Thrusting::new();
150        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
151        assert_eq!(t.update(c(12.0, 12.1, 6.9, 11.5, 1)), Some(0.0));
152    }
153
154    #[test]
155    fn first_bar_returns_zero() {
156        let mut t = Thrusting::new();
157        assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0));
158    }
159
160    #[test]
161    fn batch_equals_streaming() {
162        let candles: Vec<Candle> = (0..40)
163            .map(|i| {
164                let base = 100.0 + i as f64;
165                c(base + 5.0, base + 5.1, base - 1.0, base, i)
166            })
167            .collect();
168        let mut a = Thrusting::new();
169        let mut b = Thrusting::new();
170        assert_eq!(
171            a.batch(&candles),
172            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
173        );
174    }
175
176    #[test]
177    fn reset_clears_state() {
178        let mut t = Thrusting::new();
179        t.update(c(15.0, 15.1, 9.0, 10.0, 0));
180        t.update(c(7.0, 11.6, 6.9, 11.5, 1));
181        assert!(t.is_ready());
182        t.reset();
183        assert!(!t.is_ready());
184        assert_eq!(t.update(c(15.0, 15.1, 9.0, 10.0, 0)), Some(0.0));
185    }
186
187    #[test]
188    fn zero_range_first_bar_yields_zero() {
189        let mut t = Thrusting::new();
190        // Flat first bar (range1 == 0) -> rejected.
191        t.update(c(10.0, 10.0, 10.0, 10.0, 0));
192        assert_eq!(t.update(c(9.0, 10.0, 8.0, 9.5, 1)), Some(0.0));
193    }
194}