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    #[inline]
60    fn update(&mut self, candle: Candle) -> Option<f64> {
61        let prev = self.prev;
62        self.prev = Some(candle);
63        let bar1 = prev?;
64        self.has_emitted = true;
65        let mean_range = f64::midpoint(bar1.high - bar1.low, candle.high - candle.low);
66        let tol = 0.05 * mean_range;
67        if bar1.close < bar1.open
68            && candle.close < candle.open
69            && (candle.close - bar1.close).abs() <= tol
70        {
71            return Some(1.0);
72        }
73        Some(0.0)
74    }
75
76    fn reset(&mut self) {
77        self.prev = None;
78        self.has_emitted = false;
79    }
80
81    #[inline]
82    fn warmup_period(&self) -> usize {
83        2
84    }
85
86    #[inline]
87    fn is_ready(&self) -> bool {
88        self.has_emitted
89    }
90
91    #[inline]
92    fn name(&self) -> &'static str {
93        "MatchingLow"
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::traits::BatchExt;
101
102    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
103        Candle::new(open, high, low, close, 1.0, ts).unwrap()
104    }
105
106    #[test]
107    fn accessors_and_metadata() {
108        let t = MatchingLow::new();
109        assert_eq!(t.name(), "MatchingLow");
110        assert_eq!(t.warmup_period(), 2);
111        assert!(!t.is_ready());
112    }
113
114    #[test]
115    fn matching_low_is_plus_one() {
116        let mut t = MatchingLow::new();
117        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), None);
118        assert_eq!(t.update(c(13.0, 13.1, 9.9, 10.0, 1)), Some(1.0));
119    }
120
121    #[test]
122    fn different_close_yields_zero() {
123        let mut t = MatchingLow::new();
124        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
125        // Second close well away from the first.
126        assert_eq!(t.update(c(13.0, 13.1, 11.4, 11.5, 1)), Some(0.0));
127    }
128
129    #[test]
130    fn second_bar_white_yields_zero() {
131        let mut t = MatchingLow::new();
132        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
133        assert_eq!(t.update(c(9.0, 10.1, 8.9, 10.0, 1)), Some(0.0));
134    }
135
136    #[test]
137    fn first_bar_returns_zero() {
138        let mut t = MatchingLow::new();
139        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), None);
140    }
141
142    #[test]
143    fn batch_equals_streaming() {
144        let candles: Vec<Candle> = (0..40)
145            .map(|i| {
146                let base = 100.0 - i as f64;
147                c(base + 2.0, base + 2.1, base - 0.1, base, i)
148            })
149            .collect();
150        let mut a = MatchingLow::new();
151        let mut b = MatchingLow::new();
152        assert_eq!(
153            a.batch(&candles),
154            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
155        );
156    }
157
158    #[test]
159    fn reset_clears_state() {
160        let mut t = MatchingLow::new();
161        t.update(c(15.0, 15.1, 9.9, 10.0, 0));
162        t.update(c(13.0, 13.1, 9.9, 10.0, 1));
163        assert!(t.is_ready());
164        t.reset();
165        assert!(!t.is_ready());
166        assert_eq!(t.update(c(15.0, 15.1, 9.9, 10.0, 0)), None);
167    }
168}