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