Skip to main content

wickra_core/indicators/
hikkake_modified.rs

1//! Modified Hikkake candlestick pattern.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Fraction of the second bar's range that counts as "near" its low or high.
7const NEAR: f64 = 0.2;
8
9/// Modified Hikkake — Dan Chesler's refinement of the [`Hikkake`](crate::Hikkake)
10/// trap (TA-Lib `CDLHIKKAKEMOD`). Two nested inside bars coil the market; the
11/// first inside bar already closes at the extreme the false break will run
12/// towards, and the fourth bar breaks out of the second inside bar the wrong way.
13///
14/// ```text
15/// bar2 inside bar1 : high2 < high1  &&  low2 > low1
16/// bar3 inside bar2 : high3 < high2  &&  low3 > low2
17/// bullish (+1.0): bar4 makes a lower high AND lower low than bar3,
18///                 and bar2 closed near its low   (close2 <= low2  + 0.2 · range2)
19/// bearish (−1.0): bar4 makes a higher high AND higher low than bar3,
20///                 and bar2 closed near its high  (close2 >= high2 − 0.2 · range2)
21/// ```
22///
23/// Output is `+1.0` (bullish), `−1.0` (bearish), or `0.0` otherwise. "Near" is a
24/// fixed fifth of the second bar's range (TA-Lib's `Near` factor of 0.2 applied
25/// geometrically rather than to a rolling average). The first three bars return
26/// `None` because the four-bar window is not yet filled. The later confirmation
27/// bar is not flagged separately. Pattern-shape check only — no trend filter is
28/// applied; combine with a trend indicator for actionable signals.
29///
30/// # Signed ±1 encoding
31///
32/// This detector emits the uniform candlestick sign convention shared across the
33/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it
34/// drops straight into a machine-learning feature matrix as a single dimension.
35///
36/// # Example
37///
38/// ```
39/// use wickra_core::{Candle, HikkakeModified, Indicator};
40///
41/// let mut indicator = HikkakeModified::new();
42/// indicator.update(Candle::new(10.0, 15.0, 5.0, 12.0, 1.0, 0).unwrap());
43/// indicator.update(Candle::new(11.0, 13.0, 7.0, 7.5, 1.0, 1).unwrap());
44/// indicator.update(Candle::new(9.0, 12.0, 8.0, 10.0, 1.0, 2).unwrap());
45/// let out = indicator
46///     .update(Candle::new(9.0, 11.0, 6.0, 9.0, 1.0, 3).unwrap());
47/// assert_eq!(out, Some(1.0));
48/// ```
49#[derive(Debug, Clone, Default)]
50pub struct HikkakeModified {
51    c1: Option<Candle>,
52    c2: Option<Candle>,
53    c3: Option<Candle>,
54    has_emitted: bool,
55}
56
57impl HikkakeModified {
58    /// Construct a new Modified Hikkake detector.
59    pub const fn new() -> Self {
60        Self {
61            c1: None,
62            c2: None,
63            c3: None,
64            has_emitted: false,
65        }
66    }
67}
68
69impl Indicator for HikkakeModified {
70    type Input = Candle;
71    type Output = f64;
72
73    #[inline]
74    fn update(&mut self, candle: Candle) -> Option<f64> {
75        let (bar1, bar2, bar3) = (self.c1, self.c2, self.c3);
76        self.c1 = self.c2;
77        self.c2 = self.c3;
78        self.c3 = Some(candle);
79        let (Some(bar1), Some(bar2), Some(bar3)) = (bar1, bar2, bar3) else {
80            return None;
81        };
82        self.has_emitted = true;
83        // Two nested inside bars.
84        if !(bar2.high < bar1.high
85            && bar2.low > bar1.low
86            && bar3.high < bar2.high
87            && bar3.low > bar2.low)
88        {
89            return Some(0.0);
90        }
91        let near = NEAR * (bar2.high - bar2.low);
92        // Bullish: false downside break, the first inside bar closed near its low.
93        if candle.high < bar3.high && candle.low < bar3.low && bar2.close <= bar2.low + near {
94            return Some(1.0);
95        }
96        // Bearish: false upside break, the first inside bar closed near its high.
97        if candle.high > bar3.high && candle.low > bar3.low && bar2.close >= bar2.high - near {
98            return Some(-1.0);
99        }
100        Some(0.0)
101    }
102
103    fn reset(&mut self) {
104        self.c1 = None;
105        self.c2 = None;
106        self.c3 = None;
107        self.has_emitted = false;
108    }
109
110    #[inline]
111    fn warmup_period(&self) -> usize {
112        4
113    }
114
115    #[inline]
116    fn is_ready(&self) -> bool {
117        self.has_emitted
118    }
119
120    #[inline]
121    fn name(&self) -> &'static str {
122        "HikkakeModified"
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::traits::BatchExt;
130
131    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
132        Candle::new(open, high, low, close, 1.0, ts).unwrap()
133    }
134
135    #[test]
136    fn accessors_and_metadata() {
137        let t = HikkakeModified::new();
138        assert_eq!(t.name(), "HikkakeModified");
139        assert_eq!(t.warmup_period(), 4);
140        assert!(!t.is_ready());
141    }
142
143    #[test]
144    fn bullish_modified_hikkake_is_plus_one() {
145        let mut t = HikkakeModified::new();
146        assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), None);
147        assert_eq!(t.update(c(11.0, 13.0, 7.0, 7.5, 1)), None);
148        assert_eq!(t.update(c(9.0, 12.0, 8.0, 10.0, 2)), None);
149        assert_eq!(t.update(c(9.0, 11.0, 6.0, 9.0, 3)), Some(1.0));
150    }
151
152    #[test]
153    fn bearish_modified_hikkake_is_minus_one() {
154        let mut t = HikkakeModified::new();
155        assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), None);
156        assert_eq!(t.update(c(11.0, 13.0, 7.0, 12.5, 1)), None);
157        assert_eq!(t.update(c(9.0, 12.0, 8.0, 10.0, 2)), None);
158        assert_eq!(t.update(c(11.0, 14.0, 9.0, 13.0, 3)), Some(-1.0));
159    }
160
161    #[test]
162    fn second_bar_close_not_near_extreme_yields_zero() {
163        let mut t = HikkakeModified::new();
164        t.update(c(10.0, 15.0, 5.0, 12.0, 0));
165        // bar2 closes mid-range -> the close filter fails.
166        t.update(c(11.0, 13.0, 7.0, 10.0, 1));
167        t.update(c(9.0, 12.0, 8.0, 10.0, 2));
168        assert_eq!(t.update(c(9.0, 11.0, 6.0, 9.0, 3)), Some(0.0));
169    }
170
171    #[test]
172    fn not_double_inside_bar_yields_zero() {
173        let mut t = HikkakeModified::new();
174        t.update(c(10.0, 15.0, 5.0, 12.0, 0));
175        t.update(c(11.0, 13.0, 7.0, 7.5, 1));
176        // bar3 is not inside bar2.
177        t.update(c(9.0, 14.0, 8.0, 10.0, 2));
178        assert_eq!(t.update(c(9.0, 11.0, 6.0, 9.0, 3)), Some(0.0));
179    }
180
181    #[test]
182    fn first_three_bars_return_none() {
183        let mut t = HikkakeModified::new();
184        assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), None);
185        assert_eq!(t.update(c(11.0, 13.0, 7.0, 7.5, 1)), None);
186        assert_eq!(t.update(c(9.0, 12.0, 8.0, 10.0, 2)), None);
187    }
188
189    #[test]
190    fn batch_equals_streaming() {
191        let candles: Vec<Candle> = (0..40)
192            .map(|i| {
193                let base = 100.0 + i as f64;
194                match i % 4 {
195                    0 => c(base, base + 6.0, base - 6.0, base, i),
196                    1 => c(base, base + 4.0, base - 4.0, base - 3.5, i),
197                    2 => c(base, base + 2.0, base - 2.0, base, i),
198                    _ => c(base, base + 1.0, base - 5.0, base, i),
199                }
200            })
201            .collect();
202        let mut a = HikkakeModified::new();
203        let mut b = HikkakeModified::new();
204        assert_eq!(
205            a.batch(&candles),
206            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
207        );
208    }
209
210    #[test]
211    fn reset_clears_state() {
212        let mut t = HikkakeModified::new();
213        t.update(c(10.0, 15.0, 5.0, 12.0, 0));
214        t.update(c(11.0, 13.0, 7.0, 7.5, 1));
215        t.update(c(9.0, 12.0, 8.0, 10.0, 2));
216        t.update(c(9.0, 11.0, 6.0, 9.0, 3));
217        assert!(t.is_ready());
218        t.reset();
219        assert!(!t.is_ready());
220        assert_eq!(t.update(c(10.0, 15.0, 5.0, 12.0, 0)), None);
221    }
222
223    /// Feed four bars and return the last output.
224    fn run(bars: [Candle; 4]) -> Option<f64> {
225        let mut t = HikkakeModified::new();
226        bars.iter().map(|b| t.update(*b)).last().unwrap()
227    }
228
229    // bar1 15/5; bar2 13/8 (range 5, near = 0.2 * 5 = 1.0); bar3 12/9.
230    const BAR1: (f64, f64, f64, f64) = (10.0, 15.0, 5.0, 12.0);
231    const BAR3: (f64, f64, f64, f64) = (10.0, 12.0, 9.0, 10.0);
232
233    fn bar(t: (f64, f64, f64, f64), ts: i64) -> Candle {
234        c(t.0, t.1, t.2, t.3, ts)
235    }
236
237    #[test]
238    fn hand_computed_near_boundary_bullish() {
239        // near = 0.2 * (13 - 8) = 1.0 -> bullish needs close2 <= 8 + 1.0 = 9.0.
240        // bar4 high 11 < 12 and low 7 < 9: false downside break.
241        let bar4 = c(9.0, 11.0, 7.0, 9.0, 3);
242        assert_eq!(
243            run([bar(BAR1, 0), c(11.0, 13.0, 8.0, 9.0, 1), bar(BAR3, 2), bar4]),
244            Some(1.0)
245        );
246        assert_eq!(
247            run([bar(BAR1, 0), c(11.0, 13.0, 8.0, 8.5, 1), bar(BAR3, 2), bar4]),
248            Some(1.0)
249        );
250        // close2 = 9.01 > 9.0 -> not near the low.
251        assert_eq!(
252            run([
253                bar(BAR1, 0),
254                c(11.0, 13.0, 8.0, 9.01, 1),
255                bar(BAR3, 2),
256                bar4
257            ]),
258            Some(0.0)
259        );
260    }
261
262    #[test]
263    fn hand_computed_near_boundary_bearish() {
264        // bearish needs close2 >= 13 - 1.0 = 12.0; bar4 high 14 > 12, low 10 > 9.
265        let bar4 = c(11.0, 14.0, 10.0, 13.0, 3);
266        assert_eq!(
267            run([
268                bar(BAR1, 0),
269                c(11.0, 13.0, 8.0, 12.0, 1),
270                bar(BAR3, 2),
271                bar4
272            ]),
273            Some(-1.0)
274        );
275        // close2 = 11.99 < 12.0 -> not near the high.
276        assert_eq!(
277            run([
278                bar(BAR1, 0),
279                c(11.0, 13.0, 8.0, 11.99, 1),
280                bar(BAR3, 2),
281                bar4
282            ]),
283            Some(0.0)
284        );
285    }
286
287    #[test]
288    fn bullish_break_fails_each_condition() {
289        let bar2 = c(11.0, 13.0, 8.0, 9.0, 1);
290        // bar4 high equals bar3 high (not a lower high).
291        assert_eq!(
292            run([bar(BAR1, 0), bar2, bar(BAR3, 2), c(9.0, 12.0, 7.0, 9.0, 3)]),
293            Some(0.0)
294        );
295        // bar4 low equals bar3 low (not a lower low).
296        assert_eq!(
297            run([
298                bar(BAR1, 0),
299                bar2,
300                bar(BAR3, 2),
301                c(10.0, 11.0, 9.0, 10.0, 3)
302            ]),
303            Some(0.0)
304        );
305        // bar4 is an outside bar (higher high, lower low).
306        assert_eq!(
307            run([
308                bar(BAR1, 0),
309                bar2,
310                bar(BAR3, 2),
311                c(10.0, 13.0, 7.0, 10.0, 3)
312            ]),
313            Some(0.0)
314        );
315        // bar4 is itself an inside bar.
316        assert_eq!(
317            run([
318                bar(BAR1, 0),
319                bar2,
320                bar(BAR3, 2),
321                c(10.0, 11.0, 9.5, 10.0, 3)
322            ]),
323            Some(0.0)
324        );
325    }
326
327    #[test]
328    fn bearish_break_fails_each_condition() {
329        let bar2 = c(11.0, 13.0, 8.0, 12.0, 1);
330        // bar4 high equals bar3 high.
331        assert_eq!(
332            run([
333                bar(BAR1, 0),
334                bar2,
335                bar(BAR3, 2),
336                c(11.0, 12.0, 10.0, 11.0, 3)
337            ]),
338            Some(0.0)
339        );
340        // bar4 low equals bar3 low.
341        assert_eq!(
342            run([
343                bar(BAR1, 0),
344                bar2,
345                bar(BAR3, 2),
346                c(11.0, 14.0, 9.0, 13.0, 3)
347            ]),
348            Some(0.0)
349        );
350        // A downside break with bar2 near its high is not bullish either.
351        assert_eq!(
352            run([bar(BAR1, 0), bar2, bar(BAR3, 2), c(9.0, 11.0, 7.0, 9.0, 3)]),
353            Some(0.0)
354        );
355    }
356
357    #[test]
358    fn each_inside_bar_condition_is_required() {
359        let bar4 = c(9.0, 11.0, 7.0, 9.0, 3);
360        // high2 == high1.
361        assert_eq!(
362            run([bar(BAR1, 0), c(11.0, 15.0, 8.0, 9.0, 1), bar(BAR3, 2), bar4]),
363            Some(0.0)
364        );
365        // low2 == low1.
366        assert_eq!(
367            run([bar(BAR1, 0), c(11.0, 13.0, 5.0, 6.0, 1), bar(BAR3, 2), bar4]),
368            Some(0.0)
369        );
370        // high3 == high2.
371        let bar2 = c(11.0, 13.0, 8.0, 9.0, 1);
372        assert_eq!(
373            run([bar(BAR1, 0), bar2, c(10.0, 13.0, 9.0, 10.0, 2), bar4]),
374            Some(0.0)
375        );
376        // low3 == low2.
377        assert_eq!(
378            run([bar(BAR1, 0), bar2, c(10.0, 12.0, 8.0, 10.0, 2), bar4]),
379            Some(0.0)
380        );
381    }
382
383    #[test]
384    fn first_value_lands_exactly_at_warmup_index() {
385        let bars = [
386            bar(BAR1, 0),
387            c(11.0, 13.0, 8.0, 9.0, 1),
388            bar(BAR3, 2),
389            c(9.0, 11.0, 7.0, 9.0, 3),
390            c(9.0, 11.0, 7.0, 9.0, 4),
391        ];
392        let mut t = HikkakeModified::new();
393        let out = t.batch(&bars);
394        let warm = t.warmup_period();
395        assert!(out[..warm - 1].iter().all(Option::is_none));
396        assert_eq!(out[warm - 1], Some(1.0));
397        assert_eq!(out[warm], Some(0.0));
398    }
399
400    fn mixed_series() -> Vec<Candle> {
401        (0..40)
402            .map(|i| {
403                let base = 100.0 + f64::from(i % 7);
404                match i % 4 {
405                    0 => c(base, base + 6.0, base - 6.0, base, i64::from(i)),
406                    1 => c(base, base + 4.0, base - 4.0, base - 3.5, i64::from(i)),
407                    2 => c(base, base + 2.0, base - 2.0, base, i64::from(i)),
408                    _ => c(base, base + 1.0, base - 5.0, base, i64::from(i)),
409                }
410            })
411            .collect()
412    }
413
414    #[test]
415    fn reset_replays_identically() {
416        let candles = mixed_series();
417        let fresh = HikkakeModified::new().batch(&candles);
418        let mut t = HikkakeModified::new();
419        let _ = t.batch(&candles);
420        t.reset();
421        assert_eq!(t.batch(&candles), fresh);
422    }
423
424    #[test]
425    fn batch_nan_into_matches_streaming_bits() {
426        let candles = mixed_series();
427        let mut t = HikkakeModified::new();
428        let streamed: Vec<f64> = candles
429            .iter()
430            .map(|x| t.update(*x).unwrap_or(f64::NAN))
431            .collect();
432        let mut out = vec![0.0; candles.len()];
433        HikkakeModified::new().batch_nan_into(&candles, &mut out);
434        assert!(streamed
435            .iter()
436            .zip(&out)
437            .all(|(a, b)| a.to_bits() == b.to_bits()));
438    }
439}