1use crate::model::Bar;
23
24use super::zigzag_advanced::ZigZagNode;
25
26#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum PatternState {
57 Forming,
59 Confirmed,
61 Invalidated,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ChartPatternKind {
68 Triangle,
70 AscendingTriangle,
72 DescendingTriangle,
74 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 fn is_top(self) -> bool {
94 matches!(
95 self,
96 ChartPatternKind::DoubleTop
97 | ChartPatternKind::TripleTop
98 | ChartPatternKind::HeadAndShoulders
99 )
100 }
101
102 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 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 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]); 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 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
293pub struct ChartPatternDetector {
297 pub tolerance_pct: f64,
298 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 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 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 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 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; }
416
417 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 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; }
449
450 let convergence = 1.0 - (gap_end / gap_start);
451
452 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 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 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 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 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 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 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 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 !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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(
1054 candidate.update_state(&Bar::new(3000, 112.0, 113.0, 111.0, 112.0, 1.0)),
1055 PatternState::Forming
1056 );
1057 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 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 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 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 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 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 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}