Skip to main content

wickra_core/indicators/
rising_three_methods.rs

1//! Rising Three Methods candlestick pattern.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Rising Three Methods — a 5-bar bullish continuation. A long white candle is
7/// followed by three small black bars that drift back but stay inside its range
8/// (a brief rest), then a second long white candle opens above the last rest
9/// bar's close and closes above the first, resuming the advance (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 white & long
16/// bar2, bar3, bar4 small black bodies, each overlapping bar1's high/low range
17///                  (min(open, close) < high1 and max(open, close) > low1),
18///                  with falling closes (close3 < close2, close4 < close3)
19/// bar5 white & long, opening above bar4's close (open5 > close4)
20///                  and closing above bar1's close (close5 > close1)
21/// ```
22///
23/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Rising Three
24/// Methods is a single-direction (bullish-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` bullish, `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, Indicator, RisingThreeMethods};
40///
41/// let mut indicator = RisingThreeMethods::new();
42/// indicator.update(Candle::new(10.0, 15.1, 9.9, 15.0, 1.0, 0).unwrap());
43/// indicator.update(Candle::new(14.0, 14.1, 12.9, 13.0, 1.0, 1).unwrap());
44/// indicator.update(Candle::new(13.5, 13.6, 12.4, 12.5, 1.0, 2).unwrap());
45/// indicator.update(Candle::new(13.0, 13.1, 11.9, 12.0, 1.0, 3).unwrap());
46/// let out = indicator
47///     .update(Candle::new(12.5, 16.1, 12.4, 16.0, 1.0, 4).unwrap());
48/// assert_eq!(out, Some(1.0));
49/// ```
50#[derive(Debug, Clone, Default)]
51pub struct RisingThreeMethods {
52    c1: Option<Candle>,
53    c2: Option<Candle>,
54    c3: Option<Candle>,
55    c4: Option<Candle>,
56    has_emitted: bool,
57}
58
59impl RisingThreeMethods {
60    /// Construct a new Rising 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 RisingThreeMethods {
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.close - bar1.open;
95        if body1 < 0.5 * range1 {
96            return Some(0.0); // bar1 must be a long white body
97        }
98        // The three middle bars are small black 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.open - mid.close;
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 down against the advance.
111        if bar3.close >= bar2.close || bar4.close >= bar3.close {
112            return Some(0.0);
113        }
114        // bar5 is a long white candle that opens above bar4's close and closes
115        // above bar1's close.
116        let body5 = candle.close - candle.open;
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        "RisingThreeMethods"
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 = RisingThreeMethods::new();
163        assert_eq!(t.name(), "RisingThreeMethods");
164        assert_eq!(t.warmup_period(), 5);
165        assert!(!t.is_ready());
166    }
167
168    #[test]
169    fn rising_three_methods_is_plus_one() {
170        let mut t = RisingThreeMethods::new();
171        assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), None);
172        assert_eq!(t.update(c(14.0, 14.1, 12.9, 13.0, 1)), None);
173        assert_eq!(t.update(c(13.5, 13.6, 12.4, 12.5, 2)), None);
174        assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.0, 3)), None);
175        assert_eq!(t.update(c(12.5, 16.1, 12.4, 16.0, 4)), Some(1.0));
176    }
177
178    #[test]
179    fn middle_bar_breaks_range_yields_zero() {
180        let mut t = RisingThreeMethods::new();
181        t.update(c(10.0, 15.1, 9.9, 15.0, 0));
182        t.update(c(14.0, 14.1, 12.9, 13.0, 1));
183        // bar3's whole body sits above bar1's high.
184        t.update(c(15.5, 15.6, 15.1, 15.2, 2));
185        t.update(c(13.0, 13.1, 11.9, 12.0, 3));
186        assert_eq!(t.update(c(12.5, 16.1, 12.4, 16.0, 4)), Some(0.0));
187    }
188
189    #[test]
190    fn bar5_not_new_high_yields_zero() {
191        let mut t = RisingThreeMethods::new();
192        t.update(c(10.0, 15.1, 9.9, 15.0, 0));
193        t.update(c(14.0, 14.1, 12.9, 13.0, 1));
194        t.update(c(13.5, 13.6, 12.4, 12.5, 2));
195        t.update(c(13.0, 13.1, 11.9, 12.0, 3));
196        // bar5 white but closes below bar1's close.
197        assert_eq!(t.update(c(12.5, 14.6, 12.4, 14.5, 4)), Some(0.0));
198    }
199
200    #[test]
201    fn first_four_bars_return_zero() {
202        let mut t = RisingThreeMethods::new();
203        assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), None);
204        assert_eq!(t.update(c(14.0, 14.1, 12.9, 13.0, 1)), None);
205        assert_eq!(t.update(c(13.5, 13.6, 12.4, 12.5, 2)), None);
206        assert_eq!(t.update(c(13.0, 13.1, 11.9, 12.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 = 100.0 + i as f64;
214                c(base, base + 5.2, base - 0.1, base + 5.0, i)
215            })
216            .collect();
217        let mut a = RisingThreeMethods::new();
218        let mut b = RisingThreeMethods::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 = RisingThreeMethods::new();
228        t.update(c(10.0, 15.1, 9.9, 15.0, 0));
229        t.update(c(14.0, 14.1, 12.9, 13.0, 1));
230        t.update(c(13.5, 13.6, 12.4, 12.5, 2));
231        t.update(c(13.0, 13.1, 11.9, 12.0, 3));
232        t.update(c(12.5, 16.1, 12.4, 16.0, 4));
233        assert!(t.is_ready());
234        t.reset();
235        assert!(!t.is_ready());
236        assert_eq!(t.update(c(10.0, 15.1, 9.9, 15.0, 0)), None);
237    }
238
239    #[test]
240    fn zero_range_first_bar_yields_zero() {
241        let mut t = RisingThreeMethods::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(14.0, 14.1, 12.9, 13.0, 1));
245        t.update(c(13.5, 13.6, 12.4, 12.5, 2));
246        t.update(c(13.0, 13.1, 11.9, 12.0, 3));
247        assert_eq!(t.update(c(12.5, 16.1, 12.4, 16.0, 4)), Some(0.0));
248    }
249
250    #[test]
251    fn short_first_body_yields_zero() {
252        let mut t = RisingThreeMethods::new();
253        // bar1 has a wide range but a tiny body -> not a long white body.
254        t.update(c(10.0, 16.0, 9.0, 10.2, 0));
255        t.update(c(14.0, 14.1, 12.9, 13.0, 1));
256        t.update(c(13.5, 13.6, 12.4, 12.5, 2));
257        t.update(c(13.0, 13.1, 11.9, 12.0, 3));
258        assert_eq!(t.update(c(12.5, 16.1, 12.4, 16.0, 4)), Some(0.0));
259    }
260
261    /// The canonical pattern: bar1 10 -> 15 (range 5.2, body1 = 5, so middle
262    /// bodies may be at most 2.5), black rests closing 13, 12.5, 12, bar5 12.5 -> 16.
263    fn base() -> [Candle; 5] {
264        [
265            c(10.0, 15.1, 9.9, 15.0, 0),
266            c(14.0, 14.1, 12.9, 13.0, 1),
267            c(13.5, 13.6, 12.4, 12.5, 2),
268            c(13.0, 13.1, 11.9, 12.0, 3),
269            c(12.5, 16.1, 12.4, 16.0, 4),
270        ]
271    }
272
273    fn run(bars: [Candle; 5]) -> Option<f64> {
274        let mut t = RisingThreeMethods::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 white).
287        // Middle bodies 1.0, 1.0, 1.0 <= 2.5, black, all inside 9.9..15.1.
288        // Closes 13 > 12.5 > 12 (drifting down).
289        // body5 = 3.5 >= 0.5 * 3.7 = 1.85, open5 12.5 > close4 12, close5 16 > 15.
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 upper shadow pokes to 15.5 > high1 = 15.1; its body 14..13 is inside.
296        assert_eq!(run(with(1, c(14.0, 15.5, 12.9, 13.0, 1))), Some(1.0));
297        // bar2's body 15.4..14.9 straddles high1: min 14.9 < 15.1 -> still overlaps.
298        assert_eq!(run(with(1, c(15.4, 15.5, 14.8, 14.9, 1))), Some(1.0));
299        // bar4's lower shadow pokes below low1 = 9.9; body 10.4..10.0 overlaps.
300        assert_eq!(run(with(3, c(10.4, 10.5, 9.0, 10.0, 3))), Some(1.0));
301    }
302
303    #[test]
304    fn middle_body_fully_outside_range_yields_zero() {
305        // Body bottom exactly at high1 (min == 15.1) -> outside.
306        assert_eq!(run(with(1, c(15.6, 15.7, 15.0, 15.1, 1))), Some(0.0));
307        // Body entirely below low1 = 9.9 (max 9.8 <= 9.9).
308        assert_eq!(run(with(3, c(9.8, 9.9, 9.0, 9.2, 3))), Some(0.0));
309        // Body top exactly at low1 (max == 9.9) -> outside.
310        assert_eq!(run(with(3, c(9.9, 10.0, 9.0, 9.5, 3))), Some(0.0));
311    }
312
313    #[test]
314    fn middle_body_colour_and_size_rules() {
315        // White middle bar.
316        assert_eq!(run(with(2, c(12.5, 13.6, 12.4, 13.5, 2))), Some(0.0));
317        // Doji middle bar (body 0).
318        assert_eq!(run(with(2, c(12.5, 13.6, 12.4, 12.5, 2))), Some(0.0));
319        // Body 2.9 > 0.5 * body1 = 2.5.
320        assert_eq!(run(with(1, c(14.9, 15.0, 11.9, 12.0, 1))), Some(0.0));
321    }
322
323    #[test]
324    fn closes_not_drifting_down_yields_zero() {
325        // close3 = 13.1 >= close2 = 13.0.
326        assert_eq!(run(with(2, c(13.6, 13.7, 13.0, 13.1, 2))), Some(0.0));
327        // close3 == close2.
328        assert_eq!(run(with(2, c(13.6, 13.7, 12.9, 13.0, 2))), Some(0.0));
329        // close4 = 12.6 >= close3 = 12.5.
330        assert_eq!(run(with(3, c(13.0, 13.1, 12.5, 12.6, 3))), Some(0.0));
331    }
332
333    #[test]
334    fn fifth_bar_conditions() {
335        // Black bar5.
336        assert_eq!(run(with(4, c(16.0, 16.1, 12.4, 15.5, 4))), Some(0.0));
337        // Doji bar5 (body5 == 0).
338        assert_eq!(run(with(4, c(16.0, 16.1, 12.4, 16.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, 20.0, 12.0, 16.0, 4))), Some(0.0));
341        // open5 = 12.0 == close4 -> not above.
342        assert_eq!(run(with(4, c(12.0, 16.1, 11.9, 16.0, 4))), Some(0.0));
343        // close5 == close1 = 15.0 -> not above.
344        assert_eq!(run(with(4, c(12.5, 15.1, 12.4, 15.0, 4))), Some(0.0));
345    }
346
347    #[test]
348    fn first_value_lands_exactly_at_warmup_index() {
349        let mut t = RisingThreeMethods::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 = RisingThreeMethods::new().batch(&candles);
364        let mut t = RisingThreeMethods::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 = RisingThreeMethods::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        RisingThreeMethods::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}