1use crate::model::Bar;
12
13use super::zigzag_advanced::ZigZagNode;
14
15#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum PatternState {
46 Forming,
48 Confirmed,
50 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 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 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]); 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
174pub 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 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 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 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; }
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 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 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 !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 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 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 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 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 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 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 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 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}