1use crate::indicators::market_structure::{MarketStructure, MarketStructureState, SwingPoint};
43use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
44use crate::indicators::volatility::ATR;
45use crate::traits::Next;
46
47use std::collections::HashMap;
48
49#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51pub enum LevelSource {
52 AutoSwing {
54 origin_swing_bar: usize,
55 origin_strength: u32, },
57 UserProvided { user_id: u32 },
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
63pub enum SRInteractionType {
64 Approach,
66 Touch,
68 Breakout,
70 Reversal,
72 Retest,
74}
75
76#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
79pub struct SRInteraction {
80 pub bar: usize,
82 pub level_price: f64,
84 pub level_label: String,
86 pub is_support: bool,
88 pub interaction: SRInteractionType,
90 pub strength: f64,
94 pub bars_since_creation: u32,
96 pub distance_at_event: f64,
98 pub source: LevelSource,
100 }
105
106#[derive(Debug, Clone, PartialEq)]
110pub struct SRMonitorOutput {
111 pub structure: MarketStructureState,
112 pub interactions: Vec<SRInteraction>,
113}
114
115#[derive(Debug, Clone)]
118struct MonitoredLevel {
119 price: f64,
120 label: String,
121 is_support: bool,
122 source: LevelSource,
123 creation_bar: usize,
124
125 last_side: i32,
127 prev_valid_side: i32,
128 side_before_touch: i32,
129
130 approached: bool,
132 touched: bool,
133 breakout_happened: bool,
134 breakout_direction: i32, last_touch_bar: usize,
138 last_interaction_bar: usize,
139}
140
141#[derive(Debug, Clone)]
144pub struct SRInteractionMonitor {
145 ms: MarketStructure,
146 touch_tolerance: f64,
148 approach_zone: f64,
149 min_level_separation: f64,
150 touch_tol_atr_mult: f64,
152 approach_zone_atr_mult: f64,
153 min_level_separation_atr_mult: f64,
154 use_atr_relative: bool,
155 atr: ATR,
156 current_atr: f64,
157 max_auto_level_age_bars: usize,
158
159 max_auto_levels: usize,
160
161 levels: HashMap<u32, MonitoredLevel>,
162 next_level_id: u32,
163 next_user_id: u32,
164
165 bar_index: usize,
166}
167
168impl SRInteractionMonitor {
169 pub fn new(swing_strength: usize, touch_tolerance: f64, approach_zone: f64) -> Self {
175 let tol = touch_tolerance.max(1e-12);
176 let appr = approach_zone.max(tol * 2.0);
177 Self {
178 ms: MarketStructure::new(swing_strength),
179 touch_tolerance: tol,
180 approach_zone: appr,
181 min_level_separation: tol * 3.0,
182 touch_tol_atr_mult: 0.5,
183 approach_zone_atr_mult: 2.0,
184 min_level_separation_atr_mult: 1.5,
185 use_atr_relative: false,
186 atr: ATR::new(14),
187 current_atr: 1.0,
188 max_auto_level_age_bars: 80,
189 max_auto_levels: 64,
190 levels: HashMap::new(),
191 next_level_id: 1,
192 next_user_id: 1,
193 bar_index: 0,
194 }
195 }
196
197 pub fn new_atr_relative(
201 swing_strength: usize,
202 atr_period: usize,
203 touch_tol_atr_mult: f64,
204 approach_zone_atr_mult: f64,
205 ) -> Self {
206 let mut m = Self::new(swing_strength, 0.5, 5.0);
207 m.use_atr_relative = true;
208 m.atr = ATR::new(atr_period.max(1));
209 m.touch_tol_atr_mult = touch_tol_atr_mult.max(0.05);
210 m.approach_zone_atr_mult = approach_zone_atr_mult.max(m.touch_tol_atr_mult * 2.0);
211 m.min_level_separation_atr_mult = (m.touch_tol_atr_mult * 3.0).max(0.15);
212 m
213 }
214
215 pub fn with_params(
216 swing_strength: usize,
217 touch_tolerance: f64,
218 approach_zone: f64,
219 min_separation: f64,
220 max_auto: usize,
221 ) -> Self {
222 let mut m = Self::new(swing_strength, touch_tolerance, approach_zone);
223 m.min_level_separation = min_separation.max(m.touch_tolerance);
224 m.max_auto_levels = max_auto.max(4);
225 m
226 }
227
228 fn effective_tolerances(&self) -> (f64, f64, f64) {
229 if self.use_atr_relative {
230 let atr = self.current_atr.max(1e-8);
231 (
232 self.touch_tol_atr_mult * atr,
233 self.approach_zone_atr_mult * atr,
234 self.min_level_separation_atr_mult * atr,
235 )
236 } else {
237 (
238 self.touch_tolerance,
239 self.approach_zone,
240 self.min_level_separation,
241 )
242 }
243 }
244
245 pub fn add_user_level(&mut self, price: f64, label: impl Into<String>) -> u32 {
248 let id = self.next_level_id;
249 self.next_level_id += 1;
250 let user_id = self.next_user_id;
251 self.next_user_id += 1;
252
253 let label = label.into();
254 let is_support = true; self.levels.insert(
258 id,
259 MonitoredLevel {
260 price,
261 label: if label.is_empty() {
262 format!("UserLevel_{:.4}", price)
263 } else {
264 label
265 },
266 is_support,
267 source: LevelSource::UserProvided { user_id },
268 creation_bar: self.bar_index,
269 last_side: 0,
270 prev_valid_side: 0,
271 side_before_touch: 0,
272 approached: false,
273 touched: false,
274 breakout_happened: false,
275 breakout_direction: 0,
276 last_touch_bar: 0,
277 last_interaction_bar: 0,
278 },
279 );
280 id
281 }
282
283 pub fn remove_level(&mut self, level_id: u32) -> bool {
285 self.levels.remove(&level_id).is_some()
286 }
287
288 pub fn active_level_count(&self) -> usize {
290 self.levels.len()
291 }
292
293 pub fn current_atr(&self) -> f64 {
295 self.current_atr
296 }
297
298 pub fn levels_snapshot(&self) -> Vec<(u32, f64, String, bool, LevelSource)> {
300 self.levels
301 .iter()
302 .map(|(&id, l)| (id, l.price, l.label.clone(), l.is_support, l.source.clone()))
303 .collect()
304 }
305
306 fn detect_interactions_for_level(
309 current_bar: usize,
311 touch_tolerance: f64,
312 approach_zone: f64,
313 level: &mut MonitoredLevel,
314 high: f64,
315 low: f64,
316 close: f64,
317 ) -> Vec<SRInteraction> {
318 let mut events = Vec::new();
319
320 let level_price = level.price;
321 let distance = (close - level_price).abs();
322 let signed_distance = close - level_price;
323
324 let current_side = if close < level_price {
326 1
327 } else if close > level_price {
328 -1
329 } else {
330 0
331 };
332
333 if current_side != 0 {
334 level.prev_valid_side = current_side;
335 }
336
337 if distance <= approach_zone && !level.approached {
339 events.push(SRInteraction {
340 bar: current_bar,
341 level_price,
342 level_label: level.label.clone(),
343 is_support: level.is_support,
344 interaction: SRInteractionType::Approach,
345 strength: match &level.source {
346 LevelSource::AutoSwing { origin_strength, .. } => *origin_strength as f64,
347 LevelSource::UserProvided { .. } => 1.0,
348 },
349 bars_since_creation: (current_bar.saturating_sub(level.creation_bar)) as u32,
350 distance_at_event: signed_distance,
351 source: level.source.clone(),
352 });
353 level.approached = true;
354 level.last_interaction_bar = current_bar;
355 }
356
357 if level.last_touch_bar < current_bar {
359 if level_price >= low - touch_tolerance && level_price <= high + touch_tolerance && !level.touched {
360 level.side_before_touch = level.last_side;
361 events.push(SRInteraction {
362 bar: current_bar,
363 level_price,
364 level_label: level.label.clone(),
365 is_support: level.is_support,
366 interaction: SRInteractionType::Touch,
367 strength: match &level.source {
368 LevelSource::AutoSwing { origin_strength, .. } => *origin_strength as f64,
369 LevelSource::UserProvided { .. } => 1.0,
370 },
371 bars_since_creation: (current_bar.saturating_sub(level.creation_bar)) as u32,
372 distance_at_event: signed_distance,
373 source: level.source.clone(),
374 });
375 level.touched = true;
376 level.breakout_happened = false;
378 level.last_interaction_bar = current_bar;
379 }
380 level.last_touch_bar = current_bar;
381 }
382
383 let last_side = level.last_side;
385 if last_side != 0
386 && current_side != 0
387 && current_side != last_side
388 && !level.breakout_happened
389 {
390 let direction = if last_side == 1 && current_side == -1 { 1 } else { -1 };
391 events.push(SRInteraction {
392 bar: current_bar,
393 level_price,
394 level_label: level.label.clone(),
395 is_support: level.is_support,
396 interaction: SRInteractionType::Breakout,
397 strength: match &level.source {
398 LevelSource::AutoSwing { origin_strength, .. } => *origin_strength as f64,
399 LevelSource::UserProvided { .. } => 1.0,
400 },
401 bars_since_creation: (current_bar.saturating_sub(level.creation_bar)) as u32,
402 distance_at_event: signed_distance,
403 source: level.source.clone(),
404 });
405 level.breakout_happened = true;
406 level.breakout_direction = direction;
407 level.last_interaction_bar = current_bar;
408 }
409
410 if level.touched
412 && level.side_before_touch != 0
413 && current_side == level.side_before_touch
414 && !level.breakout_happened
415 {
416 events.push(SRInteraction {
417 bar: current_bar,
418 level_price,
419 level_label: level.label.clone(),
420 is_support: level.is_support,
421 interaction: SRInteractionType::Reversal,
422 strength: match &level.source {
423 LevelSource::AutoSwing { origin_strength, .. } => *origin_strength as f64,
424 LevelSource::UserProvided { .. } => 1.0,
425 },
426 bars_since_creation: (current_bar.saturating_sub(level.creation_bar)) as u32,
427 distance_at_event: signed_distance,
428 source: level.source.clone(),
429 });
430 level.last_interaction_bar = current_bar;
431 }
433
434 if level.breakout_happened && distance <= touch_tolerance {
436 events.push(SRInteraction {
437 bar: current_bar,
438 level_price,
439 level_label: level.label.clone(),
440 is_support: level.is_support,
441 interaction: SRInteractionType::Retest,
442 strength: match &level.source {
443 LevelSource::AutoSwing { origin_strength, .. } => *origin_strength as f64,
444 LevelSource::UserProvided { .. } => 1.0,
445 },
446 bars_since_creation: (current_bar.saturating_sub(level.creation_bar)) as u32,
447 distance_at_event: signed_distance,
448 source: level.source.clone(),
449 });
450 level.breakout_happened = false; level.last_interaction_bar = current_bar;
452 }
453
454 if distance > approach_zone {
456 level.approached = false;
457 }
458
459 level.last_side = current_side;
461
462 events
463 }
464
465 fn prune_stale_auto_levels(&mut self, state: &MarketStructureState) {
467 let flip = match &state.current_flip {
468 Some(f) => f,
469 None => {
470 let max_age = self.max_auto_level_age_bars;
471 let stale: Vec<u32> = self
472 .levels
473 .iter()
474 .filter_map(|(&id, l)| {
475 if !matches!(l.source, LevelSource::AutoSwing { .. }) {
476 return None;
477 }
478 let age = self.bar_index.saturating_sub(l.creation_bar);
479 if age > max_age {
480 Some(id)
481 } else {
482 None
483 }
484 })
485 .collect();
486 for id in stale {
487 self.levels.remove(&id);
488 }
489 return;
490 }
491 };
492
493 let to_remove: Vec<u32> = self
494 .levels
495 .iter()
496 .filter_map(|(&id, l)| {
497 if !matches!(l.source, LevelSource::AutoSwing { .. }) {
498 return None;
499 }
500 let age = self.bar_index.saturating_sub(l.creation_bar);
501 let invalidated = if flip.is_bearish {
502 l.is_support && l.price > flip.price
503 } else {
504 !l.is_support && l.price < flip.price
505 };
506 if age > self.max_auto_level_age_bars || invalidated {
507 Some(id)
508 } else {
509 None
510 }
511 })
512 .collect();
513 for id in to_remove {
514 self.levels.remove(&id);
515 }
516 }
517
518 fn maybe_add_auto_levels(&mut self, state: &MarketStructureState, min_separation: f64) {
520 if self.levels.len() >= self.max_auto_levels {
521 return;
522 }
523
524 let candidates: Vec<SwingPoint> = vec![
525 state.last_swing_high.clone(),
526 state.last_swing_low.clone(),
527 ]
528 .into_iter()
529 .flatten()
530 .collect();
531
532 for sp in candidates {
533 let too_close = self.levels.values().any(|l| {
534 (l.price - sp.price).abs() < min_separation
535 });
536 if too_close {
537 continue;
538 }
539
540 let id = self.next_level_id;
541 self.next_level_id += 1;
542
543 let label = if sp.is_high {
544 format!("R_auto_{}", sp.bar)
545 } else {
546 format!("S_auto_{}", sp.bar)
547 };
548
549 let origin_strength = if sp.is_high {
550 1u32
552 } else {
553 1u32
554 };
555
556 self.levels.insert(
557 id,
558 MonitoredLevel {
559 price: sp.price,
560 label,
561 is_support: !sp.is_high,
562 source: LevelSource::AutoSwing {
563 origin_swing_bar: sp.bar,
564 origin_strength,
565 },
566 creation_bar: self.bar_index,
567 last_side: 0,
568 prev_valid_side: 0,
569 side_before_touch: 0,
570 approached: false,
571 touched: false,
572 breakout_happened: false,
573 breakout_direction: 0,
574 last_touch_bar: 0,
575 last_interaction_bar: 0,
576 },
577 );
578 }
579 }
580}
581
582impl Default for SRInteractionMonitor {
583 fn default() -> Self {
584 Self::new(3, 0.5, 5.0) }
586}
587
588impl Next<(f64, f64, f64)> for SRInteractionMonitor {
589 type Output = SRMonitorOutput;
590
591 fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
592 self.bar_index += 1;
593
594 self.current_atr = self.atr.next((high, low, close)).max(1e-8);
595 let (touch_tol, appr_zone, min_sep) = self.effective_tolerances();
596
597 let structure = self.ms.next((high, low));
598
599 self.prune_stale_auto_levels(&structure);
600 self.maybe_add_auto_levels(&structure, min_sep);
601
602 let mut all_interactions: Vec<SRInteraction> = Vec::new();
603
604 let level_ids: Vec<u32> = self.levels.keys().copied().collect();
605 for id in level_ids {
606 if let Some(level) = self.levels.get_mut(&id) {
607 let mut evs = SRInteractionMonitor::detect_interactions_for_level(
608 self.bar_index,
609 touch_tol,
610 appr_zone,
611 level,
612 high,
613 low,
614 close,
615 );
616 all_interactions.append(&mut evs);
617 }
618 }
619
620 all_interactions.sort_by(|a, b| {
622 a.bar.cmp(&b.bar)
623 .then_with(|| a.level_price.partial_cmp(&b.level_price).unwrap_or(std::cmp::Ordering::Equal))
624 .then_with(|| (a.interaction as u8).cmp(&(b.interaction as u8)))
625 });
626
627 SRMonitorOutput {
628 structure,
629 interactions: all_interactions,
630 }
631 }
632}
633
634pub const SR_INTERACTION_MONITOR_METADATA: IndicatorMetadata = IndicatorMetadata {
635 name: "S/R Interaction Monitor (Part 67)",
636 description: "Real-time horizontal S/R monitoring with Approach/Touch/Breakout/Reversal/Retest detection. Auto levels from MarketStructure swings + dynamic user-provided levels. Rich event output designed for backtester and confluence (MQL5 Part 67 port).",
637 usage: "Use the Rust struct directly for streaming (add_user_level + next). Emits SRMonitorOutput with Vec<SRInteraction>. Ideal for event-driven backtesting and PA + regime filters. See also MarketStructure for the swing foundation.",
638 keywords: &[
639 "price-action",
640 "support-resistance",
641 "sr-interaction",
642 "breakout",
643 "retest",
644 "market-structure",
645 "mql5",
646 "part-67",
647 ],
648 ehlers_summary: "Classical price action (not DSP). Horizontal level state machine on top of adaptive swings.",
649 params: &[
650 ParamDef {
651 name: "swing_strength",
652 default: "3",
653 description: "Depth for internal MarketStructure swing detection (Part 21).",
654 },
655 ParamDef {
656 name: "touch_tolerance",
657 default: "0.5",
658 description: "Absolute price tolerance for Touch/Retest (Part 67 TouchTolerancePips scaled).",
659 },
660 ParamDef {
661 name: "approach_zone",
662 default: "5.0",
663 description: "Outer Approach zone (Part 67 ApproachZonePips).",
664 },
665 ParamDef {
666 name: "touch_tol_atr_mult",
667 default: "0.5",
668 description: "ATR-relative touch tolerance (use new_atr_relative).",
669 },
670 ParamDef {
671 name: "approach_zone_atr_mult",
672 default: "2.0",
673 description: "ATR-relative approach zone (use new_atr_relative).",
674 },
675 ],
676 formula_source: "https://www.mql5.com/en/articles/21961 (SupportResistanceMonitor.mq5) + Part 21 market_structure foundation",
677 formula_latex: r#"
678\text{side} = \text{sign}(price - level)\\
679\text{touch if } |level - [L,H]| \le tol\\
680\text{breakout if side flips}\\
681\text{retest if post-breakout distance} \le tol
682"#,
683 gold_standard_file: "", category: "Price Action",
685};
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use proptest::prelude::*;
691
692 fn batch_sr(
693 data: &[(f64, f64, f64)],
694 strength: usize,
695 touch_tol: f64,
696 appr: f64,
697 ) -> Vec<SRMonitorOutput> {
698 let mut mon = SRInteractionMonitor::new(strength, touch_tol, appr);
699 data.iter().map(|&(h, l, c)| mon.next((h, l, c))).collect()
700 }
701
702 #[test]
703 fn test_basic_user_level_interactions() {
704 let mut mon = SRInteractionMonitor::new(2, 0.2, 1.0);
705 let user_id = mon.add_user_level(100.0, "TestResist");
706
707 let series: Vec<(f64, f64, f64)> = vec![
709 (99.0, 98.5, 98.7), (99.8, 99.6, 99.7), (100.1, 99.9, 100.0), (100.3, 100.1, 100.2),(100.1, 99.9, 100.0), (99.8, 99.6, 99.7), ];
716
717 let mut any_interaction_on_level = false;
718 for (i, item) in series.iter().enumerate() {
719 let out = mon.next(*item);
720 for ev in &out.interactions {
721 if ev.level_label.contains("TestResist") {
722 any_interaction_on_level = true;
723 }
724 }
725 if i > 3 {
726 assert!(mon.active_level_count() > 0, "level should remain registered");
727 }
728 }
729 assert!(any_interaction_on_level || mon.active_level_count() == 1, "user level should participate in monitoring");
732 }
733
734 #[test]
735 fn test_auto_levels_from_structure() {
736 let mut mon = SRInteractionMonitor::new(2, 0.1, 0.5);
737 let highs: Vec<f64> = (0..30).map(|i| 100.0 + (i as f64 * 0.3) + ((i % 5) as f64 - 2.0)).collect();
739 let lows: Vec<f64> = highs.iter().map(|h| h - 0.8).collect();
740
741 let mut added_auto = false;
742 for i in 0..highs.len() {
743 let c = (highs[i] + lows[i]) / 2.0;
744 let out = mon.next((highs[i], lows[i], c));
745 if mon.active_level_count() > 0 && i > 8 {
746 added_auto = true;
747 }
748 }
749 assert!(added_auto, "Auto levels should have been promoted from swings");
750 }
751
752 proptest! {
753 #[test]
754 fn test_sr_parity(
755 input in prop::collection::vec((10.0f64..200.0, 9.0f64..199.0, 9.5f64..199.5), 20..70)
756 ) {
757 let adj: Vec<(f64,f64,f64)> = input
758 .into_iter()
759 .map(|(h,l,c)| {
760 let hh = h.max(l).max(c);
761 let ll = l.min(h).min(c);
762 let cc = c.clamp(ll, hh);
763 (hh, ll, cc)
764 })
765 .collect();
766
767 let mut streaming = SRInteractionMonitor::new(2, 0.25, 1.5);
768 let streaming_res: Vec<_> = adj.iter().map(|&x| streaming.next(x)).collect();
769
770 let batch_res = batch_sr(&adj, 2, 0.25, 1.5);
771
772 prop_assert_eq!(streaming_res.len(), batch_res.len());
773
774 for (s, b) in streaming_res.iter().zip(batch_res.iter()) {
775 prop_assert_eq!(s.structure.bias, b.structure.bias);
777 prop_assert_eq!(s.interactions.len(), b.interactions.len());
779 let s_types: Vec<_> = s.interactions.iter().map(|e| e.interaction).collect();
781 let b_types: Vec<_> = b.interactions.iter().map(|e| e.interaction).collect();
782 prop_assert_eq!(s_types, b_types);
783 }
784 }
785 }
786
787 #[test]
788 fn test_interaction_bar_indices_match_bar_counter() {
789 let mut mon = SRInteractionMonitor::new(2, 0.2, 1.0);
790 mon.add_user_level(100.0, "TestResist");
791
792 let series: Vec<(f64, f64, f64)> = vec![
793 (99.0, 98.5, 98.7),
794 (99.8, 99.6, 99.7),
795 (100.1, 99.9, 100.0),
796 (100.3, 100.1, 100.2),
797 (100.1, 99.9, 100.0),
798 ];
799
800 let n_bars = series.len();
801 let mut observed_bars = Vec::new();
802 for item in &series {
803 let out = mon.next(*item);
804 for ev in &out.interactions {
805 if ev.level_label == "TestResist" {
806 observed_bars.push(ev.bar);
807 assert_ne!(ev.bar, 0, "interaction bar must reflect the monitor bar counter");
808 }
809 }
810 }
811
812 assert!(
813 !observed_bars.is_empty(),
814 "expected at least one interaction on the user level"
815 );
816 assert!(
817 observed_bars.iter().all(|&b| (1..=n_bars).contains(&b)),
818 "interaction bars must be within 1..=len(series), got {observed_bars:?}"
819 );
820 }
821
822 #[test]
823 fn test_atr_relative_mode_produces_interactions() {
824 let mut mon = SRInteractionMonitor::new_atr_relative(2, 14, 0.3, 1.5);
825 mon.add_user_level(100.0, "ATRLevel");
826 let series: Vec<(f64, f64, f64)> = vec![
827 (99.0, 98.5, 98.7),
828 (100.1, 99.9, 100.0),
829 (100.3, 100.1, 100.2),
830 ];
831 let mut any = false;
832 for item in series {
833 let out = mon.next(item);
834 if !out.interactions.is_empty() {
835 any = true;
836 }
837 }
838 assert!(any, "ATR-relative monitor should detect interactions");
839 }
840
841 #[test]
842 fn test_prune_auto_levels_on_bos() {
843 let mut mon = SRInteractionMonitor::new(1, 0.1, 0.5);
844 let user_id = mon.add_user_level(50.0, "UserSupport");
845 let highs: Vec<f64> = (0..60)
846 .map(|i| 100.0 + (i as f64 * 0.4) + ((i % 3) as f64 - 1.0) * 0.3)
847 .collect();
848 let lows: Vec<f64> = highs.iter().map(|h| h - 0.6).collect();
849 for i in 0..highs.len() {
850 let c = (highs[i] + lows[i]) / 2.0;
851 mon.next((highs[i], lows[i], c));
852 }
853 let before = mon.active_level_count();
854 let reversal_highs: Vec<f64> = (0..25).map(|i| 124.0 - (i as f64 * 1.5)).collect();
855 let reversal_lows: Vec<f64> = reversal_highs.iter().map(|h| h - 1.0).collect();
856 for i in 0..reversal_highs.len() {
857 let c = (reversal_highs[i] + reversal_lows[i]) / 2.0;
858 mon.next((reversal_highs[i], reversal_lows[i], c));
859 }
860 assert!(
861 mon.active_level_count() <= before.max(1),
862 "BOS/age pruning should not grow unbounded"
863 );
864 assert!(
865 mon.levels_snapshot().iter().any(|(id, _, label, _, _)| {
866 *id == user_id && label == "UserSupport"
867 }),
868 "user-provided levels must survive BOS pruning"
869 );
870 }
871
872 #[test]
873 fn test_no_duplicate_events_without_reset() {
874 let mut mon = SRInteractionMonitor::new(2, 0.1, 0.5);
876 mon.add_user_level(50.0, "Level");
877
878 let data = vec![
879 (49.0, 48.8, 48.9),
880 (49.2, 49.0, 49.1),
881 (50.2, 50.0, 50.1), (50.3, 50.1, 50.2),
883 (50.4, 50.2, 50.3),
884 ];
885
886 let mut breakout_count = 0;
887 for d in data {
888 let out = mon.next(d);
889 breakout_count += out.interactions.iter().filter(|e| e.interaction == SRInteractionType::Breakout).count();
890 }
891 assert!(breakout_count <= 1, "Breakout should fire at most once without re-arming price action");
892 }
893}