1use crate::indicators::market_structure::{MarketStructure, SwingPoint};
39use crate::traits::Next;
40use serde::{Deserialize, Serialize};
41use std::collections::HashSet;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45pub enum HarmonicKind {
46 AbCd,
48 AlternateAbCd,
50 FiveZero,
52 Gartley,
54 Bat,
56 Butterfly,
58 Crab,
60 AlternateBat,
62}
63
64impl HarmonicKind {
65 pub fn as_str(&self) -> &'static str {
66 match self {
67 HarmonicKind::AbCd => "abcd",
68 HarmonicKind::AlternateAbCd => "alternate_abcd",
69 HarmonicKind::FiveZero => "5-0",
70 HarmonicKind::Gartley => "gartley",
71 HarmonicKind::Bat => "bat",
72 HarmonicKind::Butterfly => "butterfly",
73 HarmonicKind::Crab => "crab",
74 HarmonicKind::AlternateBat => "alternate_bat",
75 }
76 }
77
78 fn discriminant(&self) -> u8 {
79 match self {
80 HarmonicKind::AbCd => 0,
81 HarmonicKind::AlternateAbCd => 1,
82 HarmonicKind::FiveZero => 2,
83 HarmonicKind::Gartley => 3,
84 HarmonicKind::Bat => 4,
85 HarmonicKind::Butterfly => 5,
86 HarmonicKind::Crab => 6,
87 HarmonicKind::AlternateBat => 7,
88 }
89 }
90}
91
92struct XabcdSpec {
96 kind: HarmonicKind,
97 b_lo: f64,
99 b_hi: f64,
100 b_ideal: f64,
101 bc_lo: f64,
103 bc_hi: f64,
104 d_xa: f64,
106}
107
108const XABCD_SPECS: [XabcdSpec; 5] = [
110 XabcdSpec {
112 kind: HarmonicKind::Gartley,
113 b_lo: 0.618,
114 b_hi: 0.618,
115 b_ideal: 0.618,
116 bc_lo: 1.13,
117 bc_hi: 1.618,
118 d_xa: 0.786,
119 },
120 XabcdSpec {
122 kind: HarmonicKind::Bat,
123 b_lo: 0.382,
124 b_hi: 0.618,
125 b_ideal: 0.50,
126 bc_lo: 1.618,
127 bc_hi: 2.618,
128 d_xa: 0.886,
129 },
130 XabcdSpec {
132 kind: HarmonicKind::Butterfly,
133 b_lo: 0.786,
134 b_hi: 0.786,
135 b_ideal: 0.786,
136 bc_lo: 1.618,
137 bc_hi: 2.618,
138 d_xa: 1.27,
139 },
140 XabcdSpec {
142 kind: HarmonicKind::Crab,
143 b_lo: 0.382,
144 b_hi: 0.618,
145 b_ideal: 0.50,
146 bc_lo: 2.618,
147 bc_hi: 3.618,
148 d_xa: 1.618,
149 },
150 XabcdSpec {
152 kind: HarmonicKind::AlternateBat,
153 b_lo: 0.236,
154 b_hi: 0.382,
155 b_ideal: 0.382,
156 bc_lo: 1.618,
157 bc_hi: 3.618,
158 d_xa: 1.13,
159 },
160];
161
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct HarmonicPattern {
167 pub id: u32,
168 pub kind: HarmonicKind,
169 pub is_bull: bool,
170 pub x_bar: Option<usize>,
171 pub x_price: Option<f64>,
172 pub a_bar: usize,
173 pub a_price: f64,
174 pub b_bar: usize,
175 pub b_price: f64,
176 pub c_bar: usize,
177 pub c_price: f64,
178 pub d_bar: usize,
179 pub d_price: f64,
180 pub score: f64,
182 pub xa_ext: Option<f64>,
184 pub bc_ab: f64,
186 pub cd_ab: f64,
188 pub cd_bc: f64,
190 pub d_xa: Option<f64>,
194 pub prz_low: f64,
196 pub prz_high: f64,
197 pub size_atr: f64,
199}
200
201#[derive(Debug, Clone)]
203pub struct HarmonicConfig {
204 pub ratio_tolerance: f64,
206 pub min_score: f64,
208 pub min_size_atr: f64,
210 pub atr_period: usize,
211 pub detect_abcd: bool,
212 pub detect_alternate_abcd: bool,
213 pub detect_5_0: bool,
214 pub detect_xabcd: bool,
217}
218
219impl Default for HarmonicConfig {
220 fn default() -> Self {
221 Self {
222 ratio_tolerance: 0.10,
223 min_score: 0.5,
224 min_size_atr: 0.0,
225 atr_period: 14,
226 detect_abcd: true,
227 detect_alternate_abcd: true,
228 detect_5_0: true,
229 detect_xabcd: true,
230 }
231 }
232}
233
234fn ratio_fit(measured: f64, ideal: f64, tol: f64) -> f64 {
236 if ideal == 0.0 || tol <= 0.0 {
237 return 0.0;
238 }
239 let err = (measured / ideal - 1.0).abs();
240 (1.0 - err / tol).clamp(0.0, 1.0)
241}
242
243fn in_band(x: f64, lo: f64, hi: f64, tol: f64) -> bool {
245 x >= lo * (1.0 - tol) && x <= hi * (1.0 + tol)
246}
247
248fn leg(a: f64, b: f64) -> f64 {
249 (a - b).abs()
250}
251
252#[derive(Debug, Clone)]
256pub struct HarmonicPatternScanner {
257 ms: MarketStructure,
258 config: HarmonicConfig,
259 bar_index: usize,
260 prev_close: f64,
261 have_prev_close: bool,
262 atr: f64,
263 swings: Vec<SwingPoint>,
264 last_high_bar: Option<usize>,
267 last_low_bar: Option<usize>,
268 seen: HashSet<(u8, usize, usize, usize, usize)>,
269 next_id: u32,
270}
271
272impl HarmonicPatternScanner {
273 pub fn new(swing_strength: usize) -> Self {
274 Self::with_config(swing_strength, HarmonicConfig::default())
275 }
276
277 pub fn with_config(swing_strength: usize, config: HarmonicConfig) -> Self {
278 Self {
279 ms: MarketStructure::new(swing_strength),
280 config,
281 bar_index: 0,
282 prev_close: 0.0,
283 have_prev_close: false,
284 atr: 1.0,
285 swings: Vec::with_capacity(64),
286 last_high_bar: None,
287 last_low_bar: None,
288 seen: HashSet::new(),
289 next_id: 1,
290 }
291 }
292
293 fn update_atr(&mut self, high: f64, low: f64) {
294 let prev_close = if self.have_prev_close {
295 self.prev_close
296 } else {
297 (high + low) / 2.0
298 };
299 let tr = (high - low)
300 .max((high - prev_close).abs())
301 .max((low - prev_close).abs());
302 let p = self.config.atr_period.max(1);
303 if self.bar_index <= p {
304 let n = self.bar_index as f64;
306 self.atr = (self.atr * (n - 1.0) + tr) / n.max(1.0);
307 } else {
308 let alpha = 1.0 / p as f64;
309 self.atr = self.atr * (1.0 - alpha) + tr * alpha;
310 }
311 self.atr = self.atr.max(1e-8);
312 }
313
314 fn ingest_swing(&mut self, sp: &SwingPoint) {
317 if let Some(last) = self.swings.last() {
318 if last.bar == sp.bar && last.is_high == sp.is_high {
319 return;
320 }
321 if last.is_high == sp.is_high {
322 let more_extreme = (sp.is_high && sp.price >= last.price)
323 || (!sp.is_high && sp.price <= last.price);
324 if more_extreme {
325 self.swings.pop();
326 } else {
327 return;
328 }
329 }
330 }
331 self.swings.push(sp.clone());
332 if self.swings.len() > 64 {
333 self.swings.drain(0..16);
334 }
335 }
336
337 fn size_atr(&self, prices: &[f64]) -> f64 {
338 let hi = prices.iter().cloned().fold(f64::MIN, f64::max);
339 let lo = prices.iter().cloned().fold(f64::MAX, f64::min);
340 (hi - lo) / self.atr
341 }
342
343 fn emit(&mut self, mut p: HarmonicPattern) -> Option<HarmonicPattern> {
344 if p.score < self.config.min_score || p.size_atr < self.config.min_size_atr {
345 return None;
346 }
347 let key = (
348 p.kind.discriminant(),
349 p.x_bar.unwrap_or(usize::MAX),
350 p.a_bar,
351 p.c_bar,
352 p.d_bar,
353 );
354 if !self.seen.insert(key) {
355 return None;
356 }
357 p.id = self.next_id;
358 self.next_id += 1;
359 Some(p)
360 }
361
362 fn classify_abcd(
364 &self,
365 a: &SwingPoint,
366 b: &SwingPoint,
367 c: &SwingPoint,
368 d: &SwingPoint,
369 ) -> Option<HarmonicPattern> {
370 if !(a.bar < b.bar && b.bar < c.bar && c.bar < d.bar) {
373 return None;
374 }
375 let ab = leg(a.price, b.price);
376 let bc = leg(b.price, c.price);
377 let cd = leg(c.price, d.price);
378 if ab <= 0.0 || bc <= 0.0 || cd <= 0.0 {
379 return None;
380 }
381 let bc_ab = bc / ab;
382 let cd_ab = cd / ab;
383 let tol = self.config.ratio_tolerance;
384 if !in_band(bc_ab, 0.382, 0.886, tol) {
386 return None;
387 }
388 let is_bull = !d.is_high;
389
390 let (kind, target) = if self.config.detect_abcd && ratio_fit(cd_ab, 1.0, tol) > 0.0 {
391 (HarmonicKind::AbCd, 1.0)
392 } else if self.config.detect_alternate_abcd {
393 let nearest = if (cd_ab - 1.27).abs() <= (cd_ab - 1.618).abs() {
394 1.27
395 } else {
396 1.618
397 };
398 if ratio_fit(cd_ab, nearest, tol) > 0.0 {
399 (HarmonicKind::AlternateAbCd, nearest)
400 } else {
401 return None;
402 }
403 } else {
404 return None;
405 };
406
407 let score = ratio_fit(cd_ab, target, tol);
408 let sign = if is_bull { -1.0 } else { 1.0 };
410 let d_proj = c.price + sign * target * ab;
411 let band = tol * ab;
412 Some(HarmonicPattern {
413 id: 0,
414 kind,
415 is_bull,
416 x_bar: None,
417 x_price: None,
418 a_bar: a.bar,
419 a_price: a.price,
420 b_bar: b.bar,
421 b_price: b.price,
422 c_bar: c.bar,
423 c_price: c.price,
424 d_bar: d.bar,
425 d_price: d.price,
426 score,
427 xa_ext: None,
428 bc_ab,
429 cd_ab,
430 cd_bc: cd / bc,
431 d_xa: None,
432 prz_low: d_proj - band,
433 prz_high: d_proj + band,
434 size_atr: self.size_atr(&[a.price, b.price, c.price, d.price]),
435 })
436 }
437
438 fn classify_5_0(
440 &self,
441 x: &SwingPoint,
442 a: &SwingPoint,
443 b: &SwingPoint,
444 c: &SwingPoint,
445 d: &SwingPoint,
446 ) -> Option<HarmonicPattern> {
447 if !self.config.detect_5_0 {
448 return None;
449 }
450 if !(x.bar < a.bar && a.bar < b.bar && b.bar < c.bar && c.bar < d.bar) {
451 return None;
452 }
453 let xa = leg(x.price, a.price);
454 let ab = leg(a.price, b.price);
455 let bc = leg(b.price, c.price);
456 let cd = leg(c.price, d.price);
457 if xa <= 0.0 || ab <= 0.0 || bc <= 0.0 || cd <= 0.0 {
458 return None;
459 }
460 let xa_ext = ab / xa;
461 let bc_ab = bc / ab;
462 let cd_bc = cd / bc;
463 let cd_ab = cd / ab;
464 let tol = self.config.ratio_tolerance;
465 if !in_band(xa_ext, 1.13, 1.618, tol)
467 || !in_band(bc_ab, 1.618, 2.24, tol)
468 || ratio_fit(cd_bc, 0.5, tol) == 0.0
469 {
470 return None;
471 }
472 let is_bull = !d.is_high;
473 let score = 0.5 * ratio_fit(cd_bc, 0.5, tol) + 0.5 * ratio_fit(cd_ab, 1.0, tol * 1.5);
477 let sign = if is_bull { -1.0 } else { 1.0 };
479 let lvl_50 = c.price + sign * 0.5 * bc;
480 let lvl_abcd = c.price + sign * ab;
481 Some(HarmonicPattern {
482 id: 0,
483 kind: HarmonicKind::FiveZero,
484 is_bull,
485 x_bar: Some(x.bar),
486 x_price: Some(x.price),
487 a_bar: a.bar,
488 a_price: a.price,
489 b_bar: b.bar,
490 b_price: b.price,
491 c_bar: c.bar,
492 c_price: c.price,
493 d_bar: d.bar,
494 d_price: d.price,
495 score,
496 xa_ext: Some(xa_ext),
497 bc_ab,
498 cd_ab,
499 cd_bc,
500 d_xa: None,
501 prz_low: lvl_50.min(lvl_abcd),
502 prz_high: lvl_50.max(lvl_abcd),
503 size_atr: self.size_atr(&[x.price, a.price, b.price, c.price, d.price]),
504 })
505 }
506
507 fn classify_xabcd(
511 &self,
512 x: &SwingPoint,
513 a: &SwingPoint,
514 b: &SwingPoint,
515 c: &SwingPoint,
516 d: &SwingPoint,
517 ) -> Option<HarmonicPattern> {
518 if !self.config.detect_xabcd {
519 return None;
520 }
521 if !(x.bar < a.bar && a.bar < b.bar && b.bar < c.bar && c.bar < d.bar) {
522 return None;
523 }
524 let xa = leg(x.price, a.price);
525 let ab = leg(a.price, b.price);
526 let bc = leg(b.price, c.price);
527 let cd = leg(c.price, d.price);
528 if xa <= 0.0 || ab <= 0.0 || bc <= 0.0 || cd <= 0.0 {
529 return None;
530 }
531 let b_xa = ab / xa; let c_ab = bc / ab; let bc_proj = cd / bc; let d_xa = leg(a.price, d.price) / xa; let tol = self.config.ratio_tolerance;
536 if !in_band(c_ab, 0.382, 0.886, tol) {
537 return None;
538 }
539
540 let mut best: Option<(f64, &XabcdSpec)> = None;
543 for spec in &XABCD_SPECS {
544 if !in_band(b_xa, spec.b_lo, spec.b_hi, tol)
545 || !in_band(bc_proj, spec.bc_lo, spec.bc_hi, tol)
546 || ratio_fit(d_xa, spec.d_xa, tol) == 0.0
547 {
548 continue;
549 }
550 let score = 0.6 * ratio_fit(d_xa, spec.d_xa, tol)
551 + 0.4 * ratio_fit(b_xa, spec.b_ideal, tol * 2.0);
552 if best.is_none_or(|(bs, _)| score > bs) {
553 best = Some((score, spec));
554 }
555 }
556 let (score, spec) = best?;
557
558 let is_bull = !d.is_high;
559 let sign = if is_bull { -1.0 } else { 1.0 };
561 let d_proj = a.price + sign * spec.d_xa * xa;
562 let band = tol * xa;
563 Some(HarmonicPattern {
564 id: 0,
565 kind: spec.kind,
566 is_bull,
567 x_bar: Some(x.bar),
568 x_price: Some(x.price),
569 a_bar: a.bar,
570 a_price: a.price,
571 b_bar: b.bar,
572 b_price: b.price,
573 c_bar: c.bar,
574 c_price: c.price,
575 d_bar: d.bar,
576 d_price: d.price,
577 score,
578 xa_ext: Some(b_xa),
579 bc_ab: c_ab,
580 cd_ab: cd / ab,
581 cd_bc: bc_proj,
582 d_xa: Some(d_xa),
583 prz_low: d_proj - band,
584 prz_high: d_proj + band,
585 size_atr: self.size_atr(&[x.price, a.price, b.price, c.price, d.price]),
586 })
587 }
588
589 fn detect(&mut self) -> Vec<HarmonicPattern> {
590 let mut out = Vec::new();
591 let n = self.swings.len();
592 if n >= 5 {
593 let (x, a, b, c, d) = (
594 self.swings[n - 5].clone(),
595 self.swings[n - 4].clone(),
596 self.swings[n - 3].clone(),
597 self.swings[n - 2].clone(),
598 self.swings[n - 1].clone(),
599 );
600 if let Some(p) = self.classify_5_0(&x, &a, &b, &c, &d)
601 && let Some(p) = self.emit(p)
602 {
603 out.push(p);
604 }
605 if let Some(p) = self.classify_xabcd(&x, &a, &b, &c, &d)
606 && let Some(p) = self.emit(p)
607 {
608 out.push(p);
609 }
610 }
611 if n >= 4 {
612 let (a, b, c, d) = (
613 self.swings[n - 4].clone(),
614 self.swings[n - 3].clone(),
615 self.swings[n - 2].clone(),
616 self.swings[n - 1].clone(),
617 );
618 if let Some(p) = self.classify_abcd(&a, &b, &c, &d)
619 && let Some(p) = self.emit(p)
620 {
621 out.push(p);
622 }
623 }
624 out
625 }
626}
627
628impl Next<(f64, f64)> for HarmonicPatternScanner {
629 type Output = Vec<HarmonicPattern>;
630
631 fn next(&mut self, (high, low): (f64, f64)) -> Self::Output {
632 self.bar_index += 1;
633 self.update_atr(high, low);
634 self.prev_close = (high + low) / 2.0;
635 self.have_prev_close = true;
636
637 let state = self.ms.next((high, low));
638 if let Some(sh) = state.last_swing_high.clone()
641 && self.last_high_bar != Some(sh.bar)
642 {
643 self.last_high_bar = Some(sh.bar);
644 self.ingest_swing(&sh);
645 }
646 if let Some(sl) = state.last_swing_low.clone()
647 && self.last_low_bar != Some(sl.bar)
648 {
649 self.last_low_bar = Some(sl.bar);
650 self.ingest_swing(&sl);
651 }
652 self.detect()
653 }
654}
655
656pub fn harmonic_patterns_batch(
659 bars: &[(f64, f64)],
660 swing_strength: usize,
661 config: HarmonicConfig,
662) -> Vec<HarmonicPattern> {
663 let mut s = HarmonicPatternScanner::with_config(swing_strength, config);
664 let mut out = Vec::new();
665 for &(h, l) in bars {
666 out.extend(s.next((h, l)));
667 }
668 out
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use proptest::prelude::*;
675
676 const STRENGTH: usize = 2;
677 const SPACING: usize = 6;
678
679 fn series_from_pivots(pivots: &[(f64, bool)]) -> Vec<(f64, f64)> {
683 let eps = 0.01;
684 let start = STRENGTH + 1;
685 let n = start + (pivots.len() - 1) * SPACING + STRENGTH + 2;
686 let bars: Vec<usize> = (0..pivots.len()).map(|i| start + i * SPACING).collect();
687 let mut out = Vec::with_capacity(n);
688 for i in 0..n {
689 let mid = if i <= bars[0] {
691 pivots[0].0
692 } else if i >= *bars.last().unwrap() {
693 pivots.last().unwrap().0
694 } else {
695 let k = bars.iter().position(|&b| b > i).unwrap();
696 let (b0, b1) = (bars[k - 1], bars[k]);
697 let (v0, v1) = (pivots[k - 1].0, pivots[k].0);
698 v0 + (v1 - v0) * (i - b0) as f64 / (b1 - b0) as f64
699 };
700 out.push((mid + eps, mid - eps));
701 }
702 for (idx, &(price, is_high)) in pivots.iter().enumerate() {
703 let bar = bars[idx];
704 if is_high {
705 out[bar] = (price, price - 2.0 * eps);
706 } else {
707 out[bar] = (price + 2.0 * eps, price);
708 }
709 }
710 out
711 }
712
713 fn run(pivots: &[(f64, bool)]) -> Vec<HarmonicPattern> {
714 harmonic_patterns_batch(
715 &series_from_pivots(pivots),
716 STRENGTH,
717 HarmonicConfig::default(),
718 )
719 }
720
721 #[test]
722 fn gold_bullish_abcd_exact() {
723 let pats = run(&[
726 (110.0, true),
727 (100.0, false),
728 (106.18, true),
729 (96.18, false),
730 ]);
731 let abcd: Vec<_> = pats
732 .iter()
733 .filter(|p| p.kind == HarmonicKind::AbCd)
734 .collect();
735 assert_eq!(abcd.len(), 1, "exactly one AB=CD");
736 let p = abcd[0];
737 assert!(p.is_bull);
738 assert!(p.score > 0.95, "near-exact score, got {}", p.score);
739 assert!((p.cd_ab - 1.0).abs() < 0.02);
740 assert!((p.bc_ab - 0.618).abs() < 0.02);
741 assert_eq!(p.x_bar, None);
742 }
743
744 #[test]
745 fn gold_alternate_abcd_1_27() {
746 let pats = run(&[
748 (110.0, true),
749 (100.0, false),
750 (106.18, true),
751 (93.48, false),
752 ]);
753 let alt: Vec<_> = pats
754 .iter()
755 .filter(|p| p.kind == HarmonicKind::AlternateAbCd)
756 .collect();
757 assert_eq!(alt.len(), 1, "one Alternate AB=CD");
758 assert!((alt[0].cd_ab - 1.27).abs() < 0.03);
759 assert!(alt[0].score > 0.9);
760 }
761
762 #[test]
763 fn gold_bullish_5_0_exact() {
764 let pats = run(&[
769 (100.0, false),
770 (110.0, true),
771 (93.82, false),
772 (126.18, true),
773 (110.0, false),
774 ]);
775 let five: Vec<_> = pats
776 .iter()
777 .filter(|p| p.kind == HarmonicKind::FiveZero)
778 .collect();
779 assert_eq!(five.len(), 1, "one 5-0");
780 let p = five[0];
781 assert!(p.is_bull);
782 assert!(p.x_bar.is_some());
783 assert!((p.cd_bc - 0.5).abs() < 0.03, "cd_bc {}", p.cd_bc);
784 assert!(p.score > 0.9, "score {}", p.score);
785 }
786
787 #[test]
788 fn perturbed_abcd_rejected() {
789 let pats = run(&[
792 (110.0, true),
793 (100.0, false),
794 (106.18, true),
795 (91.18, false),
796 ]);
797 assert!(
798 pats.iter().all(|p| p.kind == HarmonicKind::FiveZero),
799 "no AB=CD family should match cd_ab=1.5"
800 );
801 }
802
803 #[test]
804 fn bad_retrace_rejected() {
805 let pats = run(&[(110.0, true), (100.0, false), (102.0, true), (92.0, false)]);
808 assert!(pats.iter().all(|p| p.kind != HarmonicKind::AbCd));
809 }
810
811 #[test]
812 fn detection_is_anti_lookahead() {
813 let series = series_from_pivots(&[
816 (110.0, true),
817 (100.0, false),
818 (106.18, true),
819 (96.18, false),
820 ]);
821 let mut s = HarmonicPatternScanner::new(STRENGTH);
822 for (i, &(h, l)) in series.iter().enumerate() {
823 for p in s.next((h, l)) {
824 assert!(
825 i >= p.d_bar,
826 "pattern emitted at bar {} before its D at {}",
827 i,
828 p.d_bar
829 );
830 }
831 }
832 }
833
834 #[test]
835 fn bearish_abcd_detected() {
836 let pats = run(&[(90.0, false), (100.0, true), (93.82, false), (103.82, true)]);
838 let abcd: Vec<_> = pats
839 .iter()
840 .filter(|p| p.kind == HarmonicKind::AbCd)
841 .collect();
842 assert_eq!(abcd.len(), 1);
843 assert!(!abcd[0].is_bull);
844 }
845
846 fn assert_xabcd(pivots: &[(f64, bool)], kind: HarmonicKind, d_xa: f64) {
848 let pats = run(pivots);
849 let hits: Vec<_> = pats.iter().filter(|p| p.kind == kind).collect();
850 assert_eq!(hits.len(), 1, "expected one {:?}, got {:?}", kind, pats);
851 let p = hits[0];
852 assert!(p.is_bull);
853 assert!(p.x_bar.is_some());
854 assert!(p.score > 0.9, "score {}", p.score);
855 assert!(
856 (p.d_xa.unwrap() - d_xa).abs() < 0.03,
857 "d_xa {}",
858 p.d_xa.unwrap()
859 );
860 }
861
862 #[test]
863 fn gold_bullish_gartley() {
864 assert_xabcd(
866 &[
867 (100.0, false),
868 (110.0, true),
869 (103.82, false),
870 (107.64, true),
871 (102.14, false),
872 ],
873 HarmonicKind::Gartley,
874 0.786,
875 );
876 }
877
878 #[test]
879 fn gold_bullish_bat() {
880 assert_xabcd(
882 &[
883 (100.0, false),
884 (110.0, true),
885 (105.0, false),
886 (108.09, true),
887 (101.14, false),
888 ],
889 HarmonicKind::Bat,
890 0.886,
891 );
892 }
893
894 #[test]
895 fn gold_bullish_butterfly() {
896 assert_xabcd(
898 &[
899 (100.0, false),
900 (110.0, true),
901 (102.14, false),
902 (106.07, true),
903 (97.30, false),
904 ],
905 HarmonicKind::Butterfly,
906 1.27,
907 );
908 }
909
910 #[test]
911 fn gold_bullish_crab() {
912 assert_xabcd(
914 &[
915 (100.0, false),
916 (110.0, true),
917 (105.0, false),
918 (109.43, true),
919 (93.82, false),
920 ],
921 HarmonicKind::Crab,
922 1.618,
923 );
924 }
925
926 #[test]
927 fn gold_bullish_alternate_bat() {
928 assert_xabcd(
930 &[
931 (100.0, false),
932 (110.0, true),
933 (106.18, false),
934 (109.18, true),
935 (98.70, false),
936 ],
937 HarmonicKind::AlternateBat,
938 1.13,
939 );
940 }
941
942 #[test]
943 fn gartley_not_misread_as_bat() {
944 let pats = run(&[
947 (100.0, false),
948 (110.0, true),
949 (103.82, false),
950 (107.64, true),
951 (102.14, false),
952 ]);
953 assert!(pats.iter().all(|p| p.kind != HarmonicKind::Bat));
954 }
955
956 proptest! {
957 #[test]
961 fn emitted_patterns_are_well_formed(
962 hl in prop::collection::vec((0.0f64..1000.0, 0.0f64..1000.0), 20..400),
963 ) {
964 let bars: Vec<(f64, f64)> = hl
965 .into_iter()
966 .map(|(a, b)| (a.max(b), a.min(b)))
967 .collect();
968 let mut s = HarmonicPatternScanner::new(STRENGTH);
969 let cfg = HarmonicConfig::default();
970 for (i, &(h, l)) in bars.iter().enumerate() {
971 for p in s.next((h, l)) {
972 prop_assert!(i >= p.d_bar, "lookahead: emitted at {} for D {}", i, p.d_bar);
973 prop_assert!(p.a_bar < p.b_bar && p.b_bar < p.c_bar && p.c_bar < p.d_bar);
974 prop_assert!(p.score >= cfg.min_score);
975 prop_assert!(p.prz_low <= p.prz_high);
976 match p.kind {
977 HarmonicKind::AbCd => {
978 prop_assert!(in_band(p.bc_ab, 0.382, 0.886, cfg.ratio_tolerance));
979 prop_assert!(ratio_fit(p.cd_ab, 1.0, cfg.ratio_tolerance) > 0.0);
980 prop_assert!(p.x_bar.is_none());
981 }
982 HarmonicKind::AlternateAbCd => {
983 prop_assert!(in_band(p.bc_ab, 0.382, 0.886, cfg.ratio_tolerance));
984 let f127 = ratio_fit(p.cd_ab, 1.27, cfg.ratio_tolerance);
985 let f1618 = ratio_fit(p.cd_ab, 1.618, cfg.ratio_tolerance);
986 prop_assert!(f127 > 0.0 || f1618 > 0.0);
987 }
988 HarmonicKind::FiveZero => {
989 prop_assert!(p.x_bar.is_some());
990 prop_assert!(in_band(p.bc_ab, 1.618, 2.24, cfg.ratio_tolerance));
991 prop_assert!(ratio_fit(p.cd_bc, 0.5, cfg.ratio_tolerance) > 0.0);
992 }
993 HarmonicKind::Gartley
996 | HarmonicKind::Bat
997 | HarmonicKind::Butterfly
998 | HarmonicKind::Crab
999 | HarmonicKind::AlternateBat => {
1000 prop_assert!(p.x_bar.is_some());
1001 prop_assert!(in_band(p.bc_ab, 0.382, 0.886, cfg.ratio_tolerance));
1002 let dxa = p.d_xa.unwrap();
1003 let hit = [0.786, 0.886, 1.27, 1.618, 1.13]
1004 .iter()
1005 .any(|&t| ratio_fit(dxa, t, cfg.ratio_tolerance) > 0.0);
1006 prop_assert!(hit, "d_xa {} matches no canonical target", dxa);
1007 }
1008 }
1009 }
1010 }
1011 }
1012 }
1013}