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 {
347 origin_strength, ..
348 } => *origin_strength as f64,
349 LevelSource::UserProvided { .. } => 1.0,
350 },
351 bars_since_creation: (current_bar.saturating_sub(level.creation_bar)) as u32,
352 distance_at_event: signed_distance,
353 source: level.source.clone(),
354 });
355 level.approached = true;
356 level.last_interaction_bar = current_bar;
357 }
358
359 if level.last_touch_bar < current_bar {
361 if level_price >= low - touch_tolerance
362 && level_price <= high + touch_tolerance
363 && !level.touched
364 {
365 level.side_before_touch = level.last_side;
366 events.push(SRInteraction {
367 bar: current_bar,
368 level_price,
369 level_label: level.label.clone(),
370 is_support: level.is_support,
371 interaction: SRInteractionType::Touch,
372 strength: match &level.source {
373 LevelSource::AutoSwing {
374 origin_strength, ..
375 } => *origin_strength as f64,
376 LevelSource::UserProvided { .. } => 1.0,
377 },
378 bars_since_creation: (current_bar.saturating_sub(level.creation_bar)) as u32,
379 distance_at_event: signed_distance,
380 source: level.source.clone(),
381 });
382 level.touched = true;
383 level.breakout_happened = false;
385 level.last_interaction_bar = current_bar;
386 }
387 level.last_touch_bar = current_bar;
388 }
389
390 let last_side = level.last_side;
392 if last_side != 0
393 && current_side != 0
394 && current_side != last_side
395 && !level.breakout_happened
396 {
397 let direction = if last_side == 1 && current_side == -1 {
398 1
399 } else {
400 -1
401 };
402 events.push(SRInteraction {
403 bar: current_bar,
404 level_price,
405 level_label: level.label.clone(),
406 is_support: level.is_support,
407 interaction: SRInteractionType::Breakout,
408 strength: match &level.source {
409 LevelSource::AutoSwing {
410 origin_strength, ..
411 } => *origin_strength as f64,
412 LevelSource::UserProvided { .. } => 1.0,
413 },
414 bars_since_creation: (current_bar.saturating_sub(level.creation_bar)) as u32,
415 distance_at_event: signed_distance,
416 source: level.source.clone(),
417 });
418 level.breakout_happened = true;
419 level.breakout_direction = direction;
420 level.last_interaction_bar = current_bar;
421 }
422
423 if level.touched
425 && level.side_before_touch != 0
426 && current_side == level.side_before_touch
427 && !level.breakout_happened
428 {
429 events.push(SRInteraction {
430 bar: current_bar,
431 level_price,
432 level_label: level.label.clone(),
433 is_support: level.is_support,
434 interaction: SRInteractionType::Reversal,
435 strength: match &level.source {
436 LevelSource::AutoSwing {
437 origin_strength, ..
438 } => *origin_strength as f64,
439 LevelSource::UserProvided { .. } => 1.0,
440 },
441 bars_since_creation: (current_bar.saturating_sub(level.creation_bar)) as u32,
442 distance_at_event: signed_distance,
443 source: level.source.clone(),
444 });
445 level.last_interaction_bar = current_bar;
446 }
448
449 if level.breakout_happened && distance <= touch_tolerance {
451 events.push(SRInteraction {
452 bar: current_bar,
453 level_price,
454 level_label: level.label.clone(),
455 is_support: level.is_support,
456 interaction: SRInteractionType::Retest,
457 strength: match &level.source {
458 LevelSource::AutoSwing {
459 origin_strength, ..
460 } => *origin_strength as f64,
461 LevelSource::UserProvided { .. } => 1.0,
462 },
463 bars_since_creation: (current_bar.saturating_sub(level.creation_bar)) as u32,
464 distance_at_event: signed_distance,
465 source: level.source.clone(),
466 });
467 level.breakout_happened = false; level.last_interaction_bar = current_bar;
469 }
470
471 if distance > approach_zone {
473 level.approached = false;
474 }
475
476 level.last_side = current_side;
478
479 events
480 }
481
482 fn prune_stale_auto_levels(&mut self, state: &MarketStructureState) {
484 let flip = match &state.current_flip {
485 Some(f) => f,
486 None => {
487 let max_age = self.max_auto_level_age_bars;
488 let stale: Vec<u32> = self
489 .levels
490 .iter()
491 .filter_map(|(&id, l)| {
492 if !matches!(l.source, LevelSource::AutoSwing { .. }) {
493 return None;
494 }
495 let age = self.bar_index.saturating_sub(l.creation_bar);
496 if age > max_age { Some(id) } else { None }
497 })
498 .collect();
499 for id in stale {
500 self.levels.remove(&id);
501 }
502 return;
503 }
504 };
505
506 let to_remove: Vec<u32> = self
507 .levels
508 .iter()
509 .filter_map(|(&id, l)| {
510 if !matches!(l.source, LevelSource::AutoSwing { .. }) {
511 return None;
512 }
513 let age = self.bar_index.saturating_sub(l.creation_bar);
514 let invalidated = if flip.is_bearish {
515 l.is_support && l.price > flip.price
516 } else {
517 !l.is_support && l.price < flip.price
518 };
519 if age > self.max_auto_level_age_bars || invalidated {
520 Some(id)
521 } else {
522 None
523 }
524 })
525 .collect();
526 for id in to_remove {
527 self.levels.remove(&id);
528 }
529 }
530
531 fn maybe_add_auto_levels(&mut self, state: &MarketStructureState, min_separation: f64) {
533 if self.levels.len() >= self.max_auto_levels {
534 return;
535 }
536
537 let candidates: Vec<SwingPoint> =
538 vec![state.last_swing_high.clone(), state.last_swing_low.clone()]
539 .into_iter()
540 .flatten()
541 .collect();
542
543 for sp in candidates {
544 let too_close = self
545 .levels
546 .values()
547 .any(|l| (l.price - sp.price).abs() < min_separation);
548 if too_close {
549 continue;
550 }
551
552 let id = self.next_level_id;
553 self.next_level_id += 1;
554
555 let label = if sp.is_high {
556 format!("R_auto_{}", sp.bar)
557 } else {
558 format!("S_auto_{}", sp.bar)
559 };
560
561 let origin_strength = if sp.is_high {
562 1u32
564 } else {
565 1u32
566 };
567
568 self.levels.insert(
569 id,
570 MonitoredLevel {
571 price: sp.price,
572 label,
573 is_support: !sp.is_high,
574 source: LevelSource::AutoSwing {
575 origin_swing_bar: sp.bar,
576 origin_strength,
577 },
578 creation_bar: self.bar_index,
579 last_side: 0,
580 prev_valid_side: 0,
581 side_before_touch: 0,
582 approached: false,
583 touched: false,
584 breakout_happened: false,
585 breakout_direction: 0,
586 last_touch_bar: 0,
587 last_interaction_bar: 0,
588 },
589 );
590 }
591 }
592}
593
594impl Default for SRInteractionMonitor {
595 fn default() -> Self {
596 Self::new(3, 0.5, 5.0) }
598}
599
600impl Next<(f64, f64, f64)> for SRInteractionMonitor {
601 type Output = SRMonitorOutput;
602
603 fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
604 self.bar_index += 1;
605
606 self.current_atr = self.atr.next((high, low, close)).max(1e-8);
607 let (touch_tol, appr_zone, min_sep) = self.effective_tolerances();
608
609 let structure = self.ms.next((high, low));
610
611 self.prune_stale_auto_levels(&structure);
612 self.maybe_add_auto_levels(&structure, min_sep);
613
614 let mut all_interactions: Vec<SRInteraction> = Vec::new();
615
616 let level_ids: Vec<u32> = self.levels.keys().copied().collect();
617 for id in level_ids {
618 if let Some(level) = self.levels.get_mut(&id) {
619 let mut evs = SRInteractionMonitor::detect_interactions_for_level(
620 self.bar_index,
621 touch_tol,
622 appr_zone,
623 level,
624 high,
625 low,
626 close,
627 );
628 all_interactions.append(&mut evs);
629 }
630 }
631
632 all_interactions.sort_by(|a, b| {
634 a.bar
635 .cmp(&b.bar)
636 .then_with(|| {
637 a.level_price
638 .partial_cmp(&b.level_price)
639 .unwrap_or(std::cmp::Ordering::Equal)
640 })
641 .then_with(|| (a.interaction as u8).cmp(&(b.interaction as u8)))
642 });
643
644 SRMonitorOutput {
645 structure,
646 interactions: all_interactions,
647 }
648 }
649}
650
651pub const SR_INTERACTION_MONITOR_METADATA: IndicatorMetadata = IndicatorMetadata {
652 name: "S/R Interaction Monitor (Part 67)",
653 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).",
654 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.",
655 keywords: &[
656 "price-action",
657 "support-resistance",
658 "sr-interaction",
659 "breakout",
660 "retest",
661 "market-structure",
662 "mql5",
663 "part-67",
664 ],
665 ehlers_summary: "Classical price action (not DSP). Horizontal level state machine on top of adaptive swings.",
666 params: &[
667 ParamDef {
668 name: "swing_strength",
669 default: "3",
670 description: "Depth for internal MarketStructure swing detection (Part 21).",
671 },
672 ParamDef {
673 name: "touch_tolerance",
674 default: "0.5",
675 description: "Absolute price tolerance for Touch/Retest (Part 67 TouchTolerancePips scaled).",
676 },
677 ParamDef {
678 name: "approach_zone",
679 default: "5.0",
680 description: "Outer Approach zone (Part 67 ApproachZonePips).",
681 },
682 ParamDef {
683 name: "touch_tol_atr_mult",
684 default: "0.5",
685 description: "ATR-relative touch tolerance (use new_atr_relative).",
686 },
687 ParamDef {
688 name: "approach_zone_atr_mult",
689 default: "2.0",
690 description: "ATR-relative approach zone (use new_atr_relative).",
691 },
692 ],
693 formula_source: "https://www.mql5.com/en/articles/21961 (SupportResistanceMonitor.mq5) + Part 21 market_structure foundation",
694 formula_latex: r#"
695\text{side} = \text{sign}(price - level)\\
696\text{touch if } |level - [L,H]| \le tol\\
697\text{breakout if side flips}\\
698\text{retest if post-breakout distance} \le tol
699"#,
700 gold_standard_file: "", category: "Price Action",
702};
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707 use proptest::prelude::*;
708
709 fn batch_sr(
710 data: &[(f64, f64, f64)],
711 strength: usize,
712 touch_tol: f64,
713 appr: f64,
714 ) -> Vec<SRMonitorOutput> {
715 let mut mon = SRInteractionMonitor::new(strength, touch_tol, appr);
716 data.iter().map(|&(h, l, c)| mon.next((h, l, c))).collect()
717 }
718
719 #[test]
720 fn test_basic_user_level_interactions() {
721 let mut mon = SRInteractionMonitor::new(2, 0.2, 1.0);
722 let _user_id = mon.add_user_level(100.0, "TestResist");
723
724 let series: Vec<(f64, f64, f64)> = vec![
726 (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), ];
733
734 let mut any_interaction_on_level = false;
735 for (i, item) in series.iter().enumerate() {
736 let out = mon.next(*item);
737 for ev in &out.interactions {
738 if ev.level_label.contains("TestResist") {
739 any_interaction_on_level = true;
740 }
741 }
742 if i > 3 {
743 assert!(
744 mon.active_level_count() > 0,
745 "level should remain registered"
746 );
747 }
748 }
749 assert!(
752 any_interaction_on_level || mon.active_level_count() == 1,
753 "user level should participate in monitoring"
754 );
755 }
756
757 #[test]
758 fn test_auto_levels_from_structure() {
759 let mut mon = SRInteractionMonitor::new(2, 0.1, 0.5);
760 let highs: Vec<f64> = (0..30)
762 .map(|i| 100.0 + (i as f64 * 0.3) + ((i % 5) as f64 - 2.0))
763 .collect();
764 let lows: Vec<f64> = highs.iter().map(|h| h - 0.8).collect();
765
766 let mut added_auto = false;
767 for i in 0..highs.len() {
768 let c = (highs[i] + lows[i]) / 2.0;
769 let _out = mon.next((highs[i], lows[i], c));
770 if mon.active_level_count() > 0 && i > 8 {
771 added_auto = true;
772 }
773 }
774 assert!(
775 added_auto,
776 "Auto levels should have been promoted from swings"
777 );
778 }
779
780 proptest! {
781 #[test]
782 fn test_sr_parity(
783 input in prop::collection::vec((10.0f64..200.0, 9.0f64..199.0, 9.5f64..199.5), 20..70)
784 ) {
785 let adj: Vec<(f64,f64,f64)> = input
786 .into_iter()
787 .map(|(h,l,c)| {
788 let hh = h.max(l).max(c);
789 let ll = l.min(h).min(c);
790 let cc = c.clamp(ll, hh);
791 (hh, ll, cc)
792 })
793 .collect();
794
795 let mut streaming = SRInteractionMonitor::new(2, 0.25, 1.5);
796 let streaming_res: Vec<_> = adj.iter().map(|&x| streaming.next(x)).collect();
797
798 let batch_res = batch_sr(&adj, 2, 0.25, 1.5);
799
800 prop_assert_eq!(streaming_res.len(), batch_res.len());
801
802 for (s, b) in streaming_res.iter().zip(batch_res.iter()) {
803 prop_assert_eq!(s.structure.bias, b.structure.bias);
805 prop_assert_eq!(s.interactions.len(), b.interactions.len());
807 let s_types: Vec<_> = s.interactions.iter().map(|e| e.interaction).collect();
809 let b_types: Vec<_> = b.interactions.iter().map(|e| e.interaction).collect();
810 prop_assert_eq!(s_types, b_types);
811 }
812 }
813 }
814
815 #[test]
816 fn test_interaction_bar_indices_match_bar_counter() {
817 let mut mon = SRInteractionMonitor::new(2, 0.2, 1.0);
818 mon.add_user_level(100.0, "TestResist");
819
820 let series: Vec<(f64, f64, f64)> = vec![
821 (99.0, 98.5, 98.7),
822 (99.8, 99.6, 99.7),
823 (100.1, 99.9, 100.0),
824 (100.3, 100.1, 100.2),
825 (100.1, 99.9, 100.0),
826 ];
827
828 let n_bars = series.len();
829 let mut observed_bars = Vec::new();
830 for item in &series {
831 let out = mon.next(*item);
832 for ev in &out.interactions {
833 if ev.level_label == "TestResist" {
834 observed_bars.push(ev.bar);
835 assert_ne!(
836 ev.bar, 0,
837 "interaction bar must reflect the monitor bar counter"
838 );
839 }
840 }
841 }
842
843 assert!(
844 !observed_bars.is_empty(),
845 "expected at least one interaction on the user level"
846 );
847 assert!(
848 observed_bars.iter().all(|&b| (1..=n_bars).contains(&b)),
849 "interaction bars must be within 1..=len(series), got {observed_bars:?}"
850 );
851 }
852
853 #[test]
854 fn test_atr_relative_mode_produces_interactions() {
855 let mut mon = SRInteractionMonitor::new_atr_relative(2, 14, 0.3, 1.5);
856 mon.add_user_level(100.0, "ATRLevel");
857 let series: Vec<(f64, f64, f64)> = vec![
858 (99.0, 98.5, 98.7),
859 (100.1, 99.9, 100.0),
860 (100.3, 100.1, 100.2),
861 ];
862 let mut any = false;
863 for item in series {
864 let out = mon.next(item);
865 if !out.interactions.is_empty() {
866 any = true;
867 }
868 }
869 assert!(any, "ATR-relative monitor should detect interactions");
870 }
871
872 #[test]
873 fn test_prune_auto_levels_on_bos() {
874 let mut mon = SRInteractionMonitor::new(1, 0.1, 0.5);
875 let user_id = mon.add_user_level(50.0, "UserSupport");
876 let highs: Vec<f64> = (0..60)
877 .map(|i| 100.0 + (i as f64 * 0.4) + ((i % 3) as f64 - 1.0) * 0.3)
878 .collect();
879 let lows: Vec<f64> = highs.iter().map(|h| h - 0.6).collect();
880 for i in 0..highs.len() {
881 let c = (highs[i] + lows[i]) / 2.0;
882 mon.next((highs[i], lows[i], c));
883 }
884 let before = mon.active_level_count();
885 let reversal_highs: Vec<f64> = (0..25).map(|i| 124.0 - (i as f64 * 1.5)).collect();
886 let reversal_lows: Vec<f64> = reversal_highs.iter().map(|h| h - 1.0).collect();
887 for i in 0..reversal_highs.len() {
888 let c = (reversal_highs[i] + reversal_lows[i]) / 2.0;
889 mon.next((reversal_highs[i], reversal_lows[i], c));
890 }
891 assert!(
892 mon.active_level_count() <= before.max(1),
893 "BOS/age pruning should not grow unbounded"
894 );
895 assert!(
896 mon.levels_snapshot()
897 .iter()
898 .any(|(id, _, label, _, _)| { *id == user_id && label == "UserSupport" }),
899 "user-provided levels must survive BOS pruning"
900 );
901 }
902
903 #[test]
904 fn test_no_duplicate_events_without_reset() {
905 let mut mon = SRInteractionMonitor::new(2, 0.1, 0.5);
907 mon.add_user_level(50.0, "Level");
908
909 let data = vec![
910 (49.0, 48.8, 48.9),
911 (49.2, 49.0, 49.1),
912 (50.2, 50.0, 50.1), (50.3, 50.1, 50.2),
914 (50.4, 50.2, 50.3),
915 ];
916
917 let mut breakout_count = 0;
918 for d in data {
919 let out = mon.next(d);
920 breakout_count += out
921 .interactions
922 .iter()
923 .filter(|e| e.interaction == SRInteractionType::Breakout)
924 .count();
925 }
926 assert!(
927 breakout_count <= 1,
928 "Breakout should fire at most once without re-arming price action"
929 );
930 }
931}