Skip to main content

wickra_core/indicators/
kicking.rs

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