Skip to main content

quantwave_core/indicators/
smc.rs

1//! Structure & SMC (Smart Money Concepts) Suite
2//!
3//! High-performance, streaming-first implementation of Smart Money Concepts (SMC)
4//! and Market Structure analytics for algorithmic trading and quantitative research.
5//!
6//! # Included Detectors
7//!
8//! 1. [`Choch`] / [`ChochDetector`]:
9//!    Market Structure Break (BOS) and Change of Character (CHoCH) detector.
10//!    Tracks swing highs and swing lows via a symmetric sliding window, determines
11//!    structural trend (Bullish, Bearish, Undetermined), and emits rich break events:
12//!    - **BOS (Break of Structure)**: Continuation of current trend (e.g., closing above
13//!      previous swing high in an uptrend, or below previous swing low in a downtrend).
14//!    - **CHoCH (Change of Character)**: Reversal of structure (e.g., closing below
15//!      previous swing low in an uptrend, or above previous swing high in a downtrend).
16//!
17//! 2. [`OrderBlock`] / [`OrderBlockDetector`]:
18//!    Institutional Order Block (OB) detector with lifecycle mitigation tracking:
19//!    - **Bullish Order Block**: The last down-closing candle prior to a strong bullish
20//!      breakout/displacement. Zone covers `[low, high]` (or body).
21//!    - **Bearish Order Block**: The last up-closing candle prior to a strong bearish
22//!      breakdown/displacement. Zone covers `[low, high]` (or body).
23//!    - Tracks incoming price action against active order blocks: unmitigated, partially
24//!      mitigated (tested), and fully mitigated (violated / invalidated).
25//!
26//! 3. [`FairValueGap`] / [`FairValueGapDetector`]:
27//!    3-bar price imbalance detector (Fair Value Gap / FVG):
28//!    - **Bullish FVG**: `low[t] > high[t - 2]`. Imbalance zone is `[high[t - 2], low[t]]`.
29//!    - **Bearish FVG**: `high[t] < low[t - 2]`. Imbalance zone is `[high[t], low[t - 2]]`.
30//!    - Tracks retest penetration and full mitigation as subsequent candles fill the imbalance.
31//!
32//! # Formulas and References
33//!
34//! - **CHoCH / BOS**:
35//!   Canonical SMC market structure:
36//!   $$
37//!   \begin{aligned}
38//!   \text{SwingHigh}_t &= \operatorname{high}_t \ge \operatorname{high}_{t \pm i}, \quad \forall i \in [1, L] \\
39//!   \text{SwingLow}_t  &= \operatorname{low}_t \le \operatorname{low}_{t \pm i}, \quad \forall i \in [1, L] \\
40//!   \text{BOS}_{\text{bull}}  &= \text{Close} > \text{SwingHigh} \quad (\text{Trend} = \text{Bullish}) \\
41//!   \text{CHoCH}_{\text{bear}} &= \text{Close} < \text{SwingLow} \quad (\text{Trend} = \text{Bullish} \to \text{Bearish})
42//!   \end{aligned}
43//!   $$
44//!   Source: <https://www.tradingview.com/support/solutions/43000703076-market-structure/>
45//!
46//! - **Order Blocks**:
47//!   Institutional footprints preceding displacement moves:
48//!   $$
49//!   \text{Bullish OB} = \text{Candle}_{t-1} (\text{Close} \le \text{Open}) \land \text{Close}_t > \text{High}_{t-1}
50//!   $$
51//!   Source: <https://www.tradingview.com/support/solutions/43000703077-order-blocks/>
52//!
53//! - **Fair Value Gaps**:
54//!   3-bar price imbalance / liquidity voids:
55//!   $$
56//!   \text{Bullish FVG} = \text{Low}_t > \text{High}_{t-2}, \quad \text{Bearish FVG} = \text{High}_t < \text{Low}_{t-2}
57//!   $$
58//!   Source: <https://www.tradingview.com/support/solutions/43000703078-fair-value-gaps/>
59
60use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
61use crate::traits::Next;
62use serde::{Deserialize, Serialize};
63use std::collections::VecDeque;
64
65// ============================================================================
66// Metadata Definitions
67// ============================================================================
68
69pub const CHOCH_METADATA: IndicatorMetadata = IndicatorMetadata {
70    name: "choch",
71    description: "Detects Market Structure Breaks (BOS) and Changes of Character (CHoCH) using swing highs and lows.",
72    usage: "Identifies trend continuations (BOS) and trend reversals (CHoCH) in market structure for Smart Money Concepts (SMC) trading strategies.",
73    keywords: &["smc", "market structure", "choch", "bos", "swings", "trend"],
74    ehlers_summary: "",
75    params: &[ParamDef {
76        name: "swing_strength",
77        default: "5",
78        description: "Number of bars required on each side to confirm a swing high or swing low.",
79    }],
80    formula_source: "https://www.tradingview.com/support/solutions/43000703076-market-structure/",
81    formula_latex: r"\[ \text{BOS} = \text{Close} > \text{SwingHigh}_{\text{bull}} \lor \text{Close} < \text{SwingLow}_{\text{bear}}, \quad \text{CHoCH} = \text{Close} < \text{SwingLow}_{\text{bull}} \lor \text{Close} > \text{SwingHigh}_{\text{bear}} \]",
82    gold_standard_file: "choch.json",
83    category: "Price Action / SMC",
84};
85
86pub const ORDER_BLOCK_METADATA: IndicatorMetadata = IndicatorMetadata {
87    name: "order_block",
88    description: "Detects Bullish and Bearish Order Blocks (institutional entry zones) and tracks their mitigation.",
89    usage: "Identifies institutional order blocks preceding energetic displacements and monitors subsequent mitigation/invalidation.",
90    keywords: &[
91        "smc",
92        "order block",
93        "ob",
94        "smart money",
95        "mitigation",
96        "institutional",
97    ],
98    ehlers_summary: "",
99    params: &[ParamDef {
100        name: "max_active_blocks",
101        default: "20",
102        description: "Maximum number of unmitigated or partially mitigated active order blocks retained in memory.",
103    }],
104    formula_source: "https://www.tradingview.com/support/solutions/43000703077-order-blocks/",
105    formula_latex: r"\[ \text{Bullish OB} = \text{Candle}_{t-1} \text{ (down)} \land \text{Close}_t > \text{High}_{t-1}, \quad \text{Bearish OB} = \text{Candle}_{t-1} \text{ (up)} \land \text{Close}_t < \text{Low}_{t-1} \]",
106    gold_standard_file: "order_block.json",
107    category: "Price Action / SMC",
108};
109
110pub const FAIR_VALUE_GAP_METADATA: IndicatorMetadata = IndicatorMetadata {
111    name: "fair_value_gap",
112    description: "Detects 3-bar Fair Value Gaps (price imbalances) and tracks their mitigation and fill levels.",
113    usage: "Identifies liquidity imbalances where price moves impulsively, leaving untraded zones that act as magnets for price retests.",
114    keywords: &[
115        "smc",
116        "fvg",
117        "fair value gap",
118        "imbalance",
119        "liquidity",
120        "smart money",
121    ],
122    ehlers_summary: "",
123    params: &[
124        ParamDef {
125            name: "min_gap_size",
126            default: "0.0",
127            description: "Minimum price distance required between outer bars to register a valid Fair Value Gap.",
128        },
129        ParamDef {
130            name: "max_active_gaps",
131            default: "20",
132            description: "Maximum number of unmitigated or partially mitigated fair value gaps retained in memory.",
133        },
134    ],
135    formula_source: "https://www.tradingview.com/support/solutions/43000703078-fair-value-gaps/",
136    formula_latex: r"\[ \text{Bullish FVG} = \text{Low}_t > \text{High}_{t-2}, \quad \text{Bearish FVG} = \text{High}_t < \text{Low}_{t-2} \]",
137    gold_standard_file: "fair_value_gap.json",
138    category: "Price Action / SMC",
139};
140
141// ============================================================================
142// Shared Domain Types & Enums
143// ============================================================================
144
145/// Structural market trend derived from swing highs and swing lows.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
147pub enum StructureTrend {
148    /// Consecutive higher highs and higher lows, or confirmed bullish break.
149    Bullish,
150    /// Consecutive lower lows and lower highs, or confirmed bearish break.
151    Bearish,
152    /// Insufficient swing structure to determine definitive trend direction.
153    Undetermined,
154}
155
156impl StructureTrend {
157    #[inline]
158    pub fn is_bullish(self) -> bool {
159        self == Self::Bullish
160    }
161
162    #[inline]
163    pub fn is_bearish(self) -> bool {
164        self == Self::Bearish
165    }
166
167    #[inline]
168    pub fn is_undetermined(self) -> bool {
169        self == Self::Undetermined
170    }
171}
172
173/// Category of structure break.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
175pub enum StructureBreakType {
176    /// Break of Structure: trend continuation break.
177    Bos,
178    /// Change of Character: trend reversal break.
179    Choch,
180}
181
182/// Direction of structure break.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
184pub enum StructureBreakDirection {
185    /// Price breaks upward through resistance / swing high.
186    Bullish,
187    /// Price breaks downward through support / swing low.
188    Bearish,
189}
190
191/// Type of swing extreme.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
193pub enum SwingType {
194    /// Local peak.
195    High,
196    /// Local trough.
197    Low,
198}
199
200/// Confirmed swing extreme with bar index and price level.
201#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
202pub struct SmcSwingPoint {
203    /// Absolute bar index where the swing extreme occurred.
204    pub bar: usize,
205    /// Price of the swing high or swing low.
206    pub price: f64,
207    /// Type of swing (High or Low).
208    pub swing_type: SwingType,
209}
210
211/// Structural break event emitted when price closes beyond an active swing level.
212#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
213pub struct StructureBreakEvent {
214    /// Break classification (BOS or CHoCH).
215    pub break_type: StructureBreakType,
216    /// Direction of the breakout.
217    pub direction: StructureBreakDirection,
218    /// Price level of the broken swing point.
219    pub level: f64,
220    /// Bar index of the broken swing point.
221    pub broken_swing_bar: usize,
222    /// Bar index that triggered the confirmation break.
223    pub trigger_bar: usize,
224    /// Close price of the triggering bar.
225    pub trigger_price: f64,
226}
227
228/// Mitigation status for order blocks and fair value gaps.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
230pub enum MitigationStatus {
231    /// Zone has not been touched by subsequent price action.
232    Unmitigated,
233    /// Price has penetrated into the zone without completely violating/filling it.
234    PartiallyMitigated,
235    /// Zone has been fully penetrated, filled, or invalidated.
236    FullyMitigated,
237}
238
239// ============================================================================
240// 1. Choch / ChochDetector Indicator
241// ============================================================================
242
243/// Snapshot output of the Market Structure (CHoCH / BOS) detector.
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct ChochState {
246    /// Current structural trend bias.
247    pub trend: StructureTrend,
248    /// Most recent structure break event (if any break has occurred).
249    pub last_break: Option<StructureBreakEvent>,
250    /// Most recently confirmed swing high.
251    pub last_swing_high: Option<SmcSwingPoint>,
252    /// Most recently confirmed swing low.
253    pub last_swing_low: Option<SmcSwingPoint>,
254    /// Current bar index.
255    pub bar_index: usize,
256}
257
258impl ChochState {
259    /// Returns the break event if one occurred on this exact bar.
260    #[inline]
261    pub fn current_break(&self) -> Option<StructureBreakEvent> {
262        self.last_break.filter(|b| b.trigger_bar == self.bar_index)
263    }
264
265    #[inline]
266    pub fn is_bullish(&self) -> bool {
267        self.trend.is_bullish()
268    }
269
270    #[inline]
271    pub fn is_bearish(&self) -> bool {
272        self.trend.is_bearish()
273    }
274}
275
276/// Market Structure detector tracking Swings, BOS, and CHoCH.
277#[derive(Debug, Clone)]
278pub struct Choch {
279    swing_strength: usize,
280    highs: VecDeque<f64>,
281    lows: VecDeque<f64>,
282    bar_index: usize,
283    trend: StructureTrend,
284    last_swing_high: Option<SmcSwingPoint>,
285    prev_swing_high: Option<SmcSwingPoint>,
286    last_swing_low: Option<SmcSwingPoint>,
287    prev_swing_low: Option<SmcSwingPoint>,
288    broken_high: bool,
289    broken_low: bool,
290    last_break: Option<StructureBreakEvent>,
291}
292
293pub type ChochDetector = Choch;
294
295impl Choch {
296    /// Construct a new `Choch` detector with specified swing strength.
297    ///
298    /// `swing_strength` defines the number of bars on either side required to confirm
299    /// a swing high or low. Clamped to a minimum of 1.
300    pub fn new(swing_strength: usize) -> Self {
301        let strength = swing_strength.max(1);
302        Self {
303            swing_strength: strength,
304            highs: VecDeque::with_capacity(2 * strength + 1),
305            lows: VecDeque::with_capacity(2 * strength + 1),
306            bar_index: 0,
307            trend: StructureTrend::Undetermined,
308            last_swing_high: None,
309            prev_swing_high: None,
310            last_swing_low: None,
311            prev_swing_low: None,
312            broken_high: false,
313            broken_low: false,
314            last_break: None,
315        }
316    }
317
318    /// Reset internal state machine.
319    pub fn reset(&mut self) {
320        self.highs.clear();
321        self.lows.clear();
322        self.bar_index = 0;
323        self.trend = StructureTrend::Undetermined;
324        self.last_swing_high = None;
325        self.prev_swing_high = None;
326        self.last_swing_low = None;
327        self.prev_swing_low = None;
328        self.broken_high = false;
329        self.broken_low = false;
330        self.last_break = None;
331    }
332
333    /// Helper to process a slice of `(high, low, close)` bars.
334    pub fn process_batch(&mut self, bars: &[(f64, f64, f64)]) -> Vec<ChochState> {
335        let mut results = Vec::with_capacity(bars.len());
336        for &bar in bars {
337            results.push(self.next(bar));
338        }
339        results
340    }
341}
342
343impl Default for Choch {
344    fn default() -> Self {
345        Self::new(5)
346    }
347}
348
349impl Next<(f64, f64, f64)> for Choch {
350    type Output = ChochState;
351
352    fn next(&mut self, input: (f64, f64, f64)) -> Self::Output {
353        let (high, low, close) = input;
354
355        // Fallback for non-finite prices
356        if !high.is_finite() || !low.is_finite() || !close.is_finite() {
357            return ChochState {
358                trend: self.trend,
359                last_break: self.last_break,
360                last_swing_high: self.last_swing_high,
361                last_swing_low: self.last_swing_low,
362                bar_index: self.bar_index,
363            };
364        }
365
366        let curr_bar = self.bar_index;
367        let window_len = 2 * self.swing_strength + 1;
368
369        // Maintain sliding window for swing detection
370        self.highs.push_back(high);
371        self.lows.push_back(low);
372        if self.highs.len() > window_len {
373            self.highs.pop_front();
374            self.lows.pop_front();
375        }
376
377        // 1. Swing Confirmation at candidate bar: curr_bar - swing_strength
378        if self.highs.len() == window_len {
379            let candidate_bar = curr_bar - self.swing_strength;
380            let center_high = self.highs[self.swing_strength];
381            let center_low = self.lows[self.swing_strength];
382
383            let mut is_swing_high = true;
384            let mut is_swing_low = true;
385
386            for i in 0..window_len {
387                if i == self.swing_strength {
388                    continue;
389                }
390                if i < self.swing_strength {
391                    if self.highs[i] > center_high {
392                        is_swing_high = false;
393                    }
394                    if self.lows[i] < center_low {
395                        is_swing_low = false;
396                    }
397                } else {
398                    // Strict inequality on right side prevents duplicate consecutive swings
399                    if self.highs[i] >= center_high {
400                        is_swing_high = false;
401                    }
402                    if self.lows[i] <= center_low {
403                        is_swing_low = false;
404                    }
405                }
406            }
407
408            if is_swing_high {
409                let point = SmcSwingPoint {
410                    bar: candidate_bar,
411                    price: center_high,
412                    swing_type: SwingType::High,
413                };
414                self.prev_swing_high = self.last_swing_high;
415                self.last_swing_high = Some(point);
416                self.broken_high = false;
417            }
418
419            if is_swing_low {
420                let point = SmcSwingPoint {
421                    bar: candidate_bar,
422                    price: center_low,
423                    swing_type: SwingType::Low,
424                };
425                self.prev_swing_low = self.last_swing_low;
426                self.last_swing_low = Some(point);
427                self.broken_low = false;
428            }
429
430            // Derive initial trend if Undetermined from consecutive swing points
431            if self.trend == StructureTrend::Undetermined
432                && let (Some(sh), Some(psh), Some(sl), Some(psl)) = (
433                    self.last_swing_high,
434                    self.prev_swing_high,
435                    self.last_swing_low,
436                    self.prev_swing_low,
437                )
438            {
439                if sh.price > psh.price && sl.price > psl.price {
440                    self.trend = StructureTrend::Bullish;
441                } else if sh.price < psh.price && sl.price < psl.price {
442                    self.trend = StructureTrend::Bearish;
443                }
444            }
445        }
446
447        // 2. Break Detection on current bar close
448        let mut triggered_break = None;
449
450        // Check break above last active swing high
451        if let Some(sh) = self.last_swing_high
452            && !self.broken_high
453            && close > sh.price
454        {
455            let (b_type, b_dir) = match self.trend {
456                StructureTrend::Bullish => {
457                    (StructureBreakType::Bos, StructureBreakDirection::Bullish)
458                }
459                StructureTrend::Bearish => {
460                    self.trend = StructureTrend::Bullish;
461                    (StructureBreakType::Choch, StructureBreakDirection::Bullish)
462                }
463                StructureTrend::Undetermined => {
464                    self.trend = StructureTrend::Bullish;
465                    (StructureBreakType::Choch, StructureBreakDirection::Bullish)
466                }
467            };
468            self.broken_high = true;
469            let event = StructureBreakEvent {
470                break_type: b_type,
471                direction: b_dir,
472                level: sh.price,
473                broken_swing_bar: sh.bar,
474                trigger_bar: curr_bar,
475                trigger_price: close,
476            };
477            triggered_break = Some(event);
478            self.last_break = Some(event);
479        }
480
481        // Check break below last active swing low (if high was not broken on same bar)
482        if triggered_break.is_none()
483            && let Some(sl) = self.last_swing_low
484            && !self.broken_low
485            && close < sl.price
486        {
487            let (b_type, b_dir) = match self.trend {
488                StructureTrend::Bearish => {
489                    (StructureBreakType::Bos, StructureBreakDirection::Bearish)
490                }
491                StructureTrend::Bullish => {
492                    self.trend = StructureTrend::Bearish;
493                    (StructureBreakType::Choch, StructureBreakDirection::Bearish)
494                }
495                StructureTrend::Undetermined => {
496                    self.trend = StructureTrend::Bearish;
497                    (StructureBreakType::Choch, StructureBreakDirection::Bearish)
498                }
499            };
500            self.broken_low = true;
501            let event = StructureBreakEvent {
502                break_type: b_type,
503                direction: b_dir,
504                level: sl.price,
505                broken_swing_bar: sl.bar,
506                trigger_bar: curr_bar,
507                trigger_price: close,
508            };
509            self.last_break = Some(event);
510        }
511
512        self.bar_index += 1;
513
514        ChochState {
515            trend: self.trend,
516            last_break: self.last_break,
517            last_swing_high: self.last_swing_high,
518            last_swing_low: self.last_swing_low,
519            bar_index: curr_bar,
520        }
521    }
522}
523
524/// Compute `Choch` over a batch of `(high, low, close)` bars.
525pub fn choch_batch(inputs: &[(f64, f64, f64)], swing_strength: usize) -> Vec<ChochState> {
526    let mut detector = Choch::new(swing_strength);
527    detector.process_batch(inputs)
528}
529
530// ============================================================================
531// 2. OrderBlock / OrderBlockDetector Indicator
532// ============================================================================
533
534/// Order block classification.
535#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
536pub enum OrderBlockType {
537    /// Bullish order block: down-candle before upward displacement.
538    Bullish,
539    /// Bearish order block: up-candle before downward displacement.
540    Bearish,
541}
542
543/// Calculation mode for the order block price zone boundaries.
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
545pub enum OrderBlockZoneMode {
546    /// Full candle wick range: `[low, high]`.
547    Wick,
548    /// Real body range: `[min(open, close), max(open, close)]`.
549    Body,
550}
551
552/// Structural representation of an identified Order Block.
553#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
554pub struct OrderBlockZone {
555    /// Monotonically increasing unique ID.
556    pub id: u64,
557    /// Bullish or Bearish type.
558    pub block_type: OrderBlockType,
559    /// Bar index of the order block candle (the down/up candle).
560    pub bar_index: usize,
561    /// Bar index where the breakout candle confirmed the displacement.
562    pub trigger_bar: usize,
563    /// Open price of the order block candle.
564    pub open: f64,
565    /// High price of the order block candle.
566    pub high: f64,
567    /// Low price of the order block candle.
568    pub low: f64,
569    /// Close price of the order block candle.
570    pub close: f64,
571    /// Upper price boundary of the zone.
572    pub zone_high: f64,
573    /// Lower price boundary of the zone.
574    pub zone_low: f64,
575    /// Mitigation status.
576    pub mitigation: MitigationStatus,
577    /// Bar index where mitigation occurred (if any).
578    pub mitigation_bar: Option<usize>,
579}
580
581impl OrderBlockZone {
582    #[inline]
583    pub fn is_unmitigated(&self) -> bool {
584        self.mitigation == MitigationStatus::Unmitigated
585    }
586
587    #[inline]
588    pub fn is_partially_mitigated(&self) -> bool {
589        self.mitigation == MitigationStatus::PartiallyMitigated
590    }
591
592    #[inline]
593    pub fn is_fully_mitigated(&self) -> bool {
594        self.mitigation == MitigationStatus::FullyMitigated
595    }
596}
597
598/// Snapshot output of the Order Block detector.
599#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
600pub struct OrderBlockState {
601    /// Currently active (unmitigated or partially mitigated) order blocks.
602    pub active_blocks: Vec<OrderBlockZone>,
603    /// Most recently formed order block (if any).
604    pub latest_formed: Option<OrderBlockZone>,
605    /// Current bar index.
606    pub bar_index: usize,
607}
608
609impl OrderBlockState {
610    /// Returns the order block formed on this exact bar, if any.
611    #[inline]
612    pub fn current_formed(&self) -> Option<&OrderBlockZone> {
613        self.latest_formed
614            .as_ref()
615            .filter(|b| b.trigger_bar == self.bar_index)
616    }
617}
618
619/// Tunable parameters for Order Block detection.
620#[derive(Debug, Clone)]
621pub struct OrderBlockConfig {
622    /// Maximum number of active order blocks retained in memory.
623    pub max_active_blocks: usize,
624    /// Zone calculation mode (Wick vs Body).
625    pub zone_mode: OrderBlockZoneMode,
626    /// Minimum displacement ratio: `displacement_body / prev_candle_range`. Default: 0.0.
627    pub min_displacement_ratio: f64,
628}
629
630impl Default for OrderBlockConfig {
631    fn default() -> Self {
632        Self {
633            max_active_blocks: 20,
634            zone_mode: OrderBlockZoneMode::Wick,
635            min_displacement_ratio: 0.0,
636        }
637    }
638}
639
640/// Institutional Order Block detector with lifecycle mitigation tracking.
641#[derive(Debug, Clone)]
642pub struct OrderBlock {
643    config: OrderBlockConfig,
644    active_blocks: Vec<OrderBlockZone>,
645    latest_formed: Option<OrderBlockZone>,
646    prev_candle: Option<(f64, f64, f64, f64, usize)>, // (open, high, low, close, bar_index)
647    bar_index: usize,
648    next_id: u64,
649}
650
651pub type OrderBlockDetector = OrderBlock;
652
653impl OrderBlock {
654    /// Construct a new `OrderBlock` detector with capacity limit.
655    pub fn new(max_active_blocks: usize) -> Self {
656        Self::with_config(OrderBlockConfig {
657            max_active_blocks: max_active_blocks.max(1),
658            ..OrderBlockConfig::default()
659        })
660    }
661
662    /// Construct with full configuration.
663    pub fn with_config(config: OrderBlockConfig) -> Self {
664        let max_active = config.max_active_blocks.max(1);
665        Self {
666            config: OrderBlockConfig {
667                max_active_blocks: max_active,
668                ..config
669            },
670            active_blocks: Vec::with_capacity(max_active),
671            latest_formed: None,
672            prev_candle: None,
673            bar_index: 0,
674            next_id: 1,
675        }
676    }
677
678    /// Reset internal state machine.
679    pub fn reset(&mut self) {
680        self.active_blocks.clear();
681        self.latest_formed = None;
682        self.prev_candle = None;
683        self.bar_index = 0;
684        self.next_id = 1;
685    }
686
687    /// Helper to process a batch of `(open, high, low, close)` bars.
688    pub fn process_batch(&mut self, bars: &[(f64, f64, f64, f64)]) -> Vec<OrderBlockState> {
689        let mut results = Vec::with_capacity(bars.len());
690        for &bar in bars {
691            results.push(self.next(bar));
692        }
693        results
694    }
695}
696
697impl Default for OrderBlock {
698    fn default() -> Self {
699        Self::new(20)
700    }
701}
702
703impl Next<(f64, f64, f64, f64)> for OrderBlock {
704    type Output = OrderBlockState;
705
706    fn next(&mut self, input: (f64, f64, f64, f64)) -> Self::Output {
707        let (open, high, low, close) = input;
708
709        // Fallback for non-finite values
710        if !open.is_finite() || !high.is_finite() || !low.is_finite() || !close.is_finite() {
711            return OrderBlockState {
712                active_blocks: self.active_blocks.clone(),
713                latest_formed: self.latest_formed,
714                bar_index: self.bar_index,
715            };
716        }
717
718        let curr_bar = self.bar_index;
719
720        // 1. Prune blocks fully mitigated in previous bars (retaining them on the mitigation bar)
721        self.active_blocks
722            .retain(|b| b.mitigation != MitigationStatus::FullyMitigated);
723
724        // 2. Update mitigation status of active blocks with incoming bar
725        for block in &mut self.active_blocks {
726            match block.block_type {
727                OrderBlockType::Bullish => {
728                    if low <= block.zone_high {
729                        if close < block.zone_low {
730                            // Violated / fully invalidated
731                            block.mitigation = MitigationStatus::FullyMitigated;
732                            block.mitigation_bar = Some(curr_bar);
733                        } else if block.mitigation == MitigationStatus::Unmitigated {
734                            // Tested / penetrated into zone
735                            block.mitigation = MitigationStatus::PartiallyMitigated;
736                            block.mitigation_bar = Some(curr_bar);
737                        }
738                    }
739                }
740                OrderBlockType::Bearish => {
741                    if high >= block.zone_low {
742                        if close > block.zone_high {
743                            // Violated / fully invalidated
744                            block.mitigation = MitigationStatus::FullyMitigated;
745                            block.mitigation_bar = Some(curr_bar);
746                        } else if block.mitigation == MitigationStatus::Unmitigated {
747                            // Tested / penetrated into zone
748                            block.mitigation = MitigationStatus::PartiallyMitigated;
749                            block.mitigation_bar = Some(curr_bar);
750                        }
751                    }
752                }
753            }
754        }
755
756        // 3. Detect new Order Block formation from previous candle -> current candle displacement
757        if let Some((p_open, p_high, p_low, p_close, p_bar)) = self.prev_candle {
758            let prev_range = (p_high - p_low).max(1e-12);
759
760            // Bullish OB: prior down-candle, current candle closes above prior high
761            if p_close < p_open && close > open && close > p_high {
762                let displacement_body = close - open;
763                if displacement_body / prev_range >= self.config.min_displacement_ratio {
764                    let (z_low, z_high) = match self.config.zone_mode {
765                        OrderBlockZoneMode::Wick => (p_low, p_high),
766                        OrderBlockZoneMode::Body => (p_close, p_open),
767                    };
768
769                    let new_block = OrderBlockZone {
770                        id: self.next_id,
771                        block_type: OrderBlockType::Bullish,
772                        bar_index: p_bar,
773                        trigger_bar: curr_bar,
774                        open: p_open,
775                        high: p_high,
776                        low: p_low,
777                        close: p_close,
778                        zone_high: z_high,
779                        zone_low: z_low,
780                        mitigation: MitigationStatus::Unmitigated,
781                        mitigation_bar: None,
782                    };
783                    self.next_id += 1;
784                    self.latest_formed = Some(new_block);
785                    self.active_blocks.push(new_block);
786                }
787            }
788            // Bearish OB: prior up-candle, current candle closes below prior low
789            else if p_close > p_open && close < open && close < p_low {
790                let displacement_body = open - close;
791                if displacement_body / prev_range >= self.config.min_displacement_ratio {
792                    let (z_low, z_high) = match self.config.zone_mode {
793                        OrderBlockZoneMode::Wick => (p_low, p_high),
794                        OrderBlockZoneMode::Body => (p_open, p_close),
795                    };
796
797                    let new_block = OrderBlockZone {
798                        id: self.next_id,
799                        block_type: OrderBlockType::Bearish,
800                        bar_index: p_bar,
801                        trigger_bar: curr_bar,
802                        open: p_open,
803                        high: p_high,
804                        low: p_low,
805                        close: p_close,
806                        zone_high: z_high,
807                        zone_low: z_low,
808                        mitigation: MitigationStatus::Unmitigated,
809                        mitigation_bar: None,
810                    };
811                    self.next_id += 1;
812                    self.latest_formed = Some(new_block);
813                    self.active_blocks.push(new_block);
814                }
815            }
816        }
817
818        // Enforce maximum active blocks capacity
819        while self.active_blocks.len() > self.config.max_active_blocks {
820            self.active_blocks.remove(0);
821        }
822
823        self.prev_candle = Some((open, high, low, close, curr_bar));
824        self.bar_index += 1;
825
826        OrderBlockState {
827            active_blocks: self.active_blocks.clone(),
828            latest_formed: self.latest_formed,
829            bar_index: curr_bar,
830        }
831    }
832}
833
834/// Compute `OrderBlock` over a batch of `(open, high, low, close)` bars.
835pub fn order_block_batch(
836    inputs: &[(f64, f64, f64, f64)],
837    max_active_blocks: usize,
838) -> Vec<OrderBlockState> {
839    let mut detector = OrderBlock::new(max_active_blocks);
840    detector.process_batch(inputs)
841}
842
843// ============================================================================
844// 3. FairValueGap / FairValueGapDetector Indicator
845// ============================================================================
846
847/// Direction of price imbalance for Fair Value Gaps.
848#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
849pub enum FvgDirection {
850    /// Bullish imbalance: upward impulse gap between high[t-2] and low[t].
851    Bullish,
852    /// Bearish imbalance: downward impulse gap between low[t-2] and high[t].
853    Bearish,
854}
855
856/// Structural representation of an identified Fair Value Gap.
857#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
858pub struct FairValueGapZone {
859    /// Monotonically increasing unique ID.
860    pub id: u64,
861    /// Bullish or Bearish direction.
862    pub direction: FvgDirection,
863    /// Bar index where the gap was confirmed (bar t).
864    pub bar_index: usize,
865    /// Upper boundary of the imbalance zone.
866    pub zone_high: f64,
867    /// Lower boundary of the imbalance zone.
868    pub zone_low: f64,
869    /// Gap width: `zone_high - zone_low`.
870    pub gap_size: f64,
871    /// Mitigation status.
872    pub mitigation: MitigationStatus,
873    /// Bar index where mitigation occurred (if any).
874    pub mitigation_bar: Option<usize>,
875}
876
877impl FairValueGapZone {
878    #[inline]
879    pub fn is_unmitigated(&self) -> bool {
880        self.mitigation == MitigationStatus::Unmitigated
881    }
882
883    #[inline]
884    pub fn is_partially_mitigated(&self) -> bool {
885        self.mitigation == MitigationStatus::PartiallyMitigated
886    }
887
888    #[inline]
889    pub fn is_fully_mitigated(&self) -> bool {
890        self.mitigation == MitigationStatus::FullyMitigated
891    }
892}
893
894/// Snapshot output of the Fair Value Gap detector.
895#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
896pub struct FvgState {
897    /// Currently active (unmitigated or partially mitigated) fair value gaps.
898    pub active_gaps: Vec<FairValueGapZone>,
899    /// Most recently formed fair value gap (if any).
900    pub latest_gap: Option<FairValueGapZone>,
901    /// Current bar index.
902    pub bar_index: usize,
903}
904
905impl FvgState {
906    /// Returns the fair value gap formed on this exact bar, if any.
907    #[inline]
908    pub fn current_gap(&self) -> Option<&FairValueGapZone> {
909        self.latest_gap
910            .as_ref()
911            .filter(|g| g.bar_index == self.bar_index)
912    }
913}
914
915/// 3-bar Fair Value Gap (FVG) detector with lifecycle fill tracking.
916#[derive(Debug, Clone)]
917pub struct FairValueGap {
918    min_gap_size: f64,
919    max_active_gaps: usize,
920    active_gaps: Vec<FairValueGapZone>,
921    latest_gap: Option<FairValueGapZone>,
922    history: VecDeque<(f64, f64, f64, usize)>, // (high, low, close, bar_index)
923    bar_index: usize,
924    next_id: u64,
925}
926
927pub type FairValueGapDetector = FairValueGap;
928
929impl FairValueGap {
930    /// Construct a new `FairValueGap` detector.
931    ///
932    /// - `min_gap_size`: Minimum price gap required between outer bars to register an FVG.
933    /// - `max_active_gaps`: Maximum number of unmitigated/partially mitigated gaps retained.
934    pub fn new(min_gap_size: f64, max_active_gaps: usize) -> Self {
935        let max_active = max_active_gaps.max(1);
936        Self {
937            min_gap_size: min_gap_size.max(0.0),
938            max_active_gaps: max_active,
939            active_gaps: Vec::with_capacity(max_active),
940            latest_gap: None,
941            history: VecDeque::with_capacity(3),
942            bar_index: 0,
943            next_id: 1,
944        }
945    }
946
947    /// Reset internal state machine.
948    pub fn reset(&mut self) {
949        self.active_gaps.clear();
950        self.latest_gap = None;
951        self.history.clear();
952        self.bar_index = 0;
953        self.next_id = 1;
954    }
955
956    /// Helper to process a batch of `(high, low, close)` bars.
957    pub fn process_batch(&mut self, bars: &[(f64, f64, f64)]) -> Vec<FvgState> {
958        let mut results = Vec::with_capacity(bars.len());
959        for &bar in bars {
960            results.push(self.next(bar));
961        }
962        results
963    }
964}
965
966impl Default for FairValueGap {
967    fn default() -> Self {
968        Self::new(0.0, 20)
969    }
970}
971
972impl Next<(f64, f64, f64)> for FairValueGap {
973    type Output = FvgState;
974
975    fn next(&mut self, input: (f64, f64, f64)) -> Self::Output {
976        let (high, low, close) = input;
977
978        // Fallback for non-finite values
979        if !high.is_finite() || !low.is_finite() || !close.is_finite() {
980            return FvgState {
981                active_gaps: self.active_gaps.clone(),
982                latest_gap: self.latest_gap,
983                bar_index: self.bar_index,
984            };
985        }
986
987        let curr_bar = self.bar_index;
988
989        // 1. Prune gaps fully mitigated in previous bars
990        self.active_gaps
991            .retain(|g| g.mitigation != MitigationStatus::FullyMitigated);
992
993        // 2. Update mitigation status of active gaps with current bar's price action
994        for gap in &mut self.active_gaps {
995            match gap.direction {
996                FvgDirection::Bullish => {
997                    if low < gap.zone_high {
998                        if low <= gap.zone_low {
999                            // Fully filled
1000                            gap.mitigation = MitigationStatus::FullyMitigated;
1001                            gap.mitigation_bar = Some(curr_bar);
1002                        } else if gap.mitigation == MitigationStatus::Unmitigated {
1003                            // Partially filled
1004                            gap.mitigation = MitigationStatus::PartiallyMitigated;
1005                            gap.mitigation_bar = Some(curr_bar);
1006                        }
1007                    }
1008                }
1009                FvgDirection::Bearish => {
1010                    if high > gap.zone_low {
1011                        if high >= gap.zone_high {
1012                            // Fully filled
1013                            gap.mitigation = MitigationStatus::FullyMitigated;
1014                            gap.mitigation_bar = Some(curr_bar);
1015                        } else if gap.mitigation == MitigationStatus::Unmitigated {
1016                            // Partially filled
1017                            gap.mitigation = MitigationStatus::PartiallyMitigated;
1018                            gap.mitigation_bar = Some(curr_bar);
1019                        }
1020                    }
1021                }
1022            }
1023        }
1024
1025        // 3. Detect new 3-bar Fair Value Gap
1026        if self.history.len() >= 2 {
1027            let (h_prev2, l_prev2, _, _) = self.history[self.history.len() - 2];
1028
1029            // Bullish FVG: low[t] > high[t - 2]
1030            if low > h_prev2 {
1031                let gap_size = low - h_prev2;
1032                if gap_size >= self.min_gap_size && gap_size > 0.0 {
1033                    let new_gap = FairValueGapZone {
1034                        id: self.next_id,
1035                        direction: FvgDirection::Bullish,
1036                        bar_index: curr_bar,
1037                        zone_high: low,
1038                        zone_low: h_prev2,
1039                        gap_size,
1040                        mitigation: MitigationStatus::Unmitigated,
1041                        mitigation_bar: None,
1042                    };
1043                    self.next_id += 1;
1044                    self.latest_gap = Some(new_gap);
1045                    self.active_gaps.push(new_gap);
1046                }
1047            }
1048            // Bearish FVG: high[t] < low[t - 2]
1049            else if high < l_prev2 {
1050                let gap_size = l_prev2 - high;
1051                if gap_size >= self.min_gap_size && gap_size > 0.0 {
1052                    let new_gap = FairValueGapZone {
1053                        id: self.next_id,
1054                        direction: FvgDirection::Bearish,
1055                        bar_index: curr_bar,
1056                        zone_high: l_prev2,
1057                        zone_low: high,
1058                        gap_size,
1059                        mitigation: MitigationStatus::Unmitigated,
1060                        mitigation_bar: None,
1061                    };
1062                    self.next_id += 1;
1063                    self.latest_gap = Some(new_gap);
1064                    self.active_gaps.push(new_gap);
1065                }
1066            }
1067        }
1068
1069        // Enforce maximum active gaps capacity
1070        while self.active_gaps.len() > self.max_active_gaps {
1071            self.active_gaps.remove(0);
1072        }
1073
1074        // Maintain 2 prior bars in history
1075        self.history.push_back((high, low, close, curr_bar));
1076        if self.history.len() > 2 {
1077            self.history.pop_front();
1078        }
1079
1080        self.bar_index += 1;
1081
1082        FvgState {
1083            active_gaps: self.active_gaps.clone(),
1084            latest_gap: self.latest_gap,
1085            bar_index: curr_bar,
1086        }
1087    }
1088}
1089
1090/// Compute `FairValueGap` over a batch of `(high, low, close)` bars.
1091pub fn fair_value_gap_batch(
1092    inputs: &[(f64, f64, f64)],
1093    min_gap_size: f64,
1094    max_active_gaps: usize,
1095) -> Vec<FvgState> {
1096    let mut detector = FairValueGap::new(min_gap_size, max_active_gaps);
1097    detector.process_batch(inputs)
1098}
1099
1100// ============================================================================
1101// Unit Tests & Proptests
1102// ============================================================================
1103
1104#[cfg(test)]
1105mod tests {
1106    use super::*;
1107    use proptest::prelude::*;
1108
1109    #[test]
1110    fn test_choch_swing_detection_basic() {
1111        let mut choch = Choch::new(2);
1112
1113        // Create a clear swing high at bar 2 (price 120.0)
1114        // Bars:
1115        // 0: (100, 95, 98)
1116        // 1: (110, 105, 108)
1117        // 2: (120, 115, 118) -> Swing High (strength 2)
1118        // 3: (112, 107, 110)
1119        // 4: (108, 102, 105) -> Confirms swing high at bar 2
1120        let bars = vec![
1121            (100.0, 95.0, 98.0),
1122            (110.0, 105.0, 108.0),
1123            (120.0, 115.0, 118.0),
1124            (112.0, 107.0, 110.0),
1125            (108.0, 102.0, 105.0),
1126        ];
1127
1128        let mut states = Vec::new();
1129        for bar in bars {
1130            states.push(choch.next(bar));
1131        }
1132
1133        let state_4 = &states[4];
1134        assert!(state_4.last_swing_high.is_some());
1135        let sh = state_4.last_swing_high.unwrap();
1136        assert_eq!(sh.bar, 2);
1137        assert!((sh.price - 120.0).abs() < 1e-6);
1138        assert_eq!(sh.swing_type, SwingType::High);
1139    }
1140
1141    #[test]
1142    fn test_choch_bullish_bos_and_bearish_choch() {
1143        let mut choch = Choch::new(2);
1144
1145        // Sequence:
1146        // 0..4: establish swing high at bar 2 (120.0) and swing low at bar 0 (95.0)
1147        let bars = vec![
1148            (100.0, 95.0, 98.0),
1149            (110.0, 105.0, 108.0),
1150            (120.0, 115.0, 118.0), // Swing High at bar 2
1151            (112.0, 107.0, 110.0),
1152            (108.0, 102.0, 105.0), // Confirms bar 2 as Swing High at bar 4
1153        ];
1154        for bar in bars {
1155            choch.next(bar);
1156        }
1157
1158        // Bar 5: Closes above swing high (125 > 120)
1159        // Trend was Undetermined, closes above swing high -> CHoCH / establishes Bullish trend
1160        let state_5 = choch.next((126.0, 118.0, 125.0));
1161        assert_eq!(state_5.trend, StructureTrend::Bullish);
1162        assert!(state_5.last_break.is_some());
1163        let brk5 = state_5.last_break.unwrap();
1164        assert_eq!(brk5.direction, StructureBreakDirection::Bullish);
1165        assert_eq!(brk5.broken_swing_bar, 2);
1166        assert_eq!(brk5.trigger_bar, 5);
1167
1168        // Now trend is Bullish!
1169        // Form a new swing high at bar 7 (140.0) and swing low at bar 5 (118.0)
1170        let bars_bull = vec![
1171            (130.0, 120.0, 128.0), // 6
1172            (140.0, 130.0, 138.0), // 7: Swing High (140.0)
1173            (135.0, 128.0, 132.0), // 8
1174            (132.0, 124.0, 126.0), // 9: Confirms bar 7 as Swing High
1175        ];
1176        for bar in bars_bull {
1177            choch.next(bar);
1178        }
1179
1180        // Bar 10: Closes above 140.0 (145.0) -> Bullish BOS!
1181        let state_10 = choch.next((146.0, 138.0, 145.0));
1182        assert_eq!(state_10.trend, StructureTrend::Bullish);
1183        let brk10 = state_10.last_break.unwrap();
1184        assert_eq!(brk10.break_type, StructureBreakType::Bos);
1185        assert_eq!(brk10.direction, StructureBreakDirection::Bullish);
1186        assert_eq!(brk10.broken_swing_bar, 7);
1187        assert_eq!(brk10.trigger_bar, 10);
1188
1189        // Now establish a swing low in the uptrend, then break it -> Bearish CHoCH!
1190        let bars_reversal = vec![
1191            (144.0, 136.0, 140.0), // 11
1192            (138.0, 125.0, 128.0), // 12: Swing Low (125.0)
1193            (135.0, 127.0, 132.0), // 13
1194            (134.0, 126.0, 130.0), // 14: Confirms swing low at bar 12
1195        ];
1196        for bar in bars_reversal {
1197            choch.next(bar);
1198        }
1199
1200        // Bar 15: Close collapses below swing low (120.0 < 125.0) -> Bearish CHoCH!
1201        let state_15 = choch.next((126.0, 118.0, 120.0));
1202        assert_eq!(state_15.trend, StructureTrend::Bearish);
1203        let brk15 = state_15.last_break.unwrap();
1204        assert_eq!(brk15.break_type, StructureBreakType::Choch);
1205        assert_eq!(brk15.direction, StructureBreakDirection::Bearish);
1206        assert_eq!(brk15.broken_swing_bar, 12);
1207        assert_eq!(brk15.trigger_bar, 15);
1208    }
1209
1210    #[test]
1211    fn test_order_block_bullish_and_bearish_lifecycle() {
1212        let mut ob = OrderBlock::new(10);
1213
1214        // Bar 0: Down candle (Open 100, High 102, Low 95, Close 96)
1215        let s0 = ob.next((100.0, 102.0, 95.0, 96.0));
1216        assert!(s0.active_blocks.is_empty());
1217
1218        // Bar 1: Strong displacement up candle (Open 97, High 110, Low 96, Close 108)
1219        // Close 108 > High(bar 0) 102 -> Bullish Order Block formed!
1220        let s1 = ob.next((97.0, 110.0, 96.0, 108.0));
1221        assert_eq!(s1.active_blocks.len(), 1);
1222        let active = &s1.active_blocks[0];
1223        assert_eq!(active.block_type, OrderBlockType::Bullish);
1224        assert_eq!(active.bar_index, 0);
1225        assert_eq!(active.trigger_bar, 1);
1226        assert!((active.zone_high - 102.0).abs() < 1e-6);
1227        assert!((active.zone_low - 95.0).abs() < 1e-6);
1228        assert_eq!(active.mitigation, MitigationStatus::Unmitigated);
1229
1230        // Bar 2: Price retraces and dips into the zone: Low 100 <= 102, Close 101 >= 95
1231        // -> PartiallyMitigated!
1232        let s2 = ob.next((107.0, 108.0, 100.0, 101.0));
1233        assert_eq!(s2.active_blocks.len(), 1);
1234        assert_eq!(
1235            s2.active_blocks[0].mitigation,
1236            MitigationStatus::PartiallyMitigated
1237        );
1238        assert_eq!(s2.active_blocks[0].mitigation_bar, Some(2));
1239
1240        // Bar 3: Price collapses completely through the zone: Close 92 < 95
1241        // -> FullyMitigated!
1242        let s3 = ob.next((101.0, 102.0, 91.0, 92.0));
1243        assert_eq!(s3.active_blocks.len(), 1);
1244        assert_eq!(
1245            s3.active_blocks[0].mitigation,
1246            MitigationStatus::FullyMitigated
1247        );
1248        assert_eq!(s3.active_blocks[0].mitigation_bar, Some(3));
1249
1250        // Bar 4: The fully mitigated block should now be pruned from active_blocks
1251        let s4 = ob.next((92.0, 94.0, 89.0, 90.0));
1252        assert!(s4.active_blocks.is_empty());
1253    }
1254
1255    #[test]
1256    fn test_order_block_bearish_formation_and_mitigation() {
1257        let mut ob = OrderBlock::new(10);
1258
1259        // Bar 0: Up candle (Open 100, High 106, Low 99, Close 105)
1260        ob.next((100.0, 106.0, 99.0, 105.0));
1261
1262        // Bar 1: Strong displacement down candle (Open 104, High 105, Low 90, Close 92)
1263        // Close 92 < Low(bar 0) 99 -> Bearish Order Block formed!
1264        let s1 = ob.next((104.0, 105.0, 90.0, 92.0));
1265        assert_eq!(s1.active_blocks.len(), 1);
1266        let block = &s1.active_blocks[0];
1267        assert_eq!(block.block_type, OrderBlockType::Bearish);
1268        assert_eq!(block.bar_index, 0);
1269        assert!((block.zone_low - 99.0).abs() < 1e-6);
1270        assert!((block.zone_high - 106.0).abs() < 1e-6);
1271        assert_eq!(block.mitigation, MitigationStatus::Unmitigated);
1272
1273        // Bar 2: Retraces up into zone: High 101 >= 99, Close 100 <= 106 -> PartiallyMitigated
1274        let s2 = ob.next((93.0, 101.0, 92.0, 100.0));
1275        assert_eq!(
1276            s2.active_blocks[0].mitigation,
1277            MitigationStatus::PartiallyMitigated
1278        );
1279
1280        // Bar 3: Breaks through top of zone: Close 108 > 106 -> FullyMitigated
1281        let s3 = ob.next((100.0, 110.0, 99.0, 108.0));
1282        assert_eq!(
1283            s3.active_blocks[0].mitigation,
1284            MitigationStatus::FullyMitigated
1285        );
1286
1287        // Bar 4: Pruned
1288        let s4 = ob.next((108.0, 112.0, 107.0, 111.0));
1289        assert!(s4.active_blocks.is_empty());
1290    }
1291
1292    #[test]
1293    fn test_fair_value_gap_bullish_and_bearish() {
1294        let mut fvg = FairValueGap::new(0.0, 10);
1295
1296        // 3-bar Bullish FVG:
1297        // Bar 0: High 100, Low 90, Close 95
1298        // Bar 1: High 120, Low 98, Close 118 (impulsive expansion)
1299        // Bar 2: High 130, Low 105, Close 128
1300        // Imbalance: Low(bar 2) 105 > High(bar 0) 100 -> Zone [100.0, 105.0]
1301        let s0 = fvg.next((100.0, 90.0, 95.0));
1302        assert!(s0.active_gaps.is_empty());
1303
1304        let s1 = fvg.next((120.0, 98.0, 118.0));
1305        assert!(s1.active_gaps.is_empty());
1306
1307        let s2 = fvg.next((130.0, 105.0, 128.0));
1308        assert_eq!(s2.active_gaps.len(), 1);
1309        let gap = &s2.active_gaps[0];
1310        assert_eq!(gap.direction, FvgDirection::Bullish);
1311        assert!((gap.zone_low - 100.0).abs() < 1e-6);
1312        assert!((gap.zone_high - 105.0).abs() < 1e-6);
1313        assert!((gap.gap_size - 5.0).abs() < 1e-6);
1314        assert_eq!(gap.mitigation, MitigationStatus::Unmitigated);
1315
1316        // Bar 3: Retests into gap: Low 102 < 105, Low > 100 -> PartiallyMitigated
1317        let s3 = fvg.next((125.0, 102.0, 120.0));
1318        assert_eq!(s3.active_gaps.len(), 1);
1319        assert_eq!(
1320            s3.active_gaps[0].mitigation,
1321            MitigationStatus::PartiallyMitigated
1322        );
1323        assert_eq!(s3.active_gaps[0].mitigation_bar, Some(3));
1324
1325        // Bar 4: Fills gap completely: Low 98 <= 100 -> FullyMitigated
1326        let s4 = fvg.next((120.0, 98.0, 105.0));
1327        assert_eq!(s4.active_gaps.len(), 1);
1328        assert_eq!(
1329            s4.active_gaps[0].mitigation,
1330            MitigationStatus::FullyMitigated
1331        );
1332
1333        // Bar 5: Fully mitigated gap is pruned
1334        let s5 = fvg.next((110.0, 104.0, 108.0));
1335        assert!(s5.active_gaps.is_empty());
1336
1337        // Now test Bearish FVG:
1338        // Bar 5: High 110, Low 104, Close 108
1339        // Bar 6: High 106, Low 85, Close 88
1340        // Bar 7: High 101, Low 80, Close 82
1341        // Imbalance: High(bar 7) 101 < Low(bar 5) 104 -> Zone [101.0, 104.0]
1342        fvg.next((106.0, 85.0, 88.0)); // bar 6
1343        let s7 = fvg.next((101.0, 80.0, 82.0)); // bar 7
1344        assert_eq!(s7.active_gaps.len(), 1);
1345        let b_gap = &s7.active_gaps[0];
1346        assert_eq!(b_gap.direction, FvgDirection::Bearish);
1347        assert!((b_gap.zone_low - 101.0).abs() < 1e-6);
1348        assert!((b_gap.zone_high - 104.0).abs() < 1e-6);
1349        assert_eq!(b_gap.mitigation, MitigationStatus::Unmitigated);
1350
1351        // Retest into bearish gap: High 102 > 101, High < 104 -> PartiallyMitigated
1352        let s8 = fvg.next((102.0, 85.0, 90.0));
1353        assert_eq!(
1354            s8.active_gaps[0].mitigation,
1355            MitigationStatus::PartiallyMitigated
1356        );
1357
1358        // Fill bearish gap completely: High 105 >= 104 -> FullyMitigated
1359        let s9 = fvg.next((105.0, 88.0, 95.0));
1360        assert_eq!(
1361            s9.active_gaps[0].mitigation,
1362            MitigationStatus::FullyMitigated
1363        );
1364    }
1365
1366    #[test]
1367    fn test_batch_equivalence() {
1368        let bars = vec![
1369            (100.0, 90.0, 95.0),
1370            (110.0, 95.0, 108.0),
1371            (120.0, 105.0, 115.0),
1372            (115.0, 100.0, 102.0),
1373            (105.0, 92.0, 94.0),
1374            (130.0, 102.0, 128.0),
1375        ];
1376
1377        let mut streaming = Choch::new(2);
1378        let streaming_results: Vec<_> = bars.iter().map(|&b| streaming.next(b)).collect();
1379        let batch_results = choch_batch(&bars, 2);
1380
1381        assert_eq!(streaming_results, batch_results);
1382
1383        let ohlc_bars = vec![
1384            (100.0, 102.0, 95.0, 96.0),
1385            (97.0, 110.0, 96.0, 108.0),
1386            (107.0, 108.0, 100.0, 101.0),
1387            (101.0, 102.0, 91.0, 92.0),
1388        ];
1389
1390        let mut ob_streaming = OrderBlock::new(10);
1391        let ob_stream_results: Vec<_> = ohlc_bars.iter().map(|&b| ob_streaming.next(b)).collect();
1392        let ob_batch_results = order_block_batch(&ohlc_bars, 10);
1393
1394        assert_eq!(ob_stream_results, ob_batch_results);
1395
1396        let mut fvg_streaming = FairValueGap::new(0.0, 10);
1397        let fvg_stream_results: Vec<_> = bars.iter().map(|&b| fvg_streaming.next(b)).collect();
1398        let fvg_batch_results = fair_value_gap_batch(&bars, 0.0, 10);
1399
1400        assert_eq!(fvg_stream_results, fvg_batch_results);
1401    }
1402
1403    proptest! {
1404        #[test]
1405        fn test_choch_parity(
1406            highs in prop::collection::vec(10.0..500.0f64, 10..80),
1407            low_offsets in prop::collection::vec(1.0..10.0f64, 10..80),
1408            close_pcts in prop::collection::vec(0.0..1.0f64, 10..80),
1409        ) {
1410            let n = highs.len().min(low_offsets.len()).min(close_pcts.len());
1411            let mut bars = Vec::with_capacity(n);
1412            for i in 0..n {
1413                let h = highs[i];
1414                let l = (h - low_offsets[i]).max(0.1);
1415                let c = l + (h - l) * close_pcts[i];
1416                bars.push((h, l, c));
1417            }
1418
1419            let mut streaming = Choch::new(3);
1420            let mut stream_outs = Vec::with_capacity(n);
1421            for &bar in &bars {
1422                stream_outs.push(streaming.next(bar));
1423            }
1424
1425            let batch_outs = choch_batch(&bars, 3);
1426            prop_assert_eq!(stream_outs, batch_outs);
1427        }
1428
1429        #[test]
1430        fn test_order_block_parity(
1431            prices in prop::collection::vec((10.0..500.0f64, 10.0..500.0f64, 10.0..500.0f64, 10.0..500.0f64), 5..60),
1432        ) {
1433            let mut ohlc = Vec::with_capacity(prices.len());
1434            for (p0, p1, p2, p3) in prices {
1435                let open = p0;
1436                let close = p1;
1437                let high = open.max(close).max(p2);
1438                let low = open.min(close).min(p3).max(0.01);
1439                ohlc.push((open, high, low, close));
1440            }
1441
1442            let mut streaming = OrderBlock::new(15);
1443            let stream_outs: Vec<_> = ohlc.iter().map(|&b| streaming.next(b)).collect();
1444            let batch_outs = order_block_batch(&ohlc, 15);
1445
1446            prop_assert_eq!(stream_outs, batch_outs);
1447        }
1448
1449        #[test]
1450        fn test_fair_value_gap_parity(
1451            highs in prop::collection::vec(10.0..500.0f64, 5..60),
1452            low_offsets in prop::collection::vec(1.0..10.0f64, 5..60),
1453            close_pcts in prop::collection::vec(0.0..1.0f64, 5..60),
1454        ) {
1455            let n = highs.len().min(low_offsets.len()).min(close_pcts.len());
1456            let mut bars = Vec::with_capacity(n);
1457            for i in 0..n {
1458                let h = highs[i];
1459                let l = (h - low_offsets[i]).max(0.1);
1460                let c = l + (h - l) * close_pcts[i];
1461                bars.push((h, l, c));
1462            }
1463
1464            let mut streaming = FairValueGap::new(0.5, 10);
1465            let stream_outs: Vec<_> = bars.iter().map(|&b| streaming.next(b)).collect();
1466            let batch_outs = fair_value_gap_batch(&bars, 0.5, 10);
1467
1468            prop_assert_eq!(stream_outs, batch_outs);
1469        }
1470    }
1471}