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#[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#[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#[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#[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 pub fn detect_market_state(indicators: &TechnicalIndicators) -> MarketState {
135 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 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 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 MarketState::Ranging
170 }
171
172 fn detect_trend_direction(indicators: &TechnicalIndicators) -> TrendDirection {
174 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 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 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 }
200
201 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![], }
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 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 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 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 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 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 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 let (agg_direction, base_strength) = if long_count > 0 && short_count > 0 {
433 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 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 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 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 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
549fn 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
577fn 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#[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 #[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); 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); 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 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 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 #[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 #[test]
816 fn test_all_weather_always_has_core() {
817 let composer = StrategyComposer::new(ComposerProfile::AllWeather, "BTC-USD");
818 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 #[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 #[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 #[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 #[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 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 i.supertrend_direction = Some(1.0);
1013 i.supertrend_value = Some(95.0);
1014 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 #[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 #[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}