use crate::indicators::incremental::ta_atr::TaATR;
use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
use crate::traits::Next;
use crate::utils::RingBuffer as VecDeque;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Bias {
Bullish,
Bearish,
Neutral,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SwingPoint {
pub bar: usize,
pub price: f64,
pub is_high: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct FlipEvent {
pub is_bearish: bool,
pub price: f64,
pub bar: usize,
pub structure_strength: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
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, PartialEq, Serialize, Deserialize)]
pub enum PAEventKind {
MarketStructureFlip(FlipEvent),
GeometricFlag(crate::indicators::geometric_patterns::FlagPattern),
GeometricHs(crate::indicators::geometric_patterns::HsPattern),
SrInteraction(crate::indicators::sr_monitor::SRInteraction),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PAEvent {
pub bar: usize,
pub kind: PAEventKind,
pub strength: f64,
pub size_atr: Option<f64>,
pub confidence: f64,
pub regime_at_event: Option<String>,
pub feature_values: Vec<(String, f64)>,
}
impl PAEvent {
pub fn from_market_structure_flip(flip: FlipEvent, bar: usize) -> Self {
let strength = (flip.structure_strength as f64 / 5.0).min(2.0);
Self {
bar,
kind: PAEventKind::MarketStructureFlip(flip),
strength,
size_atr: None,
confidence: 1.0,
regime_at_event: None,
feature_values: vec![],
}
}
pub fn from_flag(flag: crate::indicators::geometric_patterns::FlagPattern, bar: usize) -> Self {
Self {
bar,
kind: PAEventKind::GeometricFlag(flag),
strength: flag.pole_strength.min(3.0),
size_atr: Some(flag.pole_length_atr),
confidence: if flag.breakout_confirmed { 1.0 } else { 0.5 },
regime_at_event: None,
feature_values: vec![
("pole_length".into(), flag.pole_length),
("pullbacks".into(), flag.pullbacks as f64),
("pushes".into(), flag.pushes as f64),
],
}
}
pub fn from_hs(hs: crate::indicators::geometric_patterns::HsPattern, bar: usize) -> Self {
Self {
bar,
kind: PAEventKind::GeometricHs(hs),
strength: (hs.score / 100.0).min(1.0),
size_atr: Some(hs.height_atr),
confidence: if hs.breakout_confirmed {
1.0
} else {
hs.score / 100.0
},
regime_at_event: None,
feature_values: vec![
("score".into(), hs.score),
("price_symmetry".into(), hs.price_symmetry),
("time_symmetry".into(), hs.time_symmetry),
],
}
}
pub fn from_sr_interaction(sr: crate::indicators::sr_monitor::SRInteraction) -> Self {
let bar = sr.bar;
let strength = sr.strength;
let distance = sr.distance_at_event;
Self {
bar,
kind: PAEventKind::SrInteraction(sr),
strength,
size_atr: None,
confidence: 0.9,
regime_at_event: None,
feature_values: vec![("distance".into(), distance)],
}
}
}
pub fn extract_pa_events(state: &MarketStructureState) -> Vec<PAEvent> {
let mut events = Vec::new();
if let Some(flip) = state.current_flip {
events.push(PAEvent::from_market_structure_flip(flip, state.bar_index));
}
events
}
pub fn extract_all_pa_events(
state: &MarketStructureState,
flag: Option<&crate::indicators::geometric_patterns::FlagPattern>,
hs: Option<&crate::indicators::geometric_patterns::HsPattern>,
sr_interactions: &[crate::indicators::sr_monitor::SRInteraction],
) -> Vec<PAEvent> {
let mut events = extract_pa_events(state);
if let Some(f) = flag
&& f.breakout_confirmed
{
events.push(PAEvent::from_flag(*f, state.bar_index));
}
if let Some(h) = hs
&& h.breakout_confirmed
{
events.push(PAEvent::from_hs(*h, state.bar_index));
}
for sr in sr_interactions {
events.push(PAEvent::from_sr_interaction(sr.clone()));
}
events
}
#[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;
self.last_high = Some(candidate);
} 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;
self.last_high = Some(candidate);
}
}
} else {
self.last_high = Some(candidate);
}
} 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;
self.last_low = Some(candidate);
} 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;
self.last_low = Some(candidate);
}
}
} else {
self.last_low = Some(candidate);
}
}
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,
last_swing_low: self.last_low,
current_flip,
swing_depth_used: depth,
bar_index: self.bar_index,
}
}
}
fn finite_or_nan(v: f64) -> f64 {
if v.is_finite() { v } else { f64::NAN }
}
#[derive(Debug, Clone)]
pub struct PriceImbalance {
size_k: f64,
atr: TaATR,
high_1: Option<f64>,
high_2: Option<f64>,
low_1: Option<f64>,
low_2: Option<f64>,
bull_top: f64,
bull_bottom: f64,
bull_formed: Option<usize>,
bull_filled: Option<usize>,
bear_top: f64,
bear_bottom: f64,
bear_formed: Option<usize>,
bear_filled: Option<usize>,
bar_index: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PriceImbalanceState {
pub bull_top: f64,
pub bull_bottom: f64,
pub bull_gap: f64,
pub bull_open: bool,
pub bull_size_ok: bool,
pub bear_top: f64,
pub bear_bottom: f64,
pub bear_gap: f64,
pub bear_open: bool,
pub bear_size_ok: bool,
pub atr: f64,
pub bar_index: usize,
}
impl PriceImbalance {
pub fn new(atr_period: usize, size_k: f64) -> Self {
let atr_period = atr_period.max(1);
Self {
size_k,
atr: TaATR::new(atr_period),
high_1: None,
high_2: None,
low_1: None,
low_2: None,
bull_top: f64::NAN,
bull_bottom: f64::NAN,
bull_formed: None,
bull_filled: None,
bear_top: f64::NAN,
bear_bottom: f64::NAN,
bear_formed: None,
bear_filled: None,
bar_index: 0,
}
}
fn open(formed: Option<usize>, filled: Option<usize>) -> bool {
match (formed, filled) {
(Some(born), Some(dead)) => born > dead,
(Some(_), None) => true,
_ => false,
}
}
}
impl Next<(f64, f64, f64)> for PriceImbalance {
type Output = PriceImbalanceState;
fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
let atr = self.atr.next((high, low, close));
let bar = self.bar_index;
if let Some(h2) = self.high_2
&& low > h2
{
self.bull_top = low;
self.bull_bottom = h2;
self.bull_formed = Some(bar);
self.bull_filled = None;
} else if self.bull_bottom.is_finite() && low <= self.bull_bottom {
self.bull_filled = Some(bar);
}
if let Some(l2) = self.low_2
&& high < l2
{
self.bear_top = l2;
self.bear_bottom = high;
self.bear_formed = Some(bar);
self.bear_filled = None;
} else if self.bear_top.is_finite() && high >= self.bear_top {
self.bear_filled = Some(bar);
}
let bull_gap = self.bull_top - self.bull_bottom;
let bear_gap = self.bear_top - self.bear_bottom;
let bull_size_ok = atr.is_finite() && bull_gap.is_finite() && bull_gap > self.size_k * atr;
let bear_size_ok = atr.is_finite() && bear_gap.is_finite() && bear_gap >= self.size_k * atr;
self.high_2 = self.high_1;
self.high_1 = Some(high);
self.low_2 = self.low_1;
self.low_1 = Some(low);
self.bar_index = bar + 1;
PriceImbalanceState {
bull_top: finite_or_nan(self.bull_top),
bull_bottom: finite_or_nan(self.bull_bottom),
bull_gap: finite_or_nan(bull_gap),
bull_open: Self::open(self.bull_formed, self.bull_filled),
bull_size_ok,
bear_top: finite_or_nan(self.bear_top),
bear_bottom: finite_or_nan(self.bear_bottom),
bear_gap: finite_or_nan(bear_gap),
bear_open: Self::open(self.bear_formed, self.bear_filled),
bear_size_ok,
atr,
bar_index: bar,
}
}
}
#[derive(Debug, Clone)]
pub struct ExtremeReclaim {
window: usize,
size_k: f64,
atr: TaATR,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
bar_index: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExtremeReclaimState {
pub bull_pierce: bool,
pub bull_reclaim: bool,
pub bull_size_ok: bool,
pub bullish: bool,
pub bull_level: f64,
pub bull_depth: f64,
pub bear_pierce: bool,
pub bear_reclaim: bool,
pub bear_size_ok: bool,
pub bearish: bool,
pub bear_level: f64,
pub bear_depth: f64,
pub atr: f64,
pub bar_index: usize,
}
impl ExtremeReclaim {
pub fn new(window: usize, atr_period: usize, size_k: f64) -> Self {
let window = window.max(1);
let atr_period = atr_period.max(1);
Self {
window,
size_k,
atr: TaATR::new(atr_period),
highs: VecDeque::with_capacity(window),
lows: VecDeque::with_capacity(window),
bar_index: 0,
}
}
}
impl Next<(f64, f64, f64)> for ExtremeReclaim {
type Output = ExtremeReclaimState;
fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
let atr = self.atr.next((high, low, close));
let bar = self.bar_index;
let ready = self.lows.len() == self.window && self.highs.len() == self.window;
let (bull_level, bear_level, bull_depth, bear_depth) = if ready {
let prior_low = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
let prior_high = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
(prior_low, prior_high, prior_low - low, high - prior_high)
} else {
(f64::NAN, f64::NAN, f64::NAN, f64::NAN)
};
let bull_pierce = ready && low <= bull_level;
let bull_reclaim = ready && close > bull_level;
let bear_pierce = ready && high >= bear_level;
let bear_reclaim = ready && close < bear_level;
let bull_size_ok =
atr.is_finite() && bull_depth.is_finite() && bull_depth >= self.size_k * atr;
let bear_size_ok =
atr.is_finite() && bear_depth.is_finite() && bear_depth >= self.size_k * atr;
self.highs.push_back(high);
self.lows.push_back(low);
if self.highs.len() > self.window {
self.highs.pop_front();
self.lows.pop_front();
}
self.bar_index = bar + 1;
ExtremeReclaimState {
bull_pierce,
bull_reclaim,
bull_size_ok,
bullish: bull_pierce && bull_reclaim && bull_size_ok,
bull_level,
bull_depth,
bear_pierce,
bear_reclaim,
bear_size_ok,
bearish: bear_pierce && bear_reclaim && bear_size_ok,
bear_level,
bear_depth,
atr,
bar_index: bar,
}
}
}
pub const PRICE_IMBALANCE_METADATA: IndicatorMetadata = IndicatorMetadata {
name: "Price Imbalance",
description: "Latest three-bar untraded range on each side: top, bottom, whether price has traded through the far side, and whether the gap exceeds a multiple of Wilder ATR.",
usage: "Read bull_open / bear_open as the range that is still unfilled, and bull_top / bull_bottom as the two prices. Break of structure stays on Market Structure; this does not emit one.",
keywords: &["price-action", "imbalance", "gap", "atr"],
ehlers_summary: "Not Ehlers. Three-bar range from Build Alpha CustomIndicators.xml (Bergstrom). Bull gap is strict low[0] > high[2]. Size uses Wilder ATR, length 20 in that file (the article prose says 14).",
params: &[
ParamDef {
name: "atr_period",
default: "20",
description: "Wilder ATR length for the size test.",
},
ParamDef {
name: "size_k",
default: "0.5",
description: "Minimum gap as a multiple of ATR. Bull uses >, bear uses >=, matching the source file.",
},
],
formula_source: "David Bergstrom, Build Alpha, https://www.buildalpha.com/backtest-ict-and-smc/ CustomIndicators.xml (ATR length 20, k = 0.5; the article prose says ATR 14).",
formula_latex: r"\[\text{bull}: low_0 > high_2,\ \text{top}=low_0,\ \text{bottom}=high_2,\ \text{size}: (top-bottom) > k\cdot ATR\]",
gold_standard_file: "",
category: "Price Action",
};
pub const EXTREME_RECLAIM_METADATA: IndicatorMetadata = IndicatorMetadata {
name: "Extreme Reclaim",
description: "Prior N-bar high or low is pierced and the close finishes back inside, with penetration at least k times Wilder ATR.",
usage: "bullish is the full three-part test. bull_pierce and bull_reclaim are the parts, so a caller can drop the size filter. A close that stays beyond the extreme is a break, not a reclaim.",
keywords: &["price-action", "reclaim", "false-break", "atr"],
ehlers_summary: "Not Ehlers. Pierce-and-reclaim from Build Alpha CustomIndicators.xml (Bergstrom): window 20, ATR 20, k = 0.5, penetration compared with >=.",
params: &[
ParamDef {
name: "window",
default: "20",
description: "Bars in the prior extreme. The current bar is excluded.",
},
ParamDef {
name: "atr_period",
default: "20",
description: "Wilder ATR length for the penetration test.",
},
ParamDef {
name: "size_k",
default: "0.5",
description: "Minimum penetration as a multiple of ATR.",
},
],
formula_source: "David Bergstrom, Build Alpha, https://www.buildalpha.com/backtest-ict-and-smc/ CustomIndicators.xml (window 20, ATR length 20, k = 0.5).",
formula_latex: r"\[\text{bull}: low_0 \le \min(low)_{1..N},\ close_0 > \min(low)_{1..N},\ \min(low)-low_0 \ge k\cdot ATR\]",
gold_standard_file: "",
category: "Price Action",
};
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 approx::assert_relative_eq;
use proptest::prelude::*;
use serde_json;
#[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;
}
if i > 6 {
assert!(state.bias == Bias::Bullish || state.bias == Bias::Neutral);
}
}
if let Some(f) = last_flip {
assert!(f.is_bearish);
}
}
#[test]
fn price_imbalance_forms_on_the_third_bar_and_fills_later() {
let mut imb = PriceImbalance::new(20, 0.5);
let bars = [
(10.0, 8.0, 9.0),
(11.0, 9.0, 10.0),
(14.0, 12.0, 13.0),
(15.0, 10.5, 14.0),
(15.0, 9.5, 11.0),
];
let states: Vec<_> = bars.iter().copied().map(|b| imb.next(b)).collect();
assert!(!states[1].bull_open);
assert!(states[2].bull_open);
assert_relative_eq!(states[2].bull_top, 12.0);
assert_relative_eq!(states[2].bull_bottom, 10.0);
assert!(states[3].bull_open);
assert!(!states[4].bull_open);
assert_relative_eq!(states[4].bull_bottom, 10.0);
}
#[test]
fn price_imbalance_new_range_replaces_the_previous_one() {
let mut imb = PriceImbalance::new(20, 0.0);
let bars = [
(10.0, 8.0, 9.0),
(11.0, 9.0, 10.0),
(14.0, 12.0, 13.0),
(13.0, 12.2, 12.5),
(16.0, 15.0, 15.5), ];
let mut last = None;
for b in bars {
last = Some(imb.next(b));
}
let last = last.unwrap();
assert!(last.bull_open);
assert_relative_eq!(last.bull_top, 15.0);
assert_relative_eq!(last.bull_bottom, 14.0);
}
#[test]
fn price_imbalance_bear_size_uses_greater_or_equal() {
let mut imb = PriceImbalance::new(5, 0.5);
for _ in 0..10 {
let _ = imb.next((10.0, 9.0, 9.5));
}
let bear = imb.next((8.5, 8.5, 8.5));
assert!(bear.bear_open);
assert!(bear.bear_size_ok);
assert_relative_eq!(bear.bear_gap, 0.5);
}
#[test]
fn extreme_reclaim_requires_the_close_back_inside() {
let mut rec = ExtremeReclaim::new(5, 5, 0.5);
for _ in 0..8 {
let _ = rec.next((11.0, 10.0, 10.5));
}
let breakdown = rec.next((11.0, 9.0, 9.2));
assert!(breakdown.bull_pierce);
assert!(!breakdown.bull_reclaim);
assert!(!breakdown.bullish);
let mut rec = ExtremeReclaim::new(5, 5, 0.5);
for _ in 0..8 {
let _ = rec.next((11.0, 10.0, 10.5));
}
let reclaim = rec.next((11.0, 9.0, 10.2));
assert!(reclaim.bullish);
assert_relative_eq!(reclaim.bull_level, 10.0);
assert_relative_eq!(reclaim.bull_depth, 1.0);
}
proptest! {
#[test]
fn test_price_imbalance_parity(
raw in prop::collection::vec((1.0..200.0f64, 1.0..200.0f64, 1.0..200.0f64), 5..30),
atr_period in 2usize..8,
size_k in 0.0..2.0f64,
) {
let bars: Vec<(f64, f64, f64)> = raw.into_iter().map(|(a, b, c)| {
let high = a.max(b).max(c);
let low = a.min(b).min(c);
(high, low, c.clamp(low, high))
}).collect();
let mut a = PriceImbalance::new(atr_period, size_k);
let mut b = PriceImbalance::new(atr_period, size_k);
for bar in bars {
let sa = a.next(bar);
let sb = b.next(bar);
prop_assert_eq!(sa.bull_open, sb.bull_open);
prop_assert_eq!(sa.bear_open, sb.bear_open);
prop_assert_eq!(sa.bull_size_ok, sb.bull_size_ok);
prop_assert_eq!(sa.bear_size_ok, sb.bear_size_ok);
prop_assert_eq!(sa.bar_index, sb.bar_index);
}
}
#[test]
fn test_extreme_reclaim_parity(
raw in prop::collection::vec((1.0..200.0f64, 1.0..200.0f64, 1.0..200.0f64), 5..30),
window in 2usize..8,
atr_period in 2usize..8,
size_k in 0.0..2.0f64,
) {
let bars: Vec<(f64, f64, f64)> = raw.into_iter().map(|(a, b, c)| {
let high = a.max(b).max(c);
let low = a.min(b).min(c);
(high, low, c.clamp(low, high))
}).collect();
let mut a = ExtremeReclaim::new(window, atr_period, size_k);
let mut b = ExtremeReclaim::new(window, atr_period, size_k);
for bar in bars {
let sa = a.next(bar);
let sb = b.next(bar);
prop_assert_eq!(sa.bullish, sb.bullish);
prop_assert_eq!(sa.bearish, sb.bearish);
prop_assert_eq!(sa.bull_pierce, sb.bull_pierce);
prop_assert_eq!(sa.bear_reclaim, sb.bear_reclaim);
}
}
}
#[test]
fn imbalance_and_reclaim_replay_matches() {
let bars = [
(10.0, 9.0, 9.5),
(12.0, 10.0, 11.0),
(11.0, 9.5, 10.0),
(13.0, 11.5, 12.5),
(14.0, 12.0, 12.2),
(12.5, 11.0, 11.2),
(15.0, 13.0, 14.0),
(14.0, 10.0, 13.5),
];
let mut a = PriceImbalance::new(3, 0.25);
let mut b = PriceImbalance::new(3, 0.25);
for bar in bars {
let sa = a.next(bar);
let sb = b.next(bar);
assert_eq!(sa.bull_open, sb.bull_open);
assert_eq!(sa.bear_open, sb.bear_open);
assert_eq!(sa.bull_size_ok, sb.bull_size_ok);
}
}
#[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;
}
}
#[test]
fn test_rich_pa_event_synthetic_example_and_extract() {
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 captured_events: Vec<PAEvent> = vec![];
for i in 0..highs.len() {
let state = ms.next((highs[i], lows[i]));
let evs = extract_pa_events(&state);
captured_events.extend(evs);
}
let synthetic_flip = FlipEvent {
is_bearish: true,
price: 15.0,
bar: 10,
structure_strength: 3,
};
let ev = PAEvent::from_market_structure_flip(synthetic_flip, 10);
assert!(matches!(ev.kind, PAEventKind::MarketStructureFlip(_)));
assert_eq!(ev.confidence, 1.0); assert!(ev.strength > 0.0);
assert!(ev.bar == 10);
assert!(ev.size_atr.is_none()); assert!(ev.regime_at_event.is_none());
assert!(ev.feature_values.is_empty());
let _cloned = ev.clone();
let json = serde_json::to_string(&ev).expect("PAEvent must serialize for 06sz/ML");
let _deser: PAEvent = serde_json::from_str(&json).expect("PAEvent Deserialize roundtrip");
for e in &captured_events {
if let PAEventKind::MarketStructureFlip(f) = &e.kind {
assert_eq!(e.confidence, 1.0);
assert!(e.strength > 0.0);
assert!(f.structure_strength >= 2);
}
}
}
#[test]
fn test_pa_harness_synthetic_structure_generators() {
use crate::test_utils::{
generate_bearish_structure_confirmed_flip, generate_bullish_structure_confirmed_flip,
generate_clean_bull_flag, generate_flag_violation_retrace_too_deep,
generate_perfect_bear_hs,
};
let case1 = generate_bullish_structure_confirmed_flip(2, 0.1, 42);
let mut ms = MarketStructure::new(2);
let mut any_flip = false;
for &pt in &case1.data {
let st = ms.next(pt);
if st.current_flip.is_some() {
any_flip = true;
}
}
assert!(
any_flip || !case1.data.is_empty(),
"synthetic generator + MarketStructure Next exercised cleanly (Part 21)"
);
let _case2 = generate_bearish_structure_confirmed_flip(2, 0.05, 99);
let _flag = generate_clean_bull_flag(2, 1.0);
let _hs = generate_perfect_bear_hs(1.0);
let _violation = generate_flag_violation_retrace_too_deep();
}
}