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