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 bars that drift up but stay inside its range (a brief
8/// rest), then a second long black candle closes below the first, resuming the
9/// decline.
10///
11/// ```text
12/// long body = |close − open| >= 0.5 * (high − low)
13/// bar1 black & long
14/// bar2, bar3, bar4 small bodies, each contained within bar1's high/low range
15/// bar5 black, closing below bar1's close
16/// ```
17///
18/// Output is `−1.0` when the pattern completes and `0.0` otherwise. Falling Three
19/// Methods is a single-direction (bearish-only) continuation, so it never emits
20/// `+1.0`. The first four bars always return `0.0` because the five-bar window is
21/// not yet filled. Body thresholds follow the geometric house style rather than
22/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is
23/// applied; combine with a trend indicator for actionable signals.
24///
25/// # Signed ±1 encoding
26///
27/// This detector emits the uniform candlestick sign convention shared across the
28/// pattern family — `−1.0` bearish, `0.0` no pattern — so it drops straight into
29/// a machine-learning feature matrix as a single dimension.
30///
31/// # Example
32///
33/// ```
34/// use wickra_core::{Candle, FallingThreeMethods, Indicator};
35///
36/// let mut indicator = FallingThreeMethods::new();
37/// indicator.update(Candle::new(15.0, 15.1, 9.9, 10.0, 1.0, 0).unwrap());
38/// indicator.update(Candle::new(11.0, 12.1, 10.9, 12.0, 1.0, 1).unwrap());
39/// indicator.update(Candle::new(11.5, 12.6, 11.4, 12.5, 1.0, 2).unwrap());
40/// indicator.update(Candle::new(12.0, 13.1, 11.9, 13.0, 1.0, 3).unwrap());
41/// let out = indicator
42///     .update(Candle::new(12.5, 12.6, 8.9, 9.0, 1.0, 4).unwrap());
43/// assert_eq!(out, Some(-1.0));
44/// ```
45#[derive(Debug, Clone, Default)]
46pub struct FallingThreeMethods {
47    c1: Option<Candle>,
48    c2: Option<Candle>,
49    c3: Option<Candle>,
50    c4: Option<Candle>,
51    has_emitted: bool,
52}
53
54impl FallingThreeMethods {
55    /// Construct a new Falling Three Methods detector.
56    pub const fn new() -> Self {
57        Self {
58            c1: None,
59            c2: None,
60            c3: None,
61            c4: None,
62            has_emitted: false,
63        }
64    }
65}
66
67impl Indicator for FallingThreeMethods {
68    type Input = Candle;
69    type Output = f64;
70
71    #[inline]
72    fn update(&mut self, candle: Candle) -> Option<f64> {
73        let bar1 = self.c1;
74        let bar2 = self.c2;
75        let bar3 = self.c3;
76        let bar4 = self.c4;
77        self.c1 = self.c2;
78        self.c2 = self.c3;
79        self.c3 = self.c4;
80        self.c4 = Some(candle);
81        let (Some(bar1), Some(bar2), Some(bar3), Some(bar4)) = (bar1, bar2, bar3, bar4) else {
82            return None;
83        };
84        self.has_emitted = true;
85        let range1 = bar1.high - bar1.low;
86        if range1 <= 0.0 {
87            return Some(0.0);
88        }
89        let body1 = bar1.open - bar1.close;
90        if body1 < 0.5 * range1 {
91            return Some(0.0); // bar1 must be a long black body
92        }
93        // The three middle bars stay within bar1's range with smaller bodies.
94        for mid in [bar2, bar3, bar4] {
95            if (mid.close - mid.open).abs() >= body1 || mid.high > bar1.high || mid.low < bar1.low {
96                return Some(0.0);
97            }
98        }
99        // bar5 is a black candle closing below bar1's close.
100        if candle.close < candle.open && candle.close < bar1.close {
101            return Some(-1.0);
102        }
103        Some(0.0)
104    }
105
106    fn reset(&mut self) {
107        self.c1 = None;
108        self.c2 = None;
109        self.c3 = None;
110        self.c4 = None;
111        self.has_emitted = false;
112    }
113
114    #[inline]
115    fn warmup_period(&self) -> usize {
116        5
117    }
118
119    #[inline]
120    fn is_ready(&self) -> bool {
121        self.has_emitted
122    }
123
124    #[inline]
125    fn name(&self) -> &'static str {
126        "FallingThreeMethods"
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::traits::BatchExt;
134
135    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
136        Candle::new(open, high, low, close, 1.0, ts).unwrap()
137    }
138
139    #[test]
140    fn accessors_and_metadata() {
141        let t = FallingThreeMethods::new();
142        assert_eq!(t.name(), "FallingThreeMethods");
143        assert_eq!(t.warmup_period(), 5);
144        assert!(!t.is_ready());
145    }
146
147    #[test]
148    fn falling_three_methods_is_minus_one() {
149        let mut t = FallingThreeMethods::new();
150        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), None);
151        assert_eq!(t.update(c(11.0, 12.1, 10.9, 12.0, 1)), None);
152        assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.5, 2)), None);
153        assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 3)), None);
154        assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(-1.0));
155    }
156
157    #[test]
158    fn middle_bar_breaks_range_yields_zero() {
159        let mut t = FallingThreeMethods::new();
160        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
161        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
162        // bar3 pokes below bar1's low.
163        t.update(c(11.5, 12.6, 9.0, 12.5, 2));
164        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
165        assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0));
166    }
167
168    #[test]
169    fn bar5_not_new_low_yields_zero() {
170        let mut t = FallingThreeMethods::new();
171        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
172        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
173        t.update(c(11.5, 12.6, 11.4, 12.5, 2));
174        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
175        // bar5 black but closes above bar1's close.
176        assert_eq!(t.update(c(12.5, 12.6, 10.4, 10.5, 4)), Some(0.0));
177    }
178
179    #[test]
180    fn first_four_bars_return_zero() {
181        let mut t = FallingThreeMethods::new();
182        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), None);
183        assert_eq!(t.update(c(11.0, 12.1, 10.9, 12.0, 1)), None);
184        assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.5, 2)), None);
185        assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 3)), None);
186    }
187
188    #[test]
189    fn batch_equals_streaming() {
190        let candles: Vec<Candle> = (0..40)
191            .map(|i| {
192                let base = 200.0 - i as f64;
193                c(base + 5.0, base + 5.1, base - 0.1, base, i)
194            })
195            .collect();
196        let mut a = FallingThreeMethods::new();
197        let mut b = FallingThreeMethods::new();
198        assert_eq!(
199            a.batch(&candles),
200            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
201        );
202    }
203
204    #[test]
205    fn reset_clears_state() {
206        let mut t = FallingThreeMethods::new();
207        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
208        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
209        t.update(c(11.5, 12.6, 11.4, 12.5, 2));
210        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
211        t.update(c(12.5, 12.6, 8.9, 9.0, 4));
212        assert!(t.is_ready());
213        t.reset();
214        assert!(!t.is_ready());
215        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), None);
216    }
217
218    #[test]
219    fn zero_range_first_bar_yields_zero() {
220        let mut t = FallingThreeMethods::new();
221        // Flat first bar (range1 == 0) -> rejected.
222        t.update(c(10.0, 10.0, 10.0, 10.0, 0));
223        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
224        t.update(c(11.5, 12.6, 11.4, 12.5, 2));
225        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
226        assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0));
227    }
228
229    #[test]
230    fn short_first_body_yields_zero() {
231        let mut t = FallingThreeMethods::new();
232        // bar1 has a wide range but a tiny body -> not a long black body.
233        t.update(c(10.0, 16.0, 9.0, 10.2, 0));
234        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
235        t.update(c(11.5, 12.6, 11.4, 12.5, 2));
236        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
237        assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0));
238    }
239}