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