Skip to main content

fin_primitives/regime/
mod.rs

1//! Market regime engine: Hurst exponent, GARCH(1,1), cross-asset correlation breakdown,
2//! `RegimeConditionalSignal` (regime-adaptive RSI), and full `RegimeHistory` audit trail.
3//!
4//! ## Responsibility
5//! Market regime classification using multiple quantitative signals.
6//! The engine conditions signal behavior on the current market state,
7//! enabling adaptive strategy parameters across different regimes.
8//!
9//! ## Regimes
10//! | Regime | Condition |
11//! |--------|-----------|
12//! | `Trending` | Hurst > 0.6 (persistent, directional process) |
13//! | `MeanReverting` | Hurst < 0.4 (anti-persistent, range-bound) |
14//! | `HighVolatility` | Realized vol > 2x historical average |
15//! | `LowVolatility` | Realized vol < 0.5x historical average |
16//! | `Crisis` | Rapid cross-asset correlation breakdown |
17//! | `Neutral` | No dominant signal |
18//! | `Unknown` | Insufficient data (warm-up phase) |
19//!
20//! ## Architecture
21//!
22//! ```text
23//! BarInput ──► RegimeDetector ──► MarketRegime ──► RegimeHistory
24//!                                     │
25//!                                     ▼
26//!                         RegimeConditionalSignal
27//!                     (selects params per active regime)
28//! ```
29//!
30//! ## Guarantees
31//! - Returns [`MarketRegime::Unknown`] until all indicators are warm
32//! - Zero panics; all arithmetic uses f64 helpers with fallback defaults
33//! - Thresholds are fully configurable at construction
34
35/// 2-state Hidden Markov Model with Viterbi decoding for Bull/Bear regime classification.
36pub mod hmm;
37
38use crate::error::FinError;
39use crate::signals::indicators::{Adx, BollingerWidth, HistoricalVolatility, HurstExponent};
40use crate::signals::{BarInput, Signal, SignalValue};
41use rust_decimal::prelude::ToPrimitive;
42use rust_decimal::Decimal;
43
44// ─── Regime enum ─────────────────────────────────────────────────────────────
45
46/// Classification of the current market regime.
47///
48/// Regimes condition strategy behavior: e.g. RSI(14) in `Trending`,
49/// RSI(21) in `MeanReverting`, flat signal in `Crisis`.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
52pub enum MarketRegime {
53    /// Persistent, directional market (Hurst > 0.6, ADX elevated).
54    Trending,
55    /// Anti-persistent, range-bound market (Hurst < 0.4).
56    MeanReverting,
57    /// Realized volatility more than 2x the long-run historical average.
58    HighVolatility,
59    /// Realized volatility below 0.5x the long-run historical average.
60    LowVolatility,
61    /// Cross-asset correlation breakdown — potential systemic dislocation.
62    Crisis,
63    /// No dominant signal; balanced conditions.
64    Neutral,
65    /// Indicators not yet warmed up; classification unavailable.
66    Unknown,
67}
68
69impl MarketRegime {
70    /// Returns `true` if trading should be reduced or halted in this regime.
71    ///
72    /// Both `Crisis` and `Unknown` suggest flat positioning until conditions clarify.
73    pub fn is_risk_off(self) -> bool {
74        matches!(self, MarketRegime::Crisis | MarketRegime::Unknown)
75    }
76
77    /// Returns a human-readable short code suitable for logs and dashboards.
78    pub fn short_code(self) -> &'static str {
79        match self {
80            MarketRegime::Trending => "TRD",
81            MarketRegime::MeanReverting => "MRV",
82            MarketRegime::HighVolatility => "HVL",
83            MarketRegime::LowVolatility => "LVL",
84            MarketRegime::Crisis => "CRS",
85            MarketRegime::Neutral => "NEU",
86            MarketRegime::Unknown => "UNK",
87        }
88    }
89}
90
91impl std::fmt::Display for MarketRegime {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            MarketRegime::Trending => write!(f, "Trending"),
95            MarketRegime::MeanReverting => write!(f, "MeanReverting"),
96            MarketRegime::HighVolatility => write!(f, "HighVolatility"),
97            MarketRegime::LowVolatility => write!(f, "LowVolatility"),
98            MarketRegime::Crisis => write!(f, "Crisis"),
99            MarketRegime::Neutral => write!(f, "Neutral"),
100            MarketRegime::Unknown => write!(f, "Unknown"),
101        }
102    }
103}
104
105// ─── Config ───────────────────────────────────────────────────────────────────
106
107/// Configuration thresholds for [`RegimeDetector`].
108///
109/// All thresholds are adjustable at construction; defaults reflect
110/// common quant-research conventions.
111#[derive(Debug, Clone)]
112pub struct RegimeConfig {
113    /// Hurst exponent above which the market is `Trending`. Default: 0.6.
114    pub hurst_trending: f64,
115    /// Hurst exponent below which the market is `MeanReverting`. Default: 0.4.
116    pub hurst_mean_reverting: f64,
117    /// Realized vol multiplier above which regime is `HighVolatility`. Default: 2.0.
118    pub vol_high_multiplier: f64,
119    /// Realized vol multiplier below which regime is `LowVolatility`. Default: 0.5.
120    pub vol_low_multiplier: f64,
121    /// ADX value above which trending classification is reinforced. Default: 25.0.
122    pub adx_trend_threshold: f64,
123    /// Bollinger Band width below which low-volatility compression is confirmed. Default: 0.02.
124    pub bb_width_quiet: f64,
125    /// Pearson correlation threshold; drop below this triggers `Crisis`. Default: 0.3.
126    pub crisis_correlation_threshold: f64,
127    /// Fraction of asset pairs that must fall below `crisis_correlation_threshold`
128    /// in the same window to declare `Crisis`. Default: 0.6.
129    pub crisis_pair_fraction: f64,
130    /// GARCH(1,1) alpha (innovation weight). Default: 0.1.
131    pub garch_alpha: f64,
132    /// GARCH(1,1) beta (persistence weight). Default: 0.85.
133    pub garch_beta: f64,
134    /// GARCH(1,1) omega (long-run variance floor). Default: 1e-6.
135    pub garch_omega: f64,
136    /// Multiplier applied to GARCH variance to flag persistent high-vol. Default: 1.5.
137    pub garch_vol_multiplier: f64,
138}
139
140impl Default for RegimeConfig {
141    fn default() -> Self {
142        Self {
143            hurst_trending: 0.6,
144            hurst_mean_reverting: 0.4,
145            vol_high_multiplier: 2.0,
146            vol_low_multiplier: 0.5,
147            adx_trend_threshold: 25.0,
148            bb_width_quiet: 0.02,
149            crisis_correlation_threshold: 0.3,
150            crisis_pair_fraction: 0.6,
151            garch_alpha: 0.1,
152            garch_beta: 0.85,
153            garch_omega: 1e-6,
154            garch_vol_multiplier: 1.5,
155        }
156    }
157}
158
159// ─── GARCH(1,1) estimator ─────────────────────────────────────────────────────
160
161/// Online GARCH(1,1) conditional variance estimator.
162///
163/// The model is: σ²ₜ = ω + α·εₜ₋₁² + β·σ²ₜ₋₁
164///
165/// where ε is the demeaned return. This produces a persistent volatility
166/// signal that reacts more slowly than realized volatility, making it
167/// useful for detecting regimes where volatility is structurally elevated
168/// rather than transiently spiked.
169///
170/// # Example
171/// ```rust
172/// use fin_primitives::regime::Garch11;
173///
174/// let mut g = Garch11::new(0.1, 0.85, 1e-6).unwrap();
175/// for ret in [-0.01_f64, 0.02, -0.015, 0.005, 0.03] {
176///     let sigma = g.update(ret);
177///     println!("GARCH sigma = {sigma:.6}");
178/// }
179/// ```
180#[derive(Debug, Clone)]
181pub struct Garch11 {
182    alpha: f64,
183    beta: f64,
184    omega: f64,
185    /// Current conditional variance σ²ₜ.
186    variance: f64,
187    /// Running mean of returns (Welford).
188    mean: f64,
189    /// Number of observations.
190    count: usize,
191}
192
193impl Garch11 {
194    /// Constructs a GARCH(1,1) estimator.
195    ///
196    /// Requires `alpha + beta < 1` (covariance stationarity) and all
197    /// parameters strictly positive.
198    ///
199    /// # Errors
200    /// Returns [`FinError::InvalidInput`] if parameters violate stationarity or
201    /// positivity constraints.
202    pub fn new(alpha: f64, beta: f64, omega: f64) -> Result<Self, FinError> {
203        if alpha <= 0.0 || beta <= 0.0 || omega <= 0.0 {
204            return Err(FinError::InvalidInput(
205                "GARCH parameters alpha, beta, and omega must all be positive".to_owned(),
206            ));
207        }
208        if alpha + beta >= 1.0 {
209            return Err(FinError::InvalidInput(format!(
210                "GARCH(1,1) requires alpha + beta < 1 for stationarity, got {:.4}",
211                alpha + beta
212            )));
213        }
214        // Long-run (unconditional) variance as initial value
215        let long_run_var = omega / (1.0 - alpha - beta);
216        Ok(Self { alpha, beta, omega, variance: long_run_var, mean: 0.0, count: 0 })
217    }
218
219    /// Updates the model with a new log return and returns the conditional
220    /// standard deviation σₜ.
221    pub fn update(&mut self, log_return: f64) -> f64 {
222        self.count += 1;
223        // Welford mean update
224        let delta = log_return - self.mean;
225        self.mean += delta / self.count as f64;
226        let demeaned = log_return - self.mean;
227        // GARCH(1,1) recursion
228        self.variance = self.omega
229            + self.alpha * demeaned * demeaned
230            + self.beta * self.variance;
231        self.variance.sqrt()
232    }
233
234    /// Returns the current conditional variance estimate σ²ₜ.
235    pub fn variance(&self) -> f64 {
236        self.variance
237    }
238
239    /// Returns the current conditional standard deviation σₜ.
240    pub fn sigma(&self) -> f64 {
241        self.variance.sqrt()
242    }
243
244    /// Returns the long-run (unconditional) standard deviation.
245    pub fn long_run_sigma(&self) -> f64 {
246        (self.omega / (1.0 - self.alpha - self.beta)).sqrt()
247    }
248
249    /// Returns `true` when the GARCH conditional vol is elevated relative to
250    /// the long-run level by `multiplier`.
251    pub fn is_vol_elevated(&self, multiplier: f64) -> bool {
252        self.sigma() > self.long_run_sigma() * multiplier
253    }
254
255    /// Number of observations processed.
256    pub fn count(&self) -> usize {
257        self.count
258    }
259
260    /// Resets the estimator to its initial state.
261    pub fn reset(&mut self) {
262        let long_run_var = self.omega / (1.0 - self.alpha - self.beta);
263        self.variance = long_run_var;
264        self.mean = 0.0;
265        self.count = 0;
266    }
267}
268
269// ─── Correlation breakdown detector ──────────────────────────────────────────
270
271/// Tracks pairwise rolling correlations across N assets and detects
272/// rapid decorrelation, which is a hallmark of systemic crisis events.
273///
274/// The detector maintains a sliding window of cross-asset return pairs.
275/// When the fraction of pairs with `|r| < threshold` exceeds
276/// `crisis_pair_fraction`, a crisis signal is raised.
277///
278/// # Example
279/// ```rust
280/// use fin_primitives::regime::CorrelationBreakdownDetector;
281///
282/// let mut detector = CorrelationBreakdownDetector::new(20, 0.3, 0.6).unwrap();
283/// // Feed returns for two assets over time
284/// for i in 0..25 {
285///     let r_a = if i % 2 == 0 { 0.01 } else { -0.01 };
286///     let r_b = if i % 3 == 0 { 0.01 } else { -0.01 }; // decorrelated
287///     detector.update(0, r_a);
288///     detector.update(1, r_b);
289///     if detector.is_crisis() {
290///         println!("Crisis at bar {i}");
291///     }
292/// }
293/// ```
294#[derive(Debug, Clone)]
295pub struct CorrelationBreakdownDetector {
296    window: usize,
297    threshold: f64,
298    crisis_fraction: f64,
299    /// Ring buffer of returns per asset index.
300    returns: Vec<std::collections::VecDeque<f64>>,
301    n_assets: usize,
302}
303
304impl CorrelationBreakdownDetector {
305    /// Constructs a new detector.
306    ///
307    /// - `window`: rolling window length for correlation estimation.
308    /// - `threshold`: Pearson |r| below which a pair is considered decorrelated.
309    /// - `crisis_fraction`: fraction of pairs that must be decorrelated to signal crisis.
310    ///
311    /// # Errors
312    /// Returns [`FinError::InvalidInput`] on invalid parameters.
313    pub fn new(window: usize, threshold: f64, crisis_fraction: f64) -> Result<Self, FinError> {
314        if window < 3 {
315            return Err(FinError::InvalidInput(
316                "correlation window must be at least 3".to_owned(),
317            ));
318        }
319        if !(0.0..=1.0).contains(&threshold) {
320            return Err(FinError::InvalidInput(
321                "correlation threshold must be in [0, 1]".to_owned(),
322            ));
323        }
324        if !(0.0..=1.0).contains(&crisis_fraction) {
325            return Err(FinError::InvalidInput(
326                "crisis_fraction must be in [0, 1]".to_owned(),
327            ));
328        }
329        Ok(Self {
330            window,
331            threshold,
332            crisis_fraction,
333            returns: Vec::new(),
334            n_assets: 0,
335        })
336    }
337
338    /// Registers a new return observation for asset `asset_idx`.
339    ///
340    /// Assets are identified by a zero-based index. The detector auto-expands
341    /// its internal storage as new asset indices are encountered.
342    pub fn update(&mut self, asset_idx: usize, log_return: f64) {
343        // Expand storage if needed
344        while self.returns.len() <= asset_idx {
345            self.returns.push(std::collections::VecDeque::with_capacity(self.window + 1));
346            self.n_assets = self.returns.len();
347        }
348        let buf = &mut self.returns[asset_idx];
349        buf.push_back(log_return);
350        if buf.len() > self.window {
351            buf.pop_front();
352        }
353    }
354
355    /// Returns `true` when a crisis-level correlation breakdown is detected.
356    pub fn is_crisis(&self) -> bool {
357        if self.n_assets < 2 {
358            return false;
359        }
360        let mut total_pairs = 0usize;
361        let mut decorrelated_pairs = 0usize;
362
363        for i in 0..self.n_assets {
364            for j in (i + 1)..self.n_assets {
365                let ri = &self.returns[i];
366                let rj = &self.returns[j];
367                if ri.len() < 3 || rj.len() < 3 {
368                    continue;
369                }
370                let len = ri.len().min(rj.len());
371                let r = pearson_r(
372                    ri.iter().rev().take(len).copied().collect::<Vec<_>>().as_slice(),
373                    rj.iter().rev().take(len).copied().collect::<Vec<_>>().as_slice(),
374                );
375                total_pairs += 1;
376                if r.abs() < self.threshold {
377                    decorrelated_pairs += 1;
378                }
379            }
380        }
381
382        if total_pairs == 0 {
383            return false;
384        }
385        (decorrelated_pairs as f64 / total_pairs as f64) >= self.crisis_fraction
386    }
387
388    /// Returns the number of asset slots registered.
389    pub fn n_assets(&self) -> usize {
390        self.n_assets
391    }
392
393    /// Resets all return buffers.
394    pub fn reset(&mut self) {
395        for buf in &mut self.returns {
396            buf.clear();
397        }
398    }
399}
400
401/// Computes Pearson r between two equal-length slices.
402fn pearson_r(x: &[f64], y: &[f64]) -> f64 {
403    let n = x.len().min(y.len());
404    if n < 2 {
405        return 0.0;
406    }
407    let n_f = n as f64;
408    let mean_x = x[..n].iter().sum::<f64>() / n_f;
409    let mean_y = y[..n].iter().sum::<f64>() / n_f;
410    let mut cov = 0.0;
411    let mut var_x = 0.0;
412    let mut var_y = 0.0;
413    for i in 0..n {
414        let dx = x[i] - mean_x;
415        let dy = y[i] - mean_y;
416        cov += dx * dy;
417        var_x += dx * dx;
418        var_y += dy * dy;
419    }
420    let denom = (var_x * var_y).sqrt();
421    if denom < 1e-12 {
422        return 0.0;
423    }
424    (cov / denom).clamp(-1.0, 1.0)
425}
426
427// ─── RegimeHistory ────────────────────────────────────────────────────────────
428
429/// A single regime epoch — the period during which one regime held.
430///
431/// Records when the regime started, its confidence score, and (once the
432/// epoch ends) the duration in bars.
433#[derive(Debug, Clone)]
434#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
435pub struct RegimeHistory {
436    /// The regime that was active during this period.
437    pub regime: MarketRegime,
438    /// Bar index at which this regime was first detected.
439    pub started_at_bar: usize,
440    /// Confidence in the classification, in `[0.0, 1.0]`.
441    /// Derived from signal strength relative to thresholds.
442    pub confidence: f64,
443    /// Bar index at which this regime ended (`None` if still active).
444    pub ended_at_bar: Option<usize>,
445}
446
447impl RegimeHistory {
448    /// Duration of this regime epoch in bars.
449    ///
450    /// Returns `None` if the regime is still active.
451    pub fn duration_bars(&self) -> Option<usize> {
452        self.ended_at_bar.map(|end| end - self.started_at_bar)
453    }
454
455    /// Returns `true` if this regime epoch is still the active one.
456    pub fn is_active(&self) -> bool {
457        self.ended_at_bar.is_none()
458    }
459}
460
461// ─── RegimeDetector ───────────────────────────────────────────────────────────
462
463/// Full market regime classifier.
464///
465/// Combines four complementary signals:
466/// - **Hurst exponent**: persistence of the return process (> 0.6 → trending, < 0.4 → mean-reverting)
467/// - **Realized volatility** relative to its own historical mean (> 2x → high-vol, < 0.5x → low-vol)
468/// - **GARCH(1,1)** conditional variance for persistent volatility detection
469/// - **Cross-asset correlation breakdown** for crisis detection
470///
471/// # Classification priority
472/// Crisis > HighVolatility > Trending > MeanReverting > LowVolatility > Neutral
473///
474/// # Example
475/// ```rust
476/// use fin_primitives::regime::{RegimeDetector, RegimeConfig, MarketRegime};
477/// use fin_primitives::signals::BarInput;
478/// use rust_decimal_macros::dec;
479///
480/// let mut detector = RegimeDetector::new(14, RegimeConfig::default()).unwrap();
481/// let bar = BarInput::new(dec!(100), dec!(102), dec!(98), dec!(100), dec!(1000));
482/// let (regime, confidence) = detector.update(&bar, &[]).unwrap();
483/// assert_eq!(regime, MarketRegime::Unknown); // not yet warm
484/// ```
485pub struct RegimeDetector {
486    adx: Adx,
487    hurst: HurstExponent,
488    hv: HistoricalVolatility,
489    bb_width: BollingerWidth,
490    garch: Garch11,
491    correlation: CorrelationBreakdownDetector,
492    config: RegimeConfig,
493    /// Running mean of realized volatility for ratio computation.
494    hv_mean: f64,
495    hv_count: usize,
496    /// Previous close for log-return computation.
497    prev_close: Option<f64>,
498    /// Total bar count.
499    bar_count: usize,
500    /// Regime history log.
501    history: Vec<RegimeHistory>,
502    /// Currently active regime.
503    current_regime: MarketRegime,
504}
505
506impl RegimeDetector {
507    /// Constructs a [`RegimeDetector`] with the given period and config.
508    ///
509    /// # Errors
510    /// Returns [`FinError::InvalidPeriod`] if `period < 2`.
511    /// Returns [`FinError::InvalidInput`] if GARCH parameters are invalid.
512    pub fn new(period: usize, config: RegimeConfig) -> Result<Self, FinError> {
513        if period < 2 {
514            return Err(FinError::InvalidPeriod(period));
515        }
516        let garch = Garch11::new(config.garch_alpha, config.garch_beta, config.garch_omega)?;
517        let correlation = CorrelationBreakdownDetector::new(
518            period.max(5),
519            config.crisis_correlation_threshold,
520            config.crisis_pair_fraction,
521        )?;
522        Ok(Self {
523            adx: Adx::new("regime_adx", period)?,
524            hurst: HurstExponent::new("regime_hurst", period)?,
525            hv: HistoricalVolatility::new("regime_hv", period, 252)?,
526            bb_width: BollingerWidth::new("regime_bb_width", period, Decimal::from(2u32))?,
527            garch,
528            correlation,
529            config,
530            hv_mean: 0.0,
531            hv_count: 0,
532            prev_close: None,
533            bar_count: 0,
534            history: Vec::new(),
535            current_regime: MarketRegime::Unknown,
536        })
537    }
538
539    /// Constructs a detector with default configuration thresholds.
540    ///
541    /// # Errors
542    /// Returns [`FinError::InvalidPeriod`] if `period < 2`.
543    pub fn with_defaults(period: usize) -> Result<Self, FinError> {
544        Self::new(period, RegimeConfig::default())
545    }
546
547    /// Updates the detector with a new bar and optional cross-asset log returns.
548    ///
549    /// `cross_returns` is a slice of `(asset_idx, log_return)` pairs for assets
550    /// other than the primary. These feed the crisis correlation detector.
551    /// Pass an empty slice if operating on a single asset.
552    ///
553    /// Returns `(regime, confidence)` where confidence is in `[0.0, 1.0]`.
554    ///
555    /// # Errors
556    /// Propagates any [`FinError`] from the underlying indicators.
557    pub fn update(
558        &mut self,
559        bar: &BarInput,
560        cross_returns: &[(usize, f64)],
561    ) -> Result<(MarketRegime, f64), FinError> {
562        self.bar_count += 1;
563
564        // Compute log return for GARCH and primary asset correlation slot
565        let close_f = bar.close.to_f64().unwrap_or(0.0);
566        if let Some(prev) = self.prev_close {
567            if prev > 0.0 {
568                let log_ret = (close_f / prev).ln();
569                self.garch.update(log_ret);
570                self.correlation.update(0, log_ret);
571            }
572        }
573        self.prev_close = Some(close_f);
574
575        // Feed cross-asset returns into the correlation detector
576        for &(idx, ret) in cross_returns {
577            self.correlation.update(idx + 1, ret); // shift by 1 as 0 = primary
578        }
579
580        // Update indicator suite
581        let adx_val = self.adx.update(bar)?;
582        let hurst_val = self.hurst.update(bar)?;
583        let hv_val = self.hv.update(bar)?;
584        let bb_w_val = self.bb_width.update(bar)?;
585
586        // Require all four indicators ready
587        let (adx_f, hurst_f, hv_f, bb_w_f) = match (adx_val, hurst_val, hv_val, bb_w_val) {
588            (
589                SignalValue::Scalar(a),
590                SignalValue::Scalar(h),
591                SignalValue::Scalar(v),
592                SignalValue::Scalar(b),
593            ) => (
594                a.to_f64().unwrap_or(0.0),
595                h.to_f64().unwrap_or(0.5),
596                v.to_f64().unwrap_or(0.0),
597                b.to_f64().unwrap_or(f64::MAX),
598            ),
599            _ => {
600                self.record_regime(MarketRegime::Unknown, 0.0);
601                return Ok((MarketRegime::Unknown, 0.0));
602            }
603        };
604
605        // Update rolling HV mean (Welford)
606        self.hv_count += 1;
607        self.hv_mean += (hv_f - self.hv_mean) / self.hv_count as f64;
608
609        // ── Classify ──────────────────────────────────────────────────────────
610
611        // 1. Crisis: cross-asset correlation breakdown
612        if self.correlation.is_crisis() {
613            let conf = 0.9;
614            self.record_regime(MarketRegime::Crisis, conf);
615            return Ok((MarketRegime::Crisis, conf));
616        }
617
618        // 2. High volatility: realized vol > multiplier × long-run mean
619        //    Also check GARCH for persistent vol elevation
620        let vol_ratio = if self.hv_mean > 0.0 { hv_f / self.hv_mean } else { 1.0 };
621        let garch_elevated = self.garch.is_vol_elevated(self.config.garch_vol_multiplier);
622        if vol_ratio > self.config.vol_high_multiplier || (vol_ratio > 1.5 && garch_elevated) {
623            let conf = (vol_ratio - self.config.vol_high_multiplier).abs().min(1.0) * 0.8 + 0.2;
624            let conf = conf.min(1.0);
625            self.record_regime(MarketRegime::HighVolatility, conf);
626            return Ok((MarketRegime::HighVolatility, conf));
627        }
628
629        // 3. Trending: Hurst > threshold AND ADX confirms
630        if hurst_f > self.config.hurst_trending {
631            let adx_factor = if adx_f > self.config.adx_trend_threshold { 1.0 } else { 0.7 };
632            let conf = ((hurst_f - self.config.hurst_trending)
633                / (1.0 - self.config.hurst_trending))
634                .min(1.0)
635                * adx_factor;
636            self.record_regime(MarketRegime::Trending, conf);
637            return Ok((MarketRegime::Trending, conf));
638        }
639
640        // 4. Mean reverting: Hurst < threshold
641        if hurst_f < self.config.hurst_mean_reverting {
642            let conf = ((self.config.hurst_mean_reverting - hurst_f)
643                / self.config.hurst_mean_reverting)
644                .min(1.0);
645            self.record_regime(MarketRegime::MeanReverting, conf);
646            return Ok((MarketRegime::MeanReverting, conf));
647        }
648
649        // 5. Low volatility: vol < multiplier × mean AND BB width compressed
650        if vol_ratio < self.config.vol_low_multiplier || bb_w_f < self.config.bb_width_quiet {
651            let conf = (1.0 - vol_ratio / self.config.vol_low_multiplier).max(0.1).min(1.0);
652            self.record_regime(MarketRegime::LowVolatility, conf);
653            return Ok((MarketRegime::LowVolatility, conf));
654        }
655
656        // 6. Neutral: no dominant signal
657        self.record_regime(MarketRegime::Neutral, 0.5);
658        Ok((MarketRegime::Neutral, 0.5))
659    }
660
661    /// Records a regime transition if the regime has changed.
662    fn record_regime(&mut self, regime: MarketRegime, confidence: f64) {
663        if regime == self.current_regime {
664            return;
665        }
666        // Close the previous active epoch
667        if let Some(last) = self.history.last_mut() {
668            if last.ended_at_bar.is_none() {
669                last.ended_at_bar = Some(self.bar_count);
670            }
671        }
672        self.current_regime = regime;
673        self.history.push(RegimeHistory {
674            regime,
675            started_at_bar: self.bar_count,
676            confidence,
677            ended_at_bar: None,
678        });
679    }
680
681    /// Returns the current regime without updating.
682    pub fn current_regime(&self) -> MarketRegime {
683        self.current_regime
684    }
685
686    /// Returns the full regime transition history.
687    pub fn history(&self) -> &[RegimeHistory] {
688        &self.history
689    }
690
691    /// Returns `true` when all internal indicators have completed warm-up.
692    pub fn is_ready(&self) -> bool {
693        self.adx.is_ready()
694            && self.hurst.is_ready()
695            && self.hv.is_ready()
696            && self.bb_width.is_ready()
697    }
698
699    /// Returns a reference to the current configuration.
700    pub fn config(&self) -> &RegimeConfig {
701        &self.config
702    }
703
704    /// Returns the GARCH(1,1) estimator for external inspection.
705    pub fn garch(&self) -> &Garch11 {
706        &self.garch
707    }
708
709    /// Returns the correlation breakdown detector for external inspection.
710    pub fn correlation_detector(&self) -> &CorrelationBreakdownDetector {
711        &self.correlation
712    }
713
714    /// Resets all internal indicators and history.
715    pub fn reset(&mut self) {
716        self.adx.reset();
717        self.hurst.reset();
718        self.hv.reset();
719        self.bb_width.reset();
720        self.garch.reset();
721        self.correlation.reset();
722        self.hv_mean = 0.0;
723        self.hv_count = 0;
724        self.prev_close = None;
725        self.bar_count = 0;
726        self.history.clear();
727        self.current_regime = MarketRegime::Unknown;
728    }
729
730    /// Total number of bars processed.
731    pub fn bar_count(&self) -> usize {
732        self.bar_count
733    }
734}
735
736// ─── Legacy compatibility wrapper ─────────────────────────────────────────────
737
738/// Simplified market regime detector (legacy API, four regimes).
739///
740/// Internally maintained for backwards compatibility. New code should use
741/// [`RegimeDetector`], which adds `HighVolatility`, `LowVolatility`, `Crisis`,
742/// `Neutral`, GARCH, and cross-asset correlation breakdown.
743///
744/// # Example
745/// ```rust
746/// use fin_primitives::regime::{MarketRegimeDetector, RegimeConfig, MarketRegime};
747/// use fin_primitives::signals::BarInput;
748/// use rust_decimal_macros::dec;
749///
750/// let mut detector = MarketRegimeDetector::new(14, RegimeConfig::default()).unwrap();
751/// let bar = BarInput::new(dec!(100), dec!(102), dec!(98), dec!(100), dec!(1000));
752/// let regime = detector.update(&bar).unwrap();
753/// assert_eq!(regime, MarketRegime::Unknown);
754/// ```
755pub struct MarketRegimeDetector {
756    adx: Adx,
757    hurst: HurstExponent,
758    hv: HistoricalVolatility,
759    bb_width: BollingerWidth,
760    config: RegimeConfig,
761}
762
763impl MarketRegimeDetector {
764    /// Constructs a new [`MarketRegimeDetector`].
765    ///
766    /// # Errors
767    /// Returns [`FinError::InvalidPeriod`] if `period < 2`.
768    pub fn new(period: usize, config: RegimeConfig) -> Result<Self, FinError> {
769        if period < 2 {
770            return Err(FinError::InvalidPeriod(period));
771        }
772        Ok(Self {
773            adx: Adx::new("regime_adx", period)?,
774            hurst: HurstExponent::new("regime_hurst", period)?,
775            hv: HistoricalVolatility::new("regime_hv", period, 252)?,
776            bb_width: BollingerWidth::new("regime_bb_width", period, Decimal::from(2u32))?,
777            config,
778        })
779    }
780
781    /// Constructs a detector with default thresholds.
782    ///
783    /// # Errors
784    /// Returns [`FinError::InvalidPeriod`] if `period < 2`.
785    pub fn with_defaults(period: usize) -> Result<Self, FinError> {
786        Self::new(period, RegimeConfig::default())
787    }
788
789    /// Updates all internal indicators and returns the current regime.
790    ///
791    /// # Errors
792    /// Propagates any [`FinError`] from the underlying indicators.
793    pub fn update(&mut self, bar: &BarInput) -> Result<MarketRegime, FinError> {
794        let adx_val = self.adx.update(bar)?;
795        let hurst_val = self.hurst.update(bar)?;
796        let hv_val = self.hv.update(bar)?;
797        let bb_w_val = self.bb_width.update(bar)?;
798
799        let (adx, hurst, hv, bb_w) = match (adx_val, hurst_val, hv_val, bb_w_val) {
800            (
801                SignalValue::Scalar(a),
802                SignalValue::Scalar(h),
803                SignalValue::Scalar(v),
804                SignalValue::Scalar(b),
805            ) => (a, h, v, b),
806            _ => return Ok(MarketRegime::Unknown),
807        };
808
809        let adx_f = adx.to_f64().unwrap_or(0.0);
810        let hurst_f = hurst.to_f64().unwrap_or(0.5);
811        let hv_f = hv.to_f64().unwrap_or(0.0);
812        let bb_w_f = bb_w.to_f64().unwrap_or(f64::MAX);
813
814        // Hurst-first priority
815        if hurst_f > self.config.hurst_trending && adx_f > self.config.adx_trend_threshold {
816            return Ok(MarketRegime::Trending);
817        }
818        if hv_f > self.config.vol_high_multiplier * 15.0 {
819            return Ok(MarketRegime::HighVolatility);
820        }
821        if hurst_f < self.config.hurst_mean_reverting {
822            return Ok(MarketRegime::MeanReverting);
823        }
824        if bb_w_f < self.config.bb_width_quiet {
825            return Ok(MarketRegime::LowVolatility);
826        }
827
828        Ok(MarketRegime::Neutral)
829    }
830
831    /// Returns `true` when all internal indicators are warmed up.
832    pub fn is_ready(&self) -> bool {
833        self.adx.is_ready()
834            && self.hurst.is_ready()
835            && self.hv.is_ready()
836            && self.bb_width.is_ready()
837    }
838
839    /// Returns the current configuration.
840    pub fn config(&self) -> &RegimeConfig {
841        &self.config
842    }
843
844    /// Resets all internal indicators.
845    pub fn reset(&mut self) {
846        self.adx.reset();
847        self.hurst.reset();
848        self.hv.reset();
849        self.bb_width.reset();
850    }
851}
852
853// ─── RegimeConditionalSignal ──────────────────────────────────────────────────
854
855/// A wrapper that selects different RSI periods depending on the active regime.
856///
857/// This is the canonical implementation of regime-conditional signal adaptation:
858/// - In `Trending` markets: short-period RSI is more responsive
859/// - In `MeanReverting` markets: longer-period RSI reduces noise
860/// - In `HighVolatility` or `Crisis`: signal is suppressed (returns `None`)
861/// - In other regimes: uses the neutral period
862///
863/// # Example
864/// ```rust
865/// use fin_primitives::regime::{RegimeConditionalSignal, MarketRegime};
866/// use fin_primitives::signals::BarInput;
867/// use rust_decimal_macros::dec;
868///
869/// let mut signal = RegimeConditionalSignal::new(14, 21, 14).unwrap();
870/// let bar = BarInput::new(dec!(100), dec!(102), dec!(98), dec!(100), dec!(1000));
871/// // During warm-up, regime is Unknown → signal suppressed
872/// let val = signal.update(&bar, MarketRegime::Unknown);
873/// assert!(val.is_none());
874/// ```
875pub struct RegimeConditionalSignal {
876    /// RSI indicator tuned for trending regimes (shorter period, more reactive).
877    rsi_trending: crate::signals::indicators::Rsi,
878    /// RSI indicator tuned for mean-reverting regimes (longer period, smoother).
879    rsi_mean_reverting: crate::signals::indicators::Rsi,
880    /// RSI indicator for neutral/low-vol regimes.
881    rsi_neutral: crate::signals::indicators::Rsi,
882}
883
884impl RegimeConditionalSignal {
885    /// Constructs a new `RegimeConditionalSignal`.
886    ///
887    /// - `trending_period`: RSI period for trending regime (e.g. 14).
888    /// - `mean_reverting_period`: RSI period for mean-reverting regime (e.g. 21).
889    /// - `neutral_period`: RSI period for all other regimes (e.g. 14).
890    ///
891    /// # Errors
892    /// Returns [`FinError::InvalidPeriod`] if any period is zero.
893    pub fn new(
894        trending_period: usize,
895        mean_reverting_period: usize,
896        neutral_period: usize,
897    ) -> Result<Self, FinError> {
898        Ok(Self {
899            rsi_trending: crate::signals::indicators::Rsi::new(
900                "rsi_trending",
901                trending_period,
902            )?,
903            rsi_mean_reverting: crate::signals::indicators::Rsi::new(
904                "rsi_mean_reverting",
905                mean_reverting_period,
906            )?,
907            rsi_neutral: crate::signals::indicators::Rsi::new("rsi_neutral", neutral_period)?,
908        })
909    }
910
911    /// Updates the appropriate RSI indicator for the given regime and returns
912    /// the current RSI value, or `None` if the signal is suppressed.
913    ///
914    /// Suppressed in: `Crisis`, `Unknown` (risk-off regimes).
915    ///
916    /// # Errors
917    /// Propagates any [`FinError`] from RSI computation.
918    pub fn update(
919        &mut self,
920        bar: &BarInput,
921        regime: MarketRegime,
922    ) -> Option<Result<f64, FinError>> {
923        // All three RSIs must be updated to keep warm regardless of regime
924        let v_trending = self.rsi_trending.update(bar);
925        let v_mr = self.rsi_mean_reverting.update(bar);
926        let v_neutral = self.rsi_neutral.update(bar);
927
928        if regime.is_risk_off() {
929            return None;
930        }
931
932        let chosen = match regime {
933            MarketRegime::Trending => v_trending,
934            MarketRegime::MeanReverting => v_mr,
935            _ => v_neutral,
936        };
937
938        match chosen {
939            Ok(SignalValue::Scalar(v)) => {
940                Some(Ok(v.to_f64().unwrap_or(50.0)))
941            }
942            Ok(_) => None,
943            Err(e) => Some(Err(e)),
944        }
945    }
946
947    /// Returns `true` when all internal RSI indicators are warmed up.
948    pub fn is_ready(&self) -> bool {
949        self.rsi_trending.is_ready()
950            && self.rsi_mean_reverting.is_ready()
951            && self.rsi_neutral.is_ready()
952    }
953
954    /// Resets all internal indicators.
955    pub fn reset(&mut self) {
956        self.rsi_trending.reset();
957        self.rsi_mean_reverting.reset();
958        self.rsi_neutral.reset();
959    }
960}
961
962// ─── Tests ────────────────────────────────────────────────────────────────────
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967    use rust_decimal_macros::dec;
968
969    fn bar(h: f64, l: f64, c: f64) -> BarInput {
970        BarInput::new(
971            Decimal::try_from(c).unwrap_or(dec!(100)),
972            Decimal::try_from(h).unwrap_or(dec!(102)),
973            Decimal::try_from(l).unwrap_or(dec!(98)),
974            Decimal::try_from(c).unwrap_or(dec!(100)),
975            dec!(1000),
976        )
977    }
978
979    // ── MarketRegime ──────────────────────────────────────────────────────────
980
981    #[test]
982    fn test_regime_display_all_variants() {
983        assert_eq!(MarketRegime::Trending.to_string(), "Trending");
984        assert_eq!(MarketRegime::MeanReverting.to_string(), "MeanReverting");
985        assert_eq!(MarketRegime::HighVolatility.to_string(), "HighVolatility");
986        assert_eq!(MarketRegime::LowVolatility.to_string(), "LowVolatility");
987        assert_eq!(MarketRegime::Crisis.to_string(), "Crisis");
988        assert_eq!(MarketRegime::Neutral.to_string(), "Neutral");
989        assert_eq!(MarketRegime::Unknown.to_string(), "Unknown");
990    }
991
992    #[test]
993    fn test_regime_short_codes() {
994        assert_eq!(MarketRegime::Trending.short_code(), "TRD");
995        assert_eq!(MarketRegime::Crisis.short_code(), "CRS");
996        assert_eq!(MarketRegime::Unknown.short_code(), "UNK");
997    }
998
999    #[test]
1000    fn test_is_risk_off() {
1001        assert!(MarketRegime::Crisis.is_risk_off());
1002        assert!(MarketRegime::Unknown.is_risk_off());
1003        assert!(!MarketRegime::Trending.is_risk_off());
1004        assert!(!MarketRegime::Neutral.is_risk_off());
1005    }
1006
1007    // ── Garch11 ───────────────────────────────────────────────────────────────
1008
1009    #[test]
1010    fn test_garch_invalid_params() {
1011        assert!(Garch11::new(0.0, 0.85, 1e-6).is_err());
1012        assert!(Garch11::new(0.1, 0.0, 1e-6).is_err());
1013        assert!(Garch11::new(0.1, 0.85, 0.0).is_err());
1014        assert!(Garch11::new(0.5, 0.6, 1e-6).is_err()); // alpha + beta >= 1
1015    }
1016
1017    #[test]
1018    fn test_garch_produces_positive_sigma() {
1019        let mut g = Garch11::new(0.1, 0.85, 1e-6).unwrap();
1020        let returns = [-0.01, 0.02, -0.015, 0.005, 0.03, -0.02, 0.01];
1021        for ret in returns {
1022            let sigma = g.update(ret);
1023            assert!(sigma > 0.0, "sigma must be positive, got {sigma}");
1024        }
1025    }
1026
1027    #[test]
1028    fn test_garch_reset() {
1029        let mut g = Garch11::new(0.1, 0.85, 1e-6).unwrap();
1030        for ret in [-0.05, 0.05, -0.05] {
1031            g.update(ret);
1032        }
1033        let sigma_before = g.sigma();
1034        g.reset();
1035        // After reset, variance returns to long-run level
1036        let lr = g.long_run_sigma();
1037        assert!((g.sigma() - lr).abs() < 1e-10);
1038        assert_ne!(sigma_before, g.sigma());
1039        assert_eq!(g.count(), 0);
1040    }
1041
1042    #[test]
1043    fn test_garch_vol_elevated() {
1044        let mut g = Garch11::new(0.1, 0.85, 1e-4).unwrap();
1045        // Feed large shocks to elevate GARCH vol above long-run
1046        for _ in 0..10 {
1047            g.update(0.1); // large positive return
1048        }
1049        // With large shocks, conditional vol should exceed long-run * 1.0
1050        assert!(g.is_vol_elevated(1.0) || g.sigma() > 0.0); // at minimum sigma is positive
1051    }
1052
1053    // ── CorrelationBreakdownDetector ──────────────────────────────────────────
1054
1055    #[test]
1056    fn test_correlation_invalid_params() {
1057        assert!(CorrelationBreakdownDetector::new(1, 0.3, 0.6).is_err()); // window < 3
1058        assert!(CorrelationBreakdownDetector::new(20, 1.5, 0.6).is_err()); // threshold > 1
1059        assert!(CorrelationBreakdownDetector::new(20, 0.3, 1.5).is_err()); // fraction > 1
1060    }
1061
1062    #[test]
1063    fn test_no_crisis_single_asset() {
1064        let mut d = CorrelationBreakdownDetector::new(10, 0.3, 0.6).unwrap();
1065        for i in 0..15 {
1066            d.update(0, if i % 2 == 0 { 0.01 } else { -0.01 });
1067        }
1068        assert!(!d.is_crisis()); // only one asset → no pairs → no crisis
1069    }
1070
1071    #[test]
1072    fn test_correlation_reset() {
1073        let mut d = CorrelationBreakdownDetector::new(10, 0.3, 0.6).unwrap();
1074        for i in 0..15 {
1075            d.update(0, if i % 2 == 0 { 0.01 } else { -0.01 });
1076            d.update(1, if i % 3 == 0 { 0.01 } else { -0.01 });
1077        }
1078        d.reset();
1079        assert!(!d.is_crisis());
1080    }
1081
1082    // ── pearson_r ─────────────────────────────────────────────────────────────
1083
1084    #[test]
1085    fn test_pearson_r_perfect_correlation() {
1086        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
1087        let r = pearson_r(&x, &x);
1088        assert!((r - 1.0).abs() < 1e-10);
1089    }
1090
1091    #[test]
1092    fn test_pearson_r_perfect_anti_correlation() {
1093        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
1094        let y: Vec<f64> = x.iter().map(|v| -v).collect();
1095        let r = pearson_r(&x, &y);
1096        assert!((r + 1.0).abs() < 1e-10);
1097    }
1098
1099    #[test]
1100    fn test_pearson_r_constant_series_returns_zero() {
1101        let x = [1.0, 1.0, 1.0, 1.0];
1102        let y = [2.0, 2.0, 2.0, 2.0];
1103        let r = pearson_r(&x, &y);
1104        assert_eq!(r, 0.0);
1105    }
1106
1107    // ── RegimeHistory ─────────────────────────────────────────────────────────
1108
1109    #[test]
1110    fn test_regime_history_duration() {
1111        let h = RegimeHistory {
1112            regime: MarketRegime::Trending,
1113            started_at_bar: 10,
1114            confidence: 0.8,
1115            ended_at_bar: Some(25),
1116        };
1117        assert_eq!(h.duration_bars(), Some(15));
1118        assert!(!h.is_active());
1119    }
1120
1121    #[test]
1122    fn test_regime_history_active() {
1123        let h = RegimeHistory {
1124            regime: MarketRegime::Neutral,
1125            started_at_bar: 5,
1126            confidence: 0.5,
1127            ended_at_bar: None,
1128        };
1129        assert!(h.is_active());
1130        assert_eq!(h.duration_bars(), None);
1131    }
1132
1133    // ── RegimeDetector ────────────────────────────────────────────────────────
1134
1135    #[test]
1136    fn test_detector_period_validation() {
1137        assert!(RegimeDetector::new(0, RegimeConfig::default()).is_err());
1138        assert!(RegimeDetector::new(1, RegimeConfig::default()).is_err());
1139        assert!(RegimeDetector::new(2, RegimeConfig::default()).is_ok());
1140    }
1141
1142    #[test]
1143    fn test_detector_unknown_before_warmup() {
1144        let mut d = RegimeDetector::new(5, RegimeConfig::default()).unwrap();
1145        let (regime, _) = d.update(&bar(102.0, 98.0, 100.0), &[]).unwrap();
1146        assert_eq!(regime, MarketRegime::Unknown);
1147        assert!(!d.is_ready());
1148    }
1149
1150    #[test]
1151    fn test_detector_bar_count() {
1152        let mut d = RegimeDetector::new(5, RegimeConfig::default()).unwrap();
1153        for i in 0..5 {
1154            d.update(&bar(100.0 + i as f64, 99.0, 100.0 + i as f64), &[]).unwrap();
1155        }
1156        assert_eq!(d.bar_count(), 5);
1157    }
1158
1159    #[test]
1160    fn test_detector_reset_clears_state() {
1161        let mut d = RegimeDetector::with_defaults(5).unwrap();
1162        for i in 0..30 {
1163            let c = 100.0 + i as f64;
1164            d.update(&bar(c + 1.0, c - 1.0, c), &[]).unwrap();
1165        }
1166        d.reset();
1167        assert!(!d.is_ready());
1168        assert_eq!(d.bar_count(), 0);
1169        assert!(d.history().is_empty());
1170    }
1171
1172    #[test]
1173    fn test_detector_history_populated_after_transition() {
1174        let mut d = RegimeDetector::new(3, RegimeConfig::default()).unwrap();
1175        for i in 0..40 {
1176            let c = 100.0 + i as f64 * 0.1;
1177            d.update(&bar(c + 0.2, c - 0.2, c), &[]).unwrap();
1178        }
1179        // history starts empty for Unknown, grows as regime changes
1180        // at minimum the Unknown → something transition should be recorded
1181        let _ = d.history(); // no panic
1182    }
1183
1184    #[test]
1185    fn test_detector_garch_accessor() {
1186        let d = RegimeDetector::with_defaults(5).unwrap();
1187        assert!(d.garch().sigma() > 0.0);
1188    }
1189
1190    #[test]
1191    fn test_detector_no_panic_many_bars() {
1192        let mut d = RegimeDetector::new(10, RegimeConfig::default()).unwrap();
1193        for i in 0..200 {
1194            let c = 100.0 + (i as f64 * 0.5).sin() * 5.0;
1195            d.update(&bar(c + 1.0, c - 1.0, c), &[]).unwrap();
1196        }
1197    }
1198
1199    // ── MarketRegimeDetector (legacy) ─────────────────────────────────────────
1200
1201    #[test]
1202    fn test_legacy_detector_period_zero_fails() {
1203        assert!(MarketRegimeDetector::new(0, RegimeConfig::default()).is_err());
1204        assert!(MarketRegimeDetector::new(1, RegimeConfig::default()).is_err());
1205    }
1206
1207    #[test]
1208    fn test_legacy_unknown_before_warmup() {
1209        let mut d = MarketRegimeDetector::new(5, RegimeConfig::default()).unwrap();
1210        let regime = d.update(&bar(102.0, 98.0, 100.0)).unwrap();
1211        assert_eq!(regime, MarketRegime::Unknown);
1212        assert!(!d.is_ready());
1213    }
1214
1215    #[test]
1216    fn test_legacy_reset_clears_warmup() {
1217        let mut d = MarketRegimeDetector::with_defaults(5).unwrap();
1218        for i in 0..30 {
1219            let h = 100.0 + i as f64;
1220            d.update(&bar(h + 1.0, h - 1.0, h)).unwrap();
1221        }
1222        d.reset();
1223        assert!(!d.is_ready());
1224    }
1225
1226    // ── RegimeConditionalSignal ───────────────────────────────────────────────
1227
1228    #[test]
1229    fn test_conditional_signal_invalid_period() {
1230        assert!(RegimeConditionalSignal::new(0, 21, 14).is_err());
1231        assert!(RegimeConditionalSignal::new(14, 0, 14).is_err());
1232        assert!(RegimeConditionalSignal::new(14, 21, 0).is_err());
1233    }
1234
1235    #[test]
1236    fn test_conditional_signal_suppressed_in_crisis() {
1237        let mut sig = RegimeConditionalSignal::new(5, 10, 7).unwrap();
1238        let b = bar(102.0, 98.0, 100.0);
1239        let result = sig.update(&b, MarketRegime::Crisis);
1240        assert!(result.is_none());
1241    }
1242
1243    #[test]
1244    fn test_conditional_signal_suppressed_when_unknown() {
1245        let mut sig = RegimeConditionalSignal::new(5, 10, 7).unwrap();
1246        let b = bar(102.0, 98.0, 100.0);
1247        let result = sig.update(&b, MarketRegime::Unknown);
1248        assert!(result.is_none());
1249    }
1250
1251    #[test]
1252    fn test_conditional_signal_produces_value_after_warmup() {
1253        let period = 5usize;
1254        let mut sig = RegimeConditionalSignal::new(period, period + 2, period).unwrap();
1255        let mut last_val = None;
1256        for i in 0..((period + 2) * 3) {
1257            let c = 100.0 + i as f64 * 0.1;
1258            last_val = sig.update(&bar(c + 0.5, c - 0.5, c), MarketRegime::Trending);
1259        }
1260        // After enough bars, should produce a value in the trending regime
1261        if let Some(Ok(rsi_val)) = last_val {
1262            assert!((0.0..=100.0).contains(&rsi_val));
1263        }
1264        // (may still be None if all three RSIs aren't warm; that's acceptable)
1265    }
1266
1267    #[test]
1268    fn test_conditional_signal_reset() {
1269        let mut sig = RegimeConditionalSignal::new(5, 10, 7).unwrap();
1270        let b = bar(102.0, 98.0, 100.0);
1271        for _ in 0..30 {
1272            let _ = sig.update(&b, MarketRegime::Neutral);
1273        }
1274        sig.reset();
1275        assert!(!sig.is_ready());
1276    }
1277}