use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
use crate::traits::Next;
use std::collections::VecDeque;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Bias {
Bullish,
Bearish,
Neutral,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SwingPoint {
pub bar: usize,
pub price: f64,
pub is_high: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FlipEvent {
pub is_bearish: bool,
pub price: f64,
pub bar: usize,
pub structure_strength: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MarketStructureState {
pub bias: Bias,
pub last_swing_high: Option<SwingPoint>,
pub last_swing_low: Option<SwingPoint>,
pub current_flip: Option<FlipEvent>,
pub swing_depth_used: usize,
pub bar_index: usize,
}
#[derive(Debug, Clone)]
pub struct MarketStructure {
swing_strength: usize,
min_swing_distance: usize,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
bar_index: usize,
last_high: Option<SwingPoint>,
prev_high: Option<SwingPoint>,
last_low: Option<SwingPoint>,
prev_low: Option<SwingPoint>,
bias: Bias,
bull_structure_count: u32, bear_structure_count: u32,
}
impl MarketStructure {
pub fn new(swing_strength: usize) -> Self {
let strength = swing_strength.max(1);
Self {
swing_strength: strength,
min_swing_distance: strength * 2,
highs: VecDeque::with_capacity(strength * 2 + 4),
lows: VecDeque::with_capacity(strength * 2 + 4),
bar_index: 0,
last_high: None,
prev_high: None,
last_low: None,
prev_low: None,
bias: Bias::Neutral,
bull_structure_count: 0,
bear_structure_count: 0,
}
}
pub fn with_params(swing_strength: usize, min_swing_distance: usize) -> Self {
let mut s = Self::new(swing_strength);
s.min_swing_distance = min_swing_distance.max(swing_strength);
s
}
fn is_swing_high(&self, depth: usize) -> bool {
if self.highs.len() < 2 * depth + 1 {
return false;
}
let center_idx = depth;
let p = self.highs[center_idx];
for i in 0..(2 * depth + 1) {
if i == center_idx {
continue;
}
if self.highs[i] > p {
return false;
}
}
true
}
fn is_swing_low(&self, depth: usize) -> bool {
if self.lows.len() < 2 * depth + 1 {
return false;
}
let center_idx = depth;
let p = self.lows[center_idx];
for i in 0..(2 * depth + 1) {
if i == center_idx {
continue;
}
if self.lows[i] < p {
return false;
}
}
true
}
fn update_structure(&mut self, candidate: SwingPoint) -> Option<FlipEvent> {
let mut flip = None;
if candidate.is_high {
if let Some(last) = &self.last_high {
if candidate.bar.saturating_sub(last.bar) < self.min_swing_distance {
return None; }
if candidate.price > last.price {
self.bull_structure_count = self.bull_structure_count.saturating_add(1);
if self.bull_structure_count >= 2 {
self.bias = Bias::Bullish;
}
self.prev_high = self.last_high.clone();
self.last_high = Some(candidate.clone());
} else if candidate.price < last.price {
if self.bias == Bias::Bullish && self.bull_structure_count >= 2 {
flip = Some(FlipEvent {
is_bearish: true,
price: candidate.price,
bar: candidate.bar,
structure_strength: self.bull_structure_count,
});
self.bias = Bias::Bearish;
self.bear_structure_count = 1;
} else {
self.prev_high = self.last_high.clone();
self.last_high = Some(candidate.clone());
}
}
} else {
self.last_high = Some(candidate.clone());
}
} else {
if let Some(last) = &self.last_low {
if candidate.bar.saturating_sub(last.bar) < self.min_swing_distance {
return None;
}
if candidate.price < last.price {
self.bear_structure_count = self.bear_structure_count.saturating_add(1);
if self.bear_structure_count >= 2 {
self.bias = Bias::Bearish;
}
self.prev_low = self.last_low.clone();
self.last_low = Some(candidate.clone());
} else if candidate.price > last.price {
if self.bias == Bias::Bearish && self.bear_structure_count >= 2 {
flip = Some(FlipEvent {
is_bearish: false,
price: candidate.price,
bar: candidate.bar,
structure_strength: self.bear_structure_count,
});
self.bias = Bias::Bullish;
self.bull_structure_count = 1;
} else {
self.prev_low = self.last_low.clone();
self.last_low = Some(candidate.clone());
}
}
} else {
self.last_low = Some(candidate.clone());
}
}
flip
}
}
impl Default for MarketStructure {
fn default() -> Self {
Self::new(3) }
}
impl Next<(f64, f64)> for MarketStructure {
type Output = MarketStructureState;
fn next(&mut self, (high, low): (f64, f64)) -> Self::Output {
self.highs.push_back(high);
self.lows.push_back(low);
self.bar_index += 1;
let depth = self.swing_strength;
let window = 2 * depth + 1;
if self.highs.len() > window {
self.highs.pop_front();
self.lows.pop_front();
}
let mut current_flip = None;
if self.highs.len() == window {
if self.is_swing_high(depth) {
let center_price = self.highs[depth];
let center_bar = self.bar_index.saturating_sub(depth + 1); let sp = SwingPoint {
bar: center_bar,
price: center_price,
is_high: true,
};
if let Some(f) = self.update_structure(sp) {
current_flip = Some(f);
}
}
if self.lows.len() == window && self.is_swing_low(depth) {
let center_price = self.lows[depth];
let center_bar = self.bar_index.saturating_sub(depth + 1);
let sp = SwingPoint {
bar: center_bar,
price: center_price,
is_high: false,
};
if let Some(f) = self.update_structure(sp) {
current_flip = Some(f); }
}
}
MarketStructureState {
bias: self.bias,
last_swing_high: self.last_high.clone(),
last_swing_low: self.last_low.clone(),
current_flip,
swing_depth_used: depth,
bar_index: self.bar_index,
}
}
}
pub const MARKET_STRUCTURE_METADATA: IndicatorMetadata = IndicatorMetadata {
name: "Market Structure (Swings + BOS)",
description: "Adaptive swing detection with ATR-derived depth + bias tracking and confirmed Break of Structure flips (HH/HL/LL/LH). Foundation for geometric PA patterns (Flags, H&S) and S/R monitoring from the MQL5 lynnchris toolkit (Part 21).",
usage: "Use .ta.market_structure() or the Rust struct for rich PA events. Bias and flips feed position sizing, regime filters, and confluence with ML features / Ehlers regimes. Emit as Struct for backtester consumption.",
keywords: &["price-action", "structure", "swing", "bos", "market-structure", "mql5"],
ehlers_summary: "Not Ehlers DSP; classical PA structure from MQL5 series. See Part 21 for ATR-adaptive depth swings and confirmed flips only after bias (avoids premature signals).",
params: &[
ParamDef {
name: "swing_strength",
default: "3",
description: "Bar window radius for local extremum (depth). Part 21 derives this from ATR*mult; fixed here for streaming parity + immediate use (see source Flip_Detector.mq5:150).",
},
],
formula_source: "https://www.mql5.com/en/articles/17891 (Part 21) + cross Part 66/69/67",
formula_latex: r#"
\text{depth} = \max(1, \lfloor \text{ATR} \times \text{mult} \times \text{loosen} / \text{point} \rfloor)
\text{IsSwingHigh}(shift, depth) = \forall i \in [shift-depth, shift+depth], i \ne shift: High_i \le High_{shift}
"#,
gold_standard_file: "", category: "Price Action",
};
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn test_market_structure_basic_bullish_sequence() {
let mut ms = MarketStructure::new(2);
let highs = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 14.5, 16.0, 15.0, 17.0, 16.0];
let lows = vec![9.0, 9.5, 10.0, 10.5, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5];
let mut last_flip: Option<FlipEvent> = None;
for i in 0..highs.len() {
let state = ms.next((highs[i], lows[i]));
if state.current_flip.is_some() {
last_flip = state.current_flip.clone();
}
if i > 6 {
assert!(state.bias == Bias::Bullish || state.bias == Bias::Neutral);
}
}
if let Some(f) = last_flip {
assert!(f.is_bearish);
}
}
#[test]
fn test_market_structure_flip_after_bearish_bias() {
let mut ms = MarketStructure::new(2);
let highs = vec![20.0, 19.0, 18.5, 17.0, 16.0, 15.5, 16.5, 15.0, 14.0];
let lows = vec![18.0, 17.0, 16.0, 15.0, 14.0, 13.5, 14.5, 13.0, 12.0];
for i in 0..highs.len() {
let _state = ms.next((highs[i], lows[i]));
}
}
fn batch_market_structure(data: &[(f64, f64)], strength: usize) -> Vec<MarketStructureState> {
let mut ms = MarketStructure::new(strength);
data.iter().map(|&(h, l)| ms.next((h, l))).collect()
}
proptest! {
#[test]
fn test_market_structure_parity(
input in prop::collection::vec((0.0..1000.0, 0.0..1000.0), 10..80)
) {
let adj: Vec<(f64, f64)> = input
.into_iter()
.map(|(h, l): (f64, f64)| (h.max(l), l.min(h)))
.collect();
let mut streaming = MarketStructure::new(2);
let streaming_res: Vec<_> = adj.iter().map(|&x| streaming.next(x)).collect();
let batch_res = batch_market_structure(&adj, 2);
prop_assert_eq!(streaming_res.len(), batch_res.len());
for (s, b) in streaming_res.iter().zip(batch_res.iter()) {
prop_assert_eq!(s.bias, b.bias);
prop_assert_eq!(s.swing_depth_used, b.swing_depth_used);
prop_assert_eq!(s.current_flip.is_some(), b.current_flip.is_some());
}
}
}
#[test]
fn test_no_invalid_transitions() {
let mut ms = MarketStructure::new(2);
let prices: Vec<f64> = (0..50).map(|i| 100.0 + (i as f64 * 0.5 - ((i % 7) as f64 - 3.0))).collect();
let highs: Vec<f64> = prices.iter().map(|p| p + 1.0).collect();
let lows: Vec<f64> = prices.iter().map(|p| p - 1.0).collect();
let mut prev_bias = Bias::Neutral;
for i in 0..highs.len() {
let st = ms.next((highs[i], lows[i]));
if let Some(f) = &st.current_flip {
if f.is_bearish {
assert!(st.bias == Bias::Bearish || prev_bias == Bias::Bullish);
} else {
assert!(st.bias == Bias::Bullish || prev_bias == Bias::Bearish);
}
}
prev_bias = st.bias;
}
}
}