Skip to main content

hyper_strategy/
strategy_composer.rs

1use serde::{Deserialize, Serialize};
2
3use crate::rule_engine::evaluate_rules;
4use crate::strategy_templates::build_template;
5use hyper_risk::risk_defaults::{
6    apply_adx_filter, base_position_pct, volume_strength_modifier, VolumeContext,
7};
8use hyper_ta::technical_analysis::TechnicalIndicators;
9
10// ---------------------------------------------------------------------------
11// Market State
12// ---------------------------------------------------------------------------
13
14#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
15#[serde(rename_all = "snake_case")]
16pub enum TrendDirection {
17    Bullish,
18    Bearish,
19}
20
21#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
22#[serde(rename_all = "snake_case", tag = "type")]
23pub enum MarketState {
24    StrongTrend { direction: TrendDirection },
25    MildTrend { direction: TrendDirection },
26    Ranging,
27    SqueezeBuilding,
28    VolExpansion,
29    VolContraction,
30}
31
32impl std::fmt::Display for MarketState {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            MarketState::StrongTrend { direction } => {
36                write!(f, "StrongTrend ({:?})", direction)
37            }
38            MarketState::MildTrend { direction } => {
39                write!(f, "MildTrend ({:?})", direction)
40            }
41            MarketState::Ranging => write!(f, "Ranging"),
42            MarketState::SqueezeBuilding => write!(f, "SqueezeBuilding"),
43            MarketState::VolExpansion => write!(f, "VolExpansion"),
44            MarketState::VolContraction => write!(f, "VolContraction"),
45        }
46    }
47}
48
49// ---------------------------------------------------------------------------
50// Composer Profile
51// ---------------------------------------------------------------------------
52
53#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
54#[serde(rename_all = "snake_case")]
55pub enum ComposerProfile {
56    Conservative,
57    AllWeather,
58    TurtleSystem,
59}
60
61impl std::fmt::Display for ComposerProfile {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            ComposerProfile::Conservative => write!(f, "Conservative"),
65            ComposerProfile::AllWeather => write!(f, "AllWeather"),
66            ComposerProfile::TurtleSystem => write!(f, "TurtleSystem"),
67        }
68    }
69}
70
71// ---------------------------------------------------------------------------
72// Signal types
73// ---------------------------------------------------------------------------
74
75#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
76#[serde(rename_all = "snake_case")]
77pub enum SignalDirection {
78    Long,
79    Short,
80}
81
82#[derive(Serialize, Deserialize, Clone, Debug)]
83#[serde(rename_all = "camelCase")]
84pub struct ActiveStrategy {
85    pub template_id: String,
86    pub category: String,
87    pub weight: f64,
88    pub reason: String,
89}
90
91#[derive(Serialize, Deserialize, Clone, Debug)]
92#[serde(rename_all = "camelCase")]
93pub struct StrategySignal {
94    pub template_id: String,
95    pub direction: Option<SignalDirection>,
96    pub strength: f64,
97    pub triggered_rules: Vec<String>,
98}
99
100#[derive(Serialize, Deserialize, Clone, Debug)]
101#[serde(rename_all = "camelCase")]
102pub struct ComposedSignal {
103    pub market_state: MarketState,
104    pub active_strategies: Vec<ActiveStrategy>,
105    pub direction: Option<SignalDirection>,
106    pub aggregated_strength: f64,
107    pub suggested_exposure_pct: f64,
108    pub signals: Vec<StrategySignal>,
109    pub conflicts: Vec<String>,
110}
111
112// ---------------------------------------------------------------------------
113// StrategyComposer
114// ---------------------------------------------------------------------------
115
116#[derive(Serialize, Deserialize, Clone, Debug)]
117#[serde(rename_all = "camelCase")]
118pub struct StrategyComposer {
119    pub profile: ComposerProfile,
120    pub symbol: String,
121    pub max_exposure_pct: f64,
122}
123
124impl StrategyComposer {
125    pub fn new(profile: ComposerProfile, symbol: &str) -> Self {
126        Self {
127            profile,
128            symbol: symbol.to_string(),
129            max_exposure_pct: 100.0,
130        }
131    }
132
133    /// Detect current market state from indicators.
134    pub fn detect_market_state(indicators: &TechnicalIndicators) -> MarketState {
135        // Squeeze detection: BB inside KC
136        if let (Some(bb_upper), Some(bb_lower), Some(kc_upper), Some(kc_lower)) = (
137            indicators.bb_upper,
138            indicators.bb_lower,
139            indicators.kc_upper_20,
140            indicators.kc_lower_20,
141        ) {
142            if bb_upper < kc_upper && bb_lower > kc_lower {
143                return MarketState::SqueezeBuilding;
144            }
145        }
146
147        // Trend strength via ADX
148        if let Some(adx) = indicators.adx_14 {
149            if adx > 30.0 {
150                let direction = Self::detect_trend_direction(indicators);
151                return MarketState::StrongTrend { direction };
152            }
153            if adx >= 20.0 {
154                let direction = Self::detect_trend_direction(indicators);
155                return MarketState::MildTrend { direction };
156            }
157        }
158
159        // Volatility expansion / contraction
160        if let (Some(hv_20), Some(hv_60)) = (indicators.hv_20, indicators.hv_60) {
161            if hv_20 > hv_60 {
162                return MarketState::VolExpansion;
163            } else {
164                return MarketState::VolContraction;
165            }
166        }
167
168        // Default: Ranging (low ADX or missing data)
169        MarketState::Ranging
170    }
171
172    /// Determine trend direction from available indicators.
173    fn detect_trend_direction(indicators: &TechnicalIndicators) -> TrendDirection {
174        // Use SMA crossover as primary signal
175        if let (Some(sma20), Some(sma50)) = (indicators.sma_20, indicators.sma_50) {
176            return if sma20 > sma50 {
177                TrendDirection::Bullish
178            } else {
179                TrendDirection::Bearish
180            };
181        }
182        // Fallback: EMA
183        if let (Some(ema12), Some(ema26)) = (indicators.ema_12, indicators.ema_26) {
184            return if ema12 > ema26 {
185                TrendDirection::Bullish
186            } else {
187                TrendDirection::Bearish
188            };
189        }
190        // Fallback: SuperTrend direction
191        if let Some(st_dir) = indicators.supertrend_direction {
192            return if st_dir > 0.0 {
193                TrendDirection::Bullish
194            } else {
195                TrendDirection::Bearish
196            };
197        }
198        TrendDirection::Bullish // default when no data
199    }
200
201    /// Get active strategy template IDs for current market state.
202    pub fn active_strategies(&self, state: &MarketState) -> Vec<ActiveStrategy> {
203        match &self.profile {
204            ComposerProfile::Conservative => self.conservative_strategies(state),
205            ComposerProfile::AllWeather => self.all_weather_strategies(state),
206            ComposerProfile::TurtleSystem => self.turtle_strategies(state),
207        }
208    }
209
210    fn conservative_strategies(&self, state: &MarketState) -> Vec<ActiveStrategy> {
211        match state {
212            MarketState::StrongTrend { .. } => vec![
213                ActiveStrategy {
214                    template_id: "supertrend".into(),
215                    category: "trend_following".into(),
216                    weight: 1.0,
217                    reason: "Strong trend detected — SuperTrend for trend capture".into(),
218                },
219                ActiveStrategy {
220                    template_id: "adx_di_crossover".into(),
221                    category: "trend_following".into(),
222                    weight: 1.0,
223                    reason: "Strong trend detected — ADX/DI for directional confirmation".into(),
224                },
225            ],
226            MarketState::Ranging => vec![ActiveStrategy {
227                template_id: "bb_confirmed".into(),
228                category: "mean_reversion".into(),
229                weight: 1.0,
230                reason: "Ranging market — BB confirmed for mean reversion".into(),
231            }],
232            MarketState::SqueezeBuilding => vec![ActiveStrategy {
233                template_id: "keltner_squeeze".into(),
234                category: "volatility".into(),
235                weight: 1.0,
236                reason: "Squeeze building — Keltner squeeze for breakout".into(),
237            }],
238            MarketState::MildTrend { .. } => vec![ActiveStrategy {
239                template_id: "ma_crossover".into(),
240                category: "trend_following".into(),
241                weight: 0.7,
242                reason: "Mild trend — MA crossover with reduced weight".into(),
243            }],
244            _ => vec![], // VolExpansion, VolContraction — sit out
245        }
246    }
247
248    fn all_weather_strategies(&self, state: &MarketState) -> Vec<ActiveStrategy> {
249        let mut strategies = vec![
250            ActiveStrategy {
251                template_id: "cta_trend_following".into(),
252                category: "trend_following".into(),
253                weight: 1.0,
254                reason: "Always active — CTA trend following core".into(),
255            },
256            ActiveStrategy {
257                template_id: "confluence".into(),
258                category: "composite".into(),
259                weight: 0.5,
260                reason: "Always active — confluence triple-confirm filter".into(),
261            },
262        ];
263
264        match state {
265            MarketState::StrongTrend { .. } => {
266                strategies.push(ActiveStrategy {
267                    template_id: "macd_momentum".into(),
268                    category: "momentum".into(),
269                    weight: 0.8,
270                    reason: "Strong trend — MACD momentum boost".into(),
271                });
272            }
273            MarketState::Ranging => {
274                strategies.push(ActiveStrategy {
275                    template_id: "zscore_reversion".into(),
276                    category: "mean_reversion".into(),
277                    weight: 0.8,
278                    reason: "Ranging — z-score mean reversion".into(),
279                });
280                strategies.push(ActiveStrategy {
281                    template_id: "bb_confirmed".into(),
282                    category: "mean_reversion".into(),
283                    weight: 0.7,
284                    reason: "Ranging — BB confirmed mean reversion".into(),
285                });
286            }
287            MarketState::SqueezeBuilding => {
288                strategies.push(ActiveStrategy {
289                    template_id: "breakout_volume".into(),
290                    category: "volatility".into(),
291                    weight: 0.9,
292                    reason: "Squeeze building — volume breakout".into(),
293                });
294            }
295            MarketState::VolExpansion => {
296                strategies.push(ActiveStrategy {
297                    template_id: "atr_breakout".into(),
298                    category: "volatility".into(),
299                    weight: 0.8,
300                    reason: "Vol expansion — ATR breakout".into(),
301                });
302            }
303            _ => {}
304        }
305
306        strategies
307    }
308
309    fn turtle_strategies(&self, state: &MarketState) -> Vec<ActiveStrategy> {
310        let mut strategies = vec![ActiveStrategy {
311            template_id: "donchian_breakout".into(),
312            category: "trend_following".into(),
313            weight: 1.0,
314            reason: "Always active — Donchian breakout core".into(),
315        }];
316
317        if matches!(state, MarketState::StrongTrend { .. }) {
318            strategies.push(ActiveStrategy {
319                template_id: "supertrend".into(),
320                category: "trend_following".into(),
321                weight: 0.8,
322                reason: "Strong trend — SuperTrend confirmation".into(),
323            });
324        }
325
326        strategies
327    }
328
329    /// Evaluate all active strategies and aggregate signals.
330    pub fn compose_signals(
331        &self,
332        indicators: &TechnicalIndicators,
333        prev_indicators: Option<&TechnicalIndicators>,
334        volume_context: &VolumeContext,
335    ) -> ComposedSignal {
336        let market_state = Self::detect_market_state(indicators);
337        let active = self.active_strategies(&market_state);
338
339        let mut signals: Vec<StrategySignal> = Vec::new();
340        let mut long_count: usize = 0;
341        let mut short_count: usize = 0;
342        let mut long_strength_sum: f64 = 0.0;
343        let mut short_strength_sum: f64 = 0.0;
344        let mut conflicts: Vec<String> = Vec::new();
345        let mut dominant_category = String::new();
346        let mut max_weight: f64 = 0.0;
347
348        for active_strat in &active {
349            // Track dominant category (highest weight)
350            if active_strat.weight > max_weight {
351                max_weight = active_strat.weight;
352                dominant_category = active_strat.category.clone();
353            }
354
355            let group = match build_template(&active_strat.template_id, &self.symbol) {
356                Some(g) => g,
357                None => continue,
358            };
359
360            // Get rules from the default regime playbook
361            let playbook = match group.playbooks.get(&group.default_regime) {
362                Some(pb) => pb,
363                None => continue,
364            };
365
366            let evaluations = evaluate_rules(
367                playbook.effective_entry_rules(),
368                indicators,
369                prev_indicators,
370            );
371            let total_rules = playbook.effective_entry_rules().len();
372            let triggered: Vec<String> = evaluations
373                .iter()
374                .filter(|e| e.triggered)
375                .map(|e| e.signal.clone())
376                .collect();
377            let triggered_count = triggered.len();
378
379            // Classify direction from signal names
380            let (has_long, has_short) = classify_signals(&triggered);
381
382            let direction = if has_long && !has_short {
383                Some(SignalDirection::Long)
384            } else if has_short && !has_long {
385                Some(SignalDirection::Short)
386            } else {
387                None
388            };
389
390            // Per-strategy strength
391            let raw_strength = if total_rules > 0 {
392                (triggered_count as f64 / total_rules as f64) * active_strat.weight
393            } else {
394                0.0
395            };
396
397            // Apply ADX filter for mean_reversion strategies
398            let strength = if active_strat.category == "mean_reversion" {
399                apply_adx_filter(raw_strength, indicators.adx_14)
400            } else {
401                raw_strength
402            };
403
404            match &direction {
405                Some(SignalDirection::Long) => {
406                    long_count += 1;
407                    long_strength_sum += strength;
408                }
409                Some(SignalDirection::Short) => {
410                    short_count += 1;
411                    short_strength_sum += strength;
412                }
413                None => {
414                    if has_long && has_short {
415                        conflicts.push(format!(
416                            "{}: mixed long/short signals",
417                            active_strat.template_id
418                        ));
419                    }
420                }
421            }
422
423            signals.push(StrategySignal {
424                template_id: active_strat.template_id.clone(),
425                direction,
426                strength,
427                triggered_rules: triggered,
428            });
429        }
430
431        // Aggregate direction and strength
432        let (agg_direction, base_strength) = if long_count > 0 && short_count > 0 {
433            // Conflicting signals across strategies
434            conflicts.push(format!(
435                "Cross-strategy conflict: {} long vs {} short signals",
436                long_count, short_count
437            ));
438            (None, 0.0)
439        } else if long_count > 0 {
440            let avg = long_strength_sum / long_count as f64;
441            let modifier = count_modifier(long_count);
442            (Some(SignalDirection::Long), (avg * modifier).min(1.0))
443        } else if short_count > 0 {
444            let avg = short_strength_sum / short_count as f64;
445            let modifier = count_modifier(short_count);
446            (Some(SignalDirection::Short), (avg * modifier).min(1.0))
447        } else {
448            (None, 0.0)
449        };
450
451        // Apply volume modifier
452        let cat_for_volume = if dominant_category.is_empty() {
453            "unknown"
454        } else {
455            &dominant_category
456        };
457        let vol_mod = volume_strength_modifier(volume_context, cat_for_volume);
458        let aggregated_strength = (base_strength * vol_mod).min(1.0).max(0.0);
459
460        // Position sizing
461        let base_pct = base_position_pct(cat_for_volume);
462        let suggested_exposure_pct =
463            (base_pct * aggregated_strength * 100.0).min(self.max_exposure_pct);
464
465        ComposedSignal {
466            market_state,
467            active_strategies: active,
468            direction: agg_direction,
469            aggregated_strength,
470            suggested_exposure_pct,
471            signals,
472            conflicts,
473        }
474    }
475
476    /// Format a ComposedSignal for inclusion in a Claude prompt.
477    pub fn format_for_claude(&self, signal: &ComposedSignal) -> String {
478        let mut lines = Vec::new();
479
480        lines.push(format!("📊 Market State: {}", signal.market_state));
481        lines.push(format!("📋 Profile: {}", self.profile));
482
483        let active_names: Vec<&str> = signal
484            .active_strategies
485            .iter()
486            .map(|a| a.template_id.as_str())
487            .collect();
488        lines.push(format!("🎯 Active Strategies: {}", active_names.join(", ")));
489
490        match &signal.direction {
491            Some(SignalDirection::Long) => {
492                lines.push(format!(
493                    "📈 Signal: LONG (strength: {:.2})",
494                    signal.aggregated_strength
495                ));
496            }
497            Some(SignalDirection::Short) => {
498                lines.push(format!(
499                    "📉 Signal: SHORT (strength: {:.2})",
500                    signal.aggregated_strength
501                ));
502            }
503            None => {
504                if signal.conflicts.is_empty() {
505                    lines.push("⏸️ Signal: NONE (no triggers)".into());
506                } else {
507                    lines.push("⚠️ Signal: NONE (conflicting)".into());
508                }
509            }
510        }
511
512        lines.push(format!(
513            "💰 Suggested Exposure: {:.1}%",
514            signal.suggested_exposure_pct
515        ));
516
517        // Per-strategy breakdown
518        let has_triggered = signal.signals.iter().any(|s| !s.triggered_rules.is_empty());
519        if has_triggered {
520            lines.push("⚡ Triggered:".into());
521            for s in &signal.signals {
522                if !s.triggered_rules.is_empty() {
523                    let dir_str = match &s.direction {
524                        Some(SignalDirection::Long) => "LONG",
525                        Some(SignalDirection::Short) => "SHORT",
526                        None => "MIXED",
527                    };
528                    lines.push(format!(
529                        "  - {}: {} → {}",
530                        s.template_id,
531                        s.triggered_rules.join(" + "),
532                        dir_str
533                    ));
534                }
535            }
536        }
537
538        if !signal.conflicts.is_empty() {
539            lines.push("⚠️ Conflicts:".into());
540            for c in &signal.conflicts {
541                lines.push(format!("  - {}", c));
542            }
543        }
544
545        lines.join("\n")
546    }
547}
548
549// ---------------------------------------------------------------------------
550// Helpers
551// ---------------------------------------------------------------------------
552
553/// Classify signal names into long/short buckets.
554fn classify_signals(signals: &[String]) -> (bool, bool) {
555    let mut has_long = false;
556    let mut has_short = false;
557    for s in signals {
558        let lower = s.to_lowercase();
559        if lower.contains("long")
560            || lower.contains("bull")
561            || lower.contains("buy")
562            || lower.contains("golden")
563        {
564            has_long = true;
565        }
566        if lower.contains("short")
567            || lower.contains("bear")
568            || lower.contains("sell")
569            || lower.contains("death")
570        {
571            has_short = true;
572        }
573    }
574    (has_long, has_short)
575}
576
577/// Count modifier: more strategies confirming = stronger signal.
578fn count_modifier(count: usize) -> f64 {
579    match count {
580        0 => 0.0,
581        1 => 1.0,
582        2 => 1.3,
583        _ => 1.5,
584    }
585}
586
587// ---------------------------------------------------------------------------
588// Tests
589// ---------------------------------------------------------------------------
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    fn make_indicators(overrides: impl FnOnce(&mut TechnicalIndicators)) -> TechnicalIndicators {
596        let mut ind = TechnicalIndicators::empty();
597        overrides(&mut ind);
598        ind
599    }
600
601    // =======================================================================
602    // Market State Detection
603    // =======================================================================
604
605    #[test]
606    fn test_detect_strong_trend_bullish() {
607        let ind = make_indicators(|i| {
608            i.adx_14 = Some(35.0);
609            i.sma_20 = Some(110.0);
610            i.sma_50 = Some(100.0);
611        });
612        let state = StrategyComposer::detect_market_state(&ind);
613        assert_eq!(
614            state,
615            MarketState::StrongTrend {
616                direction: TrendDirection::Bullish
617            }
618        );
619    }
620
621    #[test]
622    fn test_detect_strong_trend_bearish() {
623        let ind = make_indicators(|i| {
624            i.adx_14 = Some(40.0);
625            i.sma_20 = Some(90.0);
626            i.sma_50 = Some(100.0);
627        });
628        let state = StrategyComposer::detect_market_state(&ind);
629        assert_eq!(
630            state,
631            MarketState::StrongTrend {
632                direction: TrendDirection::Bearish
633            }
634        );
635    }
636
637    #[test]
638    fn test_detect_mild_trend() {
639        let ind = make_indicators(|i| {
640            i.adx_14 = Some(25.0);
641            i.sma_20 = Some(105.0);
642            i.sma_50 = Some(100.0);
643        });
644        let state = StrategyComposer::detect_market_state(&ind);
645        assert_eq!(
646            state,
647            MarketState::MildTrend {
648                direction: TrendDirection::Bullish
649            }
650        );
651    }
652
653    #[test]
654    fn test_detect_ranging_low_adx() {
655        let ind = make_indicators(|i| {
656            i.adx_14 = Some(15.0);
657        });
658        let state = StrategyComposer::detect_market_state(&ind);
659        assert_eq!(state, MarketState::Ranging);
660    }
661
662    #[test]
663    fn test_detect_squeeze_building() {
664        let ind = make_indicators(|i| {
665            i.bb_upper = Some(105.0);
666            i.bb_lower = Some(95.0);
667            i.kc_upper_20 = Some(110.0);
668            i.kc_lower_20 = Some(90.0);
669            i.adx_14 = Some(35.0); // ADX high but squeeze takes priority
670            i.sma_20 = Some(100.0);
671            i.sma_50 = Some(100.0);
672        });
673        let state = StrategyComposer::detect_market_state(&ind);
674        assert_eq!(state, MarketState::SqueezeBuilding);
675    }
676
677    #[test]
678    fn test_detect_squeeze_not_building_bb_outside_kc() {
679        let ind = make_indicators(|i| {
680            i.bb_upper = Some(115.0); // BB wider than KC
681            i.bb_lower = Some(85.0);
682            i.kc_upper_20 = Some(110.0);
683            i.kc_lower_20 = Some(90.0);
684            i.adx_14 = Some(15.0);
685        });
686        let state = StrategyComposer::detect_market_state(&ind);
687        // Not squeeze, ADX < 20 and no HV -> Ranging
688        assert_eq!(state, MarketState::Ranging);
689    }
690
691    #[test]
692    fn test_detect_vol_expansion() {
693        let ind = make_indicators(|i| {
694            i.adx_14 = Some(15.0);
695            i.hv_20 = Some(0.5);
696            i.hv_60 = Some(0.3);
697        });
698        let state = StrategyComposer::detect_market_state(&ind);
699        assert_eq!(state, MarketState::VolExpansion);
700    }
701
702    #[test]
703    fn test_detect_vol_contraction() {
704        let ind = make_indicators(|i| {
705            i.adx_14 = Some(15.0);
706            i.hv_20 = Some(0.2);
707            i.hv_60 = Some(0.4);
708        });
709        let state = StrategyComposer::detect_market_state(&ind);
710        assert_eq!(state, MarketState::VolContraction);
711    }
712
713    #[test]
714    fn test_detect_ranging_no_data() {
715        let ind = TechnicalIndicators::empty();
716        let state = StrategyComposer::detect_market_state(&ind);
717        assert_eq!(state, MarketState::Ranging);
718    }
719
720    #[test]
721    fn test_detect_trend_direction_ema_fallback() {
722        let ind = make_indicators(|i| {
723            i.adx_14 = Some(35.0);
724            // No SMA, use EMA fallback
725            i.ema_12 = Some(90.0);
726            i.ema_26 = Some(100.0);
727        });
728        let state = StrategyComposer::detect_market_state(&ind);
729        assert_eq!(
730            state,
731            MarketState::StrongTrend {
732                direction: TrendDirection::Bearish
733            }
734        );
735    }
736
737    #[test]
738    fn test_detect_trend_direction_supertrend_fallback() {
739        let ind = make_indicators(|i| {
740            i.adx_14 = Some(35.0);
741            i.supertrend_direction = Some(1.0);
742        });
743        let state = StrategyComposer::detect_market_state(&ind);
744        assert_eq!(
745            state,
746            MarketState::StrongTrend {
747                direction: TrendDirection::Bullish
748            }
749        );
750    }
751
752    // =======================================================================
753    // Strategy Selection — Conservative
754    // =======================================================================
755
756    #[test]
757    fn test_conservative_strong_trend() {
758        let composer = StrategyComposer::new(ComposerProfile::Conservative, "BTC-USD");
759        let state = MarketState::StrongTrend {
760            direction: TrendDirection::Bullish,
761        };
762        let active = composer.active_strategies(&state);
763        assert_eq!(active.len(), 2);
764        assert_eq!(active[0].template_id, "supertrend");
765        assert_eq!(active[1].template_id, "adx_di_crossover");
766        assert_eq!(active[0].weight, 1.0);
767    }
768
769    #[test]
770    fn test_conservative_ranging() {
771        let composer = StrategyComposer::new(ComposerProfile::Conservative, "BTC-USD");
772        let active = composer.active_strategies(&MarketState::Ranging);
773        assert_eq!(active.len(), 1);
774        assert_eq!(active[0].template_id, "bb_confirmed");
775    }
776
777    #[test]
778    fn test_conservative_squeeze() {
779        let composer = StrategyComposer::new(ComposerProfile::Conservative, "BTC-USD");
780        let active = composer.active_strategies(&MarketState::SqueezeBuilding);
781        assert_eq!(active.len(), 1);
782        assert_eq!(active[0].template_id, "keltner_squeeze");
783    }
784
785    #[test]
786    fn test_conservative_mild_trend() {
787        let composer = StrategyComposer::new(ComposerProfile::Conservative, "BTC-USD");
788        let state = MarketState::MildTrend {
789            direction: TrendDirection::Bearish,
790        };
791        let active = composer.active_strategies(&state);
792        assert_eq!(active.len(), 1);
793        assert_eq!(active[0].template_id, "ma_crossover");
794        assert!((active[0].weight - 0.7).abs() < 1e-10);
795    }
796
797    #[test]
798    fn test_conservative_vol_expansion_sits_out() {
799        let composer = StrategyComposer::new(ComposerProfile::Conservative, "BTC-USD");
800        let active = composer.active_strategies(&MarketState::VolExpansion);
801        assert!(active.is_empty());
802    }
803
804    #[test]
805    fn test_conservative_vol_contraction_sits_out() {
806        let composer = StrategyComposer::new(ComposerProfile::Conservative, "BTC-USD");
807        let active = composer.active_strategies(&MarketState::VolContraction);
808        assert!(active.is_empty());
809    }
810
811    // =======================================================================
812    // Strategy Selection — AllWeather
813    // =======================================================================
814
815    #[test]
816    fn test_all_weather_always_has_core() {
817        let composer = StrategyComposer::new(ComposerProfile::AllWeather, "BTC-USD");
818        // Any state should have cta_trend_following and confluence
819        let states = vec![
820            MarketState::Ranging,
821            MarketState::VolExpansion,
822            MarketState::VolContraction,
823        ];
824        for state in &states {
825            let active = composer.active_strategies(state);
826            let ids: Vec<&str> = active.iter().map(|a| a.template_id.as_str()).collect();
827            assert!(
828                ids.contains(&"cta_trend_following"),
829                "Missing cta_trend_following for {:?}",
830                state
831            );
832            assert!(
833                ids.contains(&"confluence"),
834                "Missing confluence for {:?}",
835                state
836            );
837        }
838    }
839
840    #[test]
841    fn test_all_weather_strong_trend_adds_macd() {
842        let composer = StrategyComposer::new(ComposerProfile::AllWeather, "BTC-USD");
843        let state = MarketState::StrongTrend {
844            direction: TrendDirection::Bullish,
845        };
846        let active = composer.active_strategies(&state);
847        let ids: Vec<&str> = active.iter().map(|a| a.template_id.as_str()).collect();
848        assert!(ids.contains(&"macd_momentum"));
849    }
850
851    #[test]
852    fn test_all_weather_ranging_adds_reversion() {
853        let composer = StrategyComposer::new(ComposerProfile::AllWeather, "BTC-USD");
854        let active = composer.active_strategies(&MarketState::Ranging);
855        let ids: Vec<&str> = active.iter().map(|a| a.template_id.as_str()).collect();
856        assert!(ids.contains(&"zscore_reversion"));
857        assert!(ids.contains(&"bb_confirmed"));
858    }
859
860    #[test]
861    fn test_all_weather_squeeze_adds_breakout_volume() {
862        let composer = StrategyComposer::new(ComposerProfile::AllWeather, "BTC-USD");
863        let active = composer.active_strategies(&MarketState::SqueezeBuilding);
864        let ids: Vec<&str> = active.iter().map(|a| a.template_id.as_str()).collect();
865        assert!(ids.contains(&"breakout_volume"));
866    }
867
868    #[test]
869    fn test_all_weather_vol_expansion_adds_atr_breakout() {
870        let composer = StrategyComposer::new(ComposerProfile::AllWeather, "BTC-USD");
871        let active = composer.active_strategies(&MarketState::VolExpansion);
872        let ids: Vec<&str> = active.iter().map(|a| a.template_id.as_str()).collect();
873        assert!(ids.contains(&"atr_breakout"));
874    }
875
876    // =======================================================================
877    // Strategy Selection — Turtle
878    // =======================================================================
879
880    #[test]
881    fn test_turtle_always_has_donchian() {
882        let composer = StrategyComposer::new(ComposerProfile::TurtleSystem, "BTC-USD");
883        let active = composer.active_strategies(&MarketState::Ranging);
884        assert_eq!(active.len(), 1);
885        assert_eq!(active[0].template_id, "donchian_breakout");
886    }
887
888    #[test]
889    fn test_turtle_strong_trend_adds_supertrend() {
890        let composer = StrategyComposer::new(ComposerProfile::TurtleSystem, "BTC-USD");
891        let state = MarketState::StrongTrend {
892            direction: TrendDirection::Bearish,
893        };
894        let active = composer.active_strategies(&state);
895        assert_eq!(active.len(), 2);
896        assert_eq!(active[1].template_id, "supertrend");
897        assert!((active[1].weight - 0.8).abs() < 1e-10);
898    }
899
900    // =======================================================================
901    // Signal Classification
902    // =======================================================================
903
904    #[test]
905    fn test_classify_long_signals() {
906        let signals = vec!["supertrend_bullish".to_string(), "golden_cross".to_string()];
907        let (has_long, has_short) = classify_signals(&signals);
908        assert!(has_long);
909        assert!(!has_short);
910    }
911
912    #[test]
913    fn test_classify_short_signals() {
914        let signals = vec![
915            "supertrend_bearish".to_string(),
916            "death_cross".to_string(),
917            "sell_signal".to_string(),
918        ];
919        let (has_long, has_short) = classify_signals(&signals);
920        assert!(!has_long);
921        assert!(has_short);
922    }
923
924    #[test]
925    fn test_classify_mixed_signals() {
926        let signals = vec!["supertrend_bullish".to_string(), "macd_bearish".to_string()];
927        let (has_long, has_short) = classify_signals(&signals);
928        assert!(has_long);
929        assert!(has_short);
930    }
931
932    #[test]
933    fn test_classify_neutral_signals() {
934        let signals = vec!["some_neutral".to_string()];
935        let (has_long, has_short) = classify_signals(&signals);
936        assert!(!has_long);
937        assert!(!has_short);
938    }
939
940    #[test]
941    fn test_classify_empty() {
942        let (has_long, has_short) = classify_signals(&[]);
943        assert!(!has_long);
944        assert!(!has_short);
945    }
946
947    // =======================================================================
948    // Count Modifier
949    // =======================================================================
950
951    #[test]
952    fn test_count_modifier_values() {
953        assert_eq!(count_modifier(0), 0.0);
954        assert_eq!(count_modifier(1), 1.0);
955        assert_eq!(count_modifier(2), 1.3);
956        assert_eq!(count_modifier(3), 1.5);
957        assert_eq!(count_modifier(5), 1.5);
958    }
959
960    // =======================================================================
961    // Compose Signals — Integration
962    // =======================================================================
963
964    #[test]
965    fn test_compose_signals_no_data_returns_empty() {
966        let composer = StrategyComposer::new(ComposerProfile::Conservative, "BTC-USD");
967        let ind = TechnicalIndicators::empty();
968        let signal = composer.compose_signals(&ind, None, &VolumeContext::Normal);
969        // With empty indicators, market state = Ranging, bb_confirmed is active
970        // but rules won't trigger because indicators are missing
971        assert_eq!(signal.market_state, MarketState::Ranging);
972        assert_eq!(signal.aggregated_strength, 0.0);
973        assert_eq!(signal.suggested_exposure_pct, 0.0);
974    }
975
976    #[test]
977    fn test_compose_signals_conservative_sits_out_vol_expansion() {
978        let composer = StrategyComposer::new(ComposerProfile::Conservative, "BTC-USD");
979        let ind = make_indicators(|i| {
980            i.adx_14 = Some(15.0);
981            i.hv_20 = Some(0.5);
982            i.hv_60 = Some(0.3);
983        });
984        let signal = composer.compose_signals(&ind, None, &VolumeContext::Normal);
985        assert_eq!(signal.market_state, MarketState::VolExpansion);
986        assert!(signal.active_strategies.is_empty());
987        assert_eq!(signal.aggregated_strength, 0.0);
988    }
989
990    #[test]
991    fn test_compose_signals_exposure_capped_at_max() {
992        let mut composer = StrategyComposer::new(ComposerProfile::Conservative, "BTC-USD");
993        composer.max_exposure_pct = 10.0;
994        let ind = make_indicators(|i| {
995            i.adx_14 = Some(35.0);
996            i.sma_20 = Some(110.0);
997            i.sma_50 = Some(100.0);
998            i.supertrend_direction = Some(1.0);
999        });
1000        let signal = composer.compose_signals(&ind, None, &VolumeContext::Normal);
1001        assert!(signal.suggested_exposure_pct <= 10.0);
1002    }
1003
1004    #[test]
1005    fn test_compose_signals_turtle_strong_trend() {
1006        let composer = StrategyComposer::new(ComposerProfile::TurtleSystem, "BTC-USD");
1007        let ind = make_indicators(|i| {
1008            i.adx_14 = Some(35.0);
1009            i.sma_20 = Some(110.0);
1010            i.sma_50 = Some(100.0);
1011            // Supertrend bullish
1012            i.supertrend_direction = Some(1.0);
1013            i.supertrend_value = Some(95.0);
1014            // Donchian
1015            i.donchian_upper_20 = Some(120.0);
1016            i.donchian_lower_20 = Some(80.0);
1017            i.donchian_upper_10 = Some(115.0);
1018            i.donchian_lower_10 = Some(85.0);
1019        });
1020        let signal = composer.compose_signals(&ind, None, &VolumeContext::Normal);
1021        assert_eq!(
1022            signal.market_state,
1023            MarketState::StrongTrend {
1024                direction: TrendDirection::Bullish
1025            }
1026        );
1027        assert_eq!(signal.active_strategies.len(), 2);
1028    }
1029
1030    // =======================================================================
1031    // Format for Claude
1032    // =======================================================================
1033
1034    #[test]
1035    fn test_format_for_claude_contains_key_info() {
1036        let composer = StrategyComposer::new(ComposerProfile::AllWeather, "BTC-USD");
1037        let signal = ComposedSignal {
1038            market_state: MarketState::StrongTrend {
1039                direction: TrendDirection::Bullish,
1040            },
1041            active_strategies: vec![ActiveStrategy {
1042                template_id: "cta_trend_following".into(),
1043                category: "trend_following".into(),
1044                weight: 1.0,
1045                reason: "test".into(),
1046            }],
1047            direction: Some(SignalDirection::Long),
1048            aggregated_strength: 0.85,
1049            suggested_exposure_pct: 45.0,
1050            signals: vec![StrategySignal {
1051                template_id: "cta_trend_following".into(),
1052                direction: Some(SignalDirection::Long),
1053                strength: 0.85,
1054                triggered_rules: vec!["sma_bullish".into()],
1055            }],
1056            conflicts: vec![],
1057        };
1058        let output = composer.format_for_claude(&signal);
1059        assert!(output.contains("StrongTrend"));
1060        assert!(output.contains("AllWeather"));
1061        assert!(output.contains("LONG"));
1062        assert!(output.contains("0.85"));
1063        assert!(output.contains("45.0%"));
1064        assert!(output.contains("cta_trend_following"));
1065        assert!(output.contains("sma_bullish"));
1066    }
1067
1068    #[test]
1069    fn test_format_for_claude_no_signals() {
1070        let composer = StrategyComposer::new(ComposerProfile::Conservative, "BTC-USD");
1071        let signal = ComposedSignal {
1072            market_state: MarketState::Ranging,
1073            active_strategies: vec![],
1074            direction: None,
1075            aggregated_strength: 0.0,
1076            suggested_exposure_pct: 0.0,
1077            signals: vec![],
1078            conflicts: vec![],
1079        };
1080        let output = composer.format_for_claude(&signal);
1081        assert!(output.contains("NONE"));
1082        assert!(output.contains("no triggers"));
1083    }
1084
1085    #[test]
1086    fn test_format_for_claude_conflicts() {
1087        let composer = StrategyComposer::new(ComposerProfile::AllWeather, "BTC-USD");
1088        let signal = ComposedSignal {
1089            market_state: MarketState::Ranging,
1090            active_strategies: vec![],
1091            direction: None,
1092            aggregated_strength: 0.0,
1093            suggested_exposure_pct: 0.0,
1094            signals: vec![],
1095            conflicts: vec!["Cross-strategy conflict".into()],
1096        };
1097        let output = composer.format_for_claude(&signal);
1098        assert!(output.contains("conflicting"));
1099        assert!(output.contains("Cross-strategy conflict"));
1100    }
1101
1102    // =======================================================================
1103    // Serialization
1104    // =======================================================================
1105
1106    #[test]
1107    fn test_market_state_serialization_roundtrip() {
1108        let states = vec![
1109            MarketState::StrongTrend {
1110                direction: TrendDirection::Bullish,
1111            },
1112            MarketState::MildTrend {
1113                direction: TrendDirection::Bearish,
1114            },
1115            MarketState::Ranging,
1116            MarketState::SqueezeBuilding,
1117            MarketState::VolExpansion,
1118            MarketState::VolContraction,
1119        ];
1120        for state in &states {
1121            let json = serde_json::to_string(state).unwrap();
1122            let parsed: MarketState = serde_json::from_str(&json).unwrap();
1123            assert_eq!(*state, parsed);
1124        }
1125    }
1126
1127    #[test]
1128    fn test_composer_profile_serialization_roundtrip() {
1129        let profiles = vec![
1130            ComposerProfile::Conservative,
1131            ComposerProfile::AllWeather,
1132            ComposerProfile::TurtleSystem,
1133        ];
1134        for p in &profiles {
1135            let json = serde_json::to_string(p).unwrap();
1136            let parsed: ComposerProfile = serde_json::from_str(&json).unwrap();
1137            assert_eq!(*p, parsed);
1138        }
1139    }
1140
1141    #[test]
1142    fn test_signal_direction_serialization() {
1143        let long = SignalDirection::Long;
1144        let json = serde_json::to_string(&long).unwrap();
1145        let parsed: SignalDirection = serde_json::from_str(&json).unwrap();
1146        assert_eq!(parsed, SignalDirection::Long);
1147    }
1148
1149    #[test]
1150    fn test_composer_new_defaults() {
1151        let composer = StrategyComposer::new(ComposerProfile::AllWeather, "ETH-USD");
1152        assert_eq!(composer.symbol, "ETH-USD");
1153        assert_eq!(composer.max_exposure_pct, 100.0);
1154        assert_eq!(composer.profile, ComposerProfile::AllWeather);
1155    }
1156}