Skip to main content

kestrel_chartkit/indicator/
chart_patterns.rs

1//! Chart pattern framework: swing-based trendline fits, tolerance-checked pattern rules,
2//! confidence ranking, overlap eviction, and a lifecycle state — built on
3//! [`super::zigzag_advanced::ZigZagNode`] sequences (e.g. from
4//! [`super::zigzag_advanced::AdvancedZigZagEngine::nodes`]) rather than re-detecting swings.
5//!
6//! Covers the triangle family (symmetrical, ascending, descending) and its diverging counterpart
7//! the broadening formation, Rising/Falling Wedge, the 1-2-3 Reversal, the Wolfe Wave, the classic
8//! reversal family (double and triple top/bottom, head and shoulders and its inverse), and
9//! auto-fitted trendlines. Each detector is a fixed, documented geometric rule over swing points —
10//! a deterministic approximation of how these patterns are described in TA literature, not a claim
11//! that every instance found is a "real" tradable pattern.
12//!
13//! # Complete is not confirmed
14//!
15//! Two roughly equal highs are not a double top. They become one when the trough between them
16//! breaks. The reversal detectors therefore emit their candidates as [`PatternState::Forming`]
17//! even though every defining node is already in place, and only [`PatternState::Confirmed`] once
18//! price closes through the neckline. That distinction is the whole point of the family: the
19//! shape is visible long before it means anything, and a detector that reported the shape as the
20//! result would encode exactly the misreading these patterns are famous for.
21
22use crate::model::Bar;
23
24use super::zigzag_advanced::ZigZagNode;
25
26/// A two-point price line, usable to project a value at any timestamp.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct TrendLine {
29    pub start: (i64, f64),
30    pub end: (i64, f64),
31}
32
33impl TrendLine {
34    pub fn from_nodes(a: &ZigZagNode, b: &ZigZagNode) -> Self {
35        Self {
36            start: (a.timestamp, a.price),
37            end: (b.timestamp, b.price),
38        }
39    }
40
41    pub fn slope(&self) -> f64 {
42        let dt = (self.end.0 - self.start.0) as f64;
43        if dt == 0.0 {
44            return 0.0;
45        }
46        (self.end.1 - self.start.1) / dt
47    }
48
49    pub fn value_at(&self, timestamp: i64) -> f64 {
50        self.start.1 + self.slope() * (timestamp - self.start.0) as f64
51    }
52}
53
54/// Lifecycle of a detected pattern candidate.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum PatternState {
57    /// Still within its defining swing points; boundary not yet broken.
58    Forming,
59    /// Price broke out through a boundary in the pattern's implied direction.
60    Confirmed,
61    /// Price violated the pattern's structure without a valid breakout (e.g. closed back through
62    /// the opposite boundary first).
63    Invalidated,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ChartPatternKind {
68    /// Symmetrical triangle: both boundaries slope towards each other.
69    Triangle,
70    /// Flat resistance, rising support.
71    AscendingTriangle,
72    /// Flat support, falling resistance.
73    DescendingTriangle,
74    /// Diverging boundaries — the range widens instead of narrowing.
75    Broadening,
76    RisingWedge,
77    FallingWedge,
78    ReversalOneTwoThree,
79    WolfeWave,
80    AutoTrendline,
81    DoubleTop,
82    DoubleBottom,
83    TripleTop,
84    TripleBottom,
85    HeadAndShoulders,
86    InverseHeadAndShoulders,
87}
88
89impl ChartPatternKind {
90    /// Whether this kind resolves downward — a top rather than a bottom.
91    ///
92    /// Only meaningful for the reversal family; the others carry their direction in their lines.
93    fn is_top(self) -> bool {
94        matches!(
95            self,
96            ChartPatternKind::DoubleTop
97                | ChartPatternKind::TripleTop
98                | ChartPatternKind::HeadAndShoulders
99        )
100    }
101
102    /// The reversal family, whose members share one lifecycle rule: break the neckline.
103    fn is_reversal_family(self) -> bool {
104        matches!(
105            self,
106            ChartPatternKind::DoubleTop
107                | ChartPatternKind::DoubleBottom
108                | ChartPatternKind::TripleTop
109                | ChartPatternKind::TripleBottom
110                | ChartPatternKind::HeadAndShoulders
111                | ChartPatternKind::InverseHeadAndShoulders
112        )
113    }
114}
115
116#[derive(Debug, Clone, PartialEq)]
117pub struct ChartPatternCandidate {
118    pub kind: ChartPatternKind,
119    pub nodes: Vec<ZigZagNode>,
120    pub upper_line: Option<TrendLine>,
121    pub lower_line: Option<TrendLine>,
122    pub state: PatternState,
123    /// Heuristic `0.0..=1.0` ranking used for overlap eviction: higher generally means a cleaner
124    /// geometric fit (more converging, more parallel, etc., depending on `kind`).
125    /// 0..1 — wie gut die gefundene Form dem Muster entspricht.
126    ///
127    /// Hier heißt `confidence` zu Recht: es ist die Sicherheit des
128    /// *Erkenners* über eine Form, keine Aussage über den Ausgang eines
129    /// Trades. Nicht zu verwechseln mit dem, was bis 2026-09-08 in
130    /// `scoring::Agreement` und `CompositeSignal` so hieß und dort
131    /// Einigkeit maß — das wurde umbenannt, dieses Feld bewusst nicht.
132    pub confidence: f64,
133}
134
135impl ChartPatternCandidate {
136    fn formed_at(&self) -> i64 {
137        self.nodes.last().map(|n| n.timestamp).unwrap_or(0)
138    }
139
140    fn node_range(&self) -> (i64, i64) {
141        let start = self.nodes.first().map(|n| n.timestamp).unwrap_or(0);
142        let end = self.formed_at();
143        (start, end)
144    }
145
146    /// Advances this candidate's lifecycle given a subsequent bar, and returns the (possibly
147    /// unchanged) resulting state. A no-op once already `Confirmed`/`Invalidated` — those are
148    /// terminal.
149    pub fn update_state(&mut self, bar: &Bar) -> PatternState {
150        if self.state != PatternState::Forming {
151            return self.state;
152        }
153
154        if self.kind.is_reversal_family() {
155            self.state = self.update_reversal_state(bar);
156            return self.state;
157        }
158
159        self.state = match self.kind {
160            ChartPatternKind::Triangle
161            | ChartPatternKind::AscendingTriangle
162            | ChartPatternKind::DescendingTriangle
163            | ChartPatternKind::Broadening
164            | ChartPatternKind::RisingWedge
165            | ChartPatternKind::FallingWedge => match (&self.upper_line, &self.lower_line) {
166                (Some(upper), Some(lower)) => {
167                    if bar.close > upper.value_at(bar.timestamp)
168                        || bar.close < lower.value_at(bar.timestamp)
169                    {
170                        PatternState::Confirmed
171                    } else {
172                        PatternState::Forming
173                    }
174                }
175                _ => PatternState::Forming,
176            },
177            ChartPatternKind::AutoTrendline => match self.upper_line.or(self.lower_line) {
178                Some(line) => {
179                    let is_resistance = self.upper_line.is_some();
180                    let broke = if is_resistance {
181                        bar.close > line.value_at(bar.timestamp)
182                    } else {
183                        bar.close < line.value_at(bar.timestamp)
184                    };
185                    if broke {
186                        PatternState::Confirmed
187                    } else {
188                        PatternState::Forming
189                    }
190                }
191                None => PatternState::Forming,
192            },
193            ChartPatternKind::ReversalOneTwoThree => {
194                let (n2, n3) = (&self.nodes[1], &self.nodes[2]);
195                let bearish = n2.is_high;
196                if bearish {
197                    if bar.close < n3.price {
198                        PatternState::Confirmed
199                    } else if bar.close > n2.price {
200                        PatternState::Invalidated
201                    } else {
202                        PatternState::Forming
203                    }
204                } else if bar.close > n3.price {
205                    PatternState::Confirmed
206                } else if bar.close < n2.price {
207                    PatternState::Invalidated
208                } else {
209                    PatternState::Forming
210                }
211            }
212            ChartPatternKind::DoubleTop
213            | ChartPatternKind::DoubleBottom
214            | ChartPatternKind::TripleTop
215            | ChartPatternKind::TripleBottom
216            | ChartPatternKind::HeadAndShoulders
217            | ChartPatternKind::InverseHeadAndShoulders => unreachable!("handled above"),
218            ChartPatternKind::WolfeWave => {
219                let n5 = self.nodes[4];
220                let target_line = TrendLine::from_nodes(&self.nodes[0], &self.nodes[3]); // line 1-4 projects the target
221                let target = target_line.value_at(bar.timestamp);
222                let reverting_toward_target = if n5.is_high {
223                    bar.close < n5.price && bar.close >= target.min(n5.price)
224                } else {
225                    bar.close > n5.price && bar.close <= target.max(n5.price)
226                };
227                let continuing_past_five = if n5.is_high {
228                    bar.close > n5.price
229                } else {
230                    bar.close < n5.price
231                };
232                if reverting_toward_target {
233                    PatternState::Confirmed
234                } else if continuing_past_five {
235                    PatternState::Invalidated
236                } else {
237                    PatternState::Forming
238                }
239            }
240        };
241
242        self.state
243    }
244}
245
246impl ChartPatternCandidate {
247    /// The reversal family shares one rule: the neckline decides.
248    ///
249    /// Confirmed on a close through the neckline in the pattern's direction; invalidated on a
250    /// close past the pattern's own extreme, which is where the structure it describes stops
251    /// existing. In between it stays `Forming` — complete, and saying nothing yet.
252    fn update_reversal_state(&self, bar: &Bar) -> PatternState {
253        let top = self.kind.is_top();
254        let Some(neckline) = (if top {
255            self.lower_line
256        } else {
257            self.upper_line
258        }) else {
259            return PatternState::Forming;
260        };
261        let level = neckline.value_at(bar.timestamp);
262
263        let extreme = if top {
264            self.nodes
265                .iter()
266                .map(|n| n.price)
267                .fold(f64::NEG_INFINITY, f64::max)
268        } else {
269            self.nodes
270                .iter()
271                .map(|n| n.price)
272                .fold(f64::INFINITY, f64::min)
273        };
274
275        if top {
276            if bar.close < level {
277                PatternState::Confirmed
278            } else if bar.close > extreme {
279                PatternState::Invalidated
280            } else {
281                PatternState::Forming
282            }
283        } else if bar.close > level {
284            PatternState::Confirmed
285        } else if bar.close < extreme {
286            PatternState::Invalidated
287        } else {
288            PatternState::Forming
289        }
290    }
291}
292
293/// Scans a swing-node sequence for pattern candidates and evicts overlapping lower-confidence
294/// ones, so the result is a ranked, non-redundant set rather than every geometrically-possible
295/// match.
296pub struct ChartPatternDetector {
297    pub tolerance_pct: f64,
298    /// How much a boundary may rise or fall across the window and still count as horizontal,
299    /// as a percentage of the window's opening gap between the two boundaries.
300    ///
301    /// Its own threshold rather than a reuse of `tolerance_pct`: that one answers "are these two
302    /// prices the same level", this one answers "is this line flat". Both are decisions, but they
303    /// are not the same decision, and a caller that widens level matching to find more double tops
304    /// should not thereby start seeing ascending triangles everywhere.
305    pub flat_pct: f64,
306}
307
308impl ChartPatternDetector {
309    pub fn new(tolerance_pct: f64) -> Self {
310        Self {
311            tolerance_pct: tolerance_pct.max(0.001),
312            flat_pct: 1.5,
313        }
314    }
315
316    /// Sets the flatness threshold for the ascending/descending triangle boundary.
317    pub fn with_flat_pct(mut self, flat_pct: f64) -> Self {
318        self.flat_pct = flat_pct.max(0.0);
319        self
320    }
321
322    /// Detects all supported pattern kinds over `nodes`, then evicts overlapping candidates
323    /// (sharing any swing node), keeping the highest-confidence one per overlapping cluster.
324    pub fn scan(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
325        let mut candidates = Vec::new();
326        candidates.extend(self.scan_triangles_and_wedges(nodes));
327        candidates.extend(self.scan_reversal_one_two_three(nodes));
328        candidates.extend(self.scan_wolfe_waves(nodes));
329        candidates.extend(self.scan_double_extremes(nodes));
330        candidates.extend(self.scan_triple_extremes(nodes));
331        candidates.extend(self.scan_head_and_shoulders(nodes));
332        if let Some(trendline) = self.auto_trendline(nodes, true) {
333            candidates.push(trendline);
334        }
335        if let Some(trendline) = self.auto_trendline(nodes, false) {
336            candidates.push(trendline);
337        }
338        self.evict_overlaps(candidates)
339    }
340
341    /// Evicts overlapping candidates *within the same pattern kind* only (sliding-window
342    /// detection naturally produces redundant near-duplicates of one kind over the same swing
343    /// points). Different kinds describe different information and are allowed to coexist over
344    /// the same nodes — e.g. a 1-2-3 reversal and an unrelated 2-point auto-trendline spanning the
345    /// same three nodes are not "competing" for the same signal.
346    fn evict_overlaps(&self, candidates: Vec<ChartPatternCandidate>) -> Vec<ChartPatternCandidate> {
347        let mut by_kind: Vec<(ChartPatternKind, Vec<ChartPatternCandidate>)> = Vec::new();
348        for candidate in candidates {
349            match by_kind.iter_mut().find(|(k, _)| *k == candidate.kind) {
350                Some((_, group)) => group.push(candidate),
351                None => by_kind.push((candidate.kind, vec![candidate])),
352            }
353        }
354
355        let mut kept: Vec<ChartPatternCandidate> = Vec::new();
356        for (_, mut group) in by_kind {
357            group.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
358            'outer: for candidate in group {
359                let (c_start, c_end) = candidate.node_range();
360                for existing in &kept {
361                    if existing.kind != candidate.kind {
362                        continue;
363                    }
364                    let (e_start, e_end) = existing.node_range();
365                    let overlaps = c_start <= e_end && e_start <= c_end;
366                    if overlaps {
367                        continue 'outer;
368                    }
369                }
370                kept.push(candidate);
371            }
372        }
373        kept.sort_by_key(|c| c.formed_at());
374        kept
375    }
376
377    /// Triangles/wedges need 4 alternating swing nodes (H,L,H,L or L,H,L,H): an upper line through
378    /// the two highs, a lower line through the two lows.
379    ///
380    /// # Why the flat cases are their own kinds
381    ///
382    /// A symmetrical triangle, an ascending triangle and a descending triangle are all "two
383    /// converging boundaries", but they are not the same condition: one boundary being horizontal
384    /// is an *additional* requirement, and the literature reads the three differently. Folding
385    /// them into one kind would report the narrower case under the wider name — the caller could
386    /// not tell whether a horizontal boundary was found or merely not ruled out.
387    ///
388    /// The broadening formation is the same window scan with the convergence test reversed. It
389    /// shares the scan because it shares the geometry: four alternating nodes, two lines. Only the
390    /// sign of the gap change differs.
391    fn scan_triangles_and_wedges(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
392        let mut out = Vec::new();
393        for window in nodes.windows(4) {
394            let alternating = window.windows(2).all(|p| p[0].is_high != p[1].is_high);
395            if !alternating {
396                continue;
397            }
398            let highs: Vec<&ZigZagNode> = window.iter().filter(|n| n.is_high).collect();
399            let lows: Vec<&ZigZagNode> = window.iter().filter(|n| !n.is_high).collect();
400            if highs.len() != 2 || lows.len() != 2 {
401                continue;
402            }
403
404            let upper = TrendLine::from_nodes(highs[0], highs[1]);
405            let lower = TrendLine::from_nodes(lows[0], lows[1]);
406
407            let (start_ts, end_ts) = (
408                window.first().unwrap().timestamp,
409                window.last().unwrap().timestamp,
410            );
411            let gap_start = upper.value_at(start_ts) - lower.value_at(start_ts);
412            let gap_end = upper.value_at(end_ts) - lower.value_at(end_ts);
413            if gap_start <= 0.0 || gap_end <= 0.0 {
414                continue; // the lines cross — not a channel-shaped window at all
415            }
416
417            // Flatness as the rise *across the window*, relative to the gap the window opens
418            // with — both prices, so the ratio is dimensionless. Comparing `slope()` to a price
419            // gap instead, as this did before, compares a price-per-millisecond to a price: on
420            // real millisecond timestamps every line comes out flat.
421            let flat_tol = self.flat_pct / 100.0;
422            let rise = |line: &TrendLine| (line.value_at(end_ts) - line.value_at(start_ts)).abs();
423            let upper_flat = rise(&upper) / gap_start.max(1e-9) < flat_tol;
424            let lower_flat = rise(&lower) / gap_start.max(1e-9) < flat_tol;
425
426            // The diverging case first: a broadening formation is the one shape here that widens.
427            // It needs both boundaries to actually move apart — a single sloping line against a
428            // flat one widens too, but that is a right-angled variant and reads as a triangle
429            // turned around, so it is held to the same "both sides move" bar as the wedges.
430            if gap_end > gap_start {
431                if upper_flat || lower_flat || upper.slope() <= 0.0 || lower.slope() >= 0.0 {
432                    continue;
433                }
434                let divergence = 1.0 - (gap_start / gap_end);
435                out.push(ChartPatternCandidate {
436                    kind: ChartPatternKind::Broadening,
437                    nodes: window.to_vec(),
438                    upper_line: Some(upper),
439                    lower_line: Some(lower),
440                    state: PatternState::Forming,
441                    confidence: divergence.clamp(0.0, 1.0),
442                });
443                continue;
444            }
445
446            if gap_end >= gap_start {
447                continue; // parallel: a channel, and that is not this detector's business
448            }
449
450            let convergence = 1.0 - (gap_end / gap_start);
451
452            // Order matters: the flat cases are the narrower conditions and are tested first, or
453            // a flat-topped triangle would be reported as symmetrical.
454            let kind = if upper.slope() > 0.0 && lower.slope() > 0.0 {
455                ChartPatternKind::RisingWedge
456            } else if upper.slope() < 0.0 && lower.slope() < 0.0 {
457                ChartPatternKind::FallingWedge
458            } else if upper_flat && lower.slope() > 0.0 {
459                ChartPatternKind::AscendingTriangle
460            } else if lower_flat && upper.slope() < 0.0 {
461                ChartPatternKind::DescendingTriangle
462            } else if upper.slope() <= 0.0 && lower.slope() >= 0.0 {
463                ChartPatternKind::Triangle
464            } else {
465                continue;
466            };
467
468            out.push(ChartPatternCandidate {
469                kind,
470                nodes: window.to_vec(),
471                upper_line: Some(upper),
472                lower_line: Some(lower),
473                state: PatternState::Forming,
474                confidence: convergence.clamp(0.0, 1.0),
475            });
476        }
477        out
478    }
479
480    /// A 1-2-3 reversal: three consecutive nodes where the middle one is a failed extreme (did
481    /// not extend the trend) and the third breaks past the first's level in the opposite
482    /// direction — e.g. bearish: low(1) < high(2) fails to make a new high vs. the prior trend,
483    /// then low(3) < low(1).
484    fn scan_reversal_one_two_three(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
485        let mut out = Vec::new();
486        for window in nodes.windows(3) {
487            let (n1, n2, n3) = (&window[0], &window[1], &window[2]);
488            if n1.is_high == n2.is_high || n2.is_high == n3.is_high {
489                continue;
490            }
491
492            let bearish = !n1.is_high && n2.is_high && !n3.is_high && n3.price < n1.price;
493            let bullish = n1.is_high && !n2.is_high && n3.is_high && n3.price > n1.price;
494            if !bearish && !bullish {
495                continue;
496            }
497
498            let magnitude = (n3.price - n1.price).abs() / n1.price.abs().max(1e-9);
499            out.push(ChartPatternCandidate {
500                kind: ChartPatternKind::ReversalOneTwoThree,
501                nodes: window.to_vec(),
502                upper_line: None,
503                lower_line: None,
504                state: PatternState::Forming,
505                confidence: magnitude.min(1.0),
506            });
507        }
508        out
509    }
510
511    /// How close two prices have to be to count as "the same level" here.
512    ///
513    /// `tolerance_pct` is a percentage, as everywhere else in this module. It is the single number
514    /// that decides how many of these patterns exist: at two percent one finds few double tops, at
515    /// eight percent many. A statement about their frequency that omits it says nothing.
516    fn within_tolerance(&self, a: f64, b: f64, scale: f64) -> bool {
517        (a - b).abs() <= self.tolerance_pct / 100.0 * scale.abs().max(1e-9)
518    }
519
520    /// How well two prices match, as `0.0..=1.0` — the confidence of the equal-level family.
521    fn level_match(&self, a: f64, b: f64, scale: f64) -> f64 {
522        let allowed = self.tolerance_pct / 100.0 * scale.abs().max(1e-9);
523        if allowed <= 0.0 {
524            return 0.0;
525        }
526        (1.0 - (a - b).abs() / allowed).clamp(0.0, 1.0)
527    }
528
529    /// Double top and bottom: two extremes at roughly the same level with one counter-swing
530    /// between them.
531    ///
532    /// The counter-swing has to be more than the level tolerance away, otherwise three points of
533    /// noise on one level would qualify. The neckline is that middle node, held horizontally —
534    /// its break is what turns two equal highs into a double top.
535    fn scan_double_extremes(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
536        let mut out = Vec::new();
537        for window in nodes.windows(3) {
538            let (n1, n2, n3) = (&window[0], &window[1], &window[2]);
539            if n1.is_high != n3.is_high || n1.is_high == n2.is_high {
540                continue;
541            }
542            if !self.within_tolerance(n1.price, n3.price, n1.price) {
543                continue;
544            }
545            if self.within_tolerance(n1.price, n2.price, n1.price) {
546                continue;
547            }
548
549            let neckline = TrendLine::from_nodes(n2, n2);
550            let (kind, upper_line, lower_line) = if n1.is_high {
551                (ChartPatternKind::DoubleTop, None, Some(neckline))
552            } else {
553                (ChartPatternKind::DoubleBottom, Some(neckline), None)
554            };
555
556            out.push(ChartPatternCandidate {
557                kind,
558                nodes: window.to_vec(),
559                upper_line,
560                lower_line,
561                state: PatternState::Forming,
562                confidence: self.level_match(n1.price, n3.price, n1.price),
563            });
564        }
565        out
566    }
567
568    /// Triple top and bottom: three extremes on one level, two counter-swings between them.
569    ///
570    /// The neckline is the *further* of the two counter-swings — the lower trough for a top. The
571    /// nearer one breaking is not yet the pattern; taking the conservative level keeps
572    /// `Confirmed` meaning the same thing it means for the double.
573    fn scan_triple_extremes(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
574        let mut out = Vec::new();
575        for window in nodes.windows(5) {
576            let alternating = window.windows(2).all(|p| p[0].is_high != p[1].is_high);
577            if !alternating {
578                continue;
579            }
580            let (n1, n3, n5) = (&window[0], &window[2], &window[4]);
581            if !self.within_tolerance(n1.price, n3.price, n1.price)
582                || !self.within_tolerance(n1.price, n5.price, n1.price)
583            {
584                continue;
585            }
586            let (n2, n4) = (&window[1], &window[3]);
587            if self.within_tolerance(n1.price, n2.price, n1.price) {
588                continue;
589            }
590
591            let conservative = if n1.is_high {
592                if n2.price <= n4.price {
593                    n2
594                } else {
595                    n4
596                }
597            } else if n2.price >= n4.price {
598                n2
599            } else {
600                n4
601            };
602            let neckline = TrendLine::from_nodes(conservative, conservative);
603            let (kind, upper_line, lower_line) = if n1.is_high {
604                (ChartPatternKind::TripleTop, None, Some(neckline))
605            } else {
606                (ChartPatternKind::TripleBottom, Some(neckline), None)
607            };
608
609            let fit = self.level_match(n1.price, n3.price, n1.price)
610                * self.level_match(n1.price, n5.price, n1.price);
611            out.push(ChartPatternCandidate {
612                kind,
613                nodes: window.to_vec(),
614                upper_line,
615                lower_line,
616                state: PatternState::Forming,
617                confidence: fit,
618            });
619        }
620        out
621    }
622
623    /// Head and shoulders and its inverse: five alternating nodes whose middle extreme overshoots
624    /// both its neighbours of the same type, which sit at roughly one level.
625    ///
626    /// The neckline is the line through the two counter-swings and is deliberately *not* forced
627    /// horizontal — a sloping neckline is the common case, and flattening it would move the
628    /// confirmation level.
629    ///
630    /// Note what is not required: that the right shoulder be "well formed". It bears no weight.
631    /// The pattern completes there and is confirmed only at the neckline.
632    fn scan_head_and_shoulders(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
633        let mut out = Vec::new();
634        for window in nodes.windows(5) {
635            let alternating = window.windows(2).all(|p| p[0].is_high != p[1].is_high);
636            if !alternating {
637                continue;
638            }
639            let (n1, n2, n3, n4, n5) = (&window[0], &window[1], &window[2], &window[3], &window[4]);
640
641            let head_overshoots = if n1.is_high {
642                n3.price > n1.price && n3.price > n5.price
643            } else {
644                n3.price < n1.price && n3.price < n5.price
645            };
646            if !head_overshoots {
647                continue;
648            }
649            if !self.within_tolerance(n1.price, n5.price, n3.price) {
650                continue;
651            }
652            if !self.within_tolerance(n2.price, n4.price, n3.price) {
653                continue;
654            }
655
656            let neckline = TrendLine::from_nodes(n2, n4);
657            let (kind, upper_line, lower_line) = if n1.is_high {
658                (ChartPatternKind::HeadAndShoulders, None, Some(neckline))
659            } else {
660                (
661                    ChartPatternKind::InverseHeadAndShoulders,
662                    Some(neckline),
663                    None,
664                )
665            };
666
667            let shoulders = self.level_match(n1.price, n5.price, n3.price);
668            let necks = self.level_match(n2.price, n4.price, n3.price);
669            out.push(ChartPatternCandidate {
670                kind,
671                nodes: window.to_vec(),
672                upper_line,
673                lower_line,
674                state: PatternState::Forming,
675                confidence: shoulders * necks,
676            });
677        }
678        out
679    }
680
681    /// A Wolfe Wave: 5 alternating points where line(1-3) and line(2-4) are roughly parallel and
682    /// point 5 pierces the line(1-3) extension.
683    fn scan_wolfe_waves(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
684        let mut out = Vec::new();
685        for window in nodes.windows(5) {
686            let alternating = window.windows(2).all(|p| p[0].is_high != p[1].is_high);
687            if !alternating {
688                continue;
689            }
690            let (n1, n2, n3, n4, n5) = (&window[0], &window[1], &window[2], &window[3], &window[4]);
691
692            let line13 = TrendLine::from_nodes(n1, n3);
693            let line24 = TrendLine::from_nodes(n2, n4);
694
695            let scale = (n1.price.abs() + n3.price.abs()).max(1e-9);
696            let slope_diff = (line13.slope() - line24.slope()).abs() / scale;
697            let parallel_tol = self.tolerance_pct / 100.0 * 5.0;
698            if slope_diff > parallel_tol {
699                continue;
700            }
701
702            let projected13_at5 = line13.value_at(n5.timestamp);
703            let pierces = if n1.is_high {
704                // 1,3,5 are lows (bullish Wolfe): point 5 must undercut the 1-3 extension.
705                !n5.is_high && n5.price < projected13_at5
706            } else {
707                n5.is_high && n5.price > projected13_at5
708            };
709            if !pierces {
710                continue;
711            }
712
713            let confidence = (1.0 - slope_diff / parallel_tol.max(1e-9)).clamp(0.0, 1.0);
714            out.push(ChartPatternCandidate {
715                kind: ChartPatternKind::WolfeWave,
716                nodes: window.to_vec(),
717                upper_line: Some(if n1.is_high { line24 } else { line13 }),
718                lower_line: Some(if n1.is_high { line13 } else { line24 }),
719                state: PatternState::Forming,
720                confidence,
721            });
722        }
723        out
724    }
725
726    /// Auto-fits the best trendline through same-type swing points: the oldest and newest node of
727    /// that type, valid only if no intermediate node of the same type violates it (a resistance
728    /// line no high pierces, or a support line no low pierces).
729    fn auto_trendline(
730        &self,
731        nodes: &[ZigZagNode],
732        for_highs: bool,
733    ) -> Option<ChartPatternCandidate> {
734        let same_type: Vec<&ZigZagNode> = nodes.iter().filter(|n| n.is_high == for_highs).collect();
735        if same_type.len() < 2 {
736            return None;
737        }
738        let first = *same_type.first().unwrap();
739        let last = *same_type.last().unwrap();
740        let line = TrendLine::from_nodes(first, last);
741
742        let violated = same_type.iter().any(|n| {
743            let projected = line.value_at(n.timestamp);
744            if for_highs {
745                n.price > projected * (1.0 + self.tolerance_pct / 100.0)
746            } else {
747                n.price < projected * (1.0 - self.tolerance_pct / 100.0)
748            }
749        });
750        if violated {
751            return None;
752        }
753
754        let touches = same_type.len();
755        let confidence = ((touches as f64 - 2.0) / 4.0 + 0.5).clamp(0.0, 1.0);
756
757        Some(ChartPatternCandidate {
758            kind: ChartPatternKind::AutoTrendline,
759            nodes: same_type.into_iter().copied().collect(),
760            upper_line: for_highs.then_some(line),
761            lower_line: (!for_highs).then_some(line),
762            state: PatternState::Forming,
763            confidence,
764        })
765    }
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771
772    fn node(ts: i64, price: f64, is_high: bool) -> ZigZagNode {
773        ZigZagNode {
774            timestamp: ts,
775            price,
776            is_high,
777            confirmed: true,
778        }
779    }
780
781    #[test]
782    fn test_trendline_value_at_interpolates() {
783        let a = node(0, 100.0, true);
784        let b = node(100, 200.0, true);
785        let line = TrendLine::from_nodes(&a, &b);
786        assert!((line.value_at(50) - 150.0).abs() < 1e-9);
787    }
788
789    #[test]
790    fn test_detects_converging_triangle() {
791        let nodes = vec![
792            node(0, 110.0, true),
793            node(10, 90.0, false),
794            node(20, 105.0, true),
795            node(30, 95.0, false),
796        ];
797        let detector = ChartPatternDetector::new(50.0);
798        let candidates = detector.scan(&nodes);
799        assert!(candidates
800            .iter()
801            .any(|c| c.kind == ChartPatternKind::Triangle));
802    }
803
804    #[test]
805    fn test_ascending_triangle_needs_a_flat_top() {
806        // Upper boundary 110 → 110 (flat), lower 90 → 100 (rising): the gap narrows from one
807        // side only. That is the defining asymmetry.
808        let nodes = vec![
809            node(0, 110.0, true),
810            node(10, 90.0, false),
811            node(20, 110.0, true),
812            node(30, 100.0, false),
813        ];
814        let candidates = ChartPatternDetector::new(2.0).scan(&nodes);
815        assert!(candidates
816            .iter()
817            .any(|c| c.kind == ChartPatternKind::AscendingTriangle));
818    }
819
820    #[test]
821    fn test_descending_triangle_mirrors() {
822        let nodes = vec![
823            node(0, 90.0, false),
824            node(10, 110.0, true),
825            node(20, 90.0, false),
826            node(30, 100.0, true),
827        ];
828        let candidates = ChartPatternDetector::new(2.0).scan(&nodes);
829        assert!(candidates
830            .iter()
831            .any(|c| c.kind == ChartPatternKind::DescendingTriangle));
832    }
833
834    #[test]
835    fn test_a_sloping_top_is_symmetrical_not_ascending() {
836        // Same shape as the ascending case, but the top drops 110 → 104. Nothing here is
837        // horizontal, so the narrower name must not be used.
838        let nodes = vec![
839            node(0, 110.0, true),
840            node(10, 90.0, false),
841            node(20, 104.0, true),
842            node(30, 100.0, false),
843        ];
844        let candidates = ChartPatternDetector::new(2.0).scan(&nodes);
845        assert!(candidates
846            .iter()
847            .any(|c| c.kind == ChartPatternKind::Triangle));
848        assert!(!candidates
849            .iter()
850            .any(|c| c.kind == ChartPatternKind::AscendingTriangle));
851    }
852
853    #[test]
854    fn test_detects_broadening_formation() {
855        // Highs rise, lows fall: the range widens. The converging detectors must stay silent.
856        let nodes = vec![
857            node(0, 105.0, true),
858            node(10, 95.0, false),
859            node(20, 115.0, true),
860            node(30, 85.0, false),
861        ];
862        let candidates = ChartPatternDetector::new(2.0).scan(&nodes);
863        assert!(candidates
864            .iter()
865            .any(|c| c.kind == ChartPatternKind::Broadening));
866        assert!(!candidates.iter().any(|c| matches!(
867            c.kind,
868            ChartPatternKind::Triangle
869                | ChartPatternKind::RisingWedge
870                | ChartPatternKind::FallingWedge
871        )));
872    }
873
874    #[test]
875    fn test_broadening_confirms_on_breakout() {
876        let nodes = vec![
877            node(0, 105.0, true),
878            node(10, 95.0, false),
879            node(20, 115.0, true),
880            node(30, 85.0, false),
881        ];
882        let mut candidate = ChartPatternDetector::new(2.0)
883            .scan(&nodes)
884            .into_iter()
885            .find(|c| c.kind == ChartPatternKind::Broadening)
886            .expect("broadening candidate");
887        assert_eq!(candidate.state, PatternState::Forming);
888
889        let inside = Bar::new(40, 100.0, 100.0, 100.0, 100.0, 0.0);
890        assert_eq!(candidate.update_state(&inside), PatternState::Forming);
891
892        // The threshold moves away from price as the window is extrapolated — that is inherent
893        // to a diverging boundary, not an artefact: at ts 50 the upper line already projects 130.
894        let above = Bar::new(50, 145.0, 145.0, 145.0, 145.0, 0.0);
895        assert_eq!(candidate.update_state(&above), PatternState::Confirmed);
896    }
897
898    #[test]
899    fn test_detects_bearish_one_two_three_reversal() {
900        let nodes = vec![
901            node(0, 100.0, false),
902            node(10, 110.0, true),
903            node(20, 95.0, false),
904        ];
905        let detector = ChartPatternDetector::new(1.0);
906        let candidates = detector.scan(&nodes);
907        assert!(candidates
908            .iter()
909            .any(|c| c.kind == ChartPatternKind::ReversalOneTwoThree));
910    }
911
912    #[test]
913    fn test_auto_trendline_rejects_violated_support() {
914        // Three lows: a line from the first (100) to the last (95) projects ~97.5 at t=10, but
915        // the middle low dips to 90 -- well below that line, violating it as a support trendline.
916        let nodes = vec![
917            node(0, 100.0, false),
918            node(5, 105.0, true),
919            node(10, 90.0, false),
920            node(15, 102.0, true),
921            node(20, 95.0, false),
922        ];
923        let detector = ChartPatternDetector::new(0.1);
924        let candidates = detector.scan(&nodes);
925        assert!(!candidates.iter().any(
926            |c| c.kind == ChartPatternKind::AutoTrendline && c.nodes.iter().all(|n| !n.is_high)
927        ));
928    }
929
930    #[test]
931    fn test_evict_overlaps_keeps_only_highest_confidence_within_same_kind() {
932        // A longer alternating sequence so multiple overlapping 4-node triangle/wedge windows
933        // are genuinely detected and compete against each other for eviction.
934        let nodes = vec![
935            node(0, 130.0, true),
936            node(10, 70.0, false),
937            node(20, 120.0, true),
938            node(30, 80.0, false),
939            node(40, 110.0, true),
940            node(50, 90.0, false),
941        ];
942        let detector = ChartPatternDetector::new(50.0);
943        let candidates = detector.scan(&nodes);
944
945        // Within any single kind, no two surviving candidates may share a node timestamp range.
946        for kind in [
947            ChartPatternKind::Triangle,
948            ChartPatternKind::RisingWedge,
949            ChartPatternKind::FallingWedge,
950        ] {
951            let same_kind: Vec<&ChartPatternCandidate> =
952                candidates.iter().filter(|c| c.kind == kind).collect();
953            for (i, a) in same_kind.iter().enumerate() {
954                for b in same_kind.iter().skip(i + 1) {
955                    let (a_start, a_end) = a.node_range();
956                    let (b_start, b_end) = b.node_range();
957                    assert!(
958                        a_end < b_start || b_end < a_start,
959                        "overlapping candidates of the same kind must have been evicted"
960                    );
961                }
962            }
963        }
964
965        // Different kinds are allowed to overlap (e.g. an auto-trendline and a triangle sharing
966        // nodes describe different information), so the result set is non-empty and mixed.
967        assert!(!candidates.is_empty());
968    }
969
970    #[test]
971    fn test_triangle_confirms_on_breakout() {
972        let nodes = vec![
973            node(0, 110.0, true),
974            node(10, 90.0, false),
975            node(20, 105.0, true),
976            node(30, 95.0, false),
977        ];
978        let detector = ChartPatternDetector::new(50.0);
979        let mut candidates = detector.scan(&nodes);
980        let triangle = candidates
981            .iter_mut()
982            .find(|c| c.kind == ChartPatternKind::Triangle)
983            .unwrap();
984
985        // Still inside both lines: stays Forming.
986        let inside = Bar::new(35, 100.0, 100.5, 99.5, 100.0, 1.0);
987        assert_eq!(triangle.update_state(&inside), PatternState::Forming);
988
989        // Breaks decisively above the upper line.
990        let breakout = Bar::new(40, 130.0, 130.5, 129.5, 130.0, 1.0);
991        assert_eq!(triangle.update_state(&breakout), PatternState::Confirmed);
992
993        // Terminal: a later bar cannot change a Confirmed pattern back to Forming.
994        let after = Bar::new(50, 50.0, 50.5, 49.5, 50.0, 1.0);
995        assert_eq!(triangle.update_state(&after), PatternState::Confirmed);
996    }
997
998    #[test]
999    fn test_reversal_one_two_three_confirms_and_invalidates() {
1000        let confirm_nodes = vec![
1001            node(0, 100.0, false),
1002            node(10, 110.0, true),
1003            node(20, 95.0, false),
1004        ];
1005        let mut confirm_candidate = ChartPatternDetector::new(1.0)
1006            .scan(&confirm_nodes)
1007            .into_iter()
1008            .find(|c| c.kind == ChartPatternKind::ReversalOneTwoThree)
1009            .unwrap();
1010        let breaks_below_n3 = Bar::new(30, 90.0, 90.5, 89.5, 90.0, 1.0);
1011        assert_eq!(
1012            confirm_candidate.update_state(&breaks_below_n3),
1013            PatternState::Confirmed
1014        );
1015
1016        let mut invalidate_candidate = ChartPatternDetector::new(1.0)
1017            .scan(&confirm_nodes)
1018            .into_iter()
1019            .find(|c| c.kind == ChartPatternKind::ReversalOneTwoThree)
1020            .unwrap();
1021        let reclaims_above_n2 = Bar::new(30, 115.0, 115.5, 114.5, 115.0, 1.0);
1022        assert_eq!(
1023            invalidate_candidate.update_state(&reclaims_above_n2),
1024            PatternState::Invalidated
1025        );
1026    }
1027
1028    // -----------------------------------------------------------------------
1029    // Die Umkehrfamilie
1030    // -----------------------------------------------------------------------
1031
1032    /// Zwei annähernd gleich hohe Hochs, ein Zwischentief.
1033    fn doppeltop_nodes() -> Vec<ZigZagNode> {
1034        vec![
1035            node(0, 118.0, true),
1036            node(1200, 108.0, false),
1037            node(2400, 117.4, true),
1038        ]
1039    }
1040
1041    #[test]
1042    fn test_double_top_is_forming_until_the_neckline_breaks() {
1043        // Der Kernsatz der Familie: Die Formation ist vollständig und sagt trotzdem nichts.
1044        let detector = ChartPatternDetector::new(2.0);
1045        let mut candidate = detector
1046            .scan(&doppeltop_nodes())
1047            .into_iter()
1048            .find(|c| c.kind == ChartPatternKind::DoubleTop)
1049            .expect("double top detected");
1050        assert_eq!(candidate.state, PatternState::Forming);
1051
1052        // Über dem Zwischentief bleibt es beim Zustand.
1053        assert_eq!(
1054            candidate.update_state(&Bar::new(3000, 112.0, 113.0, 111.0, 112.0, 1.0)),
1055            PatternState::Forming
1056        );
1057        // Erst der Schluss darunter bestätigt.
1058        assert_eq!(
1059            candidate.update_state(&Bar::new(3600, 109.0, 109.5, 107.0, 107.5, 1.0)),
1060            PatternState::Confirmed
1061        );
1062    }
1063
1064    #[test]
1065    fn test_double_top_invalidates_above_its_own_extreme() {
1066        let detector = ChartPatternDetector::new(2.0);
1067        let mut candidate = detector
1068            .scan(&doppeltop_nodes())
1069            .into_iter()
1070            .find(|c| c.kind == ChartPatternKind::DoubleTop)
1071            .expect("double top detected");
1072        assert_eq!(
1073            candidate.update_state(&Bar::new(3000, 118.0, 120.0, 117.0, 119.0, 1.0)),
1074            PatternState::Invalidated
1075        );
1076    }
1077
1078    #[test]
1079    fn test_double_top_needs_the_two_highs_to_match() {
1080        // Bei enger Toleranz sind 118 und 117,4 nicht mehr dasselbe Niveau — dieselbe Pivotfolge,
1081        // ein anderes Ergebnis. Genau das ist der Ermessensspielraum.
1082        let eng = ChartPatternDetector::new(0.1);
1083        assert!(!eng
1084            .scan(&doppeltop_nodes())
1085            .iter()
1086            .any(|c| c.kind == ChartPatternKind::DoubleTop));
1087    }
1088
1089    #[test]
1090    fn test_double_bottom_mirrors() {
1091        let nodes = vec![
1092            node(0, 90.0, false),
1093            node(1200, 100.0, true),
1094            node(2400, 90.5, false),
1095        ];
1096        let mut candidate = ChartPatternDetector::new(2.0)
1097            .scan(&nodes)
1098            .into_iter()
1099            .find(|c| c.kind == ChartPatternKind::DoubleBottom)
1100            .expect("double bottom detected");
1101        assert_eq!(
1102            candidate.update_state(&Bar::new(3000, 100.5, 102.0, 100.0, 101.5, 1.0)),
1103            PatternState::Confirmed
1104        );
1105    }
1106
1107    #[test]
1108    fn test_triple_top_uses_the_lower_trough_as_neckline() {
1109        // Das nähere Zwischentief zu nehmen wäre großzügiger — und „bestätigt" hieße dann bei
1110        // Dreifach etwas anderes als bei Doppel.
1111        let nodes = vec![
1112            node(0, 120.0, true),
1113            node(600, 112.0, false),
1114            node(1200, 119.5, true),
1115            node(1800, 108.0, false),
1116            node(2400, 120.4, true),
1117        ];
1118        let mut candidate = ChartPatternDetector::new(2.0)
1119            .scan(&nodes)
1120            .into_iter()
1121            .find(|c| c.kind == ChartPatternKind::TripleTop)
1122            .expect("triple top detected");
1123
1124        // Unter dem höheren, aber über dem tieferen Zwischentief: noch nicht bestätigt.
1125        assert_eq!(
1126            candidate.update_state(&Bar::new(3000, 111.0, 111.5, 110.0, 110.0, 1.0)),
1127            PatternState::Forming
1128        );
1129        assert_eq!(
1130            candidate.update_state(&Bar::new(3600, 109.0, 109.2, 107.0, 107.4, 1.0)),
1131            PatternState::Confirmed
1132        );
1133    }
1134
1135    /// Fünf Pivots: Hoch, Tief, höheres Hoch, Tief, ähnlich hohes Hoch.
1136    fn sks_nodes() -> Vec<ZigZagNode> {
1137        vec![
1138            node(0, 112.0, true),
1139            node(600, 104.0, false),
1140            node(1200, 124.0, true),
1141            node(1800, 103.4, false),
1142            node(2400, 111.6, true),
1143        ]
1144    }
1145
1146    #[test]
1147    fn test_head_and_shoulders_confirms_on_the_sloping_neckline() {
1148        let mut candidate = ChartPatternDetector::new(2.0)
1149            .scan(&sks_nodes())
1150            .into_iter()
1151            .find(|c| c.kind == ChartPatternKind::HeadAndShoulders)
1152            .expect("head and shoulders detected");
1153        assert_eq!(
1154            candidate.state,
1155            PatternState::Forming,
1156            "die rechte Schulter bestätigt nichts"
1157        );
1158
1159        // Die Nackenlinie fällt von 104 bei t=600 auf 103,4 bei t=1800: −0,0005 je Zeiteinheit.
1160        // Bei t=3000 liegt sie damit bei 102,8.
1161        let neckline = candidate.lower_line.expect("neckline");
1162        assert!((neckline.value_at(3000) - 102.8).abs() < 1e-9);
1163
1164        assert_eq!(
1165            candidate.update_state(&Bar::new(3000, 103.5, 103.6, 103.0, 103.2, 1.0)),
1166            PatternState::Forming,
1167            "über der Linie, obwohl unter dem tieferen Zwischentief"
1168        );
1169        assert_eq!(
1170            candidate.update_state(&Bar::new(3600, 103.0, 103.1, 101.0, 101.5, 1.0)),
1171            PatternState::Confirmed
1172        );
1173    }
1174
1175    #[test]
1176    fn test_head_and_shoulders_needs_a_head() {
1177        // Ohne überragendes mittleres Hoch bleibt es eine Folge von drei Hochs.
1178        let nodes = vec![
1179            node(0, 112.0, true),
1180            node(600, 104.0, false),
1181            node(1200, 111.0, true),
1182            node(1800, 103.4, false),
1183            node(2400, 111.6, true),
1184        ];
1185        assert!(!ChartPatternDetector::new(2.0)
1186            .scan(&nodes)
1187            .iter()
1188            .any(|c| c.kind == ChartPatternKind::HeadAndShoulders));
1189    }
1190
1191    #[test]
1192    fn test_inverse_head_and_shoulders_mirrors() {
1193        let nodes = vec![
1194            node(0, 98.0, false),
1195            node(600, 106.0, true),
1196            node(1200, 86.0, false),
1197            node(1800, 106.6, true),
1198            node(2400, 98.4, false),
1199        ];
1200        let mut candidate = ChartPatternDetector::new(2.0)
1201            .scan(&nodes)
1202            .into_iter()
1203            .find(|c| c.kind == ChartPatternKind::InverseHeadAndShoulders)
1204            .expect("inverse head and shoulders detected");
1205        assert_eq!(
1206            candidate.update_state(&Bar::new(3000, 106.0, 108.0, 105.5, 107.5, 1.0)),
1207            PatternState::Confirmed
1208        );
1209    }
1210}