Skip to main content

quantwave_core/indicators/
harmonic.rs

1//! Harmonic pattern detection (AB=CD, Alternate AB=CD, 5-0, and the XABCD
2//! Gartley family: Gartley, Bat, Butterfly, Crab, Alternate Bat).
3//!
4//! Harmonic patterns are Fibonacci-ratio-constrained price structures. This
5//! module builds them on the shared swing foundation ([`MarketStructure`],
6//! Part 21) exactly like [`crate::indicators::geometric_patterns::GeometricPatternScanner`]: confirmed swing pivots are
7//! collected into an alternating sequence, and the most recent 4 (AB=CD) or 5
8//! (5-0 and the XABCD patterns) pivots are tested against the pattern's ratio
9//! gates. A pattern is emitted only once its completion pivot `D` is a
10//! *confirmed* swing — which lags the pivot by `swing_strength` bars — so
11//! detection never uses information from beyond `D` (anti-lookahead).
12//!
13//! # Attribution
14//!
15//! Harmonic Trading is the work of **Scott M. Carney** (HarmonicTrader.com).
16//! Carney named and defined these patterns; "Harmonic Trading" and several
17//! pattern names are his trademarks. The ratio definitions here follow his
18//! primary sources and are implemented for interoperability with attribution —
19//! no source text is reproduced:
20//!
21//! - AB=CD reciprocal ratio table (C retracement {0.382, 0.50, 0.618, 0.707,
22//!   0.786, 0.886} ↔ BC projection {2.618/2.24, 2.0, 1.618, 1.41, 1.27, 1.13}):
23//!   Carney, *Harmonic Trading: Volume One* (2010), Ch. 4 "The AB=CD Pattern".
24//! - Alternate AB=CD (CD = 1.27 or 1.618 × AB): *Volume One* Ch. 4 /
25//!   harmonictrader.com "Alternate ABCD Pattern".
26//! - 5-0 pattern (B = 1.13–1.618 of XA; C = 1.618–2.24 of AB; D = 50% of BC and
27//!   the reciprocal AB=CD): Carney, *Harmonic Trading: Volume Two* (2010),
28//!   Ch. 3 "New Harmonic Patterns", pp. 78–79 / harmonictrader.com "5-0".
29//! - XABCD reversal patterns, distinguished by D's retracement/extension of the
30//!   XA leg (see `XABCD_SPECS`): Gartley (D 0.786), Bat (D 0.886), Butterfly
31//!   (D 1.27), Crab (D 1.618) — *Volume One* Ch. 5–8 — and Alternate Bat
32//!   (D 1.13) — *Volume Two* Ch. 3.
33//!
34//! Signal output is a rich [`HarmonicPattern`] struct (pivots, measured ratios,
35//! fit score, PRZ), kept separate from any visualization per the project's PA
36//! design philosophy.
37
38use crate::indicators::market_structure::{MarketStructure, SwingPoint};
39use crate::traits::Next;
40use serde::{Deserialize, Serialize};
41use std::collections::HashSet;
42
43/// Which harmonic pattern was detected.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45pub enum HarmonicKind {
46    /// AB=CD: CD leg equals AB in length.
47    AbCd,
48    /// Alternate AB=CD: CD = 1.27 × AB or 1.618 × AB.
49    AlternateAbCd,
50    /// 5-0: five-point structure completing at a 50% BC retrace / reciprocal AB=CD.
51    FiveZero,
52    /// Gartley: XABCD with B = 0.618 XA, D = 0.786 XA.
53    Gartley,
54    /// Bat: XABCD with B < 0.618 XA, D = 0.886 XA.
55    Bat,
56    /// Butterfly: XABCD with B = 0.786 XA, D = 1.27 XA (extension).
57    Butterfly,
58    /// Crab: XABCD with B ≤ 0.618 XA, D = 1.618 XA (deep extension).
59    Crab,
60    /// Alternate Bat: XABCD with B = 0.382 XA, D = 1.13 XA.
61    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
92/// Ratio specification for an XABCD reversal pattern (Gartley family). `d_xa` —
93/// the D retracement/extension of the XA leg — is each pattern's defining number.
94/// Ratios per Carney, *Harmonic Trading* Vols. 1–2 (see module docs).
95struct XabcdSpec {
96    kind: HarmonicKind,
97    /// B as a retracement of XA: `[lo, hi]` gate and `ideal` for scoring.
98    b_lo: f64,
99    b_hi: f64,
100    b_ideal: f64,
101    /// BC projection gate `[lo, hi]` (D as an extension of BC).
102    bc_lo: f64,
103    bc_hi: f64,
104    /// D as a retracement/extension of XA — the defining number.
105    d_xa: f64,
106}
107
108/// Carney XABCD specs. C always retraces AB by 0.382–0.886.
109const XABCD_SPECS: [XabcdSpec; 5] = [
110    // Gartley: Vol. 1 Ch. 6 — B 0.618, BC ≤ 1.618, D 0.786 XA.
111    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    // Bat: Vol. 1 Ch. 5 — B < 0.618 (pref 0.50), BC 1.618–2.618, D 0.886 XA.
121    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    // Butterfly: Vol. 1 Ch. 8 — B 0.786, BC ≥ 1.618, D 1.27 XA.
131    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    // Crab: Vol. 1 Ch. 7 — B ≤ 0.618, BC 2.618–3.618, D 1.618 XA.
141    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    // Alternate Bat: Vol. 2 Ch. 3 — B 0.382, extreme BC, D 1.13 XA.
151    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/// A detected harmonic pattern. Points are labelled X, A, B, C, D; `x_*` is
163/// populated only for the 5-0 (four-point AB=CD patterns have no X). `is_bull`
164/// is true when the pattern completes at a low (a buy setup).
165#[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    /// Ratio-fit quality in [0, 1]; 1.0 is a textbook-exact pattern.
181    pub score: f64,
182    /// 5-0 only: |AB| / |XA| (the B-point extension of XA).
183    pub xa_ext: Option<f64>,
184    /// |BC| / |AB| — a retracement (< 1) for AB=CD, an extension (> 1) for 5-0.
185    pub bc_ab: f64,
186    /// |CD| / |AB| — 1.0 for AB=CD, 1.27/1.618 for Alternate, ~1.0 for 5-0.
187    pub cd_ab: f64,
188    /// |CD| / |BC| — ~0.5 for the 5-0 completion.
189    pub cd_bc: f64,
190    /// XABCD (Gartley family) only: D as a retracement/extension of the XA leg —
191    /// the pattern's defining number (0.786 Gartley, 0.886 Bat, 1.27 Butterfly,
192    /// 1.618 Crab, 1.13 Alternate Bat). `None` for AB=CD and 5-0.
193    pub d_xa: Option<f64>,
194    /// Potential Reversal Zone (price band where the pattern projects to complete).
195    pub prz_low: f64,
196    pub prz_high: f64,
197    /// Vertical extent of the pattern (max − min pivot price) in ATR units.
198    pub size_atr: f64,
199}
200
201/// Tunable detection thresholds.
202#[derive(Debug, Clone)]
203pub struct HarmonicConfig {
204    /// Relative tolerance on equality ratios and range-gate edges (e.g. 0.10 = ±10%).
205    pub ratio_tolerance: f64,
206    /// Minimum fit score to emit a pattern.
207    pub min_score: f64,
208    /// Minimum pattern extent in ATR units (0.0 disables the filter).
209    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    /// Enable the XABCD (Gartley / Bat / Butterfly / Crab / Alternate Bat) family;
215    /// filter the output by `kind` for finer control.
216    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
234/// 1.0 when `measured == ideal`, decreasing linearly to 0 at `±tol` relative.
235fn 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
243/// Whether `x` lies within `[lo, hi]` after expanding the edges by `tol` relative.
244fn 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/// Streaming harmonic pattern scanner. Feed `(high, low)` per bar; each call
253/// returns the patterns (usually none) whose completion pivot confirmed on that
254/// bar.
255#[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    /// Bar of the last high/low pivot already ingested, so each confirmed pivot
265    /// is appended exactly once (not re-read every bar while it stays current).
266    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            // Seed with a simple mean until the window fills.
305            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    /// Append a confirmed swing, keeping the sequence strictly alternating (a
315    /// same-direction pivot replaces the previous one when it is more extreme).
316    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);
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    /// Test the last four swings (A, B, C, D) as a (possibly Alternate) AB=CD.
363    fn classify_abcd(
364        &self,
365        a: &SwingPoint,
366        b: &SwingPoint,
367        c: &SwingPoint,
368        d: &SwingPoint,
369    ) -> Option<HarmonicPattern> {
370        // Pivots must be strictly time-ordered (MarketStructure can occasionally
371        // report a high and a low on the same bar for degenerate windows).
372        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        // C must be a retracement of AB (0.382–0.886 per the reciprocal table).
385        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        // PRZ: the AB=CD completion price (target × AB projected from C), banded.
409        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    /// Test the last five swings (X, A, B, C, D) as a 5-0.
439    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        // B = 1.13–1.618 of XA; C = 1.618–2.24 of AB; D = 50% of BC.
466        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        // D is defined by both the 50% BC retrace and the reciprocal AB=CD; score
474        // rewards proximity to both (the reciprocal on a looser band, as it only
475        // "complements" the PRZ per Vol. 2).
476        let score = 0.5 * ratio_fit(cd_bc, 0.5, tol) + 0.5 * ratio_fit(cd_ab, 1.0, tol * 1.5);
477        // PRZ spans the two projections from C: 50% of BC and the reciprocal AB=CD.
478        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    /// Test the last five swings (X, A, B, C, D) as an XABCD reversal pattern
508    /// (Gartley / Bat / Butterfly / Crab / Alternate Bat). Returns the
509    /// best-scoring match, whose defining number is D's retracement of the XA leg.
510    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; // B retracement of XA
532        let c_ab = bc / ab; // C retracement of AB
533        let bc_proj = cd / bc; // D as an extension of BC
534        let d_xa = leg(a.price, d.price) / xa; // D retracement/extension of XA
535        let tol = self.config.ratio_tolerance;
536        if !in_band(c_ab, 0.382, 0.886, tol) {
537            return None;
538        }
539
540        // Pick the best-scoring spec whose gates all pass. D's XA ratio is the
541        // defining number (weighted most); the B point differentiates lookalikes.
542        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        // PRZ: the XA-ratio completion price (the defining number), banded.
560        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],
595                self.swings[n - 4],
596                self.swings[n - 3],
597                self.swings[n - 2],
598                self.swings[n - 1],
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],
614                self.swings[n - 3],
615                self.swings[n - 2],
616                self.swings[n - 1],
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        // Ingest a pivot only when newly confirmed (its bar changed since last
639        // seen), so each pivot enters the sequence once, in confirmation order.
640        if let Some(sh) = state.last_swing_high
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
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
656/// Batch harmonic detection: fold [`HarmonicPatternScanner`] over `(high, low)`
657/// bars, returning every detected pattern in completion order.
658pub 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    /// Build an OHLC `(high, low)` series whose confirmed swings land exactly on
680    /// the given pivots. Prices ramp piecewise-linearly between pivots so each is
681    /// a strict local extreme detectable by `MarketStructure` at `STRENGTH`.
682    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            // Interpolate the "mid path" through the pivot prices at this bar.
690            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        // Bullish AB=CD: A high, B low, C high (0.618 retrace of AB), D low with
724        // CD = AB. A=110, B=100 (AB=10), C=106.18 (0.618 up), D=96.18 (CD=10).
725        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        // Same A,B,C but CD = 1.27 × AB → D = 106.18 − 12.7 = 93.48.
747        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        // 5-0: X low, A high, B low (AB = 1.618×XA), C high (BC = 2.0×AB), D low
765        // (CD = 0.5×BC = AB, both projections agree → score ~1).
766        // X=100, A=110 (XA=10), B=93.82 (AB=16.18=1.618×10), C=126.18 (BC=32.36
767        // =2.0×AB), D=110.0 (CD=16.18=0.5×BC=AB).
768        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        // CD = 1.5 × AB is neither 1.0 (AB=CD) nor 1.27/1.618 (Alternate) within
790        // 10% → no pattern. C=106.18, D = 106.18 − 15 = 91.18.
791        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        // C retraces AB by only 0.2 (< 0.382 gate) → not an AB=CD candidate.
806        // A=110, B=100, C=102 (0.2), D=92 (CD=10).
807        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        // The reported completion bar D must exist before detection: emission bar
814        // (index in the folded stream) is >= d_bar for every pattern.
815        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        // Mirror of the bullish case: A low, B high, C low, D high.
837        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    /// Assert a single XABCD pattern of the given kind is found with score ~1.
847    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        // X=100,A=110 (XA=10); B=0.618 XA=103.82; C=0.618 AB=107.64; D=0.786 XA=102.14.
865        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        // B=0.50 XA=105; C=0.618 AB=108.09; D=0.886 XA=101.14.
881        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        // B=0.786 XA=102.14; C=0.50 AB=106.07; D=1.27 XA=97.30 (extension).
897        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        // B=0.50 XA=105; C=0.886 AB=109.43; D=1.618 XA=93.82 (deep extension).
913        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        // B=0.382 XA=106.18; C=0.786 AB=109.18; D=1.13 XA=98.70.
929        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        // A textbook Gartley (BC proj ~1.44, D 0.786) must not also fire as a Bat
945        // (needs BC >= 1.618 and D 0.886).
946        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        // On arbitrary bars, every emitted pattern must satisfy the universal
958        // invariants: anti-lookahead (detection bar >= D), ordered/labelled
959        // pivots, a score above threshold, and ratios consistent with its kind.
960        #[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                        // XABCD family: 5 points, C retraces AB 0.382–0.886, and D
994                        // sits at one of the canonical XA ratios.
995                        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}