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 Triangle, Rising/Falling Wedge, the 1-2-3 Reversal, the Wolfe Wave, and auto-fitted
7//! trendlines. Each detector is a fixed, documented geometric rule over swing points — a
8//! deterministic approximation of how these patterns are described in TA literature, not a claim
9//! that every instance found is a "real" tradable pattern.
10
11use crate::model::Bar;
12
13use super::zigzag_advanced::ZigZagNode;
14
15/// A two-point price line, usable to project a value at any timestamp.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct TrendLine {
18    pub start: (i64, f64),
19    pub end: (i64, f64),
20}
21
22impl TrendLine {
23    pub fn from_nodes(a: &ZigZagNode, b: &ZigZagNode) -> Self {
24        Self {
25            start: (a.timestamp, a.price),
26            end: (b.timestamp, b.price),
27        }
28    }
29
30    pub fn slope(&self) -> f64 {
31        let dt = (self.end.0 - self.start.0) as f64;
32        if dt == 0.0 {
33            return 0.0;
34        }
35        (self.end.1 - self.start.1) / dt
36    }
37
38    pub fn value_at(&self, timestamp: i64) -> f64 {
39        self.start.1 + self.slope() * (timestamp - self.start.0) as f64
40    }
41}
42
43/// Lifecycle of a detected pattern candidate.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum PatternState {
46    /// Still within its defining swing points; boundary not yet broken.
47    Forming,
48    /// Price broke out through a boundary in the pattern's implied direction.
49    Confirmed,
50    /// Price violated the pattern's structure without a valid breakout (e.g. closed back through
51    /// the opposite boundary first).
52    Invalidated,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ChartPatternKind {
57    Triangle,
58    RisingWedge,
59    FallingWedge,
60    ReversalOneTwoThree,
61    WolfeWave,
62    AutoTrendline,
63}
64
65#[derive(Debug, Clone, PartialEq)]
66pub struct ChartPatternCandidate {
67    pub kind: ChartPatternKind,
68    pub nodes: Vec<ZigZagNode>,
69    pub upper_line: Option<TrendLine>,
70    pub lower_line: Option<TrendLine>,
71    pub state: PatternState,
72    /// Heuristic `0.0..=1.0` ranking used for overlap eviction: higher generally means a cleaner
73    /// geometric fit (more converging, more parallel, etc., depending on `kind`).
74    pub confidence: f64,
75}
76
77impl ChartPatternCandidate {
78    fn formed_at(&self) -> i64 {
79        self.nodes.last().map(|n| n.timestamp).unwrap_or(0)
80    }
81
82    fn node_range(&self) -> (i64, i64) {
83        let start = self.nodes.first().map(|n| n.timestamp).unwrap_or(0);
84        let end = self.formed_at();
85        (start, end)
86    }
87
88    /// Advances this candidate's lifecycle given a subsequent bar, and returns the (possibly
89    /// unchanged) resulting state. A no-op once already `Confirmed`/`Invalidated` — those are
90    /// terminal.
91    pub fn update_state(&mut self, bar: &Bar) -> PatternState {
92        if self.state != PatternState::Forming {
93            return self.state;
94        }
95
96        self.state = match self.kind {
97            ChartPatternKind::Triangle
98            | ChartPatternKind::RisingWedge
99            | ChartPatternKind::FallingWedge => match (&self.upper_line, &self.lower_line) {
100                (Some(upper), Some(lower)) => {
101                    if bar.close > upper.value_at(bar.timestamp)
102                        || bar.close < lower.value_at(bar.timestamp)
103                    {
104                        PatternState::Confirmed
105                    } else {
106                        PatternState::Forming
107                    }
108                }
109                _ => PatternState::Forming,
110            },
111            ChartPatternKind::AutoTrendline => match self.upper_line.or(self.lower_line) {
112                Some(line) => {
113                    let is_resistance = self.upper_line.is_some();
114                    let broke = if is_resistance {
115                        bar.close > line.value_at(bar.timestamp)
116                    } else {
117                        bar.close < line.value_at(bar.timestamp)
118                    };
119                    if broke {
120                        PatternState::Confirmed
121                    } else {
122                        PatternState::Forming
123                    }
124                }
125                None => PatternState::Forming,
126            },
127            ChartPatternKind::ReversalOneTwoThree => {
128                let (n2, n3) = (&self.nodes[1], &self.nodes[2]);
129                let bearish = n2.is_high;
130                if bearish {
131                    if bar.close < n3.price {
132                        PatternState::Confirmed
133                    } else if bar.close > n2.price {
134                        PatternState::Invalidated
135                    } else {
136                        PatternState::Forming
137                    }
138                } else if bar.close > n3.price {
139                    PatternState::Confirmed
140                } else if bar.close < n2.price {
141                    PatternState::Invalidated
142                } else {
143                    PatternState::Forming
144                }
145            }
146            ChartPatternKind::WolfeWave => {
147                let n5 = self.nodes[4];
148                let target_line = TrendLine::from_nodes(&self.nodes[0], &self.nodes[3]); // line 1-4 projects the target
149                let target = target_line.value_at(bar.timestamp);
150                let reverting_toward_target = if n5.is_high {
151                    bar.close < n5.price && bar.close >= target.min(n5.price)
152                } else {
153                    bar.close > n5.price && bar.close <= target.max(n5.price)
154                };
155                let continuing_past_five = if n5.is_high {
156                    bar.close > n5.price
157                } else {
158                    bar.close < n5.price
159                };
160                if reverting_toward_target {
161                    PatternState::Confirmed
162                } else if continuing_past_five {
163                    PatternState::Invalidated
164                } else {
165                    PatternState::Forming
166                }
167            }
168        };
169
170        self.state
171    }
172}
173
174/// Scans a swing-node sequence for pattern candidates and evicts overlapping lower-confidence
175/// ones, so the result is a ranked, non-redundant set rather than every geometrically-possible
176/// match.
177pub struct ChartPatternDetector {
178    pub tolerance_pct: f64,
179}
180
181impl ChartPatternDetector {
182    pub fn new(tolerance_pct: f64) -> Self {
183        Self {
184            tolerance_pct: tolerance_pct.max(0.001),
185        }
186    }
187
188    /// Detects all supported pattern kinds over `nodes`, then evicts overlapping candidates
189    /// (sharing any swing node), keeping the highest-confidence one per overlapping cluster.
190    pub fn scan(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
191        let mut candidates = Vec::new();
192        candidates.extend(self.scan_triangles_and_wedges(nodes));
193        candidates.extend(self.scan_reversal_one_two_three(nodes));
194        candidates.extend(self.scan_wolfe_waves(nodes));
195        if let Some(trendline) = self.auto_trendline(nodes, true) {
196            candidates.push(trendline);
197        }
198        if let Some(trendline) = self.auto_trendline(nodes, false) {
199            candidates.push(trendline);
200        }
201        self.evict_overlaps(candidates)
202    }
203
204    /// Evicts overlapping candidates *within the same pattern kind* only (sliding-window
205    /// detection naturally produces redundant near-duplicates of one kind over the same swing
206    /// points). Different kinds describe different information and are allowed to coexist over
207    /// the same nodes — e.g. a 1-2-3 reversal and an unrelated 2-point auto-trendline spanning the
208    /// same three nodes are not "competing" for the same signal.
209    fn evict_overlaps(&self, candidates: Vec<ChartPatternCandidate>) -> Vec<ChartPatternCandidate> {
210        let mut by_kind: Vec<(ChartPatternKind, Vec<ChartPatternCandidate>)> = Vec::new();
211        for candidate in candidates {
212            match by_kind.iter_mut().find(|(k, _)| *k == candidate.kind) {
213                Some((_, group)) => group.push(candidate),
214                None => by_kind.push((candidate.kind, vec![candidate])),
215            }
216        }
217
218        let mut kept: Vec<ChartPatternCandidate> = Vec::new();
219        for (_, mut group) in by_kind {
220            group.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
221            'outer: for candidate in group {
222                let (c_start, c_end) = candidate.node_range();
223                for existing in &kept {
224                    if existing.kind != candidate.kind {
225                        continue;
226                    }
227                    let (e_start, e_end) = existing.node_range();
228                    let overlaps = c_start <= e_end && e_start <= c_end;
229                    if overlaps {
230                        continue 'outer;
231                    }
232                }
233                kept.push(candidate);
234            }
235        }
236        kept.sort_by_key(|c| c.formed_at());
237        kept
238    }
239
240    /// Triangles/wedges need 4 alternating swing nodes (H,L,H,L or L,H,L,H): an upper line through
241    /// the two highs, a lower line through the two lows.
242    fn scan_triangles_and_wedges(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
243        let mut out = Vec::new();
244        for window in nodes.windows(4) {
245            let alternating = window.windows(2).all(|p| p[0].is_high != p[1].is_high);
246            if !alternating {
247                continue;
248            }
249            let highs: Vec<&ZigZagNode> = window.iter().filter(|n| n.is_high).collect();
250            let lows: Vec<&ZigZagNode> = window.iter().filter(|n| !n.is_high).collect();
251            if highs.len() != 2 || lows.len() != 2 {
252                continue;
253            }
254
255            let upper = TrendLine::from_nodes(highs[0], highs[1]);
256            let lower = TrendLine::from_nodes(lows[0], lows[1]);
257
258            let (start_ts, end_ts) = (
259                window.first().unwrap().timestamp,
260                window.last().unwrap().timestamp,
261            );
262            let gap_start = upper.value_at(start_ts) - lower.value_at(start_ts);
263            let gap_end = upper.value_at(end_ts) - lower.value_at(end_ts);
264            if gap_start <= 0.0 || gap_end <= 0.0 || gap_end >= gap_start {
265                continue; // must be converging
266            }
267
268            let convergence = 1.0 - (gap_end / gap_start);
269            let flat_tol = self.tolerance_pct / 100.0;
270            let upper_flat = upper.slope().abs() / gap_start.max(1e-9) < flat_tol;
271            let lower_flat = lower.slope().abs() / gap_start.max(1e-9) < flat_tol;
272
273            let kind = if upper.slope() > 0.0 && lower.slope() > 0.0 {
274                ChartPatternKind::RisingWedge
275            } else if upper.slope() < 0.0 && lower.slope() < 0.0 {
276                ChartPatternKind::FallingWedge
277            } else if (upper.slope() <= 0.0 || upper_flat) && (lower.slope() >= 0.0 || lower_flat) {
278                ChartPatternKind::Triangle
279            } else {
280                continue;
281            };
282
283            out.push(ChartPatternCandidate {
284                kind,
285                nodes: window.to_vec(),
286                upper_line: Some(upper),
287                lower_line: Some(lower),
288                state: PatternState::Forming,
289                confidence: convergence.clamp(0.0, 1.0),
290            });
291        }
292        out
293    }
294
295    /// A 1-2-3 reversal: three consecutive nodes where the middle one is a failed extreme (did
296    /// not extend the trend) and the third breaks past the first's level in the opposite
297    /// direction — e.g. bearish: low(1) < high(2) fails to make a new high vs. the prior trend,
298    /// then low(3) < low(1).
299    fn scan_reversal_one_two_three(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
300        let mut out = Vec::new();
301        for window in nodes.windows(3) {
302            let (n1, n2, n3) = (&window[0], &window[1], &window[2]);
303            if n1.is_high == n2.is_high || n2.is_high == n3.is_high {
304                continue;
305            }
306
307            let bearish = !n1.is_high && n2.is_high && !n3.is_high && n3.price < n1.price;
308            let bullish = n1.is_high && !n2.is_high && n3.is_high && n3.price > n1.price;
309            if !bearish && !bullish {
310                continue;
311            }
312
313            let magnitude = (n3.price - n1.price).abs() / n1.price.abs().max(1e-9);
314            out.push(ChartPatternCandidate {
315                kind: ChartPatternKind::ReversalOneTwoThree,
316                nodes: window.to_vec(),
317                upper_line: None,
318                lower_line: None,
319                state: PatternState::Forming,
320                confidence: magnitude.min(1.0),
321            });
322        }
323        out
324    }
325
326    /// A Wolfe Wave: 5 alternating points where line(1-3) and line(2-4) are roughly parallel and
327    /// point 5 pierces the line(1-3) extension.
328    fn scan_wolfe_waves(&self, nodes: &[ZigZagNode]) -> Vec<ChartPatternCandidate> {
329        let mut out = Vec::new();
330        for window in nodes.windows(5) {
331            let alternating = window.windows(2).all(|p| p[0].is_high != p[1].is_high);
332            if !alternating {
333                continue;
334            }
335            let (n1, n2, n3, n4, n5) = (&window[0], &window[1], &window[2], &window[3], &window[4]);
336
337            let line13 = TrendLine::from_nodes(n1, n3);
338            let line24 = TrendLine::from_nodes(n2, n4);
339
340            let scale = (n1.price.abs() + n3.price.abs()).max(1e-9);
341            let slope_diff = (line13.slope() - line24.slope()).abs() / scale;
342            let parallel_tol = self.tolerance_pct / 100.0 * 5.0;
343            if slope_diff > parallel_tol {
344                continue;
345            }
346
347            let projected13_at5 = line13.value_at(n5.timestamp);
348            let pierces = if n1.is_high {
349                // 1,3,5 are lows (bullish Wolfe): point 5 must undercut the 1-3 extension.
350                !n5.is_high && n5.price < projected13_at5
351            } else {
352                n5.is_high && n5.price > projected13_at5
353            };
354            if !pierces {
355                continue;
356            }
357
358            let confidence = (1.0 - slope_diff / parallel_tol.max(1e-9)).clamp(0.0, 1.0);
359            out.push(ChartPatternCandidate {
360                kind: ChartPatternKind::WolfeWave,
361                nodes: window.to_vec(),
362                upper_line: Some(if n1.is_high { line24 } else { line13 }),
363                lower_line: Some(if n1.is_high { line13 } else { line24 }),
364                state: PatternState::Forming,
365                confidence,
366            });
367        }
368        out
369    }
370
371    /// Auto-fits the best trendline through same-type swing points: the oldest and newest node of
372    /// that type, valid only if no intermediate node of the same type violates it (a resistance
373    /// line no high pierces, or a support line no low pierces).
374    fn auto_trendline(
375        &self,
376        nodes: &[ZigZagNode],
377        for_highs: bool,
378    ) -> Option<ChartPatternCandidate> {
379        let same_type: Vec<&ZigZagNode> = nodes.iter().filter(|n| n.is_high == for_highs).collect();
380        if same_type.len() < 2 {
381            return None;
382        }
383        let first = *same_type.first().unwrap();
384        let last = *same_type.last().unwrap();
385        let line = TrendLine::from_nodes(first, last);
386
387        let violated = same_type.iter().any(|n| {
388            let projected = line.value_at(n.timestamp);
389            if for_highs {
390                n.price > projected * (1.0 + self.tolerance_pct / 100.0)
391            } else {
392                n.price < projected * (1.0 - self.tolerance_pct / 100.0)
393            }
394        });
395        if violated {
396            return None;
397        }
398
399        let touches = same_type.len();
400        let confidence = ((touches as f64 - 2.0) / 4.0 + 0.5).clamp(0.0, 1.0);
401
402        Some(ChartPatternCandidate {
403            kind: ChartPatternKind::AutoTrendline,
404            nodes: same_type.into_iter().copied().collect(),
405            upper_line: for_highs.then_some(line),
406            lower_line: (!for_highs).then_some(line),
407            state: PatternState::Forming,
408            confidence,
409        })
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    fn node(ts: i64, price: f64, is_high: bool) -> ZigZagNode {
418        ZigZagNode {
419            timestamp: ts,
420            price,
421            is_high,
422            confirmed: true,
423        }
424    }
425
426    #[test]
427    fn test_trendline_value_at_interpolates() {
428        let a = node(0, 100.0, true);
429        let b = node(100, 200.0, true);
430        let line = TrendLine::from_nodes(&a, &b);
431        assert!((line.value_at(50) - 150.0).abs() < 1e-9);
432    }
433
434    #[test]
435    fn test_detects_converging_triangle() {
436        let nodes = vec![
437            node(0, 110.0, true),
438            node(10, 90.0, false),
439            node(20, 105.0, true),
440            node(30, 95.0, false),
441        ];
442        let detector = ChartPatternDetector::new(50.0);
443        let candidates = detector.scan(&nodes);
444        assert!(candidates
445            .iter()
446            .any(|c| c.kind == ChartPatternKind::Triangle));
447    }
448
449    #[test]
450    fn test_detects_bearish_one_two_three_reversal() {
451        let nodes = vec![
452            node(0, 100.0, false),
453            node(10, 110.0, true),
454            node(20, 95.0, false),
455        ];
456        let detector = ChartPatternDetector::new(1.0);
457        let candidates = detector.scan(&nodes);
458        assert!(candidates
459            .iter()
460            .any(|c| c.kind == ChartPatternKind::ReversalOneTwoThree));
461    }
462
463    #[test]
464    fn test_auto_trendline_rejects_violated_support() {
465        // Three lows: a line from the first (100) to the last (95) projects ~97.5 at t=10, but
466        // the middle low dips to 90 -- well below that line, violating it as a support trendline.
467        let nodes = vec![
468            node(0, 100.0, false),
469            node(5, 105.0, true),
470            node(10, 90.0, false),
471            node(15, 102.0, true),
472            node(20, 95.0, false),
473        ];
474        let detector = ChartPatternDetector::new(0.1);
475        let candidates = detector.scan(&nodes);
476        assert!(!candidates.iter().any(
477            |c| c.kind == ChartPatternKind::AutoTrendline && c.nodes.iter().all(|n| !n.is_high)
478        ));
479    }
480
481    #[test]
482    fn test_evict_overlaps_keeps_only_highest_confidence_within_same_kind() {
483        // A longer alternating sequence so multiple overlapping 4-node triangle/wedge windows
484        // are genuinely detected and compete against each other for eviction.
485        let nodes = vec![
486            node(0, 130.0, true),
487            node(10, 70.0, false),
488            node(20, 120.0, true),
489            node(30, 80.0, false),
490            node(40, 110.0, true),
491            node(50, 90.0, false),
492        ];
493        let detector = ChartPatternDetector::new(50.0);
494        let candidates = detector.scan(&nodes);
495
496        // Within any single kind, no two surviving candidates may share a node timestamp range.
497        for kind in [
498            ChartPatternKind::Triangle,
499            ChartPatternKind::RisingWedge,
500            ChartPatternKind::FallingWedge,
501        ] {
502            let same_kind: Vec<&ChartPatternCandidate> =
503                candidates.iter().filter(|c| c.kind == kind).collect();
504            for (i, a) in same_kind.iter().enumerate() {
505                for b in same_kind.iter().skip(i + 1) {
506                    let (a_start, a_end) = a.node_range();
507                    let (b_start, b_end) = b.node_range();
508                    assert!(
509                        a_end < b_start || b_end < a_start,
510                        "overlapping candidates of the same kind must have been evicted"
511                    );
512                }
513            }
514        }
515
516        // Different kinds are allowed to overlap (e.g. an auto-trendline and a triangle sharing
517        // nodes describe different information), so the result set is non-empty and mixed.
518        assert!(!candidates.is_empty());
519    }
520
521    #[test]
522    fn test_triangle_confirms_on_breakout() {
523        let nodes = vec![
524            node(0, 110.0, true),
525            node(10, 90.0, false),
526            node(20, 105.0, true),
527            node(30, 95.0, false),
528        ];
529        let detector = ChartPatternDetector::new(50.0);
530        let mut candidates = detector.scan(&nodes);
531        let triangle = candidates
532            .iter_mut()
533            .find(|c| c.kind == ChartPatternKind::Triangle)
534            .unwrap();
535
536        // Still inside both lines: stays Forming.
537        let inside = Bar::new(35, 100.0, 100.5, 99.5, 100.0, 1.0);
538        assert_eq!(triangle.update_state(&inside), PatternState::Forming);
539
540        // Breaks decisively above the upper line.
541        let breakout = Bar::new(40, 130.0, 130.5, 129.5, 130.0, 1.0);
542        assert_eq!(triangle.update_state(&breakout), PatternState::Confirmed);
543
544        // Terminal: a later bar cannot change a Confirmed pattern back to Forming.
545        let after = Bar::new(50, 50.0, 50.5, 49.5, 50.0, 1.0);
546        assert_eq!(triangle.update_state(&after), PatternState::Confirmed);
547    }
548
549    #[test]
550    fn test_reversal_one_two_three_confirms_and_invalidates() {
551        let confirm_nodes = vec![
552            node(0, 100.0, false),
553            node(10, 110.0, true),
554            node(20, 95.0, false),
555        ];
556        let mut confirm_candidate = ChartPatternDetector::new(1.0)
557            .scan(&confirm_nodes)
558            .into_iter()
559            .find(|c| c.kind == ChartPatternKind::ReversalOneTwoThree)
560            .unwrap();
561        let breaks_below_n3 = Bar::new(30, 90.0, 90.5, 89.5, 90.0, 1.0);
562        assert_eq!(
563            confirm_candidate.update_state(&breaks_below_n3),
564            PatternState::Confirmed
565        );
566
567        let mut invalidate_candidate = ChartPatternDetector::new(1.0)
568            .scan(&confirm_nodes)
569            .into_iter()
570            .find(|c| c.kind == ChartPatternKind::ReversalOneTwoThree)
571            .unwrap();
572        let reclaims_above_n2 = Bar::new(30, 115.0, 115.5, 114.5, 115.0, 1.0);
573        assert_eq!(
574            invalidate_candidate.update_state(&reclaims_above_n2),
575            PatternState::Invalidated
576        );
577    }
578}