Skip to main content

wickra_core/indicators/
kicking_by_length.rs

1//! Kicking-by-Length candlestick pattern.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Kicking-by-Length — the [`Kicking`](crate::Kicking) pattern with the signal
7/// taken from the *longer* of the two marubozu rather than from the gap direction.
8/// When the two shadowless candles differ in size, the bigger one is treated as
9/// the dominant force.
10///
11/// ```text
12/// marubozu = |close − open| >= 0.95 * (high − low)
13/// setup: two opposite-coloured marubozu separated by a gap
14///   black then white gapping UP, or white then black gapping DOWN
15/// signal = colour of the LONGER marubozu  (white -> +1.0, black -> −1.0)
16/// ```
17///
18/// Output is `+1.0` or `−1.0` when the kicking setup is present and `0.0`
19/// otherwise. Note this can disagree with [`Kicking`](crate::Kicking): a black
20/// marubozu kicked up by a *shorter* white marubozu reports `−1.0` here. The first
21/// bar always returns `0.0` because the two-bar window is not yet filled. The
22/// marubozu threshold follows the geometric house style rather than TA-Lib's
23/// rolling averages. Pattern-shape check only — no trend filter is applied;
24/// combine with a trend indicator for actionable signals.
25///
26/// # Signed ±1 encoding
27///
28/// This detector emits the uniform candlestick sign convention shared across the
29/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it
30/// drops straight into a machine-learning feature matrix as a single dimension.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Candle, Indicator, KickingByLength};
36///
37/// let mut indicator = KickingByLength::new();
38/// indicator.update(Candle::new(12.0, 12.0, 10.0, 10.0, 1.0, 0).unwrap());
39/// // White marubozu gaps up and is the longer body -> +1.
40/// let out = indicator
41///     .update(Candle::new(14.0, 20.0, 14.0, 20.0, 1.0, 1).unwrap());
42/// assert_eq!(out, Some(1.0));
43/// ```
44#[derive(Debug, Clone, Default)]
45pub struct KickingByLength {
46    prev: Option<Candle>,
47    has_emitted: bool,
48}
49
50impl KickingByLength {
51    /// Construct a new Kicking-by-Length detector.
52    pub const fn new() -> Self {
53        Self {
54            prev: None,
55            has_emitted: false,
56        }
57    }
58}
59
60fn is_marubozu(candle: &Candle) -> bool {
61    let range = candle.high - candle.low;
62    range > 0.0 && (candle.close - candle.open).abs() >= 0.95 * range
63}
64
65impl Indicator for KickingByLength {
66    type Input = Candle;
67    type Output = f64;
68
69    #[inline]
70    fn update(&mut self, candle: Candle) -> Option<f64> {
71        let prev = self.prev;
72        self.prev = Some(candle);
73        let bar1 = prev?;
74        self.has_emitted = true;
75        if !is_marubozu(&bar1) || !is_marubozu(&candle) {
76            return Some(0.0);
77        }
78        let body1 = bar1.close - bar1.open;
79        let body2 = candle.close - candle.open;
80        let bullish_setup = body1 < 0.0 && body2 > 0.0 && candle.low > bar1.high;
81        let bearish_setup = body1 > 0.0 && body2 < 0.0 && candle.high < bar1.low;
82        if !(bullish_setup || bearish_setup) {
83            return Some(0.0);
84        }
85        // The longer marubozu's colour is the signal.
86        let longer_is_white = if body1.abs() >= body2.abs() {
87            body1 > 0.0
88        } else {
89            body2 > 0.0
90        };
91        Some(if longer_is_white { 1.0 } else { -1.0 })
92    }
93
94    fn reset(&mut self) {
95        self.prev = None;
96        self.has_emitted = false;
97    }
98
99    #[inline]
100    fn warmup_period(&self) -> usize {
101        2
102    }
103
104    #[inline]
105    fn is_ready(&self) -> bool {
106        self.has_emitted
107    }
108
109    #[inline]
110    fn name(&self) -> &'static str {
111        "KickingByLength"
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::traits::BatchExt;
119
120    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
121        Candle::new(open, high, low, close, 1.0, ts).unwrap()
122    }
123
124    #[test]
125    fn accessors_and_metadata() {
126        let t = KickingByLength::new();
127        assert_eq!(t.name(), "KickingByLength");
128        assert_eq!(t.warmup_period(), 2);
129        assert!(!t.is_ready());
130    }
131
132    #[test]
133    fn longer_white_is_plus_one() {
134        let mut t = KickingByLength::new();
135        assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), None);
136        // White marubozu (length 6) longer than the black one (length 2).
137        assert_eq!(t.update(c(14.0, 20.0, 14.0, 20.0, 1)), Some(1.0));
138    }
139
140    #[test]
141    fn longer_black_is_minus_one() {
142        let mut t = KickingByLength::new();
143        // Black marubozu (length 6), then a shorter white marubozu (length 2)
144        // gapping up -> the longer black body wins, so -1.
145        assert_eq!(t.update(c(16.0, 16.0, 10.0, 10.0, 0)), None);
146        assert_eq!(t.update(c(18.0, 20.0, 18.0, 20.0, 1)), Some(-1.0));
147    }
148
149    #[test]
150    fn not_marubozu_yields_zero() {
151        let mut t = KickingByLength::new();
152        t.update(c(12.0, 14.0, 8.0, 10.0, 0));
153        assert_eq!(t.update(c(14.0, 20.0, 14.0, 20.0, 1)), Some(0.0));
154    }
155
156    #[test]
157    fn no_gap_yields_zero() {
158        let mut t = KickingByLength::new();
159        t.update(c(12.0, 12.0, 10.0, 10.0, 0));
160        assert_eq!(t.update(c(11.0, 13.0, 11.0, 13.0, 1)), Some(0.0));
161    }
162
163    #[test]
164    fn first_bar_returns_zero() {
165        let mut t = KickingByLength::new();
166        assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), None);
167    }
168
169    #[test]
170    fn batch_equals_streaming() {
171        let candles: Vec<Candle> = (0..40)
172            .map(|i| {
173                let base = 100.0 + i as f64 * 5.0;
174                if i % 2 == 0 {
175                    c(base + 2.0, base + 2.0, base, base, i)
176                } else {
177                    c(base + 3.0, base + 5.0, base + 3.0, base + 5.0, i)
178                }
179            })
180            .collect();
181        let mut a = KickingByLength::new();
182        let mut b = KickingByLength::new();
183        assert_eq!(
184            a.batch(&candles),
185            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
186        );
187    }
188
189    #[test]
190    fn reset_clears_state() {
191        let mut t = KickingByLength::new();
192        t.update(c(12.0, 12.0, 10.0, 10.0, 0));
193        t.update(c(14.0, 20.0, 14.0, 20.0, 1));
194        assert!(t.is_ready());
195        t.reset();
196        assert!(!t.is_ready());
197        assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), None);
198    }
199}