Skip to main content

indicators/regime/
router.rs

1//! Enhanced Strategy Router
2//!
3//! Routes market data to the appropriate trading strategy based on the detected
4//! market regime. Supports three detection methods:
5//!
6//! 1. **Indicators** — Fast, rule-based (ADX/BB/ATR)
7//! 2. **HMM** — Statistical, learns from returns
8//! 3. **Ensemble** — Combines both for robustness (recommended)
9//!
10//! The router is self-contained and emits regime classifications with strategy
11//! recommendations. The consuming service (e.g., forward service) is responsible
12//! for dispatching to actual strategy implementations.
13//!
14//! The router also exposes the most recent [`RegimeConfidence`] per asset via
15//! [`EnhancedRouter::last_regime_confidence`], giving callers access to the raw
16//! indicator values (ADX, BB width percentile, trend strength) that produced the
17//! last classification. This is used by the regime bridge to enrich neuromorphic
18//! indicator snapshots.
19//!
20
21use std::collections::HashMap;
22
23use serde::{Deserialize, Serialize};
24
25use crate::error::IndicatorError;
26use crate::indicator::{Indicator, IndicatorOutput};
27use crate::regime::detector::RegimeDetector;
28use crate::regime::ensemble::{EnsembleConfig, EnsembleRegimeDetector, EnsembleResult};
29use crate::regime::hmm::{HMMConfig, HMMRegimeDetector};
30use crate::registry::param_usize;
31use crate::types::{Candle, MarketRegime, RegimeConfidence, RegimeConfig, TrendDirection};
32
33// ── Indicator wrapper ─────────────────────────────────────────────────────────
34
35/// Batch `Indicator` adapter for [`EnhancedRouter`].
36///
37/// Uses a fixed synthetic symbol `"_batch"` to route candles through the router
38/// and collect per-bar `router_conf` and `router_position_factor`.
39#[derive(Debug, Clone)]
40pub struct RouterIndicator {
41    pub config: EnhancedRouterConfig,
42}
43
44impl RouterIndicator {
45    pub fn new(config: EnhancedRouterConfig) -> Self {
46        Self { config }
47    }
48    pub fn with_defaults() -> Self {
49        Self::new(EnhancedRouterConfig::default())
50    }
51}
52
53fn routed_regime_id(r: MarketRegime) -> f64 {
54    match r {
55        MarketRegime::MeanReverting => 1.0,
56        MarketRegime::Volatile => 2.0,
57        MarketRegime::Trending(TrendDirection::Bullish) => 3.0,
58        MarketRegime::Trending(TrendDirection::Bearish) => 4.0,
59        MarketRegime::Uncertain => 0.0,
60    }
61}
62
63impl Indicator for RouterIndicator {
64    fn name(&self) -> &'static str {
65        "Router"
66    }
67    fn required_len(&self) -> usize {
68        self.config.indicator_config.adx_period * 2
69            + self.config.indicator_config.regime_stability_bars
70    }
71    fn required_columns(&self) -> &[&'static str] {
72        &["high", "low", "close"]
73    }
74
75    fn calculate(&self, candles: &[Candle]) -> Result<IndicatorOutput, IndicatorError> {
76        self.check_len(candles)?;
77        let mut router = EnhancedRouter::new(self.config.clone());
78        let sym = "_batch";
79        let n = candles.len();
80        let mut conf_out = vec![f64::NAN; n];
81        let mut factor_out = vec![f64::NAN; n];
82        let mut regime_out = vec![f64::NAN; n];
83        for (i, c) in candles.iter().enumerate() {
84            if let Some(sig) = router.update(sym, c.high, c.low, c.close) {
85                conf_out[i] = sig.confidence;
86                factor_out[i] = sig.position_factor;
87                regime_out[i] = routed_regime_id(sig.regime);
88            }
89        }
90        Ok(IndicatorOutput::from_pairs([
91            ("router_conf", conf_out),
92            ("router_factor", factor_out),
93            ("router_regime", regime_out),
94        ]))
95    }
96}
97
98// ── Registry factory ──────────────────────────────────────────────────────────
99
100pub fn factory<S: ::std::hash::BuildHasher>(params: &HashMap<String, String, S>) -> Result<Box<dyn Indicator>, IndicatorError> {
101    let adx_period = param_usize(params, "adx_period", 14)?;
102    let bb_period = param_usize(params, "bb_period", 20)?;
103    let indicator_config = RegimeConfig {
104        adx_period,
105        bb_period,
106        ..RegimeConfig::default()
107    };
108    let config = EnhancedRouterConfig {
109        indicator_config,
110        ..EnhancedRouterConfig::default()
111    };
112    Ok(Box::new(RouterIndicator::new(config)))
113}
114
115/// Which detection method to use
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
117pub enum DetectionMethod {
118    /// Technical indicators (ADX, BB, ATR) — fast, rule-based
119    Indicators,
120    /// Hidden Markov Model — statistical, learns from returns
121    #[allow(clippy::upper_case_acronyms)]
122    HMM,
123    /// Ensemble — combines both for robustness (recommended)
124    #[default]
125    Ensemble,
126}
127
128impl std::fmt::Display for DetectionMethod {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        match self {
131            DetectionMethod::Indicators => write!(f, "Indicators"),
132            DetectionMethod::HMM => write!(f, "HMM"),
133            DetectionMethod::Ensemble => write!(f, "Ensemble"),
134        }
135    }
136}
137
138/// Configuration for enhanced router
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct EnhancedRouterConfig {
141    /// Which detection method to use
142    pub detection_method: DetectionMethod,
143
144    /// Indicator-based config
145    pub indicator_config: RegimeConfig,
146
147    /// HMM config
148    pub hmm_config: Option<HMMConfig>,
149
150    /// Ensemble config
151    pub ensemble_config: Option<EnsembleConfig>,
152
153    /// Position size multiplier for volatile markets (0.0–1.0)
154    pub volatile_position_factor: f64,
155
156    /// Minimum confidence to recommend trading
157    pub min_confidence: f64,
158
159    /// Log regime changes to stdout
160    pub log_changes: bool,
161}
162
163impl Default for EnhancedRouterConfig {
164    fn default() -> Self {
165        Self {
166            detection_method: DetectionMethod::Ensemble,
167            indicator_config: RegimeConfig::crypto_optimized(),
168            hmm_config: Some(HMMConfig::crypto_optimized()),
169            ensemble_config: Some(EnsembleConfig::default()),
170            volatile_position_factor: 0.5,
171            min_confidence: 0.5,
172            log_changes: true,
173        }
174    }
175}
176
177/// Active strategy recommendation from the router
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179pub enum ActiveStrategy {
180    /// Use a trend-following strategy (e.g., Golden Cross, EMA Pullback)
181    TrendFollowing,
182    /// Use a mean-reversion strategy (e.g., Bollinger Bands, VWAP)
183    MeanReversion,
184    /// Do not trade — regime is unclear or confidence too low
185    NoTrade,
186}
187
188impl std::fmt::Display for ActiveStrategy {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        match self {
191            ActiveStrategy::TrendFollowing => write!(f, "Trend Following"),
192            ActiveStrategy::MeanReversion => write!(f, "Mean Reversion"),
193            ActiveStrategy::NoTrade => write!(f, "No Trade"),
194        }
195    }
196}
197
198/// Signal emitted by the router indicating the recommended action
199#[derive(Debug, Clone)]
200pub struct RoutedSignal {
201    /// Recommended strategy to use
202    pub strategy: ActiveStrategy,
203    /// Detected market regime
204    pub regime: MarketRegime,
205    /// Confidence in the regime classification (0.0–1.0)
206    pub confidence: f64,
207    /// Suggested position size factor (0.0–1.0)
208    pub position_factor: f64,
209    /// Human-readable reason for the recommendation
210    pub reason: String,
211
212    /// Which detection method produced this
213    pub detection_method: DetectionMethod,
214
215    /// Did ensemble methods agree? (only populated for Ensemble)
216    pub methods_agree: Option<bool>,
217
218    /// HMM state probabilities (only populated for HMM/Ensemble)
219    pub state_probabilities: Option<Vec<f64>>,
220
221    /// Expected regime duration in bars (from HMM)
222    pub expected_duration: Option<f64>,
223
224    /// Trend direction if trending
225    pub trend_direction: Option<TrendDirection>,
226}
227
228impl std::fmt::Display for RoutedSignal {
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        write!(
231            f,
232            "Strategy: {} | Regime: {} | Conf: {:.0}% | Size: {:.0}%",
233            self.strategy,
234            self.regime,
235            self.confidence * 100.0,
236            self.position_factor * 100.0
237        )?;
238
239        if let Some(agree) = self.methods_agree {
240            write!(f, " | Agree: {}", if agree { "✓" } else { "✗" })?;
241        }
242
243        if let Some(dur) = self.expected_duration {
244            write!(f, " | ExpDur: {dur:.0} bars")?;
245        }
246
247        Ok(())
248    }
249}
250
251/// Wrapper for different detector types
252#[allow(clippy::upper_case_acronyms)]
253enum Detector {
254    Indicator(Box<RegimeDetector>),
255    HMM(Box<HMMRegimeDetector>),
256    Ensemble(Box<EnsembleRegimeDetector>),
257}
258
259impl std::fmt::Debug for Detector {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        match self {
262            Detector::Indicator(_) => write!(f, "Detector::Indicator(...)"),
263            Detector::HMM(_) => write!(f, "Detector::HMM(...)"),
264            Detector::Ensemble(_) => write!(f, "Detector::Ensemble(...)"),
265        }
266    }
267}
268
269/// Per-asset state tracked by the router
270#[derive(Debug)]
271struct AssetState {
272    detector: Detector,
273    current_strategy: ActiveStrategy,
274    last_regime: MarketRegime,
275    regime_change_count: u32,
276    /// Most recent `RegimeConfidence` from the detector, including raw
277    /// indicator values (ADX, BB width percentile, trend strength).
278    last_confidence: Option<RegimeConfidence>,
279}
280
281/// Enhanced Strategy Router
282///
283/// Manages per-asset regime detectors and emits strategy recommendations
284/// based on detected market conditions.
285///
286/// # Example
287///
288/// ```rust
289/// use indicators::{EnhancedRouter, EnhancedRouterConfig, ActiveStrategy};
290///
291/// let mut router = EnhancedRouter::with_ensemble();
292/// router.register_asset("BTC/USD");
293///
294/// // Feed OHLC data
295/// for i in 0..300 {
296///     let price = 50000.0 + i as f64 * 10.0;
297///     if let Some(signal) = router.update("BTC/USD", price + 50.0, price - 50.0, price) {
298///         if signal.strategy != ActiveStrategy::NoTrade {
299///             println!("{}", signal);
300///         }
301///     }
302/// }
303/// ```
304pub struct EnhancedRouter {
305    config: EnhancedRouterConfig,
306    assets: HashMap<String, AssetState>,
307}
308
309impl std::fmt::Debug for EnhancedRouter {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        f.debug_struct("EnhancedRouter")
312            .field("config", &self.config)
313            .field("assets", &self.assets.keys().collect::<Vec<_>>())
314            .finish()
315    }
316}
317
318impl EnhancedRouter {
319    /// Create with specific config
320    pub fn new(config: EnhancedRouterConfig) -> Self {
321        Self {
322            config,
323            assets: HashMap::new(),
324        }
325    }
326
327    /// Create with indicator-based detection
328    pub fn with_indicators() -> Self {
329        Self::new(EnhancedRouterConfig {
330            detection_method: DetectionMethod::Indicators,
331            ..Default::default()
332        })
333    }
334
335    /// Create with HMM-based detection
336    pub fn with_hmm() -> Self {
337        Self::new(EnhancedRouterConfig {
338            detection_method: DetectionMethod::HMM,
339            hmm_config: Some(HMMConfig::crypto_optimized()),
340            ..Default::default()
341        })
342    }
343
344    /// Create with Ensemble detection (recommended)
345    pub fn with_ensemble() -> Self {
346        Self::new(EnhancedRouterConfig {
347            detection_method: DetectionMethod::Ensemble,
348            ensemble_config: Some(EnsembleConfig::default()),
349            ..Default::default()
350        })
351    }
352
353    /// Register an asset for tracking.
354    ///
355    /// Creates the appropriate detector based on the configured detection method.
356    /// If the asset is already registered, this is a no-op.
357    pub fn register_asset(&mut self, symbol: &str) {
358        if self.assets.contains_key(symbol) {
359            return;
360        }
361
362        let detector = match self.config.detection_method {
363            DetectionMethod::Indicators => Detector::Indicator(Box::new(RegimeDetector::new(
364                self.config.indicator_config.clone(),
365            ))),
366            DetectionMethod::HMM => {
367                let hmm_config = self.config.hmm_config.clone().unwrap_or_default();
368                Detector::HMM(Box::new(HMMRegimeDetector::new(hmm_config)))
369            }
370            DetectionMethod::Ensemble => {
371                let ens_config = self.config.ensemble_config.clone().unwrap_or_default();
372                Detector::Ensemble(Box::new(EnsembleRegimeDetector::new(
373                    ens_config,
374                    self.config.indicator_config.clone(),
375                )))
376            }
377        };
378
379        self.assets.insert(
380            symbol.to_string(),
381            AssetState {
382                detector,
383                current_strategy: ActiveStrategy::NoTrade,
384                last_regime: MarketRegime::Uncertain,
385                regime_change_count: 0,
386                last_confidence: None,
387            },
388        );
389    }
390
391    /// Unregister an asset, removing its state and detector
392    pub fn unregister_asset(&mut self, symbol: &str) -> bool {
393        self.assets.remove(symbol).is_some()
394    }
395
396    /// Update with new OHLC data for an asset and get a routing signal.
397    ///
398    /// If the asset isn't registered, it will be auto-registered.
399    /// Returns `None` only if the detector fails internally (should not happen).
400    pub fn update(
401        &mut self,
402        symbol: &str,
403        high: f64,
404        low: f64,
405        close: f64,
406    ) -> Option<RoutedSignal> {
407        if !self.assets.contains_key(symbol) {
408            self.register_asset(symbol);
409        }
410
411        let state = self.assets.get_mut(symbol)?;
412
413        // Get regime from appropriate detector.
414        // We also store the raw `RegimeConfidence` so callers can access
415        // indicator values (ADX, BB width, trend strength) after the fact.
416        let (regime_result, methods_agree, state_probs, expected_duration) =
417            match &mut state.detector {
418                Detector::Indicator(det) => {
419                    let result = det.update(high, low, close);
420                    (result, None, None, None)
421                }
422                Detector::HMM(det) => {
423                    let result = det.update_ohlc(high, low, close);
424                    let probs = det.state_probabilities().to_vec();
425                    let duration = det.expected_regime_duration(det.current_state_index());
426                    (result, None, Some(probs), Some(duration))
427                }
428                Detector::Ensemble(det) => {
429                    let ens_result: EnsembleResult = det.update(high, low, close);
430                    let probs = det.hmm_state_probabilities().to_vec();
431                    let duration = det.expected_regime_duration();
432                    (
433                        ens_result.to_regime_confidence(),
434                        Some(ens_result.methods_agree),
435                        Some(probs),
436                        Some(duration),
437                    )
438                }
439            };
440
441        // Stash the raw result for later access via last_regime_confidence()
442        state.last_confidence = Some(regime_result);
443
444        // Check for regime change
445        if regime_result.regime != state.last_regime {
446            state.regime_change_count += 1;
447            if self.config.log_changes {
448                println!(
449                    "[{}] Regime change #{} ({:?}): {} → {} (conf: {:.2})",
450                    symbol,
451                    state.regime_change_count,
452                    self.config.detection_method,
453                    state.last_regime,
454                    regime_result.regime,
455                    regime_result.confidence
456                );
457            }
458            state.last_regime = regime_result.regime;
459        }
460
461        // Select strategy based on regime
462        let min_confidence = self.config.min_confidence;
463        let volatile_factor = self.config.volatile_position_factor;
464        let (strategy, position_factor, reason) =
465            Self::compute_strategy(&regime_result, min_confidence, volatile_factor);
466        state.current_strategy = strategy;
467
468        // Extract trend direction if trending
469        let trend_direction = match regime_result.regime {
470            MarketRegime::Trending(dir) => Some(dir),
471            _ => None,
472        };
473
474        Some(RoutedSignal {
475            strategy,
476            regime: regime_result.regime,
477            confidence: regime_result.confidence,
478            position_factor,
479            reason,
480            detection_method: self.config.detection_method,
481            methods_agree,
482            state_probabilities: state_probs,
483            expected_duration,
484            trend_direction,
485        })
486    }
487
488    /// Compute strategy recommendation from a regime classification.
489    ///
490    /// This is a pure function — no side effects.
491    fn compute_strategy(
492        regime: &RegimeConfidence,
493        min_confidence: f64,
494        volatile_factor: f64,
495    ) -> (ActiveStrategy, f64, String) {
496        if regime.confidence < min_confidence {
497            return (
498                ActiveStrategy::NoTrade,
499                0.0,
500                format!(
501                    "Confidence too low ({:.0}% < {:.0}%)",
502                    regime.confidence * 100.0,
503                    min_confidence * 100.0
504                ),
505            );
506        }
507
508        match regime.regime {
509            MarketRegime::Trending(dir) => (
510                ActiveStrategy::TrendFollowing,
511                1.0,
512                format!(
513                    "{} trend detected (ADX: {:.1}, conf: {:.0}%)",
514                    dir,
515                    regime.adx_value,
516                    regime.confidence * 100.0
517                ),
518            ),
519            MarketRegime::MeanReverting => (
520                ActiveStrategy::MeanReversion,
521                1.0,
522                format!(
523                    "Mean-reverting regime (BB%: {:.0}, conf: {:.0}%)",
524                    regime.bb_width_percentile,
525                    regime.confidence * 100.0
526                ),
527            ),
528            MarketRegime::Volatile => (
529                ActiveStrategy::MeanReversion,
530                volatile_factor,
531                format!(
532                    "Volatile regime — reduced size to {:.0}% (conf: {:.0}%)",
533                    volatile_factor * 100.0,
534                    regime.confidence * 100.0
535                ),
536            ),
537            MarketRegime::Uncertain => (
538                ActiveStrategy::NoTrade,
539                0.0,
540                "Uncertain regime — staying out".to_string(),
541            ),
542        }
543    }
544
545    // ========================================================================
546    // Public Accessors
547    // ========================================================================
548
549    /// Get current regime for an asset
550    pub fn get_regime(&self, symbol: &str) -> Option<MarketRegime> {
551        self.assets.get(symbol).map(|s| s.last_regime)
552    }
553
554    /// Get the most recent [`RegimeConfidence`] for an asset.
555    ///
556    /// This contains the raw indicator values (ADX, BB width percentile,
557    /// trend strength) that produced the last regime classification.
558    /// Returns `None` if the asset hasn't been updated yet.
559    pub fn last_regime_confidence(&self, symbol: &str) -> Option<&RegimeConfidence> {
560        self.assets
561            .get(symbol)
562            .and_then(|s| s.last_confidence.as_ref())
563    }
564
565    /// Get the current ATR (Average True Range) value for an asset.
566    ///
567    /// Delegates to the underlying detector regardless of detection method:
568    /// - **Indicators** → `RegimeDetector::atr_value()`
569    /// - **HMM** → not available (returns `None`)
570    /// - **Ensemble** → delegates to the embedded indicator detector
571    ///
572    /// Returns `None` if the asset isn't registered or the detector hasn't
573    /// warmed up yet.
574    pub fn atr_value(&self, symbol: &str) -> Option<f64> {
575        self.assets.get(symbol).and_then(|s| match &s.detector {
576            Detector::Indicator(det) => det.atr_value(),
577            Detector::HMM(_) => None,
578            Detector::Ensemble(det) => det.indicator_detector().atr_value(),
579        })
580    }
581
582    /// Get the current ADX (Average Directional Index) value for an asset.
583    ///
584    /// Delegates to the underlying detector regardless of detection method:
585    /// - **Indicators** → `RegimeDetector::adx_value()`
586    /// - **HMM** → not available (returns `None`)
587    /// - **Ensemble** → delegates to the embedded indicator detector
588    ///
589    /// Returns `None` if the asset isn't registered or the detector hasn't
590    /// warmed up yet.
591    pub fn adx_value(&self, symbol: &str) -> Option<f64> {
592        self.assets.get(symbol).and_then(|s| match &s.detector {
593            Detector::Indicator(det) => det.adx_value(),
594            Detector::HMM(_) => None,
595            Detector::Ensemble(det) => det.indicator_detector().adx_value(),
596        })
597    }
598
599    /// Get current recommended strategy for an asset
600    pub fn get_strategy(&self, symbol: &str) -> Option<ActiveStrategy> {
601        self.assets.get(symbol).map(|s| s.current_strategy)
602    }
603
604    /// Check if detector is warmed up for an asset
605    pub fn is_ready(&self, symbol: &str) -> bool {
606        self.assets.get(symbol).is_some_and(|s| match &s.detector {
607            Detector::Indicator(d) => d.is_ready(),
608            Detector::HMM(d) => d.is_ready(),
609            Detector::Ensemble(d) => d.is_ready(),
610        })
611    }
612
613    /// Get detection method being used
614    pub fn detection_method(&self) -> DetectionMethod {
615        self.config.detection_method
616    }
617
618    /// Get regime change count for an asset
619    pub fn regime_changes(&self, symbol: &str) -> u32 {
620        self.assets.get(symbol).map_or(0, |s| s.regime_change_count)
621    }
622
623    /// Get all registered asset symbols
624    pub fn registered_assets(&self) -> Vec<&str> {
625        self.assets.keys().map(String::as_str).collect()
626    }
627
628    /// Get the router configuration
629    pub fn config(&self) -> &EnhancedRouterConfig {
630        &self.config
631    }
632
633    /// Get a summary of all asset states
634    pub fn summary(&self) -> Vec<AssetSummary> {
635        self.assets
636            .iter()
637            .map(|(symbol, state)| AssetSummary {
638                symbol: symbol.clone(),
639                regime: state.last_regime,
640                strategy: state.current_strategy,
641                regime_changes: state.regime_change_count,
642                is_ready: match &state.detector {
643                    Detector::Indicator(d) => d.is_ready(),
644                    Detector::HMM(d) => d.is_ready(),
645                    Detector::Ensemble(d) => d.is_ready(),
646                },
647            })
648            .collect()
649    }
650}
651
652/// Summary of an asset's regime state
653#[derive(Debug, Clone, Serialize, Deserialize)]
654pub struct AssetSummary {
655    pub symbol: String,
656    pub regime: MarketRegime,
657    pub strategy: ActiveStrategy,
658    pub regime_changes: u32,
659    pub is_ready: bool,
660}
661
662impl std::fmt::Display for AssetSummary {
663    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
664        write!(
665            f,
666            "{}: {} → {} (changes: {}, ready: {})",
667            self.symbol, self.regime, self.strategy, self.regime_changes, self.is_ready
668        )
669    }
670}
671
672// ============================================================================
673// Tests
674// ============================================================================
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    #[test]
681    fn test_router_creation_ensemble() {
682        let router = EnhancedRouter::with_ensemble();
683        assert_eq!(router.detection_method(), DetectionMethod::Ensemble);
684    }
685
686    #[test]
687    fn test_router_creation_indicators() {
688        let router = EnhancedRouter::with_indicators();
689        assert_eq!(router.detection_method(), DetectionMethod::Indicators);
690    }
691
692    #[test]
693    fn test_router_creation_hmm() {
694        let router = EnhancedRouter::with_hmm();
695        assert_eq!(router.detection_method(), DetectionMethod::HMM);
696    }
697
698    #[test]
699    fn test_method_switching() {
700        let indicator_router = EnhancedRouter::with_indicators();
701        let hmm_router = EnhancedRouter::with_hmm();
702        let ensemble_router = EnhancedRouter::with_ensemble();
703
704        assert_eq!(
705            indicator_router.detection_method(),
706            DetectionMethod::Indicators
707        );
708        assert_eq!(hmm_router.detection_method(), DetectionMethod::HMM);
709        assert_eq!(
710            ensemble_router.detection_method(),
711            DetectionMethod::Ensemble
712        );
713    }
714
715    #[test]
716    fn test_asset_registration() {
717        let mut router = EnhancedRouter::with_ensemble();
718        router.register_asset("BTC/USD");
719        router.register_asset("ETH/USD");
720
721        assert!(router.get_regime("BTC/USD").is_some());
722        assert!(router.get_regime("ETH/USD").is_some());
723        assert!(router.get_regime("SOL/USD").is_none());
724    }
725
726    #[test]
727    fn test_asset_unregistration() {
728        let mut router = EnhancedRouter::with_ensemble();
729        router.register_asset("BTC/USD");
730        assert!(router.get_regime("BTC/USD").is_some());
731
732        assert!(router.unregister_asset("BTC/USD"));
733        assert!(router.get_regime("BTC/USD").is_none());
734
735        // Unregistering non-existent asset returns false
736        assert!(!router.unregister_asset("BTC/USD"));
737    }
738
739    #[test]
740    fn test_auto_registration() {
741        let mut router = EnhancedRouter::with_indicators();
742
743        // Should auto-register on first update
744        assert!(router.get_regime("BTC/USD").is_none());
745        let signal = router.update("BTC/USD", 101.0, 99.0, 100.0);
746        assert!(signal.is_some());
747        assert!(router.get_regime("BTC/USD").is_some());
748    }
749
750    #[test]
751    fn test_duplicate_registration_noop() {
752        let mut router = EnhancedRouter::with_ensemble();
753        router.register_asset("BTC/USD");
754
755        // Feed some data
756        for i in 0..50 {
757            let price = 100.0 + i as f64;
758            router.update("BTC/USD", price + 1.0, price - 1.0, price);
759        }
760
761        let changes_before = router.regime_changes("BTC/USD");
762
763        // Re-registering should be a no-op
764        router.register_asset("BTC/USD");
765
766        let changes_after = router.regime_changes("BTC/USD");
767        assert_eq!(changes_before, changes_after);
768    }
769
770    #[test]
771    fn test_registered_assets() {
772        let mut router = EnhancedRouter::with_ensemble();
773        router.register_asset("BTC/USD");
774        router.register_asset("ETH/USD");
775        router.register_asset("SOL/USD");
776
777        let assets = router.registered_assets();
778        assert_eq!(assets.len(), 3);
779        assert!(assets.contains(&"BTC/USD"));
780        assert!(assets.contains(&"ETH/USD"));
781        assert!(assets.contains(&"SOL/USD"));
782    }
783
784    #[test]
785    fn test_initial_regime_is_uncertain() {
786        let mut router = EnhancedRouter::with_ensemble();
787        router.register_asset("BTC/USD");
788
789        assert_eq!(router.get_regime("BTC/USD"), Some(MarketRegime::Uncertain));
790        assert_eq!(
791            router.get_strategy("BTC/USD"),
792            Some(ActiveStrategy::NoTrade)
793        );
794    }
795
796    #[test]
797    fn test_not_ready_before_warmup() {
798        let mut router = EnhancedRouter::with_indicators();
799        router.register_asset("BTC/USD");
800
801        assert!(!router.is_ready("BTC/USD"));
802
803        // Feed a few bars — not enough for warmup
804        for i in 0..10 {
805            let price = 100.0 + i as f64;
806            router.update("BTC/USD", price + 1.0, price - 1.0, price);
807        }
808
809        assert!(!router.is_ready("BTC/USD"));
810    }
811
812    #[test]
813    fn test_is_ready_unknown_asset() {
814        let router = EnhancedRouter::with_ensemble();
815        assert!(!router.is_ready("UNKNOWN"));
816    }
817
818    #[test]
819    fn test_regime_changes_counted() {
820        let mut router = EnhancedRouter::new(EnhancedRouterConfig {
821            detection_method: DetectionMethod::Indicators,
822            log_changes: false, // Suppress output in tests
823            ..Default::default()
824        });
825
826        router.register_asset("BTC/USD");
827        assert_eq!(router.regime_changes("BTC/USD"), 0);
828
829        // Feed data — regime may or may not change depending on data
830        for i in 0..300 {
831            let price = 100.0 + i as f64 * 0.5;
832            router.update("BTC/USD", price + 1.0, price - 1.0, price);
833        }
834
835        // At minimum, the regime should have been set at least once
836        // (exact count depends on stability filter and data)
837        let changes = router.regime_changes("BTC/USD");
838        let _ = changes; // Just verify it doesn't panic
839    }
840
841    #[test]
842    fn test_routed_signal_fields() {
843        let mut router = EnhancedRouter::new(EnhancedRouterConfig {
844            detection_method: DetectionMethod::Indicators,
845            log_changes: false,
846            ..Default::default()
847        });
848
849        let signal = router.update("BTC/USD", 101.0, 99.0, 100.0);
850        assert!(signal.is_some());
851
852        let signal = signal.unwrap();
853        assert_eq!(signal.detection_method, DetectionMethod::Indicators);
854        assert!((0.0..=1.0).contains(&signal.confidence));
855        assert!((0.0..=1.0).contains(&signal.position_factor));
856        assert!(!signal.reason.is_empty());
857        // Indicator method doesn't populate these
858        assert!(signal.methods_agree.is_none());
859        assert!(signal.state_probabilities.is_none());
860        assert!(signal.expected_duration.is_none());
861    }
862
863    #[test]
864    fn test_routed_signal_display() {
865        let signal = RoutedSignal {
866            strategy: ActiveStrategy::TrendFollowing,
867            regime: MarketRegime::Trending(TrendDirection::Bullish),
868            confidence: 0.85,
869            position_factor: 1.0,
870            reason: "Bullish trend".to_string(),
871            detection_method: DetectionMethod::Ensemble,
872            methods_agree: Some(true),
873            state_probabilities: Some(vec![0.6, 0.2, 0.2]),
874            expected_duration: Some(15.0),
875            trend_direction: Some(TrendDirection::Bullish),
876        };
877
878        let display = format!("{signal}");
879        assert!(display.contains("Trend Following"));
880        assert!(display.contains("85%"));
881        assert!(display.contains("100%"));
882        assert!(display.contains("✓"));
883        assert!(display.contains("15 bars"));
884    }
885
886    #[test]
887    fn test_compute_strategy_low_confidence() {
888        let regime = RegimeConfidence::new(MarketRegime::Trending(TrendDirection::Bullish), 0.3);
889        let (strategy, factor, reason) = EnhancedRouter::compute_strategy(&regime, 0.5, 0.5);
890
891        assert_eq!(strategy, ActiveStrategy::NoTrade);
892        assert_eq!(factor, 0.0);
893        assert!(reason.contains("Confidence too low"));
894    }
895
896    #[test]
897    fn test_compute_strategy_trending() {
898        let regime = RegimeConfidence::with_metrics(
899            MarketRegime::Trending(TrendDirection::Bullish),
900            0.8,
901            30.0,
902            50.0,
903            0.7,
904        );
905        let (strategy, factor, reason) = EnhancedRouter::compute_strategy(&regime, 0.5, 0.5);
906
907        assert_eq!(strategy, ActiveStrategy::TrendFollowing);
908        assert_eq!(factor, 1.0);
909        assert!(reason.contains("Bullish"));
910    }
911
912    #[test]
913    fn test_compute_strategy_mean_reverting() {
914        let regime =
915            RegimeConfidence::with_metrics(MarketRegime::MeanReverting, 0.7, 15.0, 30.0, 0.2);
916        let (strategy, factor, reason) = EnhancedRouter::compute_strategy(&regime, 0.5, 0.5);
917
918        assert_eq!(strategy, ActiveStrategy::MeanReversion);
919        assert_eq!(factor, 1.0);
920        assert!(reason.contains("Mean-reverting"));
921    }
922
923    #[test]
924    fn test_compute_strategy_volatile() {
925        let regime = RegimeConfidence::with_metrics(MarketRegime::Volatile, 0.75, 22.0, 85.0, 0.3);
926        let (strategy, factor, reason) = EnhancedRouter::compute_strategy(&regime, 0.5, 0.4);
927
928        assert_eq!(strategy, ActiveStrategy::MeanReversion);
929        assert_eq!(factor, 0.4);
930        assert!(reason.contains("Volatile"));
931        assert!(reason.contains("40%"));
932    }
933
934    #[test]
935    fn test_compute_strategy_uncertain() {
936        let regime = RegimeConfidence::new(MarketRegime::Uncertain, 0.6);
937        let (strategy, factor, _) = EnhancedRouter::compute_strategy(&regime, 0.5, 0.5);
938
939        assert_eq!(strategy, ActiveStrategy::NoTrade);
940        assert_eq!(factor, 0.0);
941    }
942
943    #[test]
944    fn test_active_strategy_display() {
945        assert_eq!(
946            format!("{}", ActiveStrategy::TrendFollowing),
947            "Trend Following"
948        );
949        assert_eq!(
950            format!("{}", ActiveStrategy::MeanReversion),
951            "Mean Reversion"
952        );
953        assert_eq!(format!("{}", ActiveStrategy::NoTrade), "No Trade");
954    }
955
956    #[test]
957    fn test_detection_method_display() {
958        assert_eq!(format!("{}", DetectionMethod::Indicators), "Indicators");
959        assert_eq!(format!("{}", DetectionMethod::HMM), "HMM");
960        assert_eq!(format!("{}", DetectionMethod::Ensemble), "Ensemble");
961    }
962
963    #[test]
964    fn test_summary() {
965        let mut router = EnhancedRouter::new(EnhancedRouterConfig {
966            detection_method: DetectionMethod::Indicators,
967            log_changes: false,
968            ..Default::default()
969        });
970
971        router.register_asset("BTC/USD");
972        router.register_asset("ETH/USD");
973
974        let summary = router.summary();
975        assert_eq!(summary.len(), 2);
976
977        for s in &summary {
978            assert!(s.symbol == "BTC/USD" || s.symbol == "ETH/USD");
979            assert_eq!(s.regime, MarketRegime::Uncertain);
980            assert_eq!(s.strategy, ActiveStrategy::NoTrade);
981            assert_eq!(s.regime_changes, 0);
982        }
983    }
984
985    #[test]
986    fn test_asset_summary_display() {
987        let summary = AssetSummary {
988            symbol: "BTC/USD".to_string(),
989            regime: MarketRegime::Trending(TrendDirection::Bullish),
990            strategy: ActiveStrategy::TrendFollowing,
991            regime_changes: 3,
992            is_ready: true,
993        };
994
995        let display = format!("{summary}");
996        assert!(display.contains("BTC/USD"));
997        assert!(display.contains("Trending"));
998        assert!(display.contains("Trend Following"));
999    }
1000
1001    #[test]
1002    fn test_hmm_signal_has_state_probs() {
1003        let mut router = EnhancedRouter::new(EnhancedRouterConfig {
1004            detection_method: DetectionMethod::HMM,
1005            log_changes: false,
1006            ..Default::default()
1007        });
1008
1009        let signal = router.update("BTC/USD", 101.0, 99.0, 100.0);
1010        let signal = signal.unwrap();
1011
1012        assert!(signal.state_probabilities.is_some());
1013        let probs = signal.state_probabilities.unwrap();
1014        assert_eq!(probs.len(), 3);
1015
1016        let sum: f64 = probs.iter().sum();
1017        assert!(
1018            (sum - 1.0).abs() < 1e-6,
1019            "State probabilities should sum to 1.0"
1020        );
1021    }
1022
1023    #[test]
1024    fn test_ensemble_signal_has_agreement() {
1025        let mut router = EnhancedRouter::new(EnhancedRouterConfig {
1026            detection_method: DetectionMethod::Ensemble,
1027            log_changes: false,
1028            ..Default::default()
1029        });
1030
1031        let signal = router.update("BTC/USD", 101.0, 99.0, 100.0);
1032        let signal = signal.unwrap();
1033
1034        assert!(signal.methods_agree.is_some());
1035        assert!(signal.state_probabilities.is_some());
1036        assert!(signal.expected_duration.is_some());
1037    }
1038}