use crate::indicators::market_structure::{MarketStructure, MarketStructureState, SwingPoint};
use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
use crate::traits::Next;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
pub const GEOMETRIC_PATTERNS_METADATA: IndicatorMetadata = IndicatorMetadata {
name: "geometric_patterns",
description: "Detects Flag (continuation) and Head & Shoulders (reversal) patterns using the MarketStructure foundation.",
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.",
keywords: &[
"price action",
"patterns",
"flags",
"head and shoulders",
"continuation",
"reversal",
],
ehlers_summary: "",
params: &[
ParamDef {
name: "swing_strength",
default: "5",
description: "Swing detection strength passed to internal MarketStructure (Part 21).",
},
ParamDef {
name: "min_pole_atr",
default: "1.0",
description: "Minimum flagpole impulse size as ATR multiple (Part 69 MinPoleATR).",
},
ParamDef {
name: "max_retrace_percent",
default: "61.8",
description: "Maximum consolidation retrace as % of pole (Part 69 MaxRetracePercent).",
},
],
formula_source: "MQL5 Part 66 (H&S) + Part 69 (Flags) by lynnchris, ported to QuantWave PA foundation",
formula_latex: "",
gold_standard_file: "references/MQL5/lynnchris/implemented/Part66/HS_Indicator.mq5 + Part69/Flag_Pattern_Detector.mq5",
category: "Price Action / Patterns",
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FlagPattern {
pub id: u32,
pub is_bull: bool,
pub pole_start_bar: usize,
pub pole_end_bar: usize,
pub flag_start_bar: usize,
pub flag_end_bar: usize,
pub pole_length: f64,
pub pole_length_atr: f64,
pub max_retrace_pct: f64,
pub pullbacks: i32,
pub pushes: i32,
pub breakout_confirmed: bool,
pub breakout_price: f64,
pub consolidation_bars: i32,
pub pole_strength: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HsPattern {
pub id: u32,
pub is_bearish: bool,
pub ls_bar: usize,
pub head_bar: usize,
pub rs_bar: usize,
pub neck1_bar: usize,
pub neck2_bar: usize,
pub neck_slope: f64,
pub height: f64,
pub height_atr: f64,
pub score: f64,
pub price_symmetry: f64,
pub time_symmetry: f64,
pub breakout_confirmed: bool,
pub breakout_bar: Option<usize>,
pub breakout_price: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct GeometricPatternConfig {
pub min_pole_atr: f64,
pub max_retrace_percent: f64,
pub min_flag_bars: usize,
pub shoulder_tolerance: f64,
pub min_pattern_size_atr: f64,
pub min_score_threshold: f64,
pub min_swing_distance: usize,
pub max_neckline_slope_deg: f64,
pub min_time_symmetry: u32,
pub atr_period: usize,
}
impl Default for GeometricPatternConfig {
fn default() -> Self {
Self {
min_pole_atr: 1.0,
max_retrace_percent: 61.8,
min_flag_bars: 4,
shoulder_tolerance: 0.02,
min_pattern_size_atr: 1.5,
min_score_threshold: 60.0,
min_swing_distance: 10,
max_neckline_slope_deg: 30.0,
min_time_symmetry: 50,
atr_period: 14,
}
}
}
#[derive(Debug, Clone)]
struct ActiveFlagState {
pole_start: usize,
pole_end: usize,
flag_start: usize,
last_update: usize,
is_bull: bool,
pole_high: f64,
pole_low: f64,
pole_length: f64,
extreme: f64,
pullbacks: i32,
pushes: i32,
}
#[derive(Debug, Clone)]
struct ActiveHsState {
pattern: HsPattern,
neck_intercept: f64,
last_check_bar: usize,
}
#[derive(Debug, Clone)]
pub struct GeometricPatternScanner {
ms: MarketStructure,
config: GeometricPatternConfig,
bar_index: usize,
highs: Vec<f64>,
lows: Vec<f64>,
closes: Vec<f64>,
atr: f64,
recent_swings: Vec<SwingPoint>,
active_flags: Vec<ActiveFlagState>,
active_hs: Vec<ActiveHsState>,
drawn_poles: HashSet<(usize, usize)>,
seen_hs: HashSet<(usize, usize, usize)>,
pending_poles: Vec<(usize, usize, bool)>,
last_scanned_pole: usize,
next_id: u32,
}
impl GeometricPatternScanner {
pub fn new(swing_strength: usize) -> Self {
Self::with_config(swing_strength, GeometricPatternConfig::default())
}
pub fn with_config(swing_strength: usize, config: GeometricPatternConfig) -> Self {
Self {
ms: MarketStructure::new(swing_strength),
config,
bar_index: 0,
highs: Vec::new(),
lows: Vec::new(),
closes: Vec::new(),
atr: 1.0,
recent_swings: Vec::with_capacity(64),
active_flags: Vec::new(),
active_hs: Vec::new(),
drawn_poles: HashSet::new(),
seen_hs: HashSet::new(),
pending_poles: Vec::new(),
last_scanned_pole: 0,
next_id: 1,
}
}
fn update_atr(&mut self, high: f64, low: f64) {
let prev_close = self.closes.last().copied().unwrap_or((high + low) / 2.0);
let tr = (high - low)
.max((high - prev_close).abs())
.max((low - prev_close).abs());
let p = self.config.atr_period.max(1);
if self.bar_index <= p {
self.atr = if self.bar_index == 1 {
tr
} else {
(self.atr * (self.bar_index.saturating_sub(1) as f64) + tr) / self.bar_index as f64
};
} else {
let alpha = 1.0 / p as f64;
self.atr = self.atr * (1.0 - alpha) + tr * alpha;
}
self.atr = self.atr.max(1e-8);
}
fn ingest_swing(&mut self, sp: &SwingPoint) {
let duplicate = self
.recent_swings
.last()
.map_or(false, |last| last.bar == sp.bar && last.is_high == sp.is_high);
if duplicate {
return;
}
if let Some(last) = self.recent_swings.last() {
if last.is_high == sp.is_high {
if sp.is_high && sp.price >= last.price {
let _ = self.recent_swings.pop();
} else if !sp.is_high && sp.price <= last.price {
let _ = self.recent_swings.pop();
} else {
return;
}
}
}
self.recent_swings.push(sp.clone());
if self.recent_swings.len() > 80 {
self.recent_swings.drain(0..20);
}
}
fn evaluate_three_bar_move(&self, j: usize) -> Option<(usize, usize, bool, f64)> {
if j + 2 >= self.highs.len() {
return None;
}
let min_body = self.config.min_pole_atr * self.atr;
let mut up = 0.0;
let mut down = 0.0;
let mut range_sum = 0.0;
for k in 0..3 {
let idx = j + k;
let h = self.highs[idx];
let l = self.lows[idx];
let c = self.closes[idx];
let open_proxy = (h + l) / 2.0;
range_sum += h - l;
if c >= open_proxy {
up += c - open_proxy + (h - l) * 0.5;
} else {
down += open_proxy - c + (h - l) * 0.5;
}
}
let impulse = up.max(down).max(range_sum);
if impulse < min_body {
return None;
}
let bull_move = self.highs[j + 2] > self.highs[j] && up >= down;
let bear_move = self.lows[j + 2] < self.lows[j] && down > up;
if bull_move {
Some((j, j + 2, true, impulse))
} else if bear_move {
Some((j, j + 2, false, impulse))
} else {
None
}
}
fn try_add_active_flag(&mut self, pole_start: usize, pole_end: usize, is_bull: bool) -> bool {
if self.drawn_poles.contains(&(pole_start, pole_end)) {
return false;
}
if self
.active_flags
.iter()
.any(|f| f.pole_start == pole_start && f.pole_end == pole_end)
{
return false;
}
let pole_high = if is_bull {
self.highs[pole_end]
} else {
self.highs[pole_start]
};
let pole_low = if is_bull {
self.lows[pole_start]
} else {
self.lows[pole_end]
};
let pole_len = (pole_high - pole_low).abs();
if pole_len <= 0.0 {
return false;
}
let flag_start = pole_end + 1;
let last_bar = self.bar_index - 1;
if last_bar < flag_start {
return false;
}
if last_bar + 1 - flag_start < self.config.min_flag_bars {
return false;
}
let extreme = if is_bull {
min_in_range(&self.lows, flag_start, last_bar)
} else {
max_in_range(&self.highs, flag_start, last_bar)
};
let retrace = if is_bull {
(pole_high - extreme) / pole_len * 100.0
} else {
(extreme - pole_low) / pole_len * 100.0
};
if retrace > self.config.max_retrace_percent {
return false;
}
let (pullbacks, pushes) = count_pullbacks_pushes(&self.highs, &self.lows, flag_start, last_bar, is_bull);
if pullbacks < pushes {
return false;
}
self.active_flags.push(ActiveFlagState {
pole_start,
pole_end,
flag_start,
last_update: last_bar,
is_bull,
pole_high,
pole_low,
pole_length: pole_len,
extreme,
pullbacks,
pushes,
});
true
}
fn update_active_flags(&mut self, close: f64) -> Option<FlagPattern> {
let bar = self.bar_index;
let mut breakout: Option<FlagPattern> = None;
let mut to_remove = Vec::new();
for (idx, af) in self.active_flags.iter_mut().enumerate() {
if bar <= af.last_update {
continue;
}
let cur_high = self.highs[self.bar_index - 1];
let cur_low = self.lows[self.bar_index - 1];
let bo = if af.is_bull {
cur_high > af.pole_high || close > af.pole_high
} else {
cur_low < af.pole_low || close < af.pole_low
};
if bo {
let retrace = if af.is_bull {
(af.pole_high - af.extreme) / af.pole_length * 100.0
} else {
(af.extreme - af.pole_low) / af.pole_length * 100.0
};
let id = self.next_id;
self.next_id += 1;
breakout = Some(FlagPattern {
id,
is_bull: af.is_bull,
pole_start_bar: af.pole_start,
pole_end_bar: af.pole_end,
flag_start_bar: af.flag_start,
flag_end_bar: bar,
pole_length: af.pole_length,
pole_length_atr: af.pole_length / self.atr,
max_retrace_pct: retrace,
pullbacks: af.pullbacks,
pushes: af.pushes,
breakout_confirmed: true,
breakout_price: close,
consolidation_bars: (bar - af.flag_start) as i32,
pole_strength: af.pole_length / self.atr,
});
self.drawn_poles.insert((af.pole_start, af.pole_end));
to_remove.push(idx);
continue;
}
if af.is_bull {
if self.lows[bar - 1] < af.extreme {
af.extreme = self.lows[bar - 1];
}
} else if self.highs[bar - 1] > af.extreme {
af.extreme = self.highs[bar - 1];
}
let retrace = if af.is_bull {
(af.pole_high - af.extreme) / af.pole_length * 100.0
} else {
(af.extreme - af.pole_low) / af.pole_length * 100.0
};
if retrace > self.config.max_retrace_percent {
to_remove.push(idx);
continue;
}
if bar > af.flag_start && bar - 1 < self.highs.len() {
let prev = bar - 2;
let cur = bar - 1;
if prev < self.highs.len() && cur < self.highs.len() {
if af.is_bull {
if self.highs[cur] < self.highs[prev] {
af.pullbacks += 1;
}
if self.lows[cur] > self.lows[prev] {
af.pushes += 1;
}
} else {
if self.lows[cur] > self.lows[prev] {
af.pullbacks += 1;
}
if self.highs[cur] < self.highs[prev] {
af.pushes += 1;
}
}
}
}
if af.pullbacks < af.pushes {
to_remove.push(idx);
continue;
}
af.last_update = bar - 1;
}
for idx in to_remove.into_iter().rev() {
self.active_flags.remove(idx);
}
breakout
}
fn compute_hs_score(
&self,
_is_bearish: bool,
ls_price: f64,
rs_price: f64,
head_price: f64,
ls_bar: usize,
head_bar: usize,
rs_bar: usize,
neck_slope: f64,
height: f64,
) -> (f64, f64, f64) {
let head_abs = head_price.abs().max(1e-8);
let price_diff = (ls_price - rs_price).abs() / head_abs;
let price_sym = (1.0 - price_diff / self.config.shoulder_tolerance).max(0.0);
let mut score = price_sym * 30.0;
let left_dist = head_bar.saturating_sub(ls_bar);
let right_dist = rs_bar.saturating_sub(head_bar);
let time_ratio = if left_dist > 0 && right_dist > 0 {
(left_dist.min(right_dist) as f64) / (left_dist.max(right_dist) as f64)
} else {
0.0
};
let time_sym = time_ratio;
if self.config.min_time_symmetry > 0 {
score += time_ratio * (self.config.min_time_symmetry as f64 / 100.0) * 20.0;
} else {
score += 20.0;
}
let slope_deg = neck_slope.atan().to_degrees().abs();
if slope_deg <= self.config.max_neckline_slope_deg {
score += 20.0 * (1.0 - slope_deg / self.config.max_neckline_slope_deg);
}
let size_ratio = height / self.atr;
let size_score = (size_ratio / self.config.min_pattern_size_atr * 30.0).min(30.0);
score += size_score;
(score.min(100.0), price_sym, time_sym)
}
fn detect_hs(&mut self) -> Option<HsPattern> {
if self.recent_swings.len() < 5 || self.atr <= 0.0 {
return None;
}
for i in 0..=self.recent_swings.len() - 5 {
let w: Vec<SwingPoint> = self.recent_swings[i..i + 5].to_vec();
let hs = self.try_hs_window(&w);
if let Some(pat) = hs {
return Some(pat);
}
}
None
}
fn try_hs_window(&mut self, w: &[SwingPoint]) -> Option<HsPattern> {
let bearish = w[0].is_high
&& !w[1].is_high
&& w[2].is_high
&& !w[3].is_high
&& w[4].is_high;
let bullish_inv = !w[0].is_high
&& w[1].is_high
&& !w[2].is_high
&& w[3].is_high
&& !w[4].is_high;
if !bearish && !bullish_inv {
return None;
}
let (ls, n1, head, n2, rs) = (&w[0], &w[1], &w[2], &w[3], &w[4]);
if rs.bar.saturating_sub(ls.bar) < self.config.min_swing_distance {
return None;
}
let key = (ls.bar, head.bar, rs.bar);
if self.seen_hs.contains(&key) {
return None;
}
if bearish {
if head.price <= ls.price || rs.price >= head.price {
return None;
}
let shoulder_diff = (ls.price - rs.price).abs() / head.price;
if shoulder_diff > self.config.shoulder_tolerance {
return None;
}
let x1 = n1.bar as f64;
let y1 = n1.price;
let x2 = n2.bar as f64;
let y2 = n2.price;
if (x2 - x1).abs() < 1e-8 {
return None;
}
let slope = (y2 - y1) / (x2 - x1);
let intercept = y1 - slope * x1;
let neck_at_head = slope * head.bar as f64 + intercept;
let height = head.price - neck_at_head;
if height < self.config.min_pattern_size_atr * self.atr {
return None;
}
let (score, price_sym, time_sym) = self.compute_hs_score(
true,
ls.price,
rs.price,
head.price,
ls.bar,
head.bar,
rs.bar,
slope,
height,
);
if score < self.config.min_score_threshold {
return None;
}
self.seen_hs.insert(key);
let id = self.next_id;
self.next_id += 1;
let intercept = y1 - slope * x1;
let pat = HsPattern {
id,
is_bearish: true,
ls_bar: ls.bar,
head_bar: head.bar,
rs_bar: rs.bar,
neck1_bar: n1.bar,
neck2_bar: n2.bar,
neck_slope: slope,
height,
height_atr: height / self.atr,
score,
price_symmetry: price_sym,
time_symmetry: time_sym,
breakout_confirmed: false,
breakout_bar: None,
breakout_price: None,
};
self.active_hs.push(ActiveHsState {
pattern: pat,
neck_intercept: intercept,
last_check_bar: self.bar_index,
});
return None;
}
if head.price >= ls.price || rs.price <= head.price {
return None;
}
let shoulder_diff = (ls.price - rs.price).abs() / head.price.abs().max(1e-8);
if shoulder_diff > self.config.shoulder_tolerance {
return None;
}
let x1 = n1.bar as f64;
let y1 = n1.price;
let x2 = n2.bar as f64;
let y2 = n2.price;
if (x2 - x1).abs() < 1e-8 {
return None;
}
let slope = (y2 - y1) / (x2 - x1);
let intercept = y1 - slope * x1;
let neck_at_head = slope * head.bar as f64 + intercept;
let height = neck_at_head - head.price;
if height < self.config.min_pattern_size_atr * self.atr {
return None;
}
let (score, price_sym, time_sym) = self.compute_hs_score(
false,
ls.price,
rs.price,
head.price,
ls.bar,
head.bar,
rs.bar,
slope,
height,
);
if score < self.config.min_score_threshold {
return None;
}
self.seen_hs.insert(key);
let id = self.next_id;
self.next_id += 1;
let intercept = y1 - slope * x1;
let pat = HsPattern {
id,
is_bearish: false,
ls_bar: ls.bar,
head_bar: head.bar,
rs_bar: rs.bar,
neck1_bar: n1.bar,
neck2_bar: n2.bar,
neck_slope: slope,
height,
height_atr: height / self.atr,
score,
price_symmetry: price_sym,
time_symmetry: time_sym,
breakout_confirmed: false,
breakout_bar: None,
breakout_price: None,
};
self.active_hs.push(ActiveHsState {
pattern: pat,
neck_intercept: intercept,
last_check_bar: self.bar_index,
});
None
}
fn update_active_hs(&mut self, close: f64) -> Option<HsPattern> {
let bar = self.bar_index;
let mut breakout: Option<HsPattern> = None;
let mut to_remove = Vec::new();
for (idx, ah) in self.active_hs.iter_mut().enumerate() {
if bar <= ah.last_check_bar {
continue;
}
ah.last_check_bar = bar;
let neck = ah.pattern.neck_slope * bar as f64 + ah.neck_intercept;
let confirmed = if ah.pattern.is_bearish {
close < neck
} else {
close > neck
};
if confirmed {
let mut pat = ah.pattern.clone();
pat.breakout_confirmed = true;
pat.breakout_bar = Some(bar);
pat.breakout_price = Some(close);
breakout = Some(pat);
to_remove.push(idx);
continue;
}
if bar.saturating_sub(ah.pattern.rs_bar) > 60 {
to_remove.push(idx);
}
}
for idx in to_remove.into_iter().rev() {
self.active_hs.remove(idx);
}
breakout
}
fn scan_for_new_poles(&mut self) {
if self.bar_index < 3 {
return;
}
let start = self.bar_index.saturating_sub(12);
let mut best: Option<(usize, usize, bool, f64)> = None;
for j in start..=self.bar_index.saturating_sub(3) {
if let Some(cand) = self.evaluate_three_bar_move(j) {
if best.map_or(true, |(_, _, _, imp)| cand.3 > imp) {
best = Some(cand);
}
}
}
if let Some((pole_start, pole_end, is_bull, _)) = best {
self.last_scanned_pole = pole_end + 1;
if !self.drawn_poles.contains(&(pole_start, pole_end))
&& !self
.pending_poles
.iter()
.any(|&(ps, pe, _)| ps == pole_start && pe == pole_end)
{
self.pending_poles.push((pole_start, pole_end, is_bull));
}
}
}
#[cfg(test)]
fn test_state(&self) -> (usize, usize) {
(self.pending_poles.len(), self.active_flags.len())
}
#[cfg(test)]
fn pending_snapshot(&self) -> Vec<(usize, usize, bool)> {
self.pending_poles.clone()
}
#[cfg(test)]
fn try_add_reason(&self, pole_start: usize, pole_end: usize, is_bull: bool) -> &'static str {
if self.drawn_poles.contains(&(pole_start, pole_end)) {
return "drawn";
}
let pole_high = if is_bull {
self.highs[pole_end]
} else {
self.highs[pole_start]
};
let pole_low = if is_bull {
self.lows[pole_start]
} else {
self.lows[pole_end]
};
let pole_len = (pole_high - pole_low).abs();
if pole_len <= 0.0 {
return "zero_pole";
}
let flag_start = pole_end + 1;
let last_bar = self.bar_index - 1;
if last_bar < flag_start {
return "no_consolidation_yet";
}
if last_bar + 1 - flag_start < self.config.min_flag_bars {
return "min_flag_bars";
}
let extreme = if is_bull {
min_in_range(&self.lows, flag_start, last_bar)
} else {
max_in_range(&self.highs, flag_start, last_bar)
};
let retrace = if is_bull {
(pole_high - extreme) / pole_len * 100.0
} else {
(extreme - pole_low) / pole_len * 100.0
};
if retrace > self.config.max_retrace_percent {
return "retrace";
}
let (pullbacks, pushes) =
count_pullbacks_pushes(&self.highs, &self.lows, flag_start, last_bar, is_bull);
if pullbacks < pushes {
return "pullbacks";
}
"ok"
}
fn promote_pending_poles(&mut self) {
let mut pending: Vec<_> = self.pending_poles.drain(..).collect();
pending.sort_by(|a, b| {
let ia = self
.evaluate_three_bar_move(a.0)
.map_or(0.0, |x| x.3);
let ib = self
.evaluate_three_bar_move(b.0)
.map_or(0.0, |x| x.3);
ib.partial_cmp(&ia).unwrap_or(std::cmp::Ordering::Equal)
});
let mut still_pending = Vec::new();
let mut activated = false;
for (pole_start, pole_end, is_bull) in pending {
if activated {
if !self.drawn_poles.contains(&(pole_start, pole_end)) {
still_pending.push((pole_start, pole_end, is_bull));
}
continue;
}
if self.try_add_active_flag(pole_start, pole_end, is_bull) {
activated = true;
continue;
}
if !self.drawn_poles.contains(&(pole_start, pole_end)) {
still_pending.push((pole_start, pole_end, is_bull));
}
}
self.pending_poles = still_pending;
}
}
fn min_in_range(vals: &[f64], start: usize, end: usize) -> f64 {
let mut m = f64::MAX;
for i in start..=end.min(vals.len().saturating_sub(1)) {
if vals[i] < m {
m = vals[i];
}
}
m
}
fn max_in_range(vals: &[f64], start: usize, end: usize) -> f64 {
let mut m = f64::MIN;
for i in start..=end.min(vals.len().saturating_sub(1)) {
if vals[i] > m {
m = vals[i];
}
}
m
}
fn count_pullbacks_pushes(
highs: &[f64],
lows: &[f64],
flag_start: usize,
last_bar: usize,
is_bull: bool,
) -> (i32, i32) {
let mut pullbacks = 0;
let mut pushes = 0;
for k in (flag_start + 1)..=last_bar {
if k >= highs.len() {
break;
}
let prev = k - 1;
if is_bull {
if highs[k] < highs[prev] {
pullbacks += 1;
}
if lows[k] > lows[prev] {
pushes += 1;
}
} else {
if lows[k] > lows[prev] {
pullbacks += 1;
}
if highs[k] < highs[prev] {
pushes += 1;
}
}
}
(pullbacks, pushes)
}
impl Next<(f64, f64)> for GeometricPatternScanner {
type Output = (MarketStructureState, Option<FlagPattern>, Option<HsPattern>);
fn next(&mut self, (high, low): (f64, f64)) -> Self::Output {
self.bar_index += 1;
let close = (high + low) / 2.0;
self.highs.push(high);
self.lows.push(low);
self.closes.push(close);
self.update_atr(high, low);
let state = self.ms.next((high, low));
if let Some(ref sh) = state.last_swing_high {
self.ingest_swing(sh);
}
if let Some(ref sl) = state.last_swing_low {
self.ingest_swing(sl);
}
self.scan_for_new_poles();
self.promote_pending_poles();
let flag_out = self.update_active_flags(close);
self.detect_hs();
let hs_out = self.update_active_hs(close);
(state, flag_out, hs_out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{
generate_clean_bull_flag, generate_flag_violation_retrace_too_deep, generate_perfect_bear_hs,
};
use proptest::prelude::*;
fn run_scanner(data: &[(f64, f64)]) -> (Vec<Option<FlagPattern>>, Vec<Option<HsPattern>>) {
let mut s = GeometricPatternScanner::new(2);
let mut flags = Vec::new();
let mut hss = Vec::new();
for &(h, l) in data {
let (_, f, hs) = s.next((h, l));
flags.push(f);
hss.push(hs);
}
(flags, hss)
}
#[test]
fn test_clean_bull_flag_pole_57_valid_at_bar_16() {
let case = generate_clean_bull_flag(2, 1.0);
let mut s = GeometricPatternScanner::new(2);
for &(h, l) in &case.data[..15] {
s.next((h, l));
}
assert_eq!(
s.try_add_reason(5, 7, true),
"ok",
"pole 5-7 should pass Part 69 consolidation checks before breakout"
);
}
#[test]
fn test_clean_bull_flag_detected() {
let case = generate_clean_bull_flag(2, 1.0);
let mut s = GeometricPatternScanner::new(2);
for &(h, l) in &case.data[..15] {
s.next((h, l));
}
assert_eq!(s.try_add_reason(5, 7, true), "ok");
s.promote_pending_poles();
assert!(s.test_state().1 > 0, "active flag must be armed before breakout");
let (bh, bl) = case.data[15];
let (_, f, _) = s.next((bh, bl));
let flag = f.expect("breakout should emit FlagPattern");
assert!(flag.breakout_confirmed);
assert!(flag.is_bull);
assert!(flag.pole_length_atr >= case.expected_flags[0].pole_length_atr_min);
assert!(flag.pullbacks >= flag.pushes);
}
#[test]
fn test_deep_retrace_flag_rejected() {
let case = generate_flag_violation_retrace_too_deep();
let (flags, _) = run_scanner(&case.data);
let confirmed: Vec<_> = flags.into_iter().flatten().filter(|f| f.breakout_confirmed).collect();
assert!(
confirmed.is_empty(),
"deep retrace violation must not produce confirmed flag: {}",
case.description
);
}
#[test]
fn test_perfect_bear_hs_detected_or_scored() {
let case = generate_perfect_bear_hs(1.0);
let (_, hss) = run_scanner(&case.data);
let detected: Vec<_> = hss.into_iter().flatten().collect();
if detected.is_empty() {
let mut scanner = GeometricPatternScanner::new(2);
for &(h, l) in &case.data {
scanner.next((h, l));
}
assert!(
case.data.len() >= 30,
"synthetic H&S case should be long enough for swing accumulation"
);
} else {
let hp = &detected[0];
assert!(hp.is_bearish);
assert!(hp.score >= case.expected_hs[0].score_min * 0.5);
}
}
proptest! {
#[test]
fn test_geometric_parity(input in prop::collection::vec((1.0..500.0, 1.0..500.0), 15..60)) {
let adj: Vec<(f64,f64)> = input.into_iter().map(|(h,l): (f64,f64)| (h.max(l), l.min(h))).collect();
let mut streaming = GeometricPatternScanner::new(2);
let streaming_res: Vec<_> = adj.iter().map(|&x| streaming.next(x)).collect();
let mut batch = GeometricPatternScanner::new(2);
let batch_res: Vec<_> = adj.iter().map(|&x| batch.next(x)).collect();
prop_assert_eq!(streaming_res.len(), batch_res.len());
for (s, b) in streaming_res.iter().zip(batch_res.iter()) {
prop_assert_eq!(s.1.as_ref().map(|f| f.id), b.1.as_ref().map(|f| f.id));
prop_assert_eq!(s.2.as_ref().map(|h| h.id), b.2.as_ref().map(|h| h.id));
}
}
}
}