Skip to main content

wickra_core/indicators/
separating_lines.rs

1//! Separating Lines candlestick pattern.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Separating Lines — a 2-bar continuation. After a counter-trend candle, the next
7/// candle of the *opposite* colour opens right back at the prior open and runs as
8/// an opening marubozu in the trend direction, so the trend "separates" from the
9/// pullback and resumes.
10///
11/// ```text
12/// long body = |close − open| >= 0.5 * (high − low)
13/// bar1, bar2 opposite colours
14/// bar2 opens at bar1's open                 (|open2 − open1| <= 0.05 · range1)
15/// bar2 is a long opening marubozu in its direction
16///   white bar2: open2 == low2  (no lower shadow)  -> +1.0
17///   black bar2: open2 == high2 (no upper shadow)  -> −1.0
18/// ```
19///
20/// Output is `+1.0` (bullish continuation) or `−1.0` (bearish continuation) when
21/// the pattern completes and `0.0` otherwise. The first bar always returns `0.0`
22/// because the two-bar window is not yet filled. Open-equality and marubozu
23/// thresholds follow the geometric house style rather than TA-Lib's rolling
24/// averages. Pattern-shape check only — no trend filter is applied; combine with
25/// a trend indicator for actionable signals.
26///
27/// # Signed ±1 encoding
28///
29/// This detector emits the uniform candlestick sign convention shared across the
30/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it
31/// drops straight into a machine-learning feature matrix where the two directions
32/// occupy a single dimension.
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{Candle, Indicator, SeparatingLines};
38///
39/// let mut indicator = SeparatingLines::new();
40/// indicator.update(Candle::new(12.0, 12.1, 9.9, 10.0, 1.0, 0).unwrap());
41/// let out = indicator
42///     .update(Candle::new(12.0, 14.1, 12.0, 14.0, 1.0, 1).unwrap());
43/// assert_eq!(out, Some(1.0));
44/// ```
45#[derive(Debug, Clone, Default)]
46pub struct SeparatingLines {
47    prev: Option<Candle>,
48    has_emitted: bool,
49}
50
51impl SeparatingLines {
52    /// Construct a new Separating Lines detector.
53    pub const fn new() -> Self {
54        Self {
55            prev: None,
56            has_emitted: false,
57        }
58    }
59}
60
61impl Indicator for SeparatingLines {
62    type Input = Candle;
63    type Output = f64;
64
65    #[inline]
66    fn update(&mut self, candle: Candle) -> Option<f64> {
67        let prev = self.prev;
68        self.prev = Some(candle);
69        let bar1 = prev?;
70        self.has_emitted = true;
71        let range1 = bar1.high - bar1.low;
72        let range2 = candle.high - candle.low;
73        if range1 <= 0.0 || range2 <= 0.0 {
74            return Some(0.0);
75        }
76        // Opens must coincide.
77        if (candle.open - bar1.open).abs() > 0.05 * range1 {
78            return Some(0.0);
79        }
80        let body2 = candle.close - candle.open;
81        if body2.abs() < 0.5 * range2 {
82            return Some(0.0); // bar2 must be a long body
83        }
84        let tol = 0.05 * range2;
85        // Bullish: bar1 black, bar2 a long white opening marubozu (no lower wick).
86        if bar1.close < bar1.open && body2 > 0.0 && candle.open - candle.low <= tol {
87            return Some(1.0);
88        }
89        // Bearish: bar1 white, bar2 a long black opening marubozu (no upper wick).
90        if bar1.close > bar1.open && body2 < 0.0 && candle.high - candle.open <= tol {
91            return Some(-1.0);
92        }
93        Some(0.0)
94    }
95
96    fn reset(&mut self) {
97        self.prev = None;
98        self.has_emitted = false;
99    }
100
101    #[inline]
102    fn warmup_period(&self) -> usize {
103        2
104    }
105
106    #[inline]
107    fn is_ready(&self) -> bool {
108        self.has_emitted
109    }
110
111    #[inline]
112    fn name(&self) -> &'static str {
113        "SeparatingLines"
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::traits::BatchExt;
121
122    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
123        Candle::new(open, high, low, close, 1.0, ts).unwrap()
124    }
125
126    #[test]
127    fn accessors_and_metadata() {
128        let t = SeparatingLines::new();
129        assert_eq!(t.name(), "SeparatingLines");
130        assert_eq!(t.warmup_period(), 2);
131        assert!(!t.is_ready());
132    }
133
134    #[test]
135    fn bullish_separating_lines_is_plus_one() {
136        let mut t = SeparatingLines::new();
137        assert_eq!(t.update(c(12.0, 12.1, 9.9, 10.0, 0)), None);
138        assert_eq!(t.update(c(12.0, 14.1, 12.0, 14.0, 1)), Some(1.0));
139    }
140
141    #[test]
142    fn bearish_separating_lines_is_minus_one() {
143        let mut t = SeparatingLines::new();
144        assert_eq!(t.update(c(10.0, 12.1, 9.9, 12.0, 0)), None);
145        assert_eq!(t.update(c(10.0, 10.0, 7.9, 8.0, 1)), Some(-1.0));
146    }
147
148    #[test]
149    fn same_color_yields_zero() {
150        let mut t = SeparatingLines::new();
151        // Both white -> not separating (need opposite colours).
152        t.update(c(12.0, 14.1, 11.9, 14.0, 0));
153        assert_eq!(t.update(c(12.0, 14.1, 12.0, 14.0, 1)), Some(0.0));
154    }
155
156    #[test]
157    fn different_open_yields_zero() {
158        let mut t = SeparatingLines::new();
159        t.update(c(12.0, 12.1, 9.9, 10.0, 0));
160        // bar2 opens far from bar1's open.
161        assert_eq!(t.update(c(13.0, 15.1, 13.0, 15.0, 1)), Some(0.0));
162    }
163
164    #[test]
165    fn opening_shadow_yields_zero() {
166        let mut t = SeparatingLines::new();
167        t.update(c(12.0, 12.1, 9.9, 10.0, 0));
168        // White bar2 but it has a lower shadow -> not an opening marubozu.
169        assert_eq!(t.update(c(12.0, 14.1, 11.0, 14.0, 1)), Some(0.0));
170    }
171
172    #[test]
173    fn first_bar_returns_zero() {
174        let mut t = SeparatingLines::new();
175        assert_eq!(t.update(c(12.0, 12.1, 9.9, 10.0, 0)), None);
176    }
177
178    #[test]
179    fn batch_equals_streaming() {
180        let candles: Vec<Candle> = (0..40)
181            .map(|i| {
182                let base = 100.0 + i as f64;
183                c(base, base + 2.0, base, base + 1.9, i)
184            })
185            .collect();
186        let mut a = SeparatingLines::new();
187        let mut b = SeparatingLines::new();
188        assert_eq!(
189            a.batch(&candles),
190            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
191        );
192    }
193
194    #[test]
195    fn reset_clears_state() {
196        let mut t = SeparatingLines::new();
197        t.update(c(12.0, 12.1, 9.9, 10.0, 0));
198        t.update(c(12.0, 14.1, 12.0, 14.0, 1));
199        assert!(t.is_ready());
200        t.reset();
201        assert!(!t.is_ready());
202        assert_eq!(t.update(c(12.0, 12.1, 9.9, 10.0, 0)), None);
203    }
204
205    #[test]
206    fn zero_range_yields_zero() {
207        let mut t = SeparatingLines::new();
208        // Flat first bar (range1 == 0) -> rejected.
209        t.update(c(10.0, 10.0, 10.0, 10.0, 0));
210        assert_eq!(t.update(c(10.0, 12.0, 9.0, 11.0, 1)), Some(0.0));
211    }
212
213    #[test]
214    fn short_second_body_yields_zero() {
215        let mut t = SeparatingLines::new();
216        t.update(c(10.0, 12.0, 8.0, 9.0, 0));
217        // Opens coincide but bar2's body is too short to be a separating line.
218        assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.1, 1)), Some(0.0));
219    }
220}