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    fn update(&mut self, candle: Candle) -> Option<f64> {
72        self.has_emitted = true;
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 Some(0.0);
83        };
84        let range1 = bar1.high - bar1.low;
85        if range1 <= 0.0 {
86            return Some(0.0);
87        }
88        let body1 = bar1.open - bar1.close;
89        if body1 < 0.5 * range1 {
90            return Some(0.0); // bar1 must be a long black body
91        }
92        // The three middle bars stay within bar1's range with smaller bodies.
93        for mid in [bar2, bar3, bar4] {
94            if (mid.close - mid.open).abs() >= body1 || mid.high > bar1.high || mid.low < bar1.low {
95                return Some(0.0);
96            }
97        }
98        // bar5 is a black candle closing below bar1's close.
99        if candle.close < candle.open && candle.close < bar1.close {
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.c3 = None;
109        self.c4 = None;
110        self.has_emitted = false;
111    }
112
113    fn warmup_period(&self) -> usize {
114        5
115    }
116
117    fn is_ready(&self) -> bool {
118        self.has_emitted
119    }
120
121    fn name(&self) -> &'static str {
122        "FallingThreeMethods"
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::traits::BatchExt;
130
131    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
132        Candle::new(open, high, low, close, 1.0, ts).unwrap()
133    }
134
135    #[test]
136    fn accessors_and_metadata() {
137        let t = FallingThreeMethods::new();
138        assert_eq!(t.name(), "FallingThreeMethods");
139        assert_eq!(t.warmup_period(), 5);
140        assert!(!t.is_ready());
141    }
142
143    #[test]
144    fn falling_three_methods_is_minus_one() {
145        let mut t = FallingThreeMethods::new();
146        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
147        assert_eq!(t.update(c(11.0, 12.1, 10.9, 12.0, 1)), Some(0.0));
148        assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.5, 2)), Some(0.0));
149        assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 3)), Some(0.0));
150        assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(-1.0));
151    }
152
153    #[test]
154    fn middle_bar_breaks_range_yields_zero() {
155        let mut t = FallingThreeMethods::new();
156        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
157        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
158        // bar3 pokes below bar1's low.
159        t.update(c(11.5, 12.6, 9.0, 12.5, 2));
160        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
161        assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0));
162    }
163
164    #[test]
165    fn bar5_not_new_low_yields_zero() {
166        let mut t = FallingThreeMethods::new();
167        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
168        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
169        t.update(c(11.5, 12.6, 11.4, 12.5, 2));
170        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
171        // bar5 black but closes above bar1's close.
172        assert_eq!(t.update(c(12.5, 12.6, 10.4, 10.5, 4)), Some(0.0));
173    }
174
175    #[test]
176    fn first_four_bars_return_zero() {
177        let mut t = FallingThreeMethods::new();
178        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
179        assert_eq!(t.update(c(11.0, 12.1, 10.9, 12.0, 1)), Some(0.0));
180        assert_eq!(t.update(c(11.5, 12.6, 11.4, 12.5, 2)), Some(0.0));
181        assert_eq!(t.update(c(12.0, 13.1, 11.9, 13.0, 3)), Some(0.0));
182    }
183
184    #[test]
185    fn batch_equals_streaming() {
186        let candles: Vec<Candle> = (0..40)
187            .map(|i| {
188                let base = 200.0 - i as f64;
189                c(base + 5.0, base + 5.1, base - 0.1, base, i)
190            })
191            .collect();
192        let mut a = FallingThreeMethods::new();
193        let mut b = FallingThreeMethods::new();
194        assert_eq!(
195            a.batch(&candles),
196            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
197        );
198    }
199
200    #[test]
201    fn reset_clears_state() {
202        let mut t = FallingThreeMethods::new();
203        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
204        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
205        t.update(c(11.5, 12.6, 11.4, 12.5, 2));
206        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
207        t.update(c(12.5, 12.6, 8.9, 9.0, 4));
208        assert!(t.is_ready());
209        t.reset();
210        assert!(!t.is_ready());
211        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
212    }
213
214    #[test]
215    fn zero_range_first_bar_yields_zero() {
216        let mut t = FallingThreeMethods::new();
217        // Flat first bar (range1 == 0) -> rejected.
218        t.update(c(10.0, 10.0, 10.0, 10.0, 0));
219        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
220        t.update(c(11.5, 12.6, 11.4, 12.5, 2));
221        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
222        assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0));
223    }
224
225    #[test]
226    fn short_first_body_yields_zero() {
227        let mut t = FallingThreeMethods::new();
228        // bar1 has a wide range but a tiny body -> not a long black body.
229        t.update(c(10.0, 16.0, 9.0, 10.2, 0));
230        t.update(c(11.0, 12.1, 10.9, 12.0, 1));
231        t.update(c(11.5, 12.6, 11.4, 12.5, 2));
232        t.update(c(12.0, 13.1, 11.9, 13.0, 3));
233        assert_eq!(t.update(c(12.5, 12.6, 8.9, 9.0, 4)), Some(0.0));
234    }
235}