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    fn update(&mut self, candle: Candle) -> Option<f64> {
70        self.has_emitted = true;
71        let prev = self.prev;
72        self.prev = Some(candle);
73        let Some(bar1) = prev else {
74            return Some(0.0);
75        };
76        if !is_marubozu(&bar1) || !is_marubozu(&candle) {
77            return Some(0.0);
78        }
79        let body1 = bar1.close - bar1.open;
80        let body2 = candle.close - candle.open;
81        let bullish_setup = body1 < 0.0 && body2 > 0.0 && candle.low > bar1.high;
82        let bearish_setup = body1 > 0.0 && body2 < 0.0 && candle.high < bar1.low;
83        if !(bullish_setup || bearish_setup) {
84            return Some(0.0);
85        }
86        // The longer marubozu's colour is the signal.
87        let longer_is_white = if body1.abs() >= body2.abs() {
88            body1 > 0.0
89        } else {
90            body2 > 0.0
91        };
92        Some(if longer_is_white { 1.0 } else { -1.0 })
93    }
94
95    fn reset(&mut self) {
96        self.prev = None;
97        self.has_emitted = false;
98    }
99
100    fn warmup_period(&self) -> usize {
101        2
102    }
103
104    fn is_ready(&self) -> bool {
105        self.has_emitted
106    }
107
108    fn name(&self) -> &'static str {
109        "KickingByLength"
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::traits::BatchExt;
117
118    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
119        Candle::new(open, high, low, close, 1.0, ts).unwrap()
120    }
121
122    #[test]
123    fn accessors_and_metadata() {
124        let t = KickingByLength::new();
125        assert_eq!(t.name(), "KickingByLength");
126        assert_eq!(t.warmup_period(), 2);
127        assert!(!t.is_ready());
128    }
129
130    #[test]
131    fn longer_white_is_plus_one() {
132        let mut t = KickingByLength::new();
133        assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0));
134        // White marubozu (length 6) longer than the black one (length 2).
135        assert_eq!(t.update(c(14.0, 20.0, 14.0, 20.0, 1)), Some(1.0));
136    }
137
138    #[test]
139    fn longer_black_is_minus_one() {
140        let mut t = KickingByLength::new();
141        // Black marubozu (length 6), then a shorter white marubozu (length 2)
142        // gapping up -> the longer black body wins, so -1.
143        assert_eq!(t.update(c(16.0, 16.0, 10.0, 10.0, 0)), Some(0.0));
144        assert_eq!(t.update(c(18.0, 20.0, 18.0, 20.0, 1)), Some(-1.0));
145    }
146
147    #[test]
148    fn not_marubozu_yields_zero() {
149        let mut t = KickingByLength::new();
150        t.update(c(12.0, 14.0, 8.0, 10.0, 0));
151        assert_eq!(t.update(c(14.0, 20.0, 14.0, 20.0, 1)), Some(0.0));
152    }
153
154    #[test]
155    fn no_gap_yields_zero() {
156        let mut t = KickingByLength::new();
157        t.update(c(12.0, 12.0, 10.0, 10.0, 0));
158        assert_eq!(t.update(c(11.0, 13.0, 11.0, 13.0, 1)), Some(0.0));
159    }
160
161    #[test]
162    fn first_bar_returns_zero() {
163        let mut t = KickingByLength::new();
164        assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0));
165    }
166
167    #[test]
168    fn batch_equals_streaming() {
169        let candles: Vec<Candle> = (0..40)
170            .map(|i| {
171                let base = 100.0 + i as f64 * 5.0;
172                if i % 2 == 0 {
173                    c(base + 2.0, base + 2.0, base, base, i)
174                } else {
175                    c(base + 3.0, base + 5.0, base + 3.0, base + 5.0, i)
176                }
177            })
178            .collect();
179        let mut a = KickingByLength::new();
180        let mut b = KickingByLength::new();
181        assert_eq!(
182            a.batch(&candles),
183            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
184        );
185    }
186
187    #[test]
188    fn reset_clears_state() {
189        let mut t = KickingByLength::new();
190        t.update(c(12.0, 12.0, 10.0, 10.0, 0));
191        t.update(c(14.0, 20.0, 14.0, 20.0, 1));
192        assert!(t.is_ready());
193        t.reset();
194        assert!(!t.is_ready());
195        assert_eq!(t.update(c(12.0, 12.0, 10.0, 10.0, 0)), Some(0.0));
196    }
197}