Skip to main content

kestrel_chartkit/indicator/
harmonics.rs

1//! Harmonic XABCD patterns: ratio checks over a swing sequence, a zone instead of a line, and a
2//! three-state lifecycle.
3//!
4//! Built on [`super::zigzag_advanced::ZigZagNode`] sequences like [`super::chart_patterns`], and
5//! for the same reason: the swing definition is the input, not something to re-derive per module.
6//!
7//! # The patterns do not differ in shape
8//!
9//! Gartley, Bat, Butterfly, Crab, Cypher and Shark are all five-point zigzags. What separates them
10//! is three numbers. This module is therefore a table plus a tolerance, and not six detectors.
11//!
12//! # Why the PRZ is a zone
13//!
14//! "D sits at 88.6 %, therefore long" is the misreading. What is looked for is the place where
15//! several *independent* projections coincide: the XA retracement, the BC extension, and the
16//! AB=CD projection. When they agree the zone is narrow and the statement sharp; when they
17//! disagree the zone is wide — and that width is information, not a defect of the drawing. A
18//! single Fibonacci line hides exactly that and looks more precise than it is.
19//!
20//! # Why the tolerance is the central decision
21//!
22//! A `b` ratio of 0.60 is not 0.618. Whether it counts as a Gartley depends on an interval
23//! somebody has to choose, and a statement about harmonic patterns without a stated tolerance says
24//! nothing. [`HarmonicConfig::ratio_tolerance`] is that number, and it is deliberately a
25//! parameter with a documented default rather than a constant buried in a comparison.
26
27use super::zigzag_advanced::ZigZagNode;
28use crate::model::Bar;
29
30/// An inclusive ratio interval.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct RatioRange {
33    pub min: f64,
34    pub max: f64,
35}
36
37impl RatioRange {
38    pub const fn new(min: f64, max: f64) -> Self {
39        Self { min, max }
40    }
41
42    /// Whether `value` lies in the interval, widened by `tolerance` on both sides.
43    pub fn contains(&self, value: f64, tolerance: f64) -> bool {
44        value >= self.min - tolerance && value <= self.max + tolerance
45    }
46
47    fn center(&self) -> f64 {
48        (self.min + self.max) / 2.0
49    }
50}
51
52/// One row of the ratio table — the entire difference between two harmonic patterns.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct HarmonicDefinition {
55    pub name: &'static str,
56    /// How far B retraces XA.
57    pub b: RatioRange,
58    /// How far C retraces AB.
59    pub c: RatioRange,
60    /// Where D sits relative to XA. Above 1.0 the pattern extends past X.
61    pub d: RatioRange,
62    /// How far CD extends BC.
63    pub cd: RatioRange,
64}
65
66/// The table the documentation prints, as data.
67///
68/// Kept here rather than in a consumer so that a figure and a chapter cannot drift apart: both
69/// read the same rows.
70pub const HARMONIC_TABLE: &[HarmonicDefinition] = &[
71    HarmonicDefinition {
72        name: "gartley",
73        b: RatioRange::new(0.586, 0.65),
74        c: RatioRange::new(0.382, 0.886),
75        d: RatioRange::new(0.75, 0.82),
76        cd: RatioRange::new(1.13, 1.618),
77    },
78    HarmonicDefinition {
79        name: "bat",
80        b: RatioRange::new(0.382, 0.5),
81        c: RatioRange::new(0.382, 0.886),
82        d: RatioRange::new(0.85, 0.92),
83        cd: RatioRange::new(1.618, 2.618),
84    },
85    HarmonicDefinition {
86        name: "butterfly",
87        b: RatioRange::new(0.75, 0.82),
88        c: RatioRange::new(0.382, 0.886),
89        d: RatioRange::new(1.13, 1.618),
90        cd: RatioRange::new(1.618, 2.618),
91    },
92    HarmonicDefinition {
93        name: "crab",
94        b: RatioRange::new(0.382, 0.618),
95        c: RatioRange::new(0.382, 0.886),
96        d: RatioRange::new(1.5, 1.7),
97        cd: RatioRange::new(2.24, 3.618),
98    },
99    HarmonicDefinition {
100        name: "deep_crab",
101        b: RatioRange::new(0.85, 0.92),
102        c: RatioRange::new(0.382, 0.886),
103        d: RatioRange::new(1.5, 1.7),
104        cd: RatioRange::new(2.24, 3.618),
105    },
106    HarmonicDefinition {
107        name: "shark",
108        b: RatioRange::new(0.382, 0.618),
109        c: RatioRange::new(1.13, 1.618),
110        d: RatioRange::new(0.85, 1.13),
111        cd: RatioRange::new(1.618, 2.24),
112    },
113];
114
115/// Where a candidate stands. The distinction the whole module exists for.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum HarmonicState {
118    /// X, A, B and C are in place; D is a projection and nothing has happened yet.
119    ///
120    /// The useful state: it says where to look *before* the fact. It is also the one most easily
121    /// mistaken for the others, because a drawn zone looks like a finding.
122    Candidate,
123    /// Price has reached the zone and the ratios hold.
124    Complete,
125    /// A reversal out of the zone has occurred.
126    Confirmed,
127    /// Price ran through the zone without turning.
128    Invalidated,
129}
130
131/// The zone where the independent projections coincide.
132#[derive(Debug, Clone, Copy, PartialEq)]
133pub struct Prz {
134    pub low: f64,
135    pub high: f64,
136}
137
138impl Prz {
139    pub fn contains(&self, price: f64) -> bool {
140        price >= self.low && price <= self.high
141    }
142
143    pub fn width(&self) -> f64 {
144        self.high - self.low
145    }
146}
147
148/// A recognised or projected XABCD structure.
149#[derive(Debug, Clone, PartialEq)]
150pub struct HarmonicCandidate {
151    pub name: &'static str,
152    /// True when the structure resolves upward at D — X high, A low, …
153    pub bullish: bool,
154    pub x: f64,
155    pub a: f64,
156    pub b: f64,
157    pub c: f64,
158    /// `None` while the structure is a candidate.
159    pub d: Option<f64>,
160    pub prz: Prz,
161    pub state: HarmonicState,
162    /// Measured ratios, in the order b, c, d, cd. `d` and `cd` are `None` for a candidate.
163    pub ratios: (f64, f64, Option<f64>, Option<f64>),
164}
165
166impl HarmonicCandidate {
167    /// Advances the lifecycle with a subsequent bar.
168    ///
169    /// A candidate becomes complete when price trades into the zone, and confirmed when it closes
170    /// back out of it in the pattern's direction. Running through the zone invalidates — and that
171    /// is the same condition read from the other side, which is why it needs no separate rule.
172    pub fn update_state(&mut self, bar: &Bar) -> HarmonicState {
173        match self.state {
174            HarmonicState::Confirmed | HarmonicState::Invalidated => self.state,
175            HarmonicState::Candidate => {
176                let reached = if self.bullish {
177                    bar.low <= self.prz.high
178                } else {
179                    bar.high >= self.prz.low
180                };
181                if reached {
182                    self.d = Some(if self.bullish { bar.low } else { bar.high });
183                    self.state = HarmonicState::Complete;
184                }
185                if self.ran_through(bar) {
186                    self.state = HarmonicState::Invalidated;
187                }
188                self.state
189            }
190            HarmonicState::Complete => {
191                if self.ran_through(bar) {
192                    self.state = HarmonicState::Invalidated;
193                } else {
194                    let turned = if self.bullish {
195                        bar.close > self.prz.high
196                    } else {
197                        bar.close < self.prz.low
198                    };
199                    if turned {
200                        self.state = HarmonicState::Confirmed;
201                    }
202                }
203                self.state
204            }
205        }
206    }
207
208    fn ran_through(&self, bar: &Bar) -> bool {
209        if self.bullish {
210            bar.close < self.prz.low
211        } else {
212            bar.close > self.prz.high
213        }
214    }
215}
216
217/// Configuration of the detector.
218#[derive(Debug, Clone, Copy, PartialEq)]
219pub struct HarmonicConfig {
220    /// Absolute widening of every ratio interval. The number that decides how many patterns exist.
221    pub ratio_tolerance: f64,
222    /// Minimum PRZ half-width as a share of XA, so a zone is never a line by accident.
223    pub min_prz_share: f64,
224}
225
226impl Default for HarmonicConfig {
227    fn default() -> Self {
228        Self {
229            ratio_tolerance: 0.03,
230            min_prz_share: 0.005,
231        }
232    }
233}
234
235/// Scans swing sequences for XABCD structures.
236pub struct HarmonicDetector {
237    pub config: HarmonicConfig,
238}
239
240impl Default for HarmonicDetector {
241    fn default() -> Self {
242        Self::new(HarmonicConfig::default())
243    }
244}
245
246impl HarmonicDetector {
247    pub fn new(config: HarmonicConfig) -> Self {
248        Self { config }
249    }
250
251    /// Every complete five-point structure whose ratios match a row of the table.
252    pub fn scan(&self, nodes: &[ZigZagNode]) -> Vec<HarmonicCandidate> {
253        let mut out = Vec::new();
254        for window in nodes.windows(5) {
255            if !window.windows(2).all(|p| p[0].is_high != p[1].is_high) {
256                continue;
257            }
258            let (x, a, b, c, d) = (
259                window[0].price,
260                window[1].price,
261                window[2].price,
262                window[3].price,
263                window[4].price,
264            );
265            let Some((b_r, c_r, d_r, cd_r)) = measure(x, a, b, c, Some(d)) else {
266                continue;
267            };
268            let (Some(d_r), Some(cd_r)) = (d_r, cd_r) else {
269                continue;
270            };
271
272            for def in HARMONIC_TABLE {
273                let t = self.config.ratio_tolerance;
274                if def.b.contains(b_r, t)
275                    && def.c.contains(c_r, t)
276                    && def.d.contains(d_r, t)
277                    && def.cd.contains(cd_r, t)
278                {
279                    let prz = self.prz(def, x, a, b, c);
280                    out.push(HarmonicCandidate {
281                        name: def.name,
282                        bullish: !window[0].is_high,
283                        x,
284                        a,
285                        b,
286                        c,
287                        d: Some(d),
288                        prz,
289                        state: HarmonicState::Complete,
290                        ratios: (b_r, c_r, Some(d_r), Some(cd_r)),
291                    });
292                }
293            }
294        }
295        out
296    }
297
298    /// Structures still missing their D — the state that says where to look.
299    pub fn scan_candidates(&self, nodes: &[ZigZagNode]) -> Vec<HarmonicCandidate> {
300        let mut out = Vec::new();
301        for window in nodes.windows(4) {
302            if !window.windows(2).all(|p| p[0].is_high != p[1].is_high) {
303                continue;
304            }
305            let (x, a, b, c) = (
306                window[0].price,
307                window[1].price,
308                window[2].price,
309                window[3].price,
310            );
311            let Some((b_r, c_r, _, _)) = measure(x, a, b, c, None) else {
312                continue;
313            };
314
315            for def in HARMONIC_TABLE {
316                let t = self.config.ratio_tolerance;
317                if def.b.contains(b_r, t) && def.c.contains(c_r, t) {
318                    out.push(HarmonicCandidate {
319                        name: def.name,
320                        bullish: !window[0].is_high,
321                        x,
322                        a,
323                        b,
324                        c,
325                        d: None,
326                        prz: self.prz(def, x, a, b, c),
327                        state: HarmonicState::Candidate,
328                        ratios: (b_r, c_r, None, None),
329                    });
330                }
331            }
332        }
333        out
334    }
335
336    /// The zone spanned by three independent projections of D.
337    ///
338    /// Their spread *is* the zone. When they agree it is narrow and the statement sharp; when they
339    /// disagree it is wide, and that is the information a single line would suppress.
340    fn prz(&self, def: &HarmonicDefinition, x: f64, a: f64, b: f64, c: f64) -> Prz {
341        let xa = a - x;
342        let bc = c - b;
343        let ab = b - a;
344
345        let from_xa = a - def.d.center() * xa;
346        let from_bc = c + def.cd.center() * (-bc);
347        let from_abcd = c + ab;
348
349        let mut low = from_xa.min(from_bc).min(from_abcd);
350        let mut high = from_xa.max(from_bc).max(from_abcd);
351
352        // A zone that collapses to a line would be read as a price, which is the misreading this
353        // whole type exists to prevent.
354        let floor = self.config.min_prz_share * xa.abs();
355        if high - low < floor {
356            let mid = (high + low) / 2.0;
357            low = mid - floor / 2.0;
358            high = mid + floor / 2.0;
359        }
360        Prz { low, high }
361    }
362}
363
364/// The four ratios, measured. `None` when a leg has zero length and the ratio is undefined.
365fn measure(
366    x: f64,
367    a: f64,
368    b: f64,
369    c: f64,
370    d: Option<f64>,
371) -> Option<(f64, f64, Option<f64>, Option<f64>)> {
372    let xa = (a - x).abs();
373    let ab = (b - a).abs();
374    let bc = (c - b).abs();
375    if xa <= f64::EPSILON || ab <= f64::EPSILON || bc <= f64::EPSILON {
376        return None;
377    }
378    let b_ratio = ab / xa;
379    let c_ratio = bc / ab;
380    let (d_ratio, cd_ratio) = match d {
381        Some(d) => (Some((a - d).abs() / xa), Some((d - c).abs() / bc)),
382        None => (None, None),
383    };
384    Some((b_ratio, c_ratio, d_ratio, cd_ratio))
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    fn node(ts: i64, price: f64, is_high: bool) -> ZigZagNode {
392        ZigZagNode {
393            timestamp: ts,
394            price,
395            is_high,
396            confirmed: true,
397        }
398    }
399
400    /// X = 100, A = 120, Gartley ratios: B = 107.64, C = 113.82, D = 104.28.
401    ///
402    /// Hand-calculated from the table this module prints, which is the point — chapter and
403    /// detector read the same numbers.
404    fn gartley_nodes() -> Vec<ZigZagNode> {
405        vec![
406            node(0, 100.0, false),
407            node(600, 120.0, true),
408            node(1200, 107.64, false),
409            node(1800, 113.82, true),
410            node(2400, 104.28, false),
411        ]
412    }
413
414    #[test]
415    fn measures_the_ratios_of_the_table() {
416        let (b, c, d, cd) = measure(100.0, 120.0, 107.64, 113.82, Some(104.28)).unwrap();
417        assert!((b - 0.618).abs() < 1e-9, "b = {b}");
418        assert!((c - 0.5).abs() < 1e-9, "c = {c}");
419        assert!((d.unwrap() - 0.786).abs() < 1e-9);
420        // CD = |104.28 - 113.82| = 9.54, BC = 6.18 → 1.5436…
421        assert!((cd.unwrap() - 9.54 / 6.18).abs() < 1e-9);
422    }
423
424    #[test]
425    fn recognises_a_gartley() {
426        let found = HarmonicDetector::default().scan(&gartley_nodes());
427        assert!(found.iter().any(|h| h.name == "gartley"), "{found:?}");
428        let g = found.iter().find(|h| h.name == "gartley").unwrap();
429        assert!(g.bullish, "X is a low, so D resolves upward");
430        assert_eq!(g.state, HarmonicState::Complete);
431    }
432
433    #[test]
434    fn the_tolerance_decides_whether_it_exists() {
435        // 0.60 instead of 0.618 — B at 108.0. Inside a three-point tolerance, outside a tight one.
436        let mut nodes = gartley_nodes();
437        nodes[2].price = 108.0;
438
439        let weit = HarmonicDetector::default();
440        assert!(weit.scan(&nodes).iter().any(|h| h.name == "gartley"));
441
442        let eng = HarmonicDetector::new(HarmonicConfig {
443            ratio_tolerance: 0.0,
444            ..HarmonicConfig::default()
445        });
446        assert!(!eng.scan(&nodes).iter().any(|h| h.name == "gartley"));
447    }
448
449    #[test]
450    fn a_candidate_has_no_d_but_a_zone() {
451        let nodes = &gartley_nodes()[..4];
452        let candidates = HarmonicDetector::default().scan_candidates(nodes);
453        let g = candidates
454            .iter()
455            .find(|h| h.name == "gartley")
456            .expect("candidate");
457        assert_eq!(g.state, HarmonicState::Candidate);
458        assert!(g.d.is_none());
459        assert!(g.prz.width() > 0.0, "a zone, never a line");
460        assert!(
461            g.prz.contains(104.28),
462            "the actual D lies inside: {:?}",
463            g.prz
464        );
465    }
466
467    #[test]
468    fn the_zone_is_never_a_line() {
469        // Degenerate case: all three projections coincide. The floor keeps it a zone.
470        let d = HarmonicDetector::default();
471        let prz = d.prz(&HARMONIC_TABLE[0], 100.0, 120.0, 107.64, 113.82);
472        assert!(prz.width() >= 0.005 * 20.0);
473    }
474
475    #[test]
476    fn a_candidate_completes_and_confirms() {
477        let mut c = HarmonicDetector::default()
478            .scan_candidates(&gartley_nodes()[..4])
479            .into_iter()
480            .find(|h| h.name == "gartley")
481            .expect("candidate");
482
483        // Above the zone: nothing has happened.
484        assert_eq!(
485            c.update_state(&Bar::new(3000, 110.0, 111.0, 109.0, 110.0, 1.0)),
486            HarmonicState::Candidate
487        );
488        // Into the zone.
489        let mitte = (c.prz.low + c.prz.high) / 2.0;
490        assert_eq!(
491            c.update_state(&Bar::new(3600, 106.0, 106.5, mitte, mitte + 0.2, 1.0)),
492            HarmonicState::Complete
493        );
494        // Back out of it upward.
495        assert_eq!(
496            c.update_state(&Bar::new(4200, 106.0, 112.0, 105.8, 111.0, 1.0)),
497            HarmonicState::Confirmed
498        );
499    }
500
501    #[test]
502    fn running_through_the_zone_invalidates() {
503        let mut c = HarmonicDetector::default()
504            .scan_candidates(&gartley_nodes()[..4])
505            .into_iter()
506            .find(|h| h.name == "gartley")
507            .expect("candidate");
508        assert_eq!(
509            c.update_state(&Bar::new(3600, 106.0, 106.5, 90.0, 91.0, 1.0)),
510            HarmonicState::Invalidated
511        );
512        // Terminal: a later recovery does not resurrect it.
513        assert_eq!(
514            c.update_state(&Bar::new(4200, 91.0, 120.0, 91.0, 119.0, 1.0)),
515            HarmonicState::Invalidated
516        );
517    }
518
519    #[test]
520    fn a_butterfly_extends_past_x_and_a_gartley_does_not() {
521        let gartley = HARMONIC_TABLE.iter().find(|d| d.name == "gartley").unwrap();
522        let butterfly = HARMONIC_TABLE
523            .iter()
524            .find(|d| d.name == "butterfly")
525            .unwrap();
526        assert!(gartley.d.max < 1.0, "retracement stays inside XA");
527        assert!(butterfly.d.min > 1.0, "extension runs past X");
528    }
529}