Skip to main content

quantwave_core/indicators/
pa_confluence.rs

1//! PA Confluence Layer — combine structure, geometric, S/R events with ML features and regimes.
2//!
3//! Sources: MQL5 Part 50 confluence patterns + quantwave-4ps ML features + cu03 PA foundation.
4//! Used by canonical notebooks and backtester filters (quantwave-8aht).
5
6use crate::indicators::market_structure::{Bias, MarketStructureState, PAEvent, PAEventKind};
7use crate::regimes::MarketRegime;
8
9/// Runtime context for confluence scoring / filtering at event time.
10#[derive(Debug, Clone, Default)]
11pub struct ConfluenceContext {
12    /// Hard regime label (1=Bull, 2=Bear, 0=Steady, etc. — matches regime_features Polars output).
13    pub regime_label: Option<u32>,
14    /// Hurst persistence at event bar (from HurstFeatureExtractor).
15    pub hurst_persistence: Option<f64>,
16    /// Minimum hurst required for trend-following setups (default 0.5).
17    pub min_hurst: f64,
18    /// Required regime label if set (e.g. 1 for bull-only flag breakouts).
19    pub required_regime: Option<u32>,
20    /// Required market structure bias (1=Bullish, 2=Bearish, 0=any).
21    pub required_bias: Option<u32>,
22}
23
24impl ConfluenceContext {
25    pub fn bull_flag_filter(regime_label: u32, hurst: f64) -> Self {
26        Self {
27            regime_label: Some(regime_label),
28            hurst_persistence: Some(hurst),
29            min_hurst: 0.5,
30            required_regime: Some(1),
31            required_bias: Some(1),
32        }
33    }
34}
35
36/// Score a PA event against ML/regime/structure context (0.0–1.0+).
37pub fn score_pa_event(event: &PAEvent, ctx: &ConfluenceContext) -> f64 {
38    let mut score = event.strength * event.confidence;
39
40    if let Some(h) = ctx.hurst_persistence {
41        if h >= ctx.min_hurst {
42            score *= 1.0 + (h - ctx.min_hurst).min(0.5);
43        } else {
44            score *= 0.5;
45        }
46    }
47
48    if let (Some(req), Some(cur)) = (ctx.required_regime, ctx.regime_label) {
49        if cur == req {
50            score *= 1.2;
51        } else {
52            score *= 0.3;
53        }
54    }
55
56    match &event.kind {
57        PAEventKind::GeometricFlag(f) if f.is_bull => score *= 1.1,
58        PAEventKind::GeometricFlag(f) if !f.is_bull => score *= 1.05,
59        PAEventKind::GeometricHs(h) if h.breakout_confirmed => score *= 1.15,
60        PAEventKind::SrInteraction(sr) => {
61            score *= 1.0 + (sr.strength / 10.0).min(0.2);
62        }
63        _ => {}
64    }
65
66    score
67}
68
69/// Returns true if the event passes all configured confluence filters.
70pub fn passes_confluence_filter(
71    event: &PAEvent,
72    ctx: &ConfluenceContext,
73    structure: Option<&MarketStructureState>,
74) -> bool {
75    if let Some(req_bias) = ctx.required_bias {
76        if let Some(st) = structure {
77            let bias = match st.bias {
78                Bias::Bullish => 1u32,
79                Bias::Bearish => 2,
80                Bias::Neutral => 0,
81            };
82            if bias != req_bias && req_bias != 0 {
83                return false;
84            }
85        }
86    }
87
88    if let Some(req) = ctx.required_regime {
89        if ctx.regime_label != Some(req) {
90            return false;
91        }
92    }
93
94    if let Some(h) = ctx.hurst_persistence {
95        if h < ctx.min_hurst {
96            return false;
97        }
98    }
99
100    match &event.kind {
101        PAEventKind::GeometricFlag(f) => f.breakout_confirmed,
102        PAEventKind::GeometricHs(h) => h.breakout_confirmed && h.score >= 60.0,
103        PAEventKind::MarketStructureFlip(_) => true,
104        PAEventKind::SrInteraction(_) => true,
105    }
106}
107
108/// Enrich a PA event with regime + feature slots for backtester / ML consumption.
109pub fn enrich_pa_event(event: &mut PAEvent, ctx: &ConfluenceContext) {
110    if let Some(label) = ctx.regime_label {
111        event.regime_at_event = Some(match label {
112            1 => "Bull".into(),
113            2 => "Bear".into(),
114            3 => "Crisis".into(),
115            _ => "Steady".into(),
116        });
117    }
118    if let Some(h) = ctx.hurst_persistence {
119        event.feature_values.push(("hurst_persistence".into(), h));
120    }
121    event
122        .feature_values
123        .push(("confluence_score".into(), score_pa_event(event, ctx)));
124}
125
126/// Filter a batch of events through confluence rules.
127pub fn filter_confluent_events(
128    events: Vec<PAEvent>,
129    ctx: &ConfluenceContext,
130    structure: Option<&MarketStructureState>,
131) -> Vec<PAEvent> {
132    events
133        .into_iter()
134        .filter(|e| passes_confluence_filter(e, ctx, structure))
135        .map(|mut e| {
136            enrich_pa_event(&mut e, ctx);
137            e
138        })
139        .collect()
140}
141
142/// Map MarketRegime to the u32 label used in Polars regime_features.
143pub fn regime_to_label(regime: MarketRegime) -> u32 {
144    match regime {
145        MarketRegime::Bull => 1,
146        MarketRegime::Bear => 2,
147        MarketRegime::Crisis => 3,
148        MarketRegime::Steady => 0,
149        MarketRegime::Cluster(c) => 4 + (c as u32),
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::indicators::geometric_patterns::FlagPattern;
157    use crate::indicators::market_structure::FlipEvent;
158
159    fn sample_flag() -> FlagPattern {
160        FlagPattern {
161            id: 1,
162            is_bull: true,
163            pole_start_bar: 10,
164            pole_end_bar: 12,
165            flag_start_bar: 13,
166            flag_end_bar: 20,
167            pole_length: 3.0,
168            pole_length_atr: 2.5,
169            max_retrace_pct: 40.0,
170            pullbacks: 2,
171            pushes: 1,
172            breakout_confirmed: true,
173            breakout_price: 105.0,
174            consolidation_bars: 7,
175            pole_strength: 2.5,
176        }
177    }
178
179    #[test]
180    fn test_bull_flag_passes_confluence() {
181        let event = PAEvent::from_flag(sample_flag(), 20);
182        let ctx = ConfluenceContext::bull_flag_filter(1, 0.6);
183        let st = MarketStructureState {
184            bias: Bias::Bullish,
185            last_swing_high: None,
186            last_swing_low: None,
187            current_flip: None,
188            swing_depth_used: 3,
189            bar_index: 20,
190        };
191        assert!(passes_confluence_filter(&event, &ctx, Some(&st)));
192        assert!(score_pa_event(&event, &ctx) > 0.5);
193    }
194
195    #[test]
196    fn test_wrong_regime_fails_filter() {
197        let event = PAEvent::from_flag(sample_flag(), 20);
198        let ctx = ConfluenceContext::bull_flag_filter(2, 0.6);
199        let st = MarketStructureState {
200            bias: Bias::Bullish,
201            last_swing_high: None,
202            last_swing_low: None,
203            current_flip: None,
204            swing_depth_used: 3,
205            bar_index: 20,
206        };
207        assert!(!passes_confluence_filter(&event, &ctx, Some(&st)));
208    }
209
210    #[test]
211    fn test_enrich_adds_metadata() {
212        let mut event = PAEvent::from_market_structure_flip(
213            FlipEvent {
214                is_bearish: false,
215                price: 100.0,
216                bar: 5,
217                structure_strength: 3,
218            },
219            5,
220        );
221        let ctx = ConfluenceContext {
222            regime_label: Some(1),
223            hurst_persistence: Some(0.65),
224            ..Default::default()
225        };
226        enrich_pa_event(&mut event, &ctx);
227        assert_eq!(event.regime_at_event, Some("Bull".into()));
228        assert!(event.feature_values.iter().any(|(k, _)| k == "confluence_score"));
229    }
230}