Skip to main content

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