Skip to main content

wickra_core/indicators/
matching_low.rs

1//! Matching Low candlestick pattern.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Matching Low — a 2-bar bullish reversal. Two black candles in a decline close
7/// at the *same* level: the second sell-off cannot push price any lower, so the
8/// matching closes mark a support floor.
9///
10/// ```text
11/// bar1, bar2 both black
12/// equal closes  = |close2 − close1| <= 0.05 · mean(range1, range2)
13/// ```
14///
15/// Output is `+1.0` when the pattern completes and `0.0` otherwise. Matching Low
16/// is a single-direction (bullish-only) reversal, so it never emits `−1.0`. The
17/// first bar always returns `0.0` because the two-bar window is not yet filled.
18/// The close-equality tolerance follows the geometric house style rather than
19/// TA-Lib's rolling averages. Pattern-shape check only — no trend filter is
20/// applied; combine with a trend indicator for actionable signals.
21///
22/// # Signed ±1 encoding
23///
24/// This detector emits the uniform candlestick sign convention shared across the
25/// pattern family — `+1.0` bullish, `0.0` no pattern — so it drops straight into
26/// a machine-learning feature matrix as a single dimension.
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{Candle, Indicator, MatchingLow};
32///
33/// let mut indicator = MatchingLow::new();
34/// indicator.update(Candle::new(15.0, 15.1, 9.9, 10.0, 1.0, 0).unwrap());
35/// let out = indicator
36///     .update(Candle::new(13.0, 13.1, 9.9, 10.0, 1.0, 1).unwrap());
37/// assert_eq!(out, Some(1.0));
38/// ```
39#[derive(Debug, Clone, Default)]
40pub struct MatchingLow {
41    prev: Option<Candle>,
42    has_emitted: bool,
43}
44
45impl MatchingLow {
46    /// Construct a new Matching Low detector.
47    pub const fn new() -> Self {
48        Self {
49            prev: None,
50            has_emitted: false,
51        }
52    }
53}
54
55impl Indicator for MatchingLow {
56    type Input = Candle;
57    type Output = f64;
58
59    fn update(&mut self, candle: Candle) -> Option<f64> {
60        self.has_emitted = true;
61        let prev = self.prev;
62        self.prev = Some(candle);
63        let Some(bar1) = prev else {
64            return Some(0.0);
65        };
66        let mean_range = 0.5 * ((bar1.high - bar1.low) + (candle.high - candle.low));
67        let tol = 0.05 * mean_range;
68        if bar1.close < bar1.open
69            && candle.close < candle.open
70            && (candle.close - bar1.close).abs() <= tol
71        {
72            return Some(1.0);
73        }
74        Some(0.0)
75    }
76
77    fn reset(&mut self) {
78        self.prev = None;
79        self.has_emitted = false;
80    }
81
82    fn warmup_period(&self) -> usize {
83        2
84    }
85
86    fn is_ready(&self) -> bool {
87        self.has_emitted
88    }
89
90    fn name(&self) -> &'static str {
91        "MatchingLow"
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::traits::BatchExt;
99
100    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
101        Candle::new(open, high, low, close, 1.0, ts).unwrap()
102    }
103
104    #[test]
105    fn accessors_and_metadata() {
106        let t = MatchingLow::new();
107        assert_eq!(t.name(), "MatchingLow");
108        assert_eq!(t.warmup_period(), 2);
109        assert!(!t.is_ready());
110    }
111
112    #[test]
113    fn matching_low_is_plus_one() {
114        let mut t = MatchingLow::new();
115        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
116        assert_eq!(t.update(c(13.0, 13.1, 9.9, 10.0, 1)), Some(1.0));
117    }
118
119    #[test]
120    fn different_close_yields_zero() {
121        let mut t = MatchingLow::new();
122        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
123        // Second close well away from the first.
124        assert_eq!(t.update(c(13.0, 13.1, 11.4, 11.5, 1)), Some(0.0));
125    }
126
127    #[test]
128    fn second_bar_white_yields_zero() {
129        let mut t = MatchingLow::new();
130        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
131        assert_eq!(t.update(c(9.0, 10.1, 8.9, 10.0, 1)), Some(0.0));
132    }
133
134    #[test]
135    fn first_bar_returns_zero() {
136        let mut t = MatchingLow::new();
137        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
138    }
139
140    #[test]
141    fn batch_equals_streaming() {
142        let candles: Vec<Candle> = (0..40)
143            .map(|i| {
144                let base = 100.0 - i as f64;
145                c(base + 2.0, base + 2.1, base - 0.1, base, i)
146            })
147            .collect();
148        let mut a = MatchingLow::new();
149        let mut b = MatchingLow::new();
150        assert_eq!(
151            a.batch(&candles),
152            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
153        );
154    }
155
156    #[test]
157    fn reset_clears_state() {
158        let mut t = MatchingLow::new();
159        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
160        t.update(c(13.0, 13.1, 9.9, 10.0, 1));
161        assert!(t.is_ready());
162        t.reset();
163        assert!(!t.is_ready());
164        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), Some(0.0));
165    }
166}