Skip to main content

wickra_core/indicators/
ladder_bottom.rs

1//! Ladder Bottom candlestick pattern.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Ladder Bottom — a 5-bar bullish reversal. Three long black candles step the
7/// market down like rungs of a ladder, a fourth black candle finally shows an
8/// upper shadow (the first sign of buying), and a white candle then gaps up into
9/// its body and closes above the fourth bar's high to confirm the turn (Nison;
10/// TA-Lib `CDLLADDERBOTTOM`).
11///
12/// ```text
13/// bar1, bar2, bar3 black, with consecutively lower opens AND closes
14/// bar4 black with an upper shadow         (high4 > open4)
15/// bar5 white, opens above bar4's body      (open5 > open4)
16///      and closes above bar4's high          (close5 > high4)
17/// ```
18///
19/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Ladder Bottom
20/// is a single-direction (bullish-only) reversal, so it never emits `−1.0`. The
21/// first four bars always return `0.0` because the five-bar window is not yet
22/// filled. Pattern-shape check only — no trend filter is applied; combine with a
23/// 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` bullish, `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, Indicator, LadderBottom};
35///
36/// let mut indicator = LadderBottom::new();
37/// indicator.update(Candle::new(20.0, 20.1, 17.9, 18.0, 1.0, 0).unwrap());
38/// indicator.update(Candle::new(18.0, 18.1, 15.9, 16.0, 1.0, 1).unwrap());
39/// indicator.update(Candle::new(16.0, 16.1, 13.9, 14.0, 1.0, 2).unwrap());
40/// indicator.update(Candle::new(14.0, 15.0, 12.4, 12.5, 1.0, 3).unwrap());
41/// let out = indicator
42///     .update(Candle::new(15.0, 17.1, 14.9, 17.0, 1.0, 4).unwrap());
43/// assert_eq!(out, Some(1.0));
44/// ```
45#[derive(Debug, Clone, Default)]
46pub struct LadderBottom {
47    c1: Option<Candle>,
48    c2: Option<Candle>,
49    c3: Option<Candle>,
50    c4: Option<Candle>,
51    has_emitted: bool,
52}
53
54impl LadderBottom {
55    /// Construct a new Ladder Bottom 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 LadderBottom {
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        if bar1.close < bar1.open
86            && bar2.close < bar2.open
87            && bar3.close < bar3.open
88            && bar2.open < bar1.open
89            && bar2.close < bar1.close
90            && bar3.open < bar2.open
91            && bar3.close < bar2.close
92            && bar4.close < bar4.open
93            && bar4.high > bar4.open
94            && candle.close > candle.open
95            && candle.open > bar4.open
96            && candle.close > bar4.high
97        {
98            return Some(1.0);
99        }
100        Some(0.0)
101    }
102
103    fn reset(&mut self) {
104        self.c1 = None;
105        self.c2 = None;
106        self.c3 = None;
107        self.c4 = None;
108        self.has_emitted = false;
109    }
110
111    #[inline]
112    fn warmup_period(&self) -> usize {
113        5
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        "LadderBottom"
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 = LadderBottom::new();
139        assert_eq!(t.name(), "LadderBottom");
140        assert_eq!(t.warmup_period(), 5);
141        assert!(!t.is_ready());
142    }
143
144    #[test]
145    fn ladder_bottom_is_plus_one() {
146        let mut t = LadderBottom::new();
147        assert_eq!(t.update(c(20.0, 20.1, 17.9, 18.0, 0)), None);
148        assert_eq!(t.update(c(18.0, 18.1, 15.9, 16.0, 1)), None);
149        assert_eq!(t.update(c(16.0, 16.1, 13.9, 14.0, 2)), None);
150        assert_eq!(t.update(c(14.0, 15.0, 12.4, 12.5, 3)), None);
151        assert_eq!(t.update(c(15.0, 17.1, 14.9, 17.0, 4)), Some(1.0));
152    }
153
154    #[test]
155    fn fourth_bar_without_upper_shadow_yields_zero() {
156        let mut t = LadderBottom::new();
157        t.update(c(20.0, 20.1, 17.9, 18.0, 0));
158        t.update(c(18.0, 18.1, 15.9, 16.0, 1));
159        t.update(c(16.0, 16.1, 13.9, 14.0, 2));
160        // bar4 opens at its high -> no upper shadow.
161        t.update(c(14.0, 14.0, 12.4, 12.5, 3));
162        assert_eq!(t.update(c(15.0, 17.1, 14.9, 17.0, 4)), Some(0.0));
163    }
164
165    #[test]
166    fn not_three_descending_blacks_yields_zero() {
167        let mut t = LadderBottom::new();
168        // bar2 is not lower than bar1.
169        t.update(c(20.0, 20.1, 17.9, 18.0, 0));
170        t.update(c(21.0, 21.1, 18.9, 19.0, 1));
171        t.update(c(16.0, 16.1, 13.9, 14.0, 2));
172        t.update(c(14.0, 15.0, 12.4, 12.5, 3));
173        assert_eq!(t.update(c(15.0, 17.1, 14.9, 17.0, 4)), Some(0.0));
174    }
175
176    #[test]
177    fn first_four_bars_return_zero() {
178        let mut t = LadderBottom::new();
179        assert_eq!(t.update(c(20.0, 20.1, 17.9, 18.0, 0)), None);
180        assert_eq!(t.update(c(18.0, 18.1, 15.9, 16.0, 1)), None);
181        assert_eq!(t.update(c(16.0, 16.1, 13.9, 14.0, 2)), None);
182        assert_eq!(t.update(c(14.0, 15.0, 12.4, 12.5, 3)), None);
183    }
184
185    #[test]
186    fn batch_equals_streaming() {
187        let candles: Vec<Candle> = (0..40)
188            .map(|i| {
189                let base = 200.0 - i as f64;
190                c(base, base + 0.1, base - 2.1, base - 2.0, i)
191            })
192            .collect();
193        let mut a = LadderBottom::new();
194        let mut b = LadderBottom::new();
195        assert_eq!(
196            a.batch(&candles),
197            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
198        );
199    }
200
201    #[test]
202    fn reset_clears_state() {
203        let mut t = LadderBottom::new();
204        t.update(c(20.0, 20.1, 17.9, 18.0, 0));
205        t.update(c(18.0, 18.1, 15.9, 16.0, 1));
206        t.update(c(16.0, 16.1, 13.9, 14.0, 2));
207        t.update(c(14.0, 15.0, 12.4, 12.5, 3));
208        t.update(c(15.0, 17.1, 14.9, 17.0, 4));
209        assert!(t.is_ready());
210        t.reset();
211        assert!(!t.is_ready());
212        assert_eq!(t.update(c(20.0, 20.1, 17.9, 18.0, 0)), None);
213    }
214
215    /// The canonical pattern from the example.
216    fn base() -> [Candle; 5] {
217        [
218            c(20.0, 20.1, 17.9, 18.0, 0),
219            c(18.0, 18.1, 15.9, 16.0, 1),
220            c(16.0, 16.1, 13.9, 14.0, 2),
221            c(14.0, 15.0, 12.4, 12.5, 3),
222            c(15.0, 17.1, 14.9, 17.0, 4),
223        ]
224    }
225
226    fn run(bars: [Candle; 5]) -> Option<f64> {
227        let mut t = LadderBottom::new();
228        bars.iter().map(|b| t.update(*b)).last().unwrap()
229    }
230
231    fn with(index: usize, candle: Candle) -> [Candle; 5] {
232        let mut bars = base();
233        bars[index] = candle;
234        bars
235    }
236
237    #[test]
238    fn close5_must_exceed_high4() {
239        // high4 = 15.0. bar5 opens at 14.5 (> open4 = 14.0) and is white.
240        // close5 = 15.0 is not > 15.0 -> no pattern.
241        assert_eq!(run(with(4, c(14.5, 15.2, 14.4, 15.0, 4))), Some(0.0));
242        // close5 = 15.05 > 15.0 -> pattern.
243        assert_eq!(run(with(4, c(14.5, 15.2, 14.4, 15.05, 4))), Some(1.0));
244    }
245
246    #[test]
247    fn every_condition_is_required() {
248        assert_eq!(run(base()), Some(1.0));
249        // bar1 white.
250        assert_eq!(run(with(0, c(18.5, 20.1, 18.4, 19.0, 0))), Some(0.0));
251        // bar2 white.
252        assert_eq!(run(with(1, c(17.0, 18.1, 15.9, 17.5, 1))), Some(0.0));
253        // bar3 white.
254        assert_eq!(run(with(2, c(14.5, 15.5, 13.9, 15.0, 2))), Some(0.0));
255        // open2 >= open1.
256        assert_eq!(run(with(1, c(20.5, 20.6, 15.9, 16.0, 1))), Some(0.0));
257        // close2 >= close1.
258        assert_eq!(run(with(1, c(19.0, 19.1, 18.4, 18.5, 1))), Some(0.0));
259        // open3 >= open2.
260        assert_eq!(run(with(2, c(18.5, 18.6, 13.9, 14.0, 2))), Some(0.0));
261        // close3 >= close2.
262        assert_eq!(run(with(2, c(17.0, 17.1, 16.4, 16.5, 2))), Some(0.0));
263        // bar4 white.
264        assert_eq!(run(with(3, c(12.5, 15.0, 12.4, 14.0, 3))), Some(0.0));
265        // bar5 black.
266        assert_eq!(run(with(4, c(17.0, 17.1, 14.9, 16.0, 4))), Some(0.0));
267        // open5 <= open4.
268        assert_eq!(run(with(4, c(13.5, 17.1, 13.4, 17.0, 4))), Some(0.0));
269    }
270
271    #[test]
272    fn first_value_lands_exactly_at_warmup_index() {
273        let mut t = LadderBottom::new();
274        let out = t.batch(&base());
275        let warm = t.warmup_period();
276        assert!(out[..warm - 1].iter().all(Option::is_none));
277        assert_eq!(out[warm - 1], Some(1.0));
278    }
279
280    fn mixed_series() -> Vec<Candle> {
281        let mut candles: Vec<Candle> = Vec::new();
282        for round in 0..6 {
283            for (k, bar) in base().iter().enumerate() {
284                let ts = i64::try_from(round * 5 + k).unwrap();
285                candles.push(Candle::new(bar.open, bar.high, bar.low, bar.close, 1.0, ts).unwrap());
286            }
287        }
288        candles
289    }
290
291    #[test]
292    fn reset_replays_identically() {
293        let candles = mixed_series();
294        let fresh = LadderBottom::new().batch(&candles);
295        let mut t = LadderBottom::new();
296        let _ = t.batch(&candles);
297        t.reset();
298        assert_eq!(t.batch(&candles), fresh);
299    }
300
301    #[test]
302    fn batch_nan_into_matches_streaming_bits() {
303        let candles = mixed_series();
304        let mut t = LadderBottom::new();
305        let streamed: Vec<f64> = candles
306            .iter()
307            .map(|x| t.update(*x).unwrap_or(f64::NAN))
308            .collect();
309        let mut out = vec![0.0; candles.len()];
310        LadderBottom::new().batch_nan_into(&candles, &mut out);
311        assert!(streamed
312            .iter()
313            .zip(&out)
314            .all(|(a, b)| a.to_bits() == b.to_bits()));
315        assert!(streamed.contains(&1.0));
316    }
317}