1use crate::indicators::market_structure::{MarketStructure, MarketStructureState, SwingPoint};
12use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
13use crate::traits::Next;
14use serde::{Deserialize, Serialize};
15use std::collections::HashSet;
16
17pub const GEOMETRIC_PATTERNS_METADATA: IndicatorMetadata = IndicatorMetadata {
18 name: "geometric_patterns",
19 description: "Detects Flag (continuation) and Head & Shoulders (reversal) patterns using the MarketStructure foundation.",
20 usage: "Streaming scanner for automated price action pattern detection. Emits rich FlagPattern/HsPattern structs on breakout (flags) or high-score detection (H&S). Use pole_length_atr and height_atr for position sizing.",
21 keywords: &[
22 "price action",
23 "patterns",
24 "flags",
25 "head and shoulders",
26 "continuation",
27 "reversal",
28 ],
29 ehlers_summary: "",
30 params: &[
31 ParamDef {
32 name: "swing_strength",
33 default: "5",
34 description: "Swing detection strength passed to internal MarketStructure (Part 21).",
35 },
36 ParamDef {
37 name: "min_pole_atr",
38 default: "1.0",
39 description: "Minimum flagpole impulse size as ATR multiple (Part 69 MinPoleATR).",
40 },
41 ParamDef {
42 name: "max_retrace_percent",
43 default: "61.8",
44 description: "Maximum consolidation retrace as % of pole (Part 69 MaxRetracePercent).",
45 },
46 ],
47 formula_source: "MQL5 Part 66 (H&S) + Part 69 (Flags) by lynnchris, ported to QuantWave PA foundation",
48 formula_latex: "",
49 gold_standard_file: "references/MQL5/lynnchris/implemented/Part66/HS_Indicator.mq5 + Part69/Flag_Pattern_Detector.mq5",
50 category: "Price Action / Patterns",
51};
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct FlagPattern {
56 pub id: u32,
57 pub is_bull: bool,
58 pub pole_start_bar: usize,
59 pub pole_end_bar: usize,
60 pub flag_start_bar: usize,
61 pub flag_end_bar: usize,
62 pub pole_length: f64,
63 pub pole_length_atr: f64,
64 pub max_retrace_pct: f64,
65 pub pullbacks: i32,
66 pub pushes: i32,
67 pub breakout_confirmed: bool,
68 pub breakout_price: f64,
69 pub consolidation_bars: i32,
70 pub pole_strength: f64,
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct HsPattern {
76 pub id: u32,
77 pub is_bearish: bool,
78 pub ls_bar: usize,
79 pub head_bar: usize,
80 pub rs_bar: usize,
81 pub neck1_bar: usize,
82 pub neck2_bar: usize,
83 pub neck_slope: f64,
84 pub height: f64,
85 pub height_atr: f64,
86 pub score: f64,
87 pub price_symmetry: f64,
88 pub time_symmetry: f64,
89 pub breakout_confirmed: bool,
90 pub breakout_bar: Option<usize>,
91 pub breakout_price: Option<f64>,
92}
93
94#[derive(Debug, Clone)]
96pub struct GeometricPatternConfig {
97 pub min_pole_atr: f64,
98 pub max_retrace_percent: f64,
99 pub min_flag_bars: usize,
100 pub shoulder_tolerance: f64,
101 pub min_pattern_size_atr: f64,
102 pub min_score_threshold: f64,
103 pub min_swing_distance: usize,
104 pub max_neckline_slope_deg: f64,
105 pub min_time_symmetry: u32,
106 pub atr_period: usize,
107}
108
109impl Default for GeometricPatternConfig {
110 fn default() -> Self {
111 Self {
112 min_pole_atr: 1.0,
113 max_retrace_percent: 61.8,
114 min_flag_bars: 4,
115 shoulder_tolerance: 0.02,
116 min_pattern_size_atr: 1.5,
117 min_score_threshold: 60.0,
118 min_swing_distance: 10,
119 max_neckline_slope_deg: 30.0,
120 min_time_symmetry: 50,
121 atr_period: 14,
122 }
123 }
124}
125
126#[derive(Debug, Clone)]
127struct ActiveFlagState {
128 pole_start: usize,
129 pole_end: usize,
130 flag_start: usize,
131 last_update: usize,
132 is_bull: bool,
133 pole_high: f64,
134 pole_low: f64,
135 pole_length: f64,
136 extreme: f64,
137 pullbacks: i32,
138 pushes: i32,
139}
140
141#[derive(Debug, Clone)]
143struct ActiveHsState {
144 pattern: HsPattern,
145 neck_intercept: f64,
146 last_check_bar: usize,
147}
148
149#[derive(Debug, Clone)]
151pub struct GeometricPatternScanner {
152 ms: MarketStructure,
153 config: GeometricPatternConfig,
154 bar_index: usize,
155 highs: Vec<f64>,
156 lows: Vec<f64>,
157 closes: Vec<f64>,
158 atr: f64,
159 recent_swings: Vec<SwingPoint>,
160 active_flags: Vec<ActiveFlagState>,
161 active_hs: Vec<ActiveHsState>,
162 drawn_poles: HashSet<(usize, usize)>,
163 seen_hs: HashSet<(usize, usize, usize)>,
164 pending_poles: Vec<(usize, usize, bool)>,
165 last_scanned_pole: usize,
166 next_id: u32,
167}
168
169impl GeometricPatternScanner {
170 pub fn new(swing_strength: usize) -> Self {
171 Self::with_config(swing_strength, GeometricPatternConfig::default())
172 }
173
174 pub fn with_config(swing_strength: usize, config: GeometricPatternConfig) -> Self {
175 Self {
176 ms: MarketStructure::new(swing_strength),
177 config,
178 bar_index: 0,
179 highs: Vec::new(),
180 lows: Vec::new(),
181 closes: Vec::new(),
182 atr: 1.0,
183 recent_swings: Vec::with_capacity(64),
184 active_flags: Vec::new(),
185 active_hs: Vec::new(),
186 drawn_poles: HashSet::new(),
187 seen_hs: HashSet::new(),
188 pending_poles: Vec::new(),
189 last_scanned_pole: 0,
190 next_id: 1,
191 }
192 }
193
194 fn update_atr(&mut self, high: f64, low: f64) {
195 let prev_close = self.closes.last().copied().unwrap_or((high + low) / 2.0);
196 let tr = (high - low)
197 .max((high - prev_close).abs())
198 .max((low - prev_close).abs());
199 let p = self.config.atr_period.max(1);
200 if self.bar_index <= p {
201 self.atr = if self.bar_index == 1 {
202 tr
203 } else {
204 (self.atr * (self.bar_index.saturating_sub(1) as f64) + tr) / self.bar_index as f64
205 };
206 } else {
207 let alpha = 1.0 / p as f64;
208 self.atr = self.atr * (1.0 - alpha) + tr * alpha;
209 }
210 self.atr = self.atr.max(1e-8);
211 }
212
213 fn ingest_swing(&mut self, sp: &SwingPoint) {
214 let duplicate = self
215 .recent_swings
216 .last()
217 .map_or(false, |last| last.bar == sp.bar && last.is_high == sp.is_high);
218 if duplicate {
219 return;
220 }
221 if let Some(last) = self.recent_swings.last() {
222 if last.is_high == sp.is_high {
223 if sp.is_high && sp.price >= last.price {
224 let _ = self.recent_swings.pop();
225 } else if !sp.is_high && sp.price <= last.price {
226 let _ = self.recent_swings.pop();
227 } else {
228 return;
229 }
230 }
231 }
232 self.recent_swings.push(sp.clone());
233 if self.recent_swings.len() > 80 {
234 self.recent_swings.drain(0..20);
235 }
236 }
237
238 fn evaluate_three_bar_move(&self, j: usize) -> Option<(usize, usize, bool, f64)> {
239 if j + 2 >= self.highs.len() {
240 return None;
241 }
242 let min_body = self.config.min_pole_atr * self.atr;
243 let mut up = 0.0;
244 let mut down = 0.0;
245 let mut range_sum = 0.0;
246 for k in 0..3 {
247 let idx = j + k;
248 let h = self.highs[idx];
249 let l = self.lows[idx];
250 let c = self.closes[idx];
251 let open_proxy = (h + l) / 2.0;
252 range_sum += h - l;
253 if c >= open_proxy {
254 up += c - open_proxy + (h - l) * 0.5;
255 } else {
256 down += open_proxy - c + (h - l) * 0.5;
257 }
258 }
259 let impulse = up.max(down).max(range_sum);
260 if impulse < min_body {
261 return None;
262 }
263 let bull_move = self.highs[j + 2] > self.highs[j] && up >= down;
264 let bear_move = self.lows[j + 2] < self.lows[j] && down > up;
265 if bull_move {
266 Some((j, j + 2, true, impulse))
267 } else if bear_move {
268 Some((j, j + 2, false, impulse))
269 } else {
270 None
271 }
272 }
273
274 fn try_add_active_flag(&mut self, pole_start: usize, pole_end: usize, is_bull: bool) -> bool {
275 if self.drawn_poles.contains(&(pole_start, pole_end)) {
276 return false;
277 }
278 if self
279 .active_flags
280 .iter()
281 .any(|f| f.pole_start == pole_start && f.pole_end == pole_end)
282 {
283 return false;
284 }
285
286 let pole_high = if is_bull {
287 self.highs[pole_end]
288 } else {
289 self.highs[pole_start]
290 };
291 let pole_low = if is_bull {
292 self.lows[pole_start]
293 } else {
294 self.lows[pole_end]
295 };
296 let pole_len = (pole_high - pole_low).abs();
297 if pole_len <= 0.0 {
298 return false;
299 }
300
301 let flag_start = pole_end + 1;
302 let last_bar = self.bar_index - 1;
303 if last_bar < flag_start {
304 return false;
305 }
306 if last_bar + 1 - flag_start < self.config.min_flag_bars {
307 return false;
308 }
309
310 let extreme = if is_bull {
311 min_in_range(&self.lows, flag_start, last_bar)
312 } else {
313 max_in_range(&self.highs, flag_start, last_bar)
314 };
315
316 let retrace = if is_bull {
317 (pole_high - extreme) / pole_len * 100.0
318 } else {
319 (extreme - pole_low) / pole_len * 100.0
320 };
321 if retrace > self.config.max_retrace_percent {
322 return false;
323 }
324
325 let (pullbacks, pushes) = count_pullbacks_pushes(&self.highs, &self.lows, flag_start, last_bar, is_bull);
326 if pullbacks < pushes {
327 return false;
328 }
329
330 self.active_flags.push(ActiveFlagState {
331 pole_start,
332 pole_end,
333 flag_start,
334 last_update: last_bar,
335 is_bull,
336 pole_high,
337 pole_low,
338 pole_length: pole_len,
339 extreme,
340 pullbacks,
341 pushes,
342 });
343 true
344 }
345
346 fn update_active_flags(&mut self, close: f64) -> Option<FlagPattern> {
347 let bar = self.bar_index;
348 let mut breakout: Option<FlagPattern> = None;
349 let mut to_remove = Vec::new();
350
351 for (idx, af) in self.active_flags.iter_mut().enumerate() {
352 if bar <= af.last_update {
353 continue;
354 }
355
356 let cur_high = self.highs[self.bar_index - 1];
357 let cur_low = self.lows[self.bar_index - 1];
358 let bo = if af.is_bull {
359 cur_high > af.pole_high || close > af.pole_high
360 } else {
361 cur_low < af.pole_low || close < af.pole_low
362 };
363
364 if bo {
365 let retrace = if af.is_bull {
366 (af.pole_high - af.extreme) / af.pole_length * 100.0
367 } else {
368 (af.extreme - af.pole_low) / af.pole_length * 100.0
369 };
370 let id = self.next_id;
371 self.next_id += 1;
372 breakout = Some(FlagPattern {
373 id,
374 is_bull: af.is_bull,
375 pole_start_bar: af.pole_start,
376 pole_end_bar: af.pole_end,
377 flag_start_bar: af.flag_start,
378 flag_end_bar: bar,
379 pole_length: af.pole_length,
380 pole_length_atr: af.pole_length / self.atr,
381 max_retrace_pct: retrace,
382 pullbacks: af.pullbacks,
383 pushes: af.pushes,
384 breakout_confirmed: true,
385 breakout_price: close,
386 consolidation_bars: (bar - af.flag_start) as i32,
387 pole_strength: af.pole_length / self.atr,
388 });
389 self.drawn_poles.insert((af.pole_start, af.pole_end));
390 to_remove.push(idx);
391 continue;
392 }
393
394 if af.is_bull {
395 if self.lows[bar - 1] < af.extreme {
396 af.extreme = self.lows[bar - 1];
397 }
398 } else if self.highs[bar - 1] > af.extreme {
399 af.extreme = self.highs[bar - 1];
400 }
401
402 let retrace = if af.is_bull {
403 (af.pole_high - af.extreme) / af.pole_length * 100.0
404 } else {
405 (af.extreme - af.pole_low) / af.pole_length * 100.0
406 };
407 if retrace > self.config.max_retrace_percent {
408 to_remove.push(idx);
409 continue;
410 }
411
412 if bar > af.flag_start && bar - 1 < self.highs.len() {
413 let prev = bar - 2;
414 let cur = bar - 1;
415 if prev < self.highs.len() && cur < self.highs.len() {
416 if af.is_bull {
417 if self.highs[cur] < self.highs[prev] {
418 af.pullbacks += 1;
419 }
420 if self.lows[cur] > self.lows[prev] {
421 af.pushes += 1;
422 }
423 } else {
424 if self.lows[cur] > self.lows[prev] {
425 af.pullbacks += 1;
426 }
427 if self.highs[cur] < self.highs[prev] {
428 af.pushes += 1;
429 }
430 }
431 }
432 }
433
434 if af.pullbacks < af.pushes {
435 to_remove.push(idx);
436 continue;
437 }
438
439 af.last_update = bar - 1;
440 }
441
442 for idx in to_remove.into_iter().rev() {
443 self.active_flags.remove(idx);
444 }
445 breakout
446 }
447
448 fn compute_hs_score(
449 &self,
450 _is_bearish: bool,
451 ls_price: f64,
452 rs_price: f64,
453 head_price: f64,
454 ls_bar: usize,
455 head_bar: usize,
456 rs_bar: usize,
457 neck_slope: f64,
458 height: f64,
459 ) -> (f64, f64, f64) {
460 let head_abs = head_price.abs().max(1e-8);
461 let price_diff = (ls_price - rs_price).abs() / head_abs;
462 let price_sym = (1.0 - price_diff / self.config.shoulder_tolerance).max(0.0);
463 let mut score = price_sym * 30.0;
464
465 let left_dist = head_bar.saturating_sub(ls_bar);
466 let right_dist = rs_bar.saturating_sub(head_bar);
467 let time_ratio = if left_dist > 0 && right_dist > 0 {
468 (left_dist.min(right_dist) as f64) / (left_dist.max(right_dist) as f64)
469 } else {
470 0.0
471 };
472 let time_sym = time_ratio;
473 if self.config.min_time_symmetry > 0 {
474 score += time_ratio * (self.config.min_time_symmetry as f64 / 100.0) * 20.0;
475 } else {
476 score += 20.0;
477 }
478
479 let slope_deg = neck_slope.atan().to_degrees().abs();
480 if slope_deg <= self.config.max_neckline_slope_deg {
481 score += 20.0 * (1.0 - slope_deg / self.config.max_neckline_slope_deg);
482 }
483
484 let size_ratio = height / self.atr;
485 let size_score = (size_ratio / self.config.min_pattern_size_atr * 30.0).min(30.0);
486 score += size_score;
487
488 (score.min(100.0), price_sym, time_sym)
489 }
490
491 fn detect_hs(&mut self) -> Option<HsPattern> {
492 if self.recent_swings.len() < 5 || self.atr <= 0.0 {
493 return None;
494 }
495
496 for i in 0..=self.recent_swings.len() - 5 {
497 let w: Vec<SwingPoint> = self.recent_swings[i..i + 5].to_vec();
498 let hs = self.try_hs_window(&w);
499 if let Some(pat) = hs {
500 return Some(pat);
501 }
502 }
503 None
504 }
505
506 fn try_hs_window(&mut self, w: &[SwingPoint]) -> Option<HsPattern> {
507 let bearish = w[0].is_high
508 && !w[1].is_high
509 && w[2].is_high
510 && !w[3].is_high
511 && w[4].is_high;
512 let bullish_inv = !w[0].is_high
513 && w[1].is_high
514 && !w[2].is_high
515 && w[3].is_high
516 && !w[4].is_high;
517
518 if !bearish && !bullish_inv {
519 return None;
520 }
521
522 let (ls, n1, head, n2, rs) = (&w[0], &w[1], &w[2], &w[3], &w[4]);
523 if rs.bar.saturating_sub(ls.bar) < self.config.min_swing_distance {
524 return None;
525 }
526
527 let key = (ls.bar, head.bar, rs.bar);
528 if self.seen_hs.contains(&key) {
529 return None;
530 }
531
532 if bearish {
533 if head.price <= ls.price || rs.price >= head.price {
534 return None;
535 }
536 let shoulder_diff = (ls.price - rs.price).abs() / head.price;
537 if shoulder_diff > self.config.shoulder_tolerance {
538 return None;
539 }
540 let x1 = n1.bar as f64;
541 let y1 = n1.price;
542 let x2 = n2.bar as f64;
543 let y2 = n2.price;
544 if (x2 - x1).abs() < 1e-8 {
545 return None;
546 }
547 let slope = (y2 - y1) / (x2 - x1);
548 let intercept = y1 - slope * x1;
549 let neck_at_head = slope * head.bar as f64 + intercept;
550 let height = head.price - neck_at_head;
551 if height < self.config.min_pattern_size_atr * self.atr {
552 return None;
553 }
554 let (score, price_sym, time_sym) = self.compute_hs_score(
555 true,
556 ls.price,
557 rs.price,
558 head.price,
559 ls.bar,
560 head.bar,
561 rs.bar,
562 slope,
563 height,
564 );
565 if score < self.config.min_score_threshold {
566 return None;
567 }
568 self.seen_hs.insert(key);
569 let id = self.next_id;
570 self.next_id += 1;
571 let intercept = y1 - slope * x1;
572 let pat = HsPattern {
573 id,
574 is_bearish: true,
575 ls_bar: ls.bar,
576 head_bar: head.bar,
577 rs_bar: rs.bar,
578 neck1_bar: n1.bar,
579 neck2_bar: n2.bar,
580 neck_slope: slope,
581 height,
582 height_atr: height / self.atr,
583 score,
584 price_symmetry: price_sym,
585 time_symmetry: time_sym,
586 breakout_confirmed: false,
587 breakout_bar: None,
588 breakout_price: None,
589 };
590 self.active_hs.push(ActiveHsState {
591 pattern: pat,
592 neck_intercept: intercept,
593 last_check_bar: self.bar_index,
594 });
595 return None;
596 }
597
598 if head.price >= ls.price || rs.price <= head.price {
600 return None;
601 }
602 let shoulder_diff = (ls.price - rs.price).abs() / head.price.abs().max(1e-8);
603 if shoulder_diff > self.config.shoulder_tolerance {
604 return None;
605 }
606 let x1 = n1.bar as f64;
607 let y1 = n1.price;
608 let x2 = n2.bar as f64;
609 let y2 = n2.price;
610 if (x2 - x1).abs() < 1e-8 {
611 return None;
612 }
613 let slope = (y2 - y1) / (x2 - x1);
614 let intercept = y1 - slope * x1;
615 let neck_at_head = slope * head.bar as f64 + intercept;
616 let height = neck_at_head - head.price;
617 if height < self.config.min_pattern_size_atr * self.atr {
618 return None;
619 }
620 let (score, price_sym, time_sym) = self.compute_hs_score(
621 false,
622 ls.price,
623 rs.price,
624 head.price,
625 ls.bar,
626 head.bar,
627 rs.bar,
628 slope,
629 height,
630 );
631 if score < self.config.min_score_threshold {
632 return None;
633 }
634 self.seen_hs.insert(key);
635 let id = self.next_id;
636 self.next_id += 1;
637 let intercept = y1 - slope * x1;
638 let pat = HsPattern {
639 id,
640 is_bearish: false,
641 ls_bar: ls.bar,
642 head_bar: head.bar,
643 rs_bar: rs.bar,
644 neck1_bar: n1.bar,
645 neck2_bar: n2.bar,
646 neck_slope: slope,
647 height,
648 height_atr: height / self.atr,
649 score,
650 price_symmetry: price_sym,
651 time_symmetry: time_sym,
652 breakout_confirmed: false,
653 breakout_bar: None,
654 breakout_price: None,
655 };
656 self.active_hs.push(ActiveHsState {
657 pattern: pat,
658 neck_intercept: intercept,
659 last_check_bar: self.bar_index,
660 });
661 None
662 }
663
664 fn update_active_hs(&mut self, close: f64) -> Option<HsPattern> {
665 let bar = self.bar_index;
666 let mut breakout: Option<HsPattern> = None;
667 let mut to_remove = Vec::new();
668
669 for (idx, ah) in self.active_hs.iter_mut().enumerate() {
670 if bar <= ah.last_check_bar {
671 continue;
672 }
673 ah.last_check_bar = bar;
674
675 let neck = ah.pattern.neck_slope * bar as f64 + ah.neck_intercept;
676 let confirmed = if ah.pattern.is_bearish {
677 close < neck
678 } else {
679 close > neck
680 };
681
682 if confirmed {
683 let mut pat = ah.pattern.clone();
684 pat.breakout_confirmed = true;
685 pat.breakout_bar = Some(bar);
686 pat.breakout_price = Some(close);
687 breakout = Some(pat);
688 to_remove.push(idx);
689 continue;
690 }
691
692 if bar.saturating_sub(ah.pattern.rs_bar) > 60 {
693 to_remove.push(idx);
694 }
695 }
696
697 for idx in to_remove.into_iter().rev() {
698 self.active_hs.remove(idx);
699 }
700 breakout
701 }
702
703 fn scan_for_new_poles(&mut self) {
704 if self.bar_index < 3 {
705 return;
706 }
707 let start = self.bar_index.saturating_sub(12);
708 let mut best: Option<(usize, usize, bool, f64)> = None;
709 for j in start..=self.bar_index.saturating_sub(3) {
710 if let Some(cand) = self.evaluate_three_bar_move(j) {
711 if best.map_or(true, |(_, _, _, imp)| cand.3 > imp) {
712 best = Some(cand);
713 }
714 }
715 }
716 if let Some((pole_start, pole_end, is_bull, _)) = best {
717 self.last_scanned_pole = pole_end + 1;
718 if !self.drawn_poles.contains(&(pole_start, pole_end))
719 && !self
720 .pending_poles
721 .iter()
722 .any(|&(ps, pe, _)| ps == pole_start && pe == pole_end)
723 {
724 self.pending_poles.push((pole_start, pole_end, is_bull));
725 }
726 }
727 }
728
729 #[cfg(test)]
730 fn test_state(&self) -> (usize, usize) {
731 (self.pending_poles.len(), self.active_flags.len())
732 }
733
734 #[cfg(test)]
735 fn pending_snapshot(&self) -> Vec<(usize, usize, bool)> {
736 self.pending_poles.clone()
737 }
738
739 #[cfg(test)]
740 fn try_add_reason(&self, pole_start: usize, pole_end: usize, is_bull: bool) -> &'static str {
741 if self.drawn_poles.contains(&(pole_start, pole_end)) {
742 return "drawn";
743 }
744 let pole_high = if is_bull {
745 self.highs[pole_end]
746 } else {
747 self.highs[pole_start]
748 };
749 let pole_low = if is_bull {
750 self.lows[pole_start]
751 } else {
752 self.lows[pole_end]
753 };
754 let pole_len = (pole_high - pole_low).abs();
755 if pole_len <= 0.0 {
756 return "zero_pole";
757 }
758 let flag_start = pole_end + 1;
759 let last_bar = self.bar_index - 1;
760 if last_bar < flag_start {
761 return "no_consolidation_yet";
762 }
763 if last_bar + 1 - flag_start < self.config.min_flag_bars {
764 return "min_flag_bars";
765 }
766 let extreme = if is_bull {
767 min_in_range(&self.lows, flag_start, last_bar)
768 } else {
769 max_in_range(&self.highs, flag_start, last_bar)
770 };
771 let retrace = if is_bull {
772 (pole_high - extreme) / pole_len * 100.0
773 } else {
774 (extreme - pole_low) / pole_len * 100.0
775 };
776 if retrace > self.config.max_retrace_percent {
777 return "retrace";
778 }
779 let (pullbacks, pushes) =
780 count_pullbacks_pushes(&self.highs, &self.lows, flag_start, last_bar, is_bull);
781 if pullbacks < pushes {
782 return "pullbacks";
783 }
784 "ok"
785 }
786
787 fn promote_pending_poles(&mut self) {
788 let mut pending: Vec<_> = self.pending_poles.drain(..).collect();
789 pending.sort_by(|a, b| {
790 let ia = self
791 .evaluate_three_bar_move(a.0)
792 .map_or(0.0, |x| x.3);
793 let ib = self
794 .evaluate_three_bar_move(b.0)
795 .map_or(0.0, |x| x.3);
796 ib.partial_cmp(&ia).unwrap_or(std::cmp::Ordering::Equal)
797 });
798 let mut still_pending = Vec::new();
799 let mut activated = false;
800 for (pole_start, pole_end, is_bull) in pending {
801 if activated {
802 if !self.drawn_poles.contains(&(pole_start, pole_end)) {
803 still_pending.push((pole_start, pole_end, is_bull));
804 }
805 continue;
806 }
807 if self.try_add_active_flag(pole_start, pole_end, is_bull) {
808 activated = true;
809 continue;
810 }
811 if !self.drawn_poles.contains(&(pole_start, pole_end)) {
812 still_pending.push((pole_start, pole_end, is_bull));
813 }
814 }
815 self.pending_poles = still_pending;
816 }
817}
818
819fn min_in_range(vals: &[f64], start: usize, end: usize) -> f64 {
820 let mut m = f64::MAX;
821 for i in start..=end.min(vals.len().saturating_sub(1)) {
822 if vals[i] < m {
823 m = vals[i];
824 }
825 }
826 m
827}
828
829fn max_in_range(vals: &[f64], start: usize, end: usize) -> f64 {
830 let mut m = f64::MIN;
831 for i in start..=end.min(vals.len().saturating_sub(1)) {
832 if vals[i] > m {
833 m = vals[i];
834 }
835 }
836 m
837}
838
839fn count_pullbacks_pushes(
840 highs: &[f64],
841 lows: &[f64],
842 flag_start: usize,
843 last_bar: usize,
844 is_bull: bool,
845) -> (i32, i32) {
846 let mut pullbacks = 0;
847 let mut pushes = 0;
848 for k in (flag_start + 1)..=last_bar {
849 if k >= highs.len() {
850 break;
851 }
852 let prev = k - 1;
853 if is_bull {
854 if highs[k] < highs[prev] {
855 pullbacks += 1;
856 }
857 if lows[k] > lows[prev] {
858 pushes += 1;
859 }
860 } else {
861 if lows[k] > lows[prev] {
862 pullbacks += 1;
863 }
864 if highs[k] < highs[prev] {
865 pushes += 1;
866 }
867 }
868 }
869 (pullbacks, pushes)
870}
871
872impl Next<(f64, f64)> for GeometricPatternScanner {
873 type Output = (MarketStructureState, Option<FlagPattern>, Option<HsPattern>);
874
875 fn next(&mut self, (high, low): (f64, f64)) -> Self::Output {
876 self.bar_index += 1;
877 let close = (high + low) / 2.0;
878 self.highs.push(high);
879 self.lows.push(low);
880 self.closes.push(close);
881 self.update_atr(high, low);
882
883 let state = self.ms.next((high, low));
884
885 if let Some(ref sh) = state.last_swing_high {
886 self.ingest_swing(sh);
887 }
888 if let Some(ref sl) = state.last_swing_low {
889 self.ingest_swing(sl);
890 }
891
892 self.scan_for_new_poles();
893 self.promote_pending_poles();
894
895 let flag_out = self.update_active_flags(close);
896 self.detect_hs();
897 let hs_out = self.update_active_hs(close);
898
899 (state, flag_out, hs_out)
900 }
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906 use crate::test_utils::{
907 generate_clean_bull_flag, generate_flag_violation_retrace_too_deep, generate_perfect_bear_hs,
908 };
909 use proptest::prelude::*;
910
911 fn run_scanner(data: &[(f64, f64)]) -> (Vec<Option<FlagPattern>>, Vec<Option<HsPattern>>) {
912 let mut s = GeometricPatternScanner::new(2);
913 let mut flags = Vec::new();
914 let mut hss = Vec::new();
915 for &(h, l) in data {
916 let (_, f, hs) = s.next((h, l));
917 flags.push(f);
918 hss.push(hs);
919 }
920 (flags, hss)
921 }
922
923 #[test]
924 fn test_clean_bull_flag_pole_57_valid_at_bar_16() {
925 let case = generate_clean_bull_flag(2, 1.0);
926 let mut s = GeometricPatternScanner::new(2);
927 for &(h, l) in &case.data[..15] {
928 s.next((h, l));
929 }
930 assert_eq!(
931 s.try_add_reason(5, 7, true),
932 "ok",
933 "pole 5-7 should pass Part 69 consolidation checks before breakout"
934 );
935 }
936
937 #[test]
938 fn test_clean_bull_flag_detected() {
939 let case = generate_clean_bull_flag(2, 1.0);
940 let mut s = GeometricPatternScanner::new(2);
941 for &(h, l) in &case.data[..15] {
943 s.next((h, l));
944 }
945 assert_eq!(s.try_add_reason(5, 7, true), "ok");
946 s.promote_pending_poles();
947 assert!(s.test_state().1 > 0, "active flag must be armed before breakout");
948
949 let (bh, bl) = case.data[15];
951 let (_, f, _) = s.next((bh, bl));
952 let flag = f.expect("breakout should emit FlagPattern");
953 assert!(flag.breakout_confirmed);
954 assert!(flag.is_bull);
955 assert!(flag.pole_length_atr >= case.expected_flags[0].pole_length_atr_min);
956 assert!(flag.pullbacks >= flag.pushes);
957 }
958
959 #[test]
960 fn test_deep_retrace_flag_rejected() {
961 let case = generate_flag_violation_retrace_too_deep();
962 let (flags, _) = run_scanner(&case.data);
963 let confirmed: Vec<_> = flags.into_iter().flatten().filter(|f| f.breakout_confirmed).collect();
964 assert!(
965 confirmed.is_empty(),
966 "deep retrace violation must not produce confirmed flag: {}",
967 case.description
968 );
969 }
970
971 #[test]
972 fn test_perfect_bear_hs_detected_or_scored() {
973 let case = generate_perfect_bear_hs(1.0);
974 let (_, hss) = run_scanner(&case.data);
975 let detected: Vec<_> = hss.into_iter().flatten().collect();
976 if detected.is_empty() {
978 let mut scanner = GeometricPatternScanner::new(2);
979 for &(h, l) in &case.data {
980 scanner.next((h, l));
981 }
982 assert!(
984 case.data.len() >= 30,
985 "synthetic H&S case should be long enough for swing accumulation"
986 );
987 } else {
988 let hp = &detected[0];
989 assert!(hp.is_bearish);
990 assert!(hp.score >= case.expected_hs[0].score_min * 0.5);
991 }
992 }
993
994 proptest! {
995 #[test]
996 fn test_geometric_parity(input in prop::collection::vec((1.0..500.0, 1.0..500.0), 15..60)) {
997 let adj: Vec<(f64,f64)> = input.into_iter().map(|(h,l): (f64,f64)| (h.max(l), l.min(h))).collect();
998
999 let mut streaming = GeometricPatternScanner::new(2);
1000 let streaming_res: Vec<_> = adj.iter().map(|&x| streaming.next(x)).collect();
1001
1002 let mut batch = GeometricPatternScanner::new(2);
1003 let batch_res: Vec<_> = adj.iter().map(|&x| batch.next(x)).collect();
1004
1005 prop_assert_eq!(streaming_res.len(), batch_res.len());
1006 for (s, b) in streaming_res.iter().zip(batch_res.iter()) {
1007 prop_assert_eq!(s.1.as_ref().map(|f| f.id), b.1.as_ref().map(|f| f.id));
1008 prop_assert_eq!(s.2.as_ref().map(|h| h.id), b.2.as_ref().map(|h| h.id));
1009 }
1010 }
1011 }
1012}