Skip to main content

wickra_core/indicators/
stalled_pattern.rs

1//! Stalled Pattern (Deliberation) candlestick pattern.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Stalled Pattern (also called Deliberation) — a 3-bar bearish reversal warning.
7/// Two long white candles push higher, then a small-bodied white candle opens at
8/// or near the top of the second body and barely advances — the rally is running
9/// out of breath, hinting that buyers are losing control.
10///
11/// ```text
12/// long body  = |close − open| >= 0.5 * (high − low)
13/// small body = |close − open| <= 0.3 * (high − low)
14/// bar1, bar2 long white; bar3 small white
15/// rising closes: close3 > close2 > close1
16/// bar3 rides the shoulder: open3 >= close2 − 0.1 * (high2 − low2)
17/// ```
18///
19/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Stalled Pattern
20/// is a single-direction (bearish-only) warning, so it never emits `+1.0`. The
21/// first two bars always return `0.0` because the three-bar window is not yet
22/// filled. Body thresholds follow the geometric house style rather than TA-Lib's
23/// rolling averages. Pattern-shape check only — no trend filter is applied; combine
24/// with a trend indicator for actionable signals.
25///
26/// # Signed ±1 encoding
27///
28/// This detector emits the uniform candlestick sign convention shared across the
29/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into
30/// a machine-learning feature matrix as a single dimension.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Candle, Indicator, StalledPattern};
36///
37/// let mut indicator = StalledPattern::new();
38/// indicator.update(Candle::new(10.0, 12.05, 9.9, 12.0, 1.0, 0).unwrap());
39/// indicator.update(Candle::new(11.0, 14.05, 10.9, 14.0, 1.0, 1).unwrap());
40/// let out = indicator
41///     .update(Candle::new(14.0, 14.6, 13.95, 14.15, 1.0, 2).unwrap());
42/// assert_eq!(out, Some(-1.0));
43/// ```
44#[derive(Debug, Clone, Default)]
45pub struct StalledPattern {
46    c1: Option<Candle>,
47    c2: Option<Candle>,
48    has_emitted: bool,
49}
50
51impl StalledPattern {
52    /// Construct a new Stalled Pattern detector.
53    pub const fn new() -> Self {
54        Self {
55            c1: None,
56            c2: None,
57            has_emitted: false,
58        }
59    }
60}
61
62impl Indicator for StalledPattern {
63    type Input = Candle;
64    type Output = f64;
65
66    #[inline]
67    fn update(&mut self, candle: Candle) -> Option<f64> {
68        let bar1 = self.c1;
69        let bar2 = self.c2;
70        self.c1 = self.c2;
71        self.c2 = Some(candle);
72        let (Some(bar1), Some(bar2)) = (bar1, bar2) else {
73            return None;
74        };
75        self.has_emitted = true;
76        let range1 = bar1.high - bar1.low;
77        let range2 = bar2.high - bar2.low;
78        let range3 = candle.high - candle.low;
79        if range1 <= 0.0 || range2 <= 0.0 || range3 <= 0.0 {
80            return Some(0.0);
81        }
82        // All three candles are white.
83        if bar1.close <= bar1.open || bar2.close <= bar2.open || candle.close <= candle.open {
84            return Some(0.0);
85        }
86        // Rising closes.
87        if candle.close <= bar2.close || bar2.close <= bar1.close {
88            return Some(0.0);
89        }
90        // bar1 and bar2 are long bodies.
91        if bar1.close - bar1.open < 0.5 * range1 || bar2.close - bar2.open < 0.5 * range2 {
92            return Some(0.0);
93        }
94        // bar3 is a small body.
95        if candle.close - candle.open > 0.3 * range3 {
96            return Some(0.0);
97        }
98        // bar3 opens at or near the top of bar2's body (rides the shoulder).
99        if candle.open >= bar2.close - 0.1 * range2 {
100            return Some(-1.0);
101        }
102        Some(0.0)
103    }
104
105    fn reset(&mut self) {
106        self.c1 = None;
107        self.c2 = None;
108        self.has_emitted = false;
109    }
110
111    #[inline]
112    fn warmup_period(&self) -> usize {
113        3
114    }
115
116    #[inline]
117    fn is_ready(&self) -> bool {
118        self.has_emitted
119    }
120
121    #[inline]
122    fn name(&self) -> &'static str {
123        "StalledPattern"
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::traits::BatchExt;
131
132    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
133        Candle::new(open, high, low, close, 1.0, ts).unwrap()
134    }
135
136    #[test]
137    fn accessors_and_metadata() {
138        let t = StalledPattern::new();
139        assert_eq!(t.name(), "StalledPattern");
140        assert_eq!(t.warmup_period(), 3);
141        assert!(!t.is_ready());
142    }
143
144    #[test]
145    fn stalled_pattern_is_minus_one() {
146        let mut t = StalledPattern::new();
147        assert_eq!(t.update(c(10.0, 12.05, 9.9, 12.0, 0)), None);
148        assert_eq!(t.update(c(11.0, 14.05, 10.9, 14.0, 1)), None);
149        assert_eq!(t.update(c(14.0, 14.6, 13.95, 14.15, 2)), Some(-1.0));
150    }
151
152    #[test]
153    fn first_two_bars_return_zero() {
154        let mut t = StalledPattern::new();
155        assert_eq!(t.update(c(10.0, 12.05, 9.9, 12.0, 0)), None);
156        assert_eq!(t.update(c(11.0, 14.05, 10.9, 14.0, 1)), None);
157    }
158
159    #[test]
160    fn zero_range_yields_zero() {
161        let mut t = StalledPattern::new();
162        t.update(c(10.0, 12.05, 9.9, 12.0, 0));
163        t.update(c(11.0, 14.05, 10.9, 14.0, 1));
164        // bar3 has zero range.
165        assert_eq!(t.update(c(14.0, 14.0, 14.0, 14.0, 2)), Some(0.0));
166    }
167
168    #[test]
169    fn non_white_yields_zero() {
170        let mut t = StalledPattern::new();
171        t.update(c(10.0, 12.05, 9.9, 12.0, 0));
172        t.update(c(11.0, 14.05, 10.9, 14.0, 1));
173        // bar3 is black.
174        assert_eq!(t.update(c(14.2, 14.6, 13.95, 14.05, 2)), Some(0.0));
175    }
176
177    #[test]
178    fn non_rising_closes_yield_zero() {
179        let mut t = StalledPattern::new();
180        t.update(c(10.0, 12.05, 9.9, 12.0, 0));
181        t.update(c(11.0, 14.05, 10.9, 14.0, 1));
182        // bar3 closes below bar2's close (white but not advancing).
183        assert_eq!(t.update(c(13.5, 14.0, 13.45, 13.6, 2)), Some(0.0));
184    }
185
186    #[test]
187    fn short_first_bodies_yield_zero() {
188        let mut t = StalledPattern::new();
189        // bar1 is white but its body is short relative to range.
190        t.update(c(11.5, 14.0, 10.0, 12.0, 0));
191        t.update(c(11.0, 14.05, 10.9, 14.0, 1));
192        assert_eq!(t.update(c(14.0, 14.6, 13.95, 14.15, 2)), Some(0.0));
193    }
194
195    #[test]
196    fn large_third_body_yields_zero() {
197        let mut t = StalledPattern::new();
198        t.update(c(10.0, 12.05, 9.9, 12.0, 0));
199        t.update(c(11.0, 14.05, 10.9, 14.0, 1));
200        // bar3 has a large body (not a small stalling candle).
201        assert_eq!(t.update(c(14.0, 16.05, 13.95, 16.0, 2)), Some(0.0));
202    }
203
204    #[test]
205    fn third_bar_off_shoulder_yields_zero() {
206        let mut t = StalledPattern::new();
207        t.update(c(10.0, 12.05, 9.9, 12.0, 0));
208        t.update(c(11.0, 14.05, 10.9, 14.0, 1));
209        // bar3 is a small white candle but opens well below bar2's close,
210        // so it is not riding the shoulder.
211        assert_eq!(t.update(c(13.6, 14.1, 12.55, 14.05, 2)), Some(0.0));
212    }
213
214    #[test]
215    fn batch_equals_streaming() {
216        let candles: Vec<Candle> = (0..40)
217            .map(|i| {
218                let base = 100.0 + i as f64;
219                c(base, base + 2.05, base - 0.1, base + 2.0, i)
220            })
221            .collect();
222        let mut a = StalledPattern::new();
223        let mut b = StalledPattern::new();
224        assert_eq!(
225            a.batch(&candles),
226            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
227        );
228    }
229
230    #[test]
231    fn reset_clears_state() {
232        let mut t = StalledPattern::new();
233        t.update(c(10.0, 12.05, 9.9, 12.0, 0));
234        t.update(c(11.0, 14.05, 10.9, 14.0, 1));
235        t.update(c(14.0, 14.6, 13.95, 14.15, 2));
236        assert!(t.is_ready());
237        t.reset();
238        assert!(!t.is_ready());
239        assert_eq!(t.update(c(10.0, 12.05, 9.9, 12.0, 0)), None);
240    }
241}