Skip to main content

quantwave_core/indicators/patterns/
batch_c.rs

1//! Native O(1) streaming patterns for Batch C
2//! (CDLPIERCING, CDLSEPARATINGLINES, CDLSHOOTINGSTAR, CDLTHRUSTING, CDLKICKING, CDLKICKINGBYLENGTH, CDLHIKKAKE, CDLHIKKAKEMOD, CDLSTICKSANDWICH, CDL2CROWS)
3//! Matches `talib_rs::pattern::*`.
4
5use crate::indicators::patterns::candle_settings::*;
6use crate::traits::Next;
7
8// =============================================================================
9// CDLPIERCING
10// =============================================================================
11#[derive(Debug, Clone)]
12#[allow(non_camel_case_types)]
13pub struct CDLPIERCING {
14    bars_seen: usize,
15    window: CandleWindow,
16    body_long: RollingCandleAvg,
17}
18
19impl CDLPIERCING {
20    pub fn new() -> Self {
21        Self {
22            bars_seen: 0,
23            window: CandleWindow::new(2),
24            body_long: RollingCandleAvg::new(BODY_LONG),
25        }
26    }
27}
28
29impl Default for CDLPIERCING {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl Next<(f64, f64, f64, f64)> for CDLPIERCING {
36    type Output = f64;
37    fn next(&mut self, (open, high, low, close): (f64, f64, f64, f64)) -> Self::Output {
38        self.bars_seen += 1;
39        self.window.push(open, high, low, close);
40        self.body_long.push(open, high, low, close);
41
42        let lookback = BODY_LONG.avg_period + 1;
43        if self.bars_seen <= lookback {
44            return 0.0;
45        }
46
47        let b1 = self.window.bar(1);
48        let b0 = self.window.bar(0);
49
50        let is_piercing = candle_color(b1.open, b1.close) == -1
51            && real_body(b1.open, b1.close) > self.body_long.val(1)
52            && candle_color(b0.open, b0.close) == 1
53            && real_body(b0.open, b0.close) > self.body_long.val(0)
54            && b0.open < b1.low
55            && b0.close < b1.open
56            && b0.close > b1.close + real_body(b1.open, b1.close) * 0.5;
57
58        if is_piercing { 100.0 } else { 0.0 }
59    }
60}
61
62// =============================================================================
63// CDLSEPARATINGLINES
64// =============================================================================
65#[derive(Debug, Clone)]
66#[allow(non_camel_case_types)]
67pub struct CDLSEPARATINGLINES {
68    bars_seen: usize,
69    window: CandleWindow,
70    shadow_vs: RollingCandleAvg,
71    body_long: RollingCandleAvg,
72    equal: RollingCandleAvg,
73}
74
75impl CDLSEPARATINGLINES {
76    pub fn new() -> Self {
77        Self {
78            bars_seen: 0,
79            window: CandleWindow::new(2),
80            shadow_vs: RollingCandleAvg::new(SHADOW_VERY_SHORT),
81            body_long: RollingCandleAvg::new(BODY_LONG),
82            equal: RollingCandleAvg::new(EQUAL),
83        }
84    }
85}
86
87impl Default for CDLSEPARATINGLINES {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl Next<(f64, f64, f64, f64)> for CDLSEPARATINGLINES {
94    type Output = f64;
95    fn next(&mut self, (open, high, low, close): (f64, f64, f64, f64)) -> Self::Output {
96        self.bars_seen += 1;
97        self.window.push(open, high, low, close);
98        self.shadow_vs.push(open, high, low, close);
99        self.body_long.push(open, high, low, close);
100        self.equal.push(open, high, low, close);
101
102        let lookback = SHADOW_VERY_SHORT
103            .avg_period
104            .max(BODY_LONG.avg_period)
105            .max(EQUAL.avg_period)
106            + 1;
107        if self.bars_seen <= lookback {
108            return 0.0;
109        }
110
111        let b1 = self.window.bar(1);
112        let b0 = self.window.bar(0);
113
114        let color_prev = candle_color(b1.open, b1.close);
115        let color_curr = candle_color(b0.open, b0.close);
116
117        let base = color_prev != color_curr
118            && (b0.open - b1.open).abs() <= self.equal.val(1)
119            && real_body(b0.open, b0.close) > self.body_long.val(0);
120
121        let bull = base
122            && color_curr == 1
123            && lower_shadow(b0.open, b0.low, b0.close) < self.shadow_vs.val(0);
124        let bear = base
125            && color_curr == -1
126            && upper_shadow(b0.open, b0.high, b0.close) < self.shadow_vs.val(0);
127
128        if bull {
129            100.0
130        } else if bear {
131            -100.0
132        } else {
133            0.0
134        }
135    }
136}
137
138// =============================================================================
139// CDLSHOOTINGSTAR
140// =============================================================================
141#[derive(Debug, Clone)]
142#[allow(non_camel_case_types)]
143pub struct CDLSHOOTINGSTAR {
144    bars_seen: usize,
145    window: CandleWindow,
146    body_short: RollingCandleAvg,
147    shadow_long: RollingCandleAvg,
148    shadow_vs: RollingCandleAvg,
149}
150
151impl CDLSHOOTINGSTAR {
152    pub fn new() -> Self {
153        Self {
154            bars_seen: 0,
155            window: CandleWindow::new(2),
156            body_short: RollingCandleAvg::new(BODY_SHORT),
157            shadow_long: RollingCandleAvg::new(SHADOW_LONG),
158            shadow_vs: RollingCandleAvg::new(SHADOW_VERY_SHORT),
159        }
160    }
161}
162
163impl Default for CDLSHOOTINGSTAR {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169impl Next<(f64, f64, f64, f64)> for CDLSHOOTINGSTAR {
170    type Output = f64;
171    fn next(&mut self, (open, high, low, close): (f64, f64, f64, f64)) -> Self::Output {
172        self.bars_seen += 1;
173        self.window.push(open, high, low, close);
174        self.body_short.push(open, high, low, close);
175        self.shadow_long.push(open, high, low, close);
176        self.shadow_vs.push(open, high, low, close);
177
178        // SHADOW_LONG.avg_period is 0 and cannot raise the max.
179        let lookback = BODY_SHORT.avg_period.max(SHADOW_VERY_SHORT.avg_period) + 1;
180        if self.bars_seen <= lookback {
181            return 0.0;
182        }
183
184        let b1 = self.window.bar(1);
185        let b0 = self.window.bar(0);
186
187        let gap_up = b0.open.min(b0.close) > b1.open.max(b1.close);
188
189        let is_shooting_star = real_body(b0.open, b0.close) < self.body_short.val(0)
190            && upper_shadow(b0.open, b0.high, b0.close) > self.shadow_long.val(0)
191            && lower_shadow(b0.open, b0.low, b0.close) < self.shadow_vs.val(0)
192            && gap_up;
193
194        if is_shooting_star { -100.0 } else { 0.0 }
195    }
196}
197
198// =============================================================================
199// CDLTHRUSTING
200// =============================================================================
201#[derive(Debug, Clone)]
202#[allow(non_camel_case_types)]
203pub struct CDLTHRUSTING {
204    bars_seen: usize,
205    window: CandleWindow,
206    equal: RollingCandleAvg,
207    body_long: RollingCandleAvg,
208}
209
210impl CDLTHRUSTING {
211    pub fn new() -> Self {
212        Self {
213            bars_seen: 0,
214            window: CandleWindow::new(2),
215            equal: RollingCandleAvg::new(EQUAL),
216            body_long: RollingCandleAvg::new(BODY_LONG),
217        }
218    }
219}
220
221impl Default for CDLTHRUSTING {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227impl Next<(f64, f64, f64, f64)> for CDLTHRUSTING {
228    type Output = f64;
229    fn next(&mut self, (open, high, low, close): (f64, f64, f64, f64)) -> Self::Output {
230        self.bars_seen += 1;
231        self.window.push(open, high, low, close);
232        self.equal.push(open, high, low, close);
233        self.body_long.push(open, high, low, close);
234
235        let lookback = EQUAL.avg_period.max(BODY_LONG.avg_period) + 1;
236        if self.bars_seen <= lookback {
237            return 0.0;
238        }
239
240        let b1 = self.window.bar(1);
241        let b0 = self.window.bar(0);
242
243        let is_thrusting = candle_color(b1.open, b1.close) == -1
244            && real_body(b1.open, b1.close) > self.body_long.val(1)
245            && candle_color(b0.open, b0.close) == 1
246            && b0.open < b1.low
247            && b0.close > b1.close + self.equal.val(1)
248            && b0.close <= b1.close + real_body(b1.open, b1.close) * 0.5;
249
250        if is_thrusting { -100.0 } else { 0.0 }
251    }
252}
253
254// =============================================================================
255// CDLKICKING
256// =============================================================================
257#[derive(Debug, Clone)]
258#[allow(non_camel_case_types)]
259pub struct CDLKICKING {
260    bars_seen: usize,
261    window: CandleWindow,
262    shadow_vs: RollingCandleAvg,
263    body_long: RollingCandleAvg,
264}
265
266impl CDLKICKING {
267    pub fn new() -> Self {
268        Self {
269            bars_seen: 0,
270            window: CandleWindow::new(2),
271            shadow_vs: RollingCandleAvg::new(SHADOW_VERY_SHORT),
272            body_long: RollingCandleAvg::new(BODY_LONG),
273        }
274    }
275}
276
277impl Default for CDLKICKING {
278    fn default() -> Self {
279        Self::new()
280    }
281}
282
283impl Next<(f64, f64, f64, f64)> for CDLKICKING {
284    type Output = f64;
285    fn next(&mut self, (open, high, low, close): (f64, f64, f64, f64)) -> Self::Output {
286        self.bars_seen += 1;
287        self.window.push(open, high, low, close);
288        self.shadow_vs.push(open, high, low, close);
289        self.body_long.push(open, high, low, close);
290
291        let lookback = SHADOW_VERY_SHORT.avg_period.max(BODY_LONG.avg_period) + 1;
292        if self.bars_seen <= lookback {
293            return 0.0;
294        }
295
296        let b1 = self.window.bar(1);
297        let b0 = self.window.bar(0);
298
299        let color_prev = candle_color(b1.open, b1.close);
300        let color_curr = candle_color(b0.open, b0.close);
301
302        if color_prev != color_curr
303            && real_body(b1.open, b1.close) > self.body_long.val(1)
304            && upper_shadow(b1.open, b1.high, b1.close) < self.shadow_vs.val(1)
305            && lower_shadow(b1.open, b1.low, b1.close) < self.shadow_vs.val(1)
306            && real_body(b0.open, b0.close) > self.body_long.val(0)
307            && upper_shadow(b0.open, b0.high, b0.close) < self.shadow_vs.val(0)
308            && lower_shadow(b0.open, b0.low, b0.close) < self.shadow_vs.val(0)
309        {
310            let bull = color_prev == -1 && color_curr == 1 && b0.open > b1.open;
311            let bear = color_prev == 1 && color_curr == -1 && b0.open < b1.open;
312            if bull {
313                100.0
314            } else if bear {
315                -100.0
316            } else {
317                0.0
318            }
319        } else {
320            0.0
321        }
322    }
323}
324
325// =============================================================================
326// CDLKICKINGBYLENGTH
327// =============================================================================
328#[derive(Debug, Clone)]
329#[allow(non_camel_case_types)]
330pub struct CDLKICKINGBYLENGTH {
331    bars_seen: usize,
332    window: CandleWindow,
333    shadow_vs: RollingCandleAvg,
334    body_long: RollingCandleAvg,
335}
336
337impl CDLKICKINGBYLENGTH {
338    pub fn new() -> Self {
339        Self {
340            bars_seen: 0,
341            window: CandleWindow::new(2),
342            shadow_vs: RollingCandleAvg::new(SHADOW_VERY_SHORT),
343            body_long: RollingCandleAvg::new(BODY_LONG),
344        }
345    }
346}
347
348impl Default for CDLKICKINGBYLENGTH {
349    fn default() -> Self {
350        Self::new()
351    }
352}
353
354impl Next<(f64, f64, f64, f64)> for CDLKICKINGBYLENGTH {
355    type Output = f64;
356    fn next(&mut self, (open, high, low, close): (f64, f64, f64, f64)) -> Self::Output {
357        self.bars_seen += 1;
358        self.window.push(open, high, low, close);
359        self.shadow_vs.push(open, high, low, close);
360        self.body_long.push(open, high, low, close);
361
362        let lookback = SHADOW_VERY_SHORT.avg_period.max(BODY_LONG.avg_period) + 1;
363        if self.bars_seen <= lookback {
364            return 0.0;
365        }
366
367        let b1 = self.window.bar(1);
368        let b0 = self.window.bar(0);
369
370        let color_prev = candle_color(b1.open, b1.close);
371        let color_curr = candle_color(b0.open, b0.close);
372
373        if color_prev != color_curr
374            && real_body(b1.open, b1.close) > self.body_long.val(1)
375            && upper_shadow(b1.open, b1.high, b1.close) < self.shadow_vs.val(1)
376            && lower_shadow(b1.open, b1.low, b1.close) < self.shadow_vs.val(1)
377            && real_body(b0.open, b0.close) > self.body_long.val(0)
378            && upper_shadow(b0.open, b0.high, b0.close) < self.shadow_vs.val(0)
379            && lower_shadow(b0.open, b0.low, b0.close) < self.shadow_vs.val(0)
380        {
381            let has_gap = (color_prev == -1 && color_curr == 1 && b0.open > b1.open)
382                || (color_prev == 1 && color_curr == -1 && b0.open < b1.open);
383            let curr_longer = real_body(b0.open, b0.close) >= real_body(b1.open, b1.close);
384            let color = if curr_longer { color_curr } else { color_prev };
385
386            if has_gap { (color as f64) * 100.0 } else { 0.0 }
387        } else {
388            0.0
389        }
390    }
391}
392
393// =============================================================================
394// CDLHIKKAKE
395// =============================================================================
396#[derive(Debug, Clone)]
397#[allow(non_camel_case_types)]
398pub struct CDLHIKKAKE {
399    bars_seen: usize,
400    window: CandleWindow,
401    /// Bars elapsed since the setup bar; `None` once confirmed or expired.
402    pattern_age: Option<usize>,
403    pattern_result: i32,
404    /// High/low of the inside bar (`pattern_idx - 1` in the batch reference),
405    /// captured at setup so no backwards indexing is needed while streaming.
406    ref_high: f64,
407    ref_low: f64,
408}
409
410impl CDLHIKKAKE {
411    pub fn new() -> Self {
412        Self {
413            bars_seen: 0,
414            window: CandleWindow::new(3),
415            pattern_age: None,
416            pattern_result: 0,
417            ref_high: 0.0,
418            ref_low: 0.0,
419        }
420    }
421
422    /// Breakout confirmation against the stored inside-bar levels.
423    fn confirm(&mut self, close: f64) -> f64 {
424        if let Some(age) = self.pattern_age
425            && (1..=3).contains(&age)
426        {
427            {
428                if self.pattern_result > 0 && close > self.ref_high {
429                    self.pattern_age = None;
430                    return (self.pattern_result + 100) as f64;
431                } else if self.pattern_result < 0 && close < self.ref_low {
432                    self.pattern_age = None;
433                    return (self.pattern_result - 100) as f64;
434                }
435            }
436        }
437        0.0
438    }
439}
440
441impl Default for CDLHIKKAKE {
442    fn default() -> Self {
443        Self::new()
444    }
445}
446
447impl Next<(f64, f64, f64, f64)> for CDLHIKKAKE {
448    type Output = f64;
449
450    fn next(&mut self, (open, high, low, close): (f64, f64, f64, f64)) -> Self::Output {
451        const LOOKBACK: usize = 5;
452        self.bars_seen += 1;
453        self.window.push(open, high, low, close);
454
455        if let Some(age) = self.pattern_age {
456            self.pattern_age = Some(age + 1);
457        }
458
459        if self.window.len() < 3 {
460            return 0.0;
461        }
462
463        let b0 = self.window.bar(0);
464        let b1 = self.window.bar(1);
465        let b2 = self.window.bar(2);
466
467        // The batch reference pre-scans bars before `start`: it updates the setup
468        // state but never emits and never runs confirmation during that pre-scan.
469        let emitting = self.bars_seen > LOOKBACK;
470
471        let inside = b1.high < b2.high && b1.low > b2.low;
472        if inside && b0.high < b1.high && b0.low < b1.low {
473            self.pattern_result = 100;
474            self.pattern_age = Some(0);
475            self.ref_high = b1.high;
476            self.ref_low = b1.low;
477            return if emitting { 100.0 } else { 0.0 };
478        }
479        if inside && b0.high > b1.high && b0.low > b1.low {
480            self.pattern_result = -100;
481            self.pattern_age = Some(0);
482            self.ref_high = b1.high;
483            self.ref_low = b1.low;
484            return if emitting { -100.0 } else { 0.0 };
485        }
486
487        if !emitting {
488            return 0.0;
489        }
490        self.confirm(b0.close)
491    }
492}
493
494// =============================================================================
495// CDLHIKKAKEMOD
496// =============================================================================
497#[derive(Debug, Clone)]
498#[allow(non_camel_case_types)]
499pub struct CDLHIKKAKEMOD {
500    bars_seen: usize,
501    window: CandleWindow,
502    near_avg: RollingCandleAvg,
503    /// Bars elapsed since the setup bar; `None` once confirmed or expired.
504    pattern_age: Option<usize>,
505    pattern_result: i32,
506    ref_high: f64,
507    ref_low: f64,
508}
509
510impl CDLHIKKAKEMOD {
511    pub fn new() -> Self {
512        Self {
513            bars_seen: 0,
514            window: CandleWindow::new(4),
515            near_avg: RollingCandleAvg::new(NEAR),
516            pattern_age: None,
517            pattern_result: 0,
518            ref_high: 0.0,
519            ref_low: 0.0,
520        }
521    }
522}
523
524impl Default for CDLHIKKAKEMOD {
525    fn default() -> Self {
526        Self::new()
527    }
528}
529
530impl Next<(f64, f64, f64, f64)> for CDLHIKKAKEMOD {
531    type Output = f64;
532
533    fn next(&mut self, (open, high, low, close): (f64, f64, f64, f64)) -> Self::Output {
534        self.bars_seen += 1;
535        self.window.push(open, high, low, close);
536        self.near_avg.push(open, high, low, close);
537
538        if let Some(age) = self.pattern_age {
539            self.pattern_age = Some(age + 1);
540        }
541
542        // C TA-Lib: lookback = max(1, NEAR.avg_period) + 5
543        let lookback = 1usize.max(NEAR.avg_period) + 5;
544        if self.bars_seen <= lookback {
545            return 0.0;
546        }
547
548        let b0 = self.window.bar(0);
549        let b1 = self.window.bar(1);
550        let b2 = self.window.bar(2);
551        let b3 = self.window.bar(3);
552
553        // Two nested inside bars, then a breakout.
554        if b1.high < b2.high && b1.low > b2.low && b2.high < b3.high && b2.low > b3.low {
555            let near_avg = self.near_avg.val(2);
556            if b0.high < b1.high && b0.low < b1.low && b2.close <= b2.low + near_avg {
557                self.pattern_result = 100;
558                self.pattern_age = Some(0);
559                self.ref_high = b1.high;
560                self.ref_low = b1.low;
561            } else if b0.high > b1.high && b0.low > b1.low && b2.close >= b2.high - near_avg {
562                self.pattern_result = -100;
563                self.pattern_age = Some(0);
564                self.ref_high = b1.high;
565                self.ref_low = b1.low;
566            }
567        }
568
569        // Confirmation — unlike CDLHIKKAKE this can fire on the setup bar itself,
570        // and the setup alone never emits.
571        if let Some(age) = self.pattern_age
572            && age <= 3
573        {
574            {
575                if self.pattern_result > 0 && b0.close > self.ref_high {
576                    self.pattern_age = None;
577                    return (self.pattern_result + 100) as f64;
578                } else if self.pattern_result < 0 && b0.close < self.ref_low {
579                    self.pattern_age = None;
580                    return (self.pattern_result - 100) as f64;
581                }
582            }
583        }
584        0.0
585    }
586}
587
588// =============================================================================
589// CDLSTICKSANDWICH
590// =============================================================================
591#[derive(Debug, Clone)]
592#[allow(non_camel_case_types)]
593pub struct CDLSTICKSANDWICH {
594    bars_seen: usize,
595    window: CandleWindow,
596    equal: RollingCandleAvg,
597}
598
599impl CDLSTICKSANDWICH {
600    pub fn new() -> Self {
601        Self {
602            bars_seen: 0,
603            window: CandleWindow::new(3),
604            equal: RollingCandleAvg::new(EQUAL),
605        }
606    }
607}
608
609impl Default for CDLSTICKSANDWICH {
610    fn default() -> Self {
611        Self::new()
612    }
613}
614
615impl Next<(f64, f64, f64, f64)> for CDLSTICKSANDWICH {
616    type Output = f64;
617    fn next(&mut self, (open, high, low, close): (f64, f64, f64, f64)) -> Self::Output {
618        self.bars_seen += 1;
619        self.window.push(open, high, low, close);
620        self.equal.push(open, high, low, close);
621
622        let lookback = EQUAL.avg_period + 2;
623        if self.bars_seen <= lookback {
624            return 0.0;
625        }
626
627        let b2 = self.window.bar(2);
628        let b1 = self.window.bar(1);
629        let b0 = self.window.bar(0);
630
631        let is_sandwich = candle_color(b2.open, b2.close) == -1
632            && candle_color(b1.open, b1.close) == 1
633            && candle_color(b0.open, b0.close) == -1
634            && b1.low > b2.close
635            && (b0.close - b2.close).abs() <= self.equal.val(2);
636
637        if is_sandwich { 100.0 } else { 0.0 }
638    }
639}
640
641// =============================================================================
642// CDL2CROWS
643// =============================================================================
644#[derive(Debug, Clone)]
645#[allow(non_camel_case_types)]
646pub struct CDL2CROWS {
647    bars_seen: usize,
648    window: CandleWindow,
649    body_long: RollingCandleAvg,
650}
651
652impl CDL2CROWS {
653    pub fn new() -> Self {
654        Self {
655            bars_seen: 0,
656            window: CandleWindow::new(3),
657            body_long: RollingCandleAvg::new(BODY_LONG),
658        }
659    }
660}
661
662impl Default for CDL2CROWS {
663    fn default() -> Self {
664        Self::new()
665    }
666}
667
668impl Next<(f64, f64, f64, f64)> for CDL2CROWS {
669    type Output = f64;
670    fn next(&mut self, (open, high, low, close): (f64, f64, f64, f64)) -> Self::Output {
671        self.bars_seen += 1;
672        self.window.push(open, high, low, close);
673        self.body_long.push(open, high, low, close);
674
675        let lookback = BODY_LONG.avg_period + 2;
676        if self.bars_seen <= lookback {
677            return 0.0;
678        }
679
680        let b2 = self.window.bar(2);
681        let b1 = self.window.bar(1);
682        let b0 = self.window.bar(0);
683
684        let is_2crows = candle_color(b2.open, b2.close) == 1
685            && real_body(b2.open, b2.close) > self.body_long.val(2)
686            && candle_color(b1.open, b1.close) == -1
687            && b1.open.min(b1.close) > b2.open.max(b2.close) // real body gap up
688            && candle_color(b0.open, b0.close) == -1
689            && b0.open < b1.open && b0.open > b1.close
690            && b0.close > b2.open && b0.close < b2.close;
691
692        if is_2crows { -100.0 } else { 0.0 }
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use crate::indicators::patterns::fixtures as fx;
700    use crate::test_pattern_parity;
701
702    test_pattern_parity!(
703        test_cdlpiercing,
704        CDLPIERCING,
705        talib_rs::pattern::cdl_piercing,
706        fx::piercing
707    );
708    test_pattern_parity!(
709        test_cdlseparatinglines,
710        CDLSEPARATINGLINES,
711        talib_rs::pattern::cdl_separatinglines
712    );
713    test_pattern_parity!(
714        test_cdlshootingstar,
715        CDLSHOOTINGSTAR,
716        talib_rs::pattern::cdl_shootingstar,
717        fx::shootingstar
718    );
719    test_pattern_parity!(
720        test_cdlthrusting,
721        CDLTHRUSTING,
722        talib_rs::pattern::cdl_thrusting,
723        fx::thrusting
724    );
725    test_pattern_parity!(
726        test_cdlkicking,
727        CDLKICKING,
728        talib_rs::pattern::cdl_kicking,
729        fx::kicking
730    );
731    test_pattern_parity!(
732        test_cdlkickingbylength,
733        CDLKICKINGBYLENGTH,
734        talib_rs::pattern::cdl_kickingbylength,
735        fx::kickingbylength
736    );
737    test_pattern_parity!(test_cdlhikkake, CDLHIKKAKE, talib_rs::pattern::cdl_hikkake);
738    // The talib-rs oracle's NEAR rolling sum for this pattern is arithmetically
739    // incoherent, so exact parity is impossible; the native implementation is the
740    // correct one. Evidence (all reproducible on the harness' 200k walk):
741    //
742    //  * talib-rs seeds `near_sum` over bars [lookback-3-P, lookback-4] (the window
743    //    for bar `lookback-3`) but consumes it as the average for bar `i-2`
744    //    = `lookback-2`, then updates it with `+= cr(i-2) - cr(i-2-P)`. Seed and
745    //    update disagree, so the window is never repaired. Simulating the index set
746    //    (P = NEAR.avg_period = 5, lookback = 10) gives, at i = 1510,
747    //    {2, 1503, 1504, 1505, 1506, 1507} — SIX terms still divided by five, and
748    //    bar 2 is pinned in the sum forever. The correct window is {1503..1507}.
749    //    Every other averaged pattern in talib-rs (e.g. cdl_homingpigeon) uses the
750    //    contiguous "P bars strictly before the target bar" convention that
751    //    `RollingCandleAvg` implements, which is why the other 58 patterns match.
752    //  * An independent from-scratch model of C TA-Lib semantics (contiguous NEAR
753    //    window ending at bar i-3, used for bar i-2) reproduces the native output
754    //    EXACTLY — 0 mismatches over 200,000 bars — and differs from the oracle at
755    //    4 bars. A one-bar-shifted variant matches neither. So native == C TA-Lib.
756    //  * talib-rs also drops C's `else` between the setup block and the
757    //    confirmation block, i.e. it lets a setup confirm on its own bar. That is
758    //    provably a no-op here: a bullish setup requires high[i] < high[i-1] and
759    //    confirmation requires close[i] > high[i-1] >= high[i] >= close[i]
760    //    (contradiction); the bearish case is symmetric. Native keeps the same
761    //    shape and is unaffected.
762    //
763    // Measured divergence: 4 bars / 200,000 on the deterministic walk, and never
764    // more than 1 per run over 40,000 random walks of length <= 200. Bound of 8
765    // gives 2x headroom while staying well below the oracle's 20 non-zero signals,
766    // so silencing or over-firing the pattern still fails the test.
767    test_pattern_parity!(
768        test_cdlhikkakemod,
769        CDLHIKKAKEMOD,
770        talib_rs::pattern::cdl_hikkakemod,
771        fx::hikkakemod,
772        oracle_exempt = "talib-rs's cdl_hikkakemod NEAR rolling sum is non-contiguous: it \
773                         seeds the window one bar early and then updates it with a rule that \
774                         does not match the seed, leaving 6 terms divided by 5 with one \
775                         ancient bar pinned in the sum forever. An independent model of C \
776                         TA-Lib semantics matches the native implementation exactly (0/200,000) \
777                         and the oracle at only 4 bars.",
778        max_mismatches = 8,
779        reference_mismatches = 4
780    );
781    test_pattern_parity!(
782        test_cdlsticksandwich,
783        CDLSTICKSANDWICH,
784        talib_rs::pattern::cdl_sticksandwich,
785        fx::sticksandwich
786    );
787    test_pattern_parity!(
788        test_cdl2crows,
789        CDL2CROWS,
790        talib_rs::pattern::cdl_2crows,
791        fx::two_crows
792    );
793}