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 .is_some_and(|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 && last.is_high == sp.is_high
223 {
224 if (sp.is_high && sp.price >= last.price) || (!sp.is_high && sp.price <= last.price) {
225 let _ = self.recent_swings.pop();
226 } else {
227 return;
228 }
229 }
230 self.recent_swings.push(sp.clone());
231 if self.recent_swings.len() > 80 {
232 self.recent_swings.drain(0..20);
233 }
234 }
235
236 fn evaluate_three_bar_move(&self, j: usize) -> Option<(usize, usize, bool, f64)> {
237 if j + 2 >= self.highs.len() {
238 return None;
239 }
240 let min_body = self.config.min_pole_atr * self.atr;
241 let mut up = 0.0;
242 let mut down = 0.0;
243 let mut range_sum = 0.0;
244 for k in 0..3 {
245 let idx = j + k;
246 let h = self.highs[idx];
247 let l = self.lows[idx];
248 let c = self.closes[idx];
249 let open_proxy = (h + l) / 2.0;
250 range_sum += h - l;
251 if c >= open_proxy {
252 up += c - open_proxy + (h - l) * 0.5;
253 } else {
254 down += open_proxy - c + (h - l) * 0.5;
255 }
256 }
257 let impulse = up.max(down).max(range_sum);
258 if impulse < min_body {
259 return None;
260 }
261 let bull_move = self.highs[j + 2] > self.highs[j] && up >= down;
262 let bear_move = self.lows[j + 2] < self.lows[j] && down > up;
263 if bull_move {
264 Some((j, j + 2, true, impulse))
265 } else if bear_move {
266 Some((j, j + 2, false, impulse))
267 } else {
268 None
269 }
270 }
271
272 fn try_add_active_flag(&mut self, pole_start: usize, pole_end: usize, is_bull: bool) -> bool {
273 if self.drawn_poles.contains(&(pole_start, pole_end)) {
274 return false;
275 }
276 if self
277 .active_flags
278 .iter()
279 .any(|f| f.pole_start == pole_start && f.pole_end == pole_end)
280 {
281 return false;
282 }
283
284 let pole_high = if is_bull {
285 self.highs[pole_end]
286 } else {
287 self.highs[pole_start]
288 };
289 let pole_low = if is_bull {
290 self.lows[pole_start]
291 } else {
292 self.lows[pole_end]
293 };
294 let pole_len = (pole_high - pole_low).abs();
295 if pole_len <= 0.0 {
296 return false;
297 }
298
299 let flag_start = pole_end + 1;
300 let last_bar = self.bar_index - 1;
301 if last_bar < flag_start {
302 return false;
303 }
304 if last_bar + 1 - flag_start < self.config.min_flag_bars {
305 return false;
306 }
307
308 let extreme = if is_bull {
309 min_in_range(&self.lows, flag_start, last_bar)
310 } else {
311 max_in_range(&self.highs, flag_start, last_bar)
312 };
313
314 let retrace = if is_bull {
315 (pole_high - extreme) / pole_len * 100.0
316 } else {
317 (extreme - pole_low) / pole_len * 100.0
318 };
319 if retrace > self.config.max_retrace_percent {
320 return false;
321 }
322
323 let (pullbacks, pushes) =
324 count_pullbacks_pushes(&self.highs, &self.lows, flag_start, last_bar, is_bull);
325 if pullbacks < pushes {
326 return false;
327 }
328
329 self.active_flags.push(ActiveFlagState {
330 pole_start,
331 pole_end,
332 flag_start,
333 last_update: last_bar,
334 is_bull,
335 pole_high,
336 pole_low,
337 pole_length: pole_len,
338 extreme,
339 pullbacks,
340 pushes,
341 });
342 true
343 }
344
345 fn update_active_flags(&mut self, close: f64) -> Option<FlagPattern> {
346 let bar = self.bar_index;
347 let mut breakout: Option<FlagPattern> = None;
348 let mut to_remove = Vec::new();
349
350 for (idx, af) in self.active_flags.iter_mut().enumerate() {
351 if bar <= af.last_update {
352 continue;
353 }
354
355 let cur_high = self.highs[self.bar_index - 1];
356 let cur_low = self.lows[self.bar_index - 1];
357 let bo = if af.is_bull {
358 cur_high > af.pole_high || close > af.pole_high
359 } else {
360 cur_low < af.pole_low || close < af.pole_low
361 };
362
363 if bo {
364 let retrace = if af.is_bull {
365 (af.pole_high - af.extreme) / af.pole_length * 100.0
366 } else {
367 (af.extreme - af.pole_low) / af.pole_length * 100.0
368 };
369 let id = self.next_id;
370 self.next_id += 1;
371 breakout = Some(FlagPattern {
372 id,
373 is_bull: af.is_bull,
374 pole_start_bar: af.pole_start,
375 pole_end_bar: af.pole_end,
376 flag_start_bar: af.flag_start,
377 flag_end_bar: bar,
378 pole_length: af.pole_length,
379 pole_length_atr: af.pole_length / self.atr,
380 max_retrace_pct: retrace,
381 pullbacks: af.pullbacks,
382 pushes: af.pushes,
383 breakout_confirmed: true,
384 breakout_price: close,
385 consolidation_bars: (bar - af.flag_start) as i32,
386 pole_strength: af.pole_length / self.atr,
387 });
388 self.drawn_poles.insert((af.pole_start, af.pole_end));
389 to_remove.push(idx);
390 continue;
391 }
392
393 if af.is_bull {
394 if self.lows[bar - 1] < af.extreme {
395 af.extreme = self.lows[bar - 1];
396 }
397 } else if self.highs[bar - 1] > af.extreme {
398 af.extreme = self.highs[bar - 1];
399 }
400
401 let retrace = if af.is_bull {
402 (af.pole_high - af.extreme) / af.pole_length * 100.0
403 } else {
404 (af.extreme - af.pole_low) / af.pole_length * 100.0
405 };
406 if retrace > self.config.max_retrace_percent {
407 to_remove.push(idx);
408 continue;
409 }
410
411 if bar > af.flag_start && bar - 1 < self.highs.len() {
412 let prev = bar - 2;
413 let cur = bar - 1;
414 if prev < self.highs.len() && cur < self.highs.len() {
415 if af.is_bull {
416 if self.highs[cur] < self.highs[prev] {
417 af.pullbacks += 1;
418 }
419 if self.lows[cur] > self.lows[prev] {
420 af.pushes += 1;
421 }
422 } else {
423 if self.lows[cur] > self.lows[prev] {
424 af.pullbacks += 1;
425 }
426 if self.highs[cur] < self.highs[prev] {
427 af.pushes += 1;
428 }
429 }
430 }
431 }
432
433 if af.pullbacks < af.pushes {
434 to_remove.push(idx);
435 continue;
436 }
437
438 af.last_update = bar - 1;
439 }
440
441 for idx in to_remove.into_iter().rev() {
442 self.active_flags.remove(idx);
443 }
444 breakout
445 }
446
447 #[allow(clippy::too_many_arguments)]
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 =
508 w[0].is_high && !w[1].is_high && w[2].is_high && !w[3].is_high && w[4].is_high;
509 let bullish_inv =
510 !w[0].is_high && w[1].is_high && !w[2].is_high && w[3].is_high && !w[4].is_high;
511
512 if !bearish && !bullish_inv {
513 return None;
514 }
515
516 let (ls, n1, head, n2, rs) = (&w[0], &w[1], &w[2], &w[3], &w[4]);
517 if rs.bar.saturating_sub(ls.bar) < self.config.min_swing_distance {
518 return None;
519 }
520
521 let key = (ls.bar, head.bar, rs.bar);
522 if self.seen_hs.contains(&key) {
523 return None;
524 }
525
526 if bearish {
527 if head.price <= ls.price || rs.price >= head.price {
528 return None;
529 }
530 let shoulder_diff = (ls.price - rs.price).abs() / head.price;
531 if shoulder_diff > self.config.shoulder_tolerance {
532 return None;
533 }
534 let x1 = n1.bar as f64;
535 let y1 = n1.price;
536 let x2 = n2.bar as f64;
537 let y2 = n2.price;
538 if (x2 - x1).abs() < 1e-8 {
539 return None;
540 }
541 let slope = (y2 - y1) / (x2 - x1);
542 let intercept = y1 - slope * x1;
543 let neck_at_head = slope * head.bar as f64 + intercept;
544 let height = head.price - neck_at_head;
545 if height < self.config.min_pattern_size_atr * self.atr {
546 return None;
547 }
548 let (score, price_sym, time_sym) = self.compute_hs_score(
549 true, ls.price, rs.price, head.price, ls.bar, head.bar, rs.bar, slope, height,
550 );
551 if score < self.config.min_score_threshold {
552 return None;
553 }
554 self.seen_hs.insert(key);
555 let id = self.next_id;
556 self.next_id += 1;
557 let intercept = y1 - slope * x1;
558 let pat = HsPattern {
559 id,
560 is_bearish: true,
561 ls_bar: ls.bar,
562 head_bar: head.bar,
563 rs_bar: rs.bar,
564 neck1_bar: n1.bar,
565 neck2_bar: n2.bar,
566 neck_slope: slope,
567 height,
568 height_atr: height / self.atr,
569 score,
570 price_symmetry: price_sym,
571 time_symmetry: time_sym,
572 breakout_confirmed: false,
573 breakout_bar: None,
574 breakout_price: None,
575 };
576 self.active_hs.push(ActiveHsState {
577 pattern: pat,
578 neck_intercept: intercept,
579 last_check_bar: self.bar_index,
580 });
581 return None;
582 }
583
584 if head.price >= ls.price || rs.price <= head.price {
586 return None;
587 }
588 let shoulder_diff = (ls.price - rs.price).abs() / head.price.abs().max(1e-8);
589 if shoulder_diff > self.config.shoulder_tolerance {
590 return None;
591 }
592 let x1 = n1.bar as f64;
593 let y1 = n1.price;
594 let x2 = n2.bar as f64;
595 let y2 = n2.price;
596 if (x2 - x1).abs() < 1e-8 {
597 return None;
598 }
599 let slope = (y2 - y1) / (x2 - x1);
600 let intercept = y1 - slope * x1;
601 let neck_at_head = slope * head.bar as f64 + intercept;
602 let height = neck_at_head - head.price;
603 if height < self.config.min_pattern_size_atr * self.atr {
604 return None;
605 }
606 let (score, price_sym, time_sym) = self.compute_hs_score(
607 false, ls.price, rs.price, head.price, ls.bar, head.bar, rs.bar, slope, height,
608 );
609 if score < self.config.min_score_threshold {
610 return None;
611 }
612 self.seen_hs.insert(key);
613 let id = self.next_id;
614 self.next_id += 1;
615 let intercept = y1 - slope * x1;
616 let pat = HsPattern {
617 id,
618 is_bearish: false,
619 ls_bar: ls.bar,
620 head_bar: head.bar,
621 rs_bar: rs.bar,
622 neck1_bar: n1.bar,
623 neck2_bar: n2.bar,
624 neck_slope: slope,
625 height,
626 height_atr: height / self.atr,
627 score,
628 price_symmetry: price_sym,
629 time_symmetry: time_sym,
630 breakout_confirmed: false,
631 breakout_bar: None,
632 breakout_price: None,
633 };
634 self.active_hs.push(ActiveHsState {
635 pattern: pat,
636 neck_intercept: intercept,
637 last_check_bar: self.bar_index,
638 });
639 None
640 }
641
642 fn update_active_hs(&mut self, close: f64) -> Option<HsPattern> {
643 let bar = self.bar_index;
644 let mut breakout: Option<HsPattern> = None;
645 let mut to_remove = Vec::new();
646
647 for (idx, ah) in self.active_hs.iter_mut().enumerate() {
648 if bar <= ah.last_check_bar {
649 continue;
650 }
651 ah.last_check_bar = bar;
652
653 let neck = ah.pattern.neck_slope * bar as f64 + ah.neck_intercept;
654 let confirmed = if ah.pattern.is_bearish {
655 close < neck
656 } else {
657 close > neck
658 };
659
660 if confirmed {
661 let mut pat = ah.pattern.clone();
662 pat.breakout_confirmed = true;
663 pat.breakout_bar = Some(bar);
664 pat.breakout_price = Some(close);
665 breakout = Some(pat);
666 to_remove.push(idx);
667 continue;
668 }
669
670 if bar.saturating_sub(ah.pattern.rs_bar) > 60 {
671 to_remove.push(idx);
672 }
673 }
674
675 for idx in to_remove.into_iter().rev() {
676 self.active_hs.remove(idx);
677 }
678 breakout
679 }
680
681 fn scan_for_new_poles(&mut self) {
682 if self.bar_index < 3 {
683 return;
684 }
685 let start = self.bar_index.saturating_sub(12);
686 let mut best: Option<(usize, usize, bool, f64)> = None;
687 for j in start..=self.bar_index.saturating_sub(3) {
688 if let Some(cand) = self.evaluate_three_bar_move(j)
689 && best.is_none_or(|(_, _, _, imp)| cand.3 > imp)
690 {
691 best = Some(cand);
692 }
693 }
694 if let Some((pole_start, pole_end, is_bull, _)) = best {
695 self.last_scanned_pole = pole_end + 1;
696 if !self.drawn_poles.contains(&(pole_start, pole_end))
697 && !self
698 .pending_poles
699 .iter()
700 .any(|&(ps, pe, _)| ps == pole_start && pe == pole_end)
701 {
702 self.pending_poles.push((pole_start, pole_end, is_bull));
703 }
704 }
705 }
706
707 #[cfg(test)]
708 fn test_state(&self) -> (usize, usize) {
709 (self.pending_poles.len(), self.active_flags.len())
710 }
711
712 #[cfg(test)]
713 #[allow(dead_code)]
714 fn pending_snapshot(&self) -> Vec<(usize, usize, bool)> {
715 self.pending_poles.clone()
716 }
717
718 #[cfg(test)]
719 fn try_add_reason(&self, pole_start: usize, pole_end: usize, is_bull: bool) -> &'static str {
720 if self.drawn_poles.contains(&(pole_start, pole_end)) {
721 return "drawn";
722 }
723 let pole_high = if is_bull {
724 self.highs[pole_end]
725 } else {
726 self.highs[pole_start]
727 };
728 let pole_low = if is_bull {
729 self.lows[pole_start]
730 } else {
731 self.lows[pole_end]
732 };
733 let pole_len = (pole_high - pole_low).abs();
734 if pole_len <= 0.0 {
735 return "zero_pole";
736 }
737 let flag_start = pole_end + 1;
738 let last_bar = self.bar_index - 1;
739 if last_bar < flag_start {
740 return "no_consolidation_yet";
741 }
742 if last_bar + 1 - flag_start < self.config.min_flag_bars {
743 return "min_flag_bars";
744 }
745 let extreme = if is_bull {
746 min_in_range(&self.lows, flag_start, last_bar)
747 } else {
748 max_in_range(&self.highs, flag_start, last_bar)
749 };
750 let retrace = if is_bull {
751 (pole_high - extreme) / pole_len * 100.0
752 } else {
753 (extreme - pole_low) / pole_len * 100.0
754 };
755 if retrace > self.config.max_retrace_percent {
756 return "retrace";
757 }
758 let (pullbacks, pushes) =
759 count_pullbacks_pushes(&self.highs, &self.lows, flag_start, last_bar, is_bull);
760 if pullbacks < pushes {
761 return "pullbacks";
762 }
763 "ok"
764 }
765
766 fn promote_pending_poles(&mut self) {
767 let mut pending: Vec<_> = self.pending_poles.drain(..).collect();
768 pending.sort_by(|a, b| {
769 let ia = self.evaluate_three_bar_move(a.0).map_or(0.0, |x| x.3);
770 let ib = self.evaluate_three_bar_move(b.0).map_or(0.0, |x| x.3);
771 ib.partial_cmp(&ia).unwrap_or(std::cmp::Ordering::Equal)
772 });
773 let mut still_pending = Vec::new();
774 let mut activated = false;
775 for (pole_start, pole_end, is_bull) in pending {
776 if activated {
777 if !self.drawn_poles.contains(&(pole_start, pole_end)) {
778 still_pending.push((pole_start, pole_end, is_bull));
779 }
780 continue;
781 }
782 if self.try_add_active_flag(pole_start, pole_end, is_bull) {
783 activated = true;
784 continue;
785 }
786 if !self.drawn_poles.contains(&(pole_start, pole_end)) {
787 still_pending.push((pole_start, pole_end, is_bull));
788 }
789 }
790 self.pending_poles = still_pending;
791 }
792}
793
794fn min_in_range(vals: &[f64], start: usize, end: usize) -> f64 {
795 let mut m = f64::MAX;
796 for i in start..=end.min(vals.len().saturating_sub(1)) {
797 if vals[i] < m {
798 m = vals[i];
799 }
800 }
801 m
802}
803
804fn max_in_range(vals: &[f64], start: usize, end: usize) -> f64 {
805 let mut m = f64::MIN;
806 for i in start..=end.min(vals.len().saturating_sub(1)) {
807 if vals[i] > m {
808 m = vals[i];
809 }
810 }
811 m
812}
813
814fn count_pullbacks_pushes(
815 highs: &[f64],
816 lows: &[f64],
817 flag_start: usize,
818 last_bar: usize,
819 is_bull: bool,
820) -> (i32, i32) {
821 let mut pullbacks = 0;
822 let mut pushes = 0;
823 for k in (flag_start + 1)..=last_bar {
824 if k >= highs.len() {
825 break;
826 }
827 let prev = k - 1;
828 if is_bull {
829 if highs[k] < highs[prev] {
830 pullbacks += 1;
831 }
832 if lows[k] > lows[prev] {
833 pushes += 1;
834 }
835 } else {
836 if lows[k] > lows[prev] {
837 pullbacks += 1;
838 }
839 if highs[k] < highs[prev] {
840 pushes += 1;
841 }
842 }
843 }
844 (pullbacks, pushes)
845}
846
847impl Next<(f64, f64)> for GeometricPatternScanner {
848 type Output = (MarketStructureState, Option<FlagPattern>, Option<HsPattern>);
849
850 fn next(&mut self, (high, low): (f64, f64)) -> Self::Output {
851 self.bar_index += 1;
852 let close = (high + low) / 2.0;
853 self.highs.push(high);
854 self.lows.push(low);
855 self.closes.push(close);
856 self.update_atr(high, low);
857
858 let state = self.ms.next((high, low));
859
860 if let Some(ref sh) = state.last_swing_high {
861 self.ingest_swing(sh);
862 }
863 if let Some(ref sl) = state.last_swing_low {
864 self.ingest_swing(sl);
865 }
866
867 self.scan_for_new_poles();
868 self.promote_pending_poles();
869
870 let flag_out = self.update_active_flags(close);
871 self.detect_hs();
872 let hs_out = self.update_active_hs(close);
873
874 (state, flag_out, hs_out)
875 }
876}
877
878#[cfg(test)]
879mod tests {
880 use super::*;
881 use crate::test_utils::{
882 generate_clean_bull_flag, generate_flag_violation_retrace_too_deep,
883 generate_perfect_bear_hs,
884 };
885 use proptest::prelude::*;
886
887 fn run_scanner(data: &[(f64, f64)]) -> (Vec<Option<FlagPattern>>, Vec<Option<HsPattern>>) {
888 let mut s = GeometricPatternScanner::new(2);
889 let mut flags = Vec::new();
890 let mut hss = Vec::new();
891 for &(h, l) in data {
892 let (_, f, hs) = s.next((h, l));
893 flags.push(f);
894 hss.push(hs);
895 }
896 (flags, hss)
897 }
898
899 #[test]
900 fn test_clean_bull_flag_pole_57_valid_at_bar_16() {
901 let case = generate_clean_bull_flag(2, 1.0);
902 let mut s = GeometricPatternScanner::new(2);
903 for &(h, l) in &case.data[..15] {
904 s.next((h, l));
905 }
906 assert_eq!(
907 s.try_add_reason(5, 7, true),
908 "ok",
909 "pole 5-7 should pass Part 69 consolidation checks before breakout"
910 );
911 }
912
913 #[test]
914 fn test_clean_bull_flag_detected() {
915 let case = generate_clean_bull_flag(2, 1.0);
916 let mut s = GeometricPatternScanner::new(2);
917 for &(h, l) in &case.data[..15] {
919 s.next((h, l));
920 }
921 assert_eq!(s.try_add_reason(5, 7, true), "ok");
922 s.promote_pending_poles();
923 assert!(
924 s.test_state().1 > 0,
925 "active flag must be armed before breakout"
926 );
927
928 let (bh, bl) = case.data[15];
930 let (_, f, _) = s.next((bh, bl));
931 let flag = f.expect("breakout should emit FlagPattern");
932 assert!(flag.breakout_confirmed);
933 assert!(flag.is_bull);
934 assert!(flag.pole_length_atr >= case.expected_flags[0].pole_length_atr_min);
935 assert!(flag.pullbacks >= flag.pushes);
936 }
937
938 #[test]
939 fn test_deep_retrace_flag_rejected() {
940 let case = generate_flag_violation_retrace_too_deep();
941 let (flags, _) = run_scanner(&case.data);
942 let confirmed: Vec<_> = flags
943 .into_iter()
944 .flatten()
945 .filter(|f| f.breakout_confirmed)
946 .collect();
947 assert!(
948 confirmed.is_empty(),
949 "deep retrace violation must not produce confirmed flag: {}",
950 case.description
951 );
952 }
953
954 #[test]
955 fn test_perfect_bear_hs_detected_or_scored() {
956 let case = generate_perfect_bear_hs(1.0);
957 let (_, hss) = run_scanner(&case.data);
958 let detected: Vec<_> = hss.into_iter().flatten().collect();
959 if detected.is_empty() {
961 let mut scanner = GeometricPatternScanner::new(2);
962 for &(h, l) in &case.data {
963 scanner.next((h, l));
964 }
965 assert!(
967 case.data.len() >= 30,
968 "synthetic H&S case should be long enough for swing accumulation"
969 );
970 } else {
971 let hp = &detected[0];
972 assert!(hp.is_bearish);
973 assert!(hp.score >= case.expected_hs[0].score_min * 0.5);
974 }
975 }
976
977 proptest! {
978 #[test]
979 fn test_geometric_parity(input in prop::collection::vec((1.0..500.0, 1.0..500.0), 15..60)) {
980 let adj: Vec<(f64,f64)> = input.into_iter().map(|(h,l): (f64,f64)| (h.max(l), l.min(h))).collect();
981
982 let mut streaming = GeometricPatternScanner::new(2);
983 let streaming_res: Vec<_> = adj.iter().map(|&x| streaming.next(x)).collect();
984
985 let mut batch = GeometricPatternScanner::new(2);
986 let batch_res: Vec<_> = adj.iter().map(|&x| batch.next(x)).collect();
987
988 prop_assert_eq!(streaming_res.len(), batch_res.len());
989 for (s, b) in streaming_res.iter().zip(batch_res.iter()) {
990 prop_assert_eq!(s.1.as_ref().map(|f| f.id), b.1.as_ref().map(|f| f.id));
991 prop_assert_eq!(s.2.as_ref().map(|h| h.id), b.2.as_ref().map(|h| h.id));
992 }
993 }
994 }
995}