Skip to main content

wickra_core/indicators/
falling_three_methods.rs

1//! Falling Three Methods candlestick pattern.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Falling Three Methods — a 5-bar bearish continuation. A long black candle is
7/// followed by three small white bars that drift up but stay inside its range (a
8/// brief rest), then a second long black candle opens below the last rest bar's
9/// close and closes below the first, resuming the decline (Nison; TA-Lib
10/// `CDLRISEFALL3METHODS`).
11///
12/// ```text
13/// long body = |close − open| >= 0.5 * (high − low)
14/// small body = |close − open| <= 0.5 * body1
15/// bar1 black & long
16/// bar2, bar3, bar4 small white bodies, each overlapping bar1's high/low range
17///                  (min(open, close) < high1 and max(open, close) > low1),
18///                  with rising closes (close3 > close2, close4 > close3)
19/// bar5 black & long, opening below bar4's close (open5 < close4)
20///                  and closing below bar1's close (close5 < close1)
21/// ```
22///
23/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Falling Three
24/// Methods is a single-direction (bearish-only) continuation, so it never emits
25/// `+1.0`. The first four bars always return `0.0` because the five-bar window is
26/// not yet filled. Body thresholds follow the geometric house style rather than
27/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is
28/// applied; combine with a trend indicator for actionable signals.
29///
30/// # Signed ±1 encoding
31///
32/// This detector emits the uniform candlestick sign convention shared across the
33/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into
34/// a machine-learning feature matrix as a single dimension.
35///
36/// # Example
37///
38/// ```
39/// use wickra_core::{Candle, FallingThreeMethods, Indicator};
40///
41/// let mut indicator = FallingThreeMethods::new();
42/// indicator.update(Candle::new(15.0, 15.1, 9.9, 10.0, 1.0, 0).unwrap());
43/// indicator.update(Candle::new(11.0, 12.1, 10.9, 12.0, 1.0, 1).unwrap());
44/// indicator.update(Candle::new(11.5, 12.6, 11.4, 12.5, 1.0, 2).unwrap());
45/// indicator.update(Candle::new(12.0, 13.1, 11.9, 13.0, 1.0, 3).unwrap());
46/// let out = indicator
47///     .update(Candle::new(12.5, 12.6, 8.9, 9.0, 1.0, 4).unwrap());
48/// assert_eq!(out, Some(-1.0));
49/// ```
50#[derive(Debug, Clone, Default)]
51pub struct FallingThreeMethods {
52    c1: Option<Candle>,
53    c2: Option<Candle>,
54    c3: Option<Candle>,
55    c4: Option<Candle>,
56    has_emitted: bool,
57}
58
59impl FallingThreeMethods {
60    /// Construct a new Falling Three Methods detector.
61    pub const fn new() -> Self {
62        Self {
63            c1: None,
64            c2: None,
65            c3: None,
66            c4: None,
67            has_emitted: false,
68        }
69    }
70}
71
72impl Indicator for FallingThreeMethods {
73    type Input = Candle;
74    type Output = f64;
75
76    #[inline]
77    fn update(&mut self, candle: Candle) -> Option<f64> {
78        let bar1 = self.c1;
79        let bar2 = self.c2;
80        let bar3 = self.c3;
81        let bar4 = self.c4;
82        self.c1 = self.c2;
83        self.c2 = self.c3;
84        self.c3 = self.c4;
85        self.c4 = Some(candle);
86        let (Some(bar1), Some(bar2), Some(bar3), Some(bar4)) = (bar1, bar2, bar3, bar4) else {
87            return None;
88        };
89        self.has_emitted = true;
90        let range1 = bar1.high - bar1.low;
91        if range1 <= 0.0 {
92            return Some(0.0);
93        }
94        let body1 = bar1.open - bar1.close;
95        if body1 < 0.5 * range1 {
96            return Some(0.0); // bar1 must be a long black body
97        }
98        // The three middle bars are small white bodies whose real body
99        // reaches into bar1's range (TA-Lib: part of each body within bar1).
100        for mid in [bar2, bar3, bar4] {
101            let body = mid.close - mid.open;
102            if body <= 0.0
103                || body > 0.5 * body1
104                || mid.open.min(mid.close) >= bar1.high
105                || mid.open.max(mid.close) <= bar1.low
106            {
107                return Some(0.0);
108            }
109        }
110        // ... drifting up against the decline.
111        if bar3.close <= bar2.close || bar4.close <= bar3.close {
112            return Some(0.0);
113        }
114        // bar5 is a long black candle that opens below bar4's close and closes
115        // below bar1's close.
116        let body5 = candle.open - candle.close;
117        if body5 > 0.0
118            && body5 >= 0.5 * (candle.high - candle.low)
119            && candle.open < bar4.close
120            && candle.close < bar1.close
121        {
122            return Some(-1.0);
123        }
124        Some(0.0)
125    }
126
127    fn reset(&mut self) {
128        self.c1 = None;
129        self.c2 = None;
130        self.c3 = None;
131        self.c4 = None;
132        self.has_emitted = false;
133    }
134
135    #[inline]
136    fn warmup_period(&self) -> usize {
137        5
138    }
139
140    #[inline]
141    fn is_ready(&self) -> bool {
142        self.has_emitted
143    }
144
145    #[inline]
146    fn name(&self) -> &'static str {
147        "FallingThreeMethods"
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::traits::BatchExt;
155
156    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
157        Candle::new(open, high, low, close, 1.0, ts).unwrap()
158    }
159
160    #[test]
161    fn accessors_and_metadata() {
162        let t = FallingThreeMethods::new();
163        assert_eq!(t.name(), "FallingThreeMethods");
164        assert_eq!(t.warmup_period(), 5);
165        assert!(!t.is_ready());
166    }
167
168    #[test]
169    fn falling_three_methods_is_minus_one() {
170        let mut t = FallingThreeMethods::new();
171        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), None);
172        assert_eq!(t.update(c(11.0, 12.1, 10.9, 12.0, 1)), None);
173        assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.5, 2)), None);
174        assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 3)), None);
175        assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(-1.0));
176    }
177
178    #[test]
179    fn middle_bar_breaks_range_yields_zero() {
180        let mut t = FallingThreeMethods::new();
181        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
182        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
183        // bar3's whole body sits below bar1's low.
184        t.update(c(9.5, 9.85, 9.4, 9.8, 2));
185        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
186        assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0));
187    }
188
189    #[test]
190    fn bar5_not_new_low_yields_zero() {
191        let mut t = FallingThreeMethods::new();
192        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
193        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
194        t.update(c(11.5, 12.6, 11.4, 12.5, 2));
195        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
196        // bar5 black but closes above bar1's close.
197        assert_eq!(t.update(c(12.5, 12.6, 10.4, 10.5, 4)), Some(0.0));
198    }
199
200    #[test]
201    fn first_four_bars_return_zero() {
202        let mut t = FallingThreeMethods::new();
203        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), None);
204        assert_eq!(t.update(c(11.0, 12.1, 10.9, 12.0, 1)), None);
205        assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.5, 2)), None);
206        assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 3)), None);
207    }
208
209    #[test]
210    fn batch_equals_streaming() {
211        let candles: Vec<Candle> = (0..40)
212            .map(|i| {
213                let base = 200.0 - i as f64;
214                c(base + 5.0, base + 5.1, base - 0.1, base, i)
215            })
216            .collect();
217        let mut a = FallingThreeMethods::new();
218        let mut b = FallingThreeMethods::new();
219        assert_eq!(
220            a.batch(&candles),
221            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
222        );
223    }
224
225    #[test]
226    fn reset_clears_state() {
227        let mut t = FallingThreeMethods::new();
228        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
229        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
230        t.update(c(11.5, 12.6, 11.4, 12.5, 2));
231        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
232        t.update(c(12.5, 12.6, 8.9, 9.0, 4));
233        assert!(t.is_ready());
234        t.reset();
235        assert!(!t.is_ready());
236        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), None);
237    }
238
239    #[test]
240    fn zero_range_first_bar_yields_zero() {
241        let mut t = FallingThreeMethods::new();
242        // Flat first bar (range1 == 0) -> rejected.
243        t.update(c(10.0, 10.0, 10.0, 10.0, 0));
244        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
245        t.update(c(11.5, 12.6, 11.4, 12.5, 2));
246        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
247        assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0));
248    }
249
250    #[test]
251    fn short_first_body_yields_zero() {
252        let mut t = FallingThreeMethods::new();
253        // bar1 has a wide range but a tiny body -> not a long black body.
254        t.update(c(10.0, 16.0, 9.0, 10.2, 0));
255        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
256        t.update(c(11.5, 12.6, 11.4, 12.5, 2));
257        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
258        assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0));
259    }
260
261    /// The canonical pattern: bar1 15 -> 10 (range 5.2, body1 = 5, so middle
262    /// bodies may be at most 2.5), white rests closing 12, 12.5, 13, bar5 12.5 -> 9.
263    fn base() -> [Candle; 5] {
264        [
265            c(15.0, 15.1, 9.9, 10.0, 0),
266            c(11.0, 12.1, 10.9, 12.0, 1),
267            c(11.5, 12.6, 11.4, 12.5, 2),
268            c(12.0, 13.1, 11.9, 13.0, 3),
269            c(12.5, 12.6, 8.9, 9.0, 4),
270        ]
271    }
272
273    fn run(bars: [Candle; 5]) -> Option<f64> {
274        let mut t = FallingThreeMethods::new();
275        bars.iter().map(|b| t.update(*b)).last().unwrap()
276    }
277
278    fn with(index: usize, candle: Candle) -> [Candle; 5] {
279        let mut bars = base();
280        bars[index] = candle;
281        bars
282    }
283
284    #[test]
285    fn hand_computed_pattern() {
286        // body1 = 15 - 10 = 5 >= 0.5 * 5.2 = 2.6 (long black).
287        // Middle bodies 1.0 each <= 2.5, white, all inside 9.9..15.1.
288        // Closes 12 < 12.5 < 13 (drifting up).
289        // body5 = 3.5 >= 0.5 * 3.7 = 1.85, open5 12.5 < close4 13, close5 9 < 10.
290        assert_eq!(run(base()), Some(-1.0));
291    }
292
293    #[test]
294    fn middle_shadow_outside_range_but_body_overlapping_fires() {
295        // bar2's lower shadow pokes to 9.0 < low1 = 9.9; its body 11..12 is inside.
296        assert_eq!(run(with(1, c(11.0, 12.1, 9.0, 12.0, 1))), Some(-1.0));
297        // bar2's body 9.6..10.1 straddles low1: max 10.1 > 9.9 -> still overlaps.
298        assert_eq!(run(with(1, c(9.6, 10.2, 9.5, 10.1, 1))), Some(-1.0));
299        // bar4's upper shadow pokes above high1 = 15.1; body 14.6..15.0 overlaps.
300        assert_eq!(run(with(3, c(14.6, 16.0, 14.5, 15.0, 3))), Some(-1.0));
301    }
302
303    #[test]
304    fn middle_body_fully_outside_range_yields_zero() {
305        // Body top exactly at low1 (max == 9.9) -> outside.
306        assert_eq!(run(with(1, c(9.4, 10.0, 9.3, 9.9, 1))), Some(0.0));
307        // Body entirely above high1 = 15.1 (min 15.2 >= 15.1).
308        assert_eq!(run(with(3, c(15.2, 15.6, 15.1, 15.5, 3))), Some(0.0));
309        // Body bottom exactly at high1 (min == 15.1) -> outside.
310        assert_eq!(run(with(3, c(15.1, 15.6, 15.0, 15.5, 3))), Some(0.0));
311    }
312
313    #[test]
314    fn middle_body_colour_and_size_rules() {
315        // Black middle bar.
316        assert_eq!(run(with(2, c(12.5, 12.6, 11.4, 11.5, 2))), Some(0.0));
317        // Doji middle bar (body 0).
318        assert_eq!(run(with(2, c(12.5, 12.6, 11.4, 12.5, 2))), Some(0.0));
319        // Body 3.0 > 0.5 * body1 = 2.5.
320        assert_eq!(run(with(1, c(9.0, 12.1, 8.9, 12.0, 1))), Some(0.0));
321    }
322
323    #[test]
324    fn closes_not_drifting_up_yields_zero() {
325        // close3 = 11.9 <= close2 = 12.0.
326        assert_eq!(run(with(2, c(11.5, 12.0, 11.4, 11.9, 2))), Some(0.0));
327        // close3 == close2.
328        assert_eq!(run(with(2, c(11.5, 12.1, 11.4, 12.0, 2))), Some(0.0));
329        // close4 = 12.4 <= close3 = 12.5.
330        assert_eq!(run(with(3, c(12.0, 12.5, 11.9, 12.4, 3))), Some(0.0));
331    }
332
333    #[test]
334    fn fifth_bar_conditions() {
335        // White bar5.
336        assert_eq!(run(with(4, c(9.0, 12.6, 8.9, 9.5, 4))), Some(0.0));
337        // Doji bar5 (body5 == 0).
338        assert_eq!(run(with(4, c(9.0, 12.6, 8.9, 9.0, 4))), Some(0.0));
339        // Not long: body 3.5 < 0.5 * range 8.0 = 4.0.
340        assert_eq!(run(with(4, c(12.5, 13.0, 5.0, 9.0, 4))), Some(0.0));
341        // open5 = 13.0 == close4 -> not below.
342        assert_eq!(run(with(4, c(13.0, 13.1, 8.9, 9.0, 4))), Some(0.0));
343        // close5 == close1 = 10.0 -> not below.
344        assert_eq!(run(with(4, c(12.5, 12.6, 9.9, 10.0, 4))), Some(0.0));
345    }
346
347    #[test]
348    fn first_value_lands_exactly_at_warmup_index() {
349        let mut t = FallingThreeMethods::new();
350        let out = t.batch(&base());
351        let warm = t.warmup_period();
352        assert!(out[..warm - 1].iter().all(Option::is_none));
353        assert_eq!(out[warm - 1], Some(-1.0));
354    }
355
356    fn mixed_series() -> Vec<Candle> {
357        base().iter().cycle().take(30).copied().collect()
358    }
359
360    #[test]
361    fn reset_replays_identically() {
362        let candles = mixed_series();
363        let fresh = FallingThreeMethods::new().batch(&candles);
364        let mut t = FallingThreeMethods::new();
365        let _ = t.batch(&candles);
366        t.reset();
367        assert_eq!(t.batch(&candles), fresh);
368    }
369
370    #[test]
371    fn batch_nan_into_matches_streaming_bits() {
372        let candles = mixed_series();
373        let mut t = FallingThreeMethods::new();
374        let streamed: Vec<f64> = candles
375            .iter()
376            .map(|x| t.update(*x).unwrap_or(f64::NAN))
377            .collect();
378        let mut out = vec![0.0; candles.len()];
379        FallingThreeMethods::new().batch_nan_into(&candles, &mut out);
380        assert!(streamed
381            .iter()
382            .zip(&out)
383            .all(|(a, b)| a.to_bits() == b.to_bits()));
384        assert!(streamed.contains(&-1.0));
385    }
386}