1use crate::indicators::Indicator;
20
21use super::{Signal, Strategy, StrategyContext};
22use crate::backtesting::signal::{SignalDirection, SignalStrength};
23
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub enum EnsembleMode {
27 Unanimous,
37
38 #[default]
62 WeightedMajority,
63
64 AnySignal,
70
71 StrongestSignal,
73}
74
75pub struct EnsembleStrategy {
86 name: String,
87 strategies: Vec<(Box<dyn Strategy>, f64)>,
88 mode: EnsembleMode,
89}
90
91impl EnsembleStrategy {
92 pub fn new(name: impl Into<String>) -> Self {
96 Self {
97 name: name.into(),
98 strategies: Vec::new(),
99 mode: EnsembleMode::default(),
100 }
101 }
102
103 pub fn add<S: Strategy + 'static>(mut self, strategy: S, weight: f64) -> Self {
108 self.strategies.push((Box::new(strategy), weight.max(0.0)));
109 self
110 }
111
112 pub fn mode(mut self, mode: EnsembleMode) -> Self {
114 self.mode = mode;
115 self
116 }
117
118 pub fn build(self) -> Self {
121 self
122 }
123
124 fn any_signal(&self, ctx: &StrategyContext) -> Signal {
127 for (strategy, _) in &self.strategies {
128 let signal = strategy.on_candle(ctx);
129 if !signal.is_hold() {
130 return signal;
131 }
132 }
133 Signal::hold()
134 }
135
136 fn unanimous(&self, ctx: &StrategyContext) -> Signal {
137 let mut first_dir: Option<SignalDirection> = None;
140 let mut first_signal: Option<Signal> = None;
141 let mut total_strength = 0.0_f64;
142 let mut active_count = 0_usize;
143
144 for (strategy, _) in &self.strategies {
145 let signal = strategy.on_candle(ctx);
146 if signal.is_hold() {
147 continue;
148 }
149 match first_dir {
150 None => {
151 first_dir = Some(signal.direction);
152 total_strength = signal.strength.value();
153 first_signal = Some(signal);
154 active_count = 1;
155 }
156 Some(dir) if dir == signal.direction => {
157 total_strength += signal.strength.value();
158 active_count += 1;
159 }
160 _ => return Signal::hold(), }
162 }
163
164 let Some(mut sig) = first_signal else {
165 return Signal::hold();
166 };
167
168 let dir = first_dir.unwrap();
169 let avg_strength = total_strength / active_count as f64;
170 let original_reason = sig.reason.take();
171 sig.strength = SignalStrength::clamped(avg_strength);
172 sig.reason = Some(format!(
173 "Unanimous ({} of {} agree): {}",
174 active_count,
175 self.strategies.len(),
176 original_reason.as_deref().unwrap_or(&dir.to_string())
177 ));
178 sig
179 }
180
181 fn weighted_majority(&self, ctx: &StrategyContext) -> Signal {
182 let total_potential: f64 = self.strategies.iter().map(|(_, w)| *w).sum();
186 if total_potential < f64::EPSILON {
187 return Signal::hold();
188 }
189
190 let mut long_weight = 0.0_f64;
191 let mut short_weight = 0.0_f64;
192 let mut exit_weight = 0.0_f64;
193 let mut scale_in_weight = 0.0_f64;
194 let mut scale_out_weight = 0.0_f64;
195
196 let mut scale_in_frac_score = 0.0_f64;
198 let mut scale_out_frac_score = 0.0_f64;
199
200 let mut best_long: Option<(Signal, f64)> = None;
203 let mut best_short: Option<(Signal, f64)> = None;
204 let mut best_exit: Option<(Signal, f64)> = None;
205 let mut best_scale_in: Option<(Signal, f64)> = None;
206 let mut best_scale_out: Option<(Signal, f64)> = None;
207
208 let has_position = ctx.has_position();
209
210 for (strategy, weight) in &self.strategies {
211 let signal = strategy.on_candle(ctx);
212 let score = weight * signal.strength.value();
214 match signal.direction {
215 SignalDirection::Long => {
216 long_weight += score;
217 if best_long.as_ref().is_none_or(|&(_, s)| score > s) {
218 best_long = Some((signal, score));
219 }
220 }
221 SignalDirection::Short => {
222 short_weight += score;
223 if best_short.as_ref().is_none_or(|&(_, s)| score > s) {
224 best_short = Some((signal, score));
225 }
226 }
227 SignalDirection::Exit if has_position => {
231 exit_weight += score;
232 if best_exit.as_ref().is_none_or(|&(_, s)| score > s) {
233 best_exit = Some((signal, score));
234 }
235 }
236 SignalDirection::ScaleIn if has_position => {
237 let frac = signal.scale_fraction.unwrap_or(0.0);
238 scale_in_weight += score;
239 scale_in_frac_score += frac * score;
240 if best_scale_in.as_ref().is_none_or(|&(_, s)| score > s) {
241 best_scale_in = Some((signal, score));
242 }
243 }
244 SignalDirection::ScaleOut if has_position => {
245 let frac = signal.scale_fraction.unwrap_or(0.0);
246 scale_out_weight += score;
247 scale_out_frac_score += frac * score;
248 if best_scale_out.as_ref().is_none_or(|&(_, s)| score > s) {
249 best_scale_out = Some((signal, score));
250 }
251 }
252 _ => {}
253 }
254 }
255
256 let total_active =
258 long_weight + short_weight + exit_weight + scale_in_weight + scale_out_weight;
259 if total_active < f64::EPSILON {
260 return Signal::hold();
261 }
262
263 let (winner, winner_score) = if long_weight > short_weight
265 && long_weight > exit_weight
266 && long_weight > scale_in_weight
267 && long_weight > scale_out_weight
268 {
269 (best_long, long_weight)
270 } else if short_weight > long_weight
271 && short_weight > exit_weight
272 && short_weight > scale_in_weight
273 && short_weight > scale_out_weight
274 {
275 (best_short, short_weight)
276 } else if exit_weight > long_weight
277 && exit_weight > short_weight
278 && exit_weight > scale_in_weight
279 && exit_weight > scale_out_weight
280 {
281 (best_exit, exit_weight)
282 } else if scale_in_weight > long_weight
283 && scale_in_weight > short_weight
284 && scale_in_weight > exit_weight
285 && scale_in_weight > scale_out_weight
286 {
287 (best_scale_in, scale_in_weight)
288 } else if scale_out_weight > long_weight
289 && scale_out_weight > short_weight
290 && scale_out_weight > exit_weight
291 && scale_out_weight > scale_in_weight
292 {
293 (best_scale_out, scale_out_weight)
294 } else {
295 return Signal::hold();
296 };
297
298 let Some((mut sig, _)) = winner else {
299 return Signal::hold();
300 };
301
302 sig.strength = SignalStrength::clamped(winner_score / total_potential);
304
305 if sig.direction == SignalDirection::ScaleIn && scale_in_weight > f64::EPSILON {
308 sig.scale_fraction = Some((scale_in_frac_score / scale_in_weight).clamp(0.0, 1.0));
309 } else if sig.direction == SignalDirection::ScaleOut && scale_out_weight > f64::EPSILON {
310 sig.scale_fraction = Some((scale_out_frac_score / scale_out_weight).clamp(0.0, 1.0));
311 }
312
313 sig.reason = Some(format!(
314 "WeightedMajority: long={long_weight:.2} short={short_weight:.2} \
315 exit={exit_weight:.2} scale_in={scale_in_weight:.2} scale_out={scale_out_weight:.2}"
316 ));
317 sig
318 }
319
320 fn strongest_signal(&self, ctx: &StrategyContext) -> Signal {
321 self.strategies
322 .iter()
323 .map(|(s, _)| s.on_candle(ctx))
324 .filter(|s| !s.is_hold())
325 .max_by(|a, b| a.strength.value().total_cmp(&b.strength.value()))
326 .unwrap_or_else(Signal::hold)
327 }
328}
329
330impl std::fmt::Debug for EnsembleStrategy {
331 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332 f.debug_struct("EnsembleStrategy")
333 .field("name", &self.name)
334 .field("strategies_count", &self.strategies.len())
335 .field("mode", &self.mode)
336 .finish()
337 }
338}
339
340impl Strategy for EnsembleStrategy {
341 fn name(&self) -> &str {
342 &self.name
343 }
344
345 fn required_indicators(&self) -> Vec<(String, Indicator)> {
346 let mut indicators: Vec<(String, Indicator)> = self
347 .strategies
348 .iter()
349 .flat_map(|(s, _)| s.required_indicators())
350 .collect();
351 indicators.sort_by(|a, b| a.0.cmp(&b.0));
352 indicators.dedup_by(|a, b| a == b);
353 indicators
354 }
355
356 fn setup(&mut self, indicators: &std::collections::HashMap<String, Vec<Option<f64>>>) {
357 for (strategy, _) in &mut self.strategies {
358 strategy.setup(indicators);
359 }
360 }
361
362 fn warmup_period(&self) -> usize {
363 self.strategies
364 .iter()
365 .map(|(s, _)| s.warmup_period())
366 .max()
367 .unwrap_or(1)
368 }
369
370 fn tracks_position_extremes(&self) -> bool {
371 self.strategies
372 .iter()
373 .any(|(s, _)| s.tracks_position_extremes())
374 }
375
376 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
377 if self.strategies.is_empty() {
378 return Signal::hold();
379 }
380 match self.mode {
381 EnsembleMode::AnySignal => self.any_signal(ctx),
382 EnsembleMode::Unanimous => self.unanimous(ctx),
383 EnsembleMode::WeightedMajority => self.weighted_majority(ctx),
384 EnsembleMode::StrongestSignal => self.strongest_signal(ctx),
385 }
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::backtesting::signal::SignalDirection;
393 use crate::backtesting::strategy::Strategy;
394 use crate::indicators::Indicator;
395 use crate::models::chart::Candle;
396 use std::collections::HashMap;
397
398 fn make_candle(ts: i64, price: f64) -> Candle {
399 Candle {
400 timestamp: ts,
401 open: price,
402 high: price,
403 low: price,
404 close: price,
405 volume: 1000,
406 adj_close: None,
407 provider_id: None,
408 }
409 }
410
411 fn make_ctx<'a>(
412 candles: &'a [Candle],
413 indicators: &'a HashMap<String, Vec<Option<f64>>>,
414 ) -> StrategyContext<'a> {
415 StrategyContext {
416 candles,
417 index: candles.len() - 1,
418 position: None,
419 equity: 10_000.0,
420 indicators,
421 extremes: None,
422 indicator_index: None,
423 }
424 }
425
426 struct FixedStrategy {
428 direction: SignalDirection,
429 strength: f64,
430 }
431
432 impl Strategy for FixedStrategy {
433 fn name(&self) -> &str {
434 "Fixed"
435 }
436 fn required_indicators(&self) -> Vec<(String, Indicator)> {
437 vec![]
438 }
439 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
440 match self.direction {
441 SignalDirection::Long => {
442 let mut s = Signal::long(ctx.timestamp(), ctx.close());
443 s.strength = SignalStrength::clamped(self.strength);
444 s
445 }
446 SignalDirection::Short => {
447 let mut s = Signal::short(ctx.timestamp(), ctx.close());
448 s.strength = SignalStrength::clamped(self.strength);
449 s
450 }
451 SignalDirection::Exit => {
452 let mut s = Signal::exit(ctx.timestamp(), ctx.close());
453 s.strength = SignalStrength::clamped(self.strength);
454 s
455 }
456 SignalDirection::ScaleIn => {
457 let mut s = Signal::scale_in(0.1, ctx.timestamp(), ctx.close());
458 s.strength = SignalStrength::clamped(self.strength);
459 s
460 }
461 SignalDirection::ScaleOut => {
462 let mut s = Signal::scale_out(0.5, ctx.timestamp(), ctx.close());
463 s.strength = SignalStrength::clamped(self.strength);
464 s
465 }
466 _ => Signal::hold(),
467 }
468 }
469 }
470
471 fn make_ctx_with_position<'a>(
472 candles: &'a [Candle],
473 indicators: &'a HashMap<String, Vec<Option<f64>>>,
474 position: &'a crate::backtesting::Position,
475 ) -> StrategyContext<'a> {
476 StrategyContext {
477 candles,
478 index: candles.len() - 1,
479 position: Some(position),
480 equity: 10_000.0,
481 indicators,
482 extremes: None,
483 indicator_index: None,
484 }
485 }
486
487 fn candles() -> Vec<Candle> {
488 vec![make_candle(1, 100.0), make_candle(2, 101.0)]
489 }
490
491 fn empty_indicators() -> HashMap<String, Vec<Option<f64>>> {
492 HashMap::new()
493 }
494
495 #[test]
496 fn test_any_signal_returns_first_non_hold() {
497 let c = candles();
498 let ind = empty_indicators();
499 let ctx = make_ctx(&c, &ind);
500
501 let ensemble = EnsembleStrategy::new("test")
502 .add(
503 FixedStrategy {
504 direction: SignalDirection::Hold,
505 strength: 1.0,
506 },
507 1.0,
508 )
509 .add(
510 FixedStrategy {
511 direction: SignalDirection::Long,
512 strength: 0.8,
513 },
514 1.0,
515 )
516 .add(
517 FixedStrategy {
518 direction: SignalDirection::Short,
519 strength: 1.0,
520 },
521 1.0,
522 )
523 .mode(EnsembleMode::AnySignal)
524 .build();
525
526 let signal = ensemble.on_candle(&ctx);
527 assert_eq!(signal.direction, SignalDirection::Long);
528 }
529
530 #[test]
531 fn test_unanimous_all_agree() {
532 let c = candles();
533 let ind = empty_indicators();
534 let ctx = make_ctx(&c, &ind);
535
536 let ensemble = EnsembleStrategy::new("test")
537 .add(
538 FixedStrategy {
539 direction: SignalDirection::Long,
540 strength: 1.0,
541 },
542 1.0,
543 )
544 .add(
545 FixedStrategy {
546 direction: SignalDirection::Long,
547 strength: 0.6,
548 },
549 1.0,
550 )
551 .mode(EnsembleMode::Unanimous)
552 .build();
553
554 let signal = ensemble.on_candle(&ctx);
555 assert_eq!(signal.direction, SignalDirection::Long);
556 assert!((signal.strength.value() - 0.8).abs() < 1e-9); }
558
559 #[test]
560 fn test_unanimous_disagreement_returns_hold() {
561 let c = candles();
562 let ind = empty_indicators();
563 let ctx = make_ctx(&c, &ind);
564
565 let ensemble = EnsembleStrategy::new("test")
566 .add(
567 FixedStrategy {
568 direction: SignalDirection::Long,
569 strength: 1.0,
570 },
571 1.0,
572 )
573 .add(
574 FixedStrategy {
575 direction: SignalDirection::Short,
576 strength: 1.0,
577 },
578 1.0,
579 )
580 .mode(EnsembleMode::Unanimous)
581 .build();
582
583 let signal = ensemble.on_candle(&ctx);
584 assert!(signal.is_hold());
585 }
586
587 #[test]
588 fn test_weighted_majority_long_wins() {
589 let c = candles();
590 let ind = empty_indicators();
591 let ctx = make_ctx(&c, &ind);
592
593 let ensemble = EnsembleStrategy::new("test")
594 .add(
595 FixedStrategy {
596 direction: SignalDirection::Long,
597 strength: 1.0,
598 },
599 2.0,
600 )
601 .add(
602 FixedStrategy {
603 direction: SignalDirection::Short,
604 strength: 1.0,
605 },
606 1.0,
607 )
608 .mode(EnsembleMode::WeightedMajority)
609 .build();
610
611 let signal = ensemble.on_candle(&ctx);
612 assert_eq!(signal.direction, SignalDirection::Long);
613 assert!((signal.strength.value() - 2.0 / 3.0).abs() < 1e-9);
615 }
616
617 #[test]
618 fn test_weighted_majority_tie_returns_hold() {
619 let c = candles();
620 let ind = empty_indicators();
621 let ctx = make_ctx(&c, &ind);
622
623 let ensemble = EnsembleStrategy::new("test")
624 .add(
625 FixedStrategy {
626 direction: SignalDirection::Long,
627 strength: 1.0,
628 },
629 1.0,
630 )
631 .add(
632 FixedStrategy {
633 direction: SignalDirection::Short,
634 strength: 1.0,
635 },
636 1.0,
637 )
638 .mode(EnsembleMode::WeightedMajority)
639 .build();
640
641 let signal = ensemble.on_candle(&ctx);
642 assert!(signal.is_hold());
643 }
644
645 #[test]
646 fn test_strongest_signal() {
647 let c = candles();
648 let ind = empty_indicators();
649 let ctx = make_ctx(&c, &ind);
650
651 let ensemble = EnsembleStrategy::new("test")
652 .add(
653 FixedStrategy {
654 direction: SignalDirection::Long,
655 strength: 0.4,
656 },
657 1.0,
658 )
659 .add(
660 FixedStrategy {
661 direction: SignalDirection::Short,
662 strength: 0.9,
663 },
664 1.0,
665 )
666 .mode(EnsembleMode::StrongestSignal)
667 .build();
668
669 let signal = ensemble.on_candle(&ctx);
670 assert_eq!(signal.direction, SignalDirection::Short);
671 assert!((signal.strength.value() - 0.9).abs() < 1e-9);
672 }
673
674 #[test]
675 fn test_empty_ensemble_returns_hold() {
676 let c = candles();
677 let ind = empty_indicators();
678 let ctx = make_ctx(&c, &ind);
679
680 let ensemble = EnsembleStrategy::new("empty").build();
681 assert!(ensemble.on_candle(&ctx).is_hold());
682 }
683
684 #[test]
685 fn test_warmup_is_max_of_sub_strategies() {
686 struct WarmupStrategy(usize);
687 impl Strategy for WarmupStrategy {
688 fn name(&self) -> &str {
689 "Warmup"
690 }
691 fn required_indicators(&self) -> Vec<(String, Indicator)> {
692 vec![]
693 }
694 fn on_candle(&self, _ctx: &StrategyContext) -> Signal {
695 Signal::hold()
696 }
697 fn warmup_period(&self) -> usize {
698 self.0
699 }
700 }
701
702 let ensemble = EnsembleStrategy::new("test")
703 .add(WarmupStrategy(10), 1.0)
704 .add(WarmupStrategy(25), 1.0)
705 .add(WarmupStrategy(5), 1.0)
706 .build();
707
708 assert_eq!(ensemble.warmup_period(), 25);
709 }
710
711 #[test]
712 fn test_weighted_majority_exit_ignored_when_flat() {
713 let c = candles();
715 let ind = empty_indicators();
716 let ctx = make_ctx(&c, &ind); let ensemble = EnsembleStrategy::new("test")
721 .add(
722 FixedStrategy {
723 direction: SignalDirection::Long,
724 strength: 1.0,
725 },
726 2.0,
727 )
728 .add(
729 FixedStrategy {
730 direction: SignalDirection::Exit,
731 strength: 1.0,
732 },
733 3.0,
734 )
735 .mode(EnsembleMode::WeightedMajority)
736 .build();
737
738 let signal = ensemble.on_candle(&ctx);
739 assert_eq!(signal.direction, SignalDirection::Long);
740 assert!((signal.strength.value() - 2.0 / 5.0).abs() < 1e-9);
743 }
744
745 #[test]
746 fn test_weighted_majority_scale_in_wins_when_position_open() {
747 use crate::backtesting::{Position, PositionSide};
748
749 let c = candles();
750 let ind = empty_indicators();
751 let pos = Position::new(
752 PositionSide::Long,
753 1,
754 100.0,
755 10.0,
756 0.0,
757 Signal::long(1, 100.0),
758 );
759 let ctx = make_ctx_with_position(&c, &ind, &pos);
760
761 let ensemble = EnsembleStrategy::new("test")
763 .add(
764 FixedStrategy {
765 direction: SignalDirection::Long,
766 strength: 1.0,
767 },
768 2.0,
769 )
770 .add(
771 FixedStrategy {
772 direction: SignalDirection::ScaleIn,
773 strength: 1.0,
774 },
775 3.0,
776 )
777 .mode(EnsembleMode::WeightedMajority)
778 .build();
779
780 let signal = ensemble.on_candle(&ctx);
781 assert_eq!(signal.direction, SignalDirection::ScaleIn);
782 assert!((signal.strength.value() - 0.6).abs() < 1e-9);
784 let frac = signal.scale_fraction.expect("scale_fraction must be set");
786 assert!((frac - 0.1).abs() < 1e-9, "expected 0.10, got {frac}");
787 }
788
789 #[test]
790 fn test_weighted_majority_scale_fraction_is_conviction_weighted_average() {
791 use crate::backtesting::{Position, PositionSide};
792
793 let c = candles();
794 let ind = empty_indicators();
795 let pos = Position::new(
796 PositionSide::Long,
797 1,
798 100.0,
799 10.0,
800 0.0,
801 Signal::long(1, 100.0),
802 );
803 let ctx = make_ctx_with_position(&c, &ind, &pos);
804
805 struct ScaleOutStrategy {
809 fraction: f64,
810 }
811 impl Strategy for ScaleOutStrategy {
812 fn name(&self) -> &str {
813 "ScaleOut"
814 }
815 fn required_indicators(&self) -> Vec<(String, Indicator)> {
816 vec![]
817 }
818 fn on_candle(&self, ctx: &StrategyContext) -> Signal {
819 Signal::scale_out(self.fraction, ctx.timestamp(), ctx.close())
820 }
821 }
822
823 let ensemble = EnsembleStrategy::new("test")
824 .add(ScaleOutStrategy { fraction: 0.10 }, 1.0)
825 .add(ScaleOutStrategy { fraction: 0.50 }, 1.0)
826 .mode(EnsembleMode::WeightedMajority)
827 .build();
828
829 let signal = ensemble.on_candle(&ctx);
830 assert_eq!(signal.direction, SignalDirection::ScaleOut);
831 let frac = signal.scale_fraction.expect("scale_fraction must be set");
832 assert!((frac - 0.30).abs() < 1e-9, "expected 0.30, got {frac}");
833 }
834
835 #[test]
836 fn test_weighted_majority_scale_in_ignored_when_flat() {
837 let c = candles();
838 let ind = empty_indicators();
839 let ctx = make_ctx(&c, &ind); let ensemble = EnsembleStrategy::new("test")
843 .add(
844 FixedStrategy {
845 direction: SignalDirection::Long,
846 strength: 1.0,
847 },
848 2.0,
849 )
850 .add(
851 FixedStrategy {
852 direction: SignalDirection::ScaleIn,
853 strength: 1.0,
854 },
855 3.0,
856 )
857 .mode(EnsembleMode::WeightedMajority)
858 .build();
859
860 let signal = ensemble.on_candle(&ctx);
861 assert_eq!(signal.direction, SignalDirection::Long);
862 assert!((signal.strength.value() - 2.0 / 5.0).abs() < 1e-9);
865 }
866
867 #[test]
868 fn test_required_indicators_deduplication() {
869 struct IndStrategy(Vec<(String, Indicator)>);
870 impl Strategy for IndStrategy {
871 fn name(&self) -> &str {
872 "Ind"
873 }
874 fn required_indicators(&self) -> Vec<(String, Indicator)> {
875 self.0.clone()
876 }
877 fn on_candle(&self, _ctx: &StrategyContext) -> Signal {
878 Signal::hold()
879 }
880 }
881
882 let ensemble = EnsembleStrategy::new("test")
883 .add(
884 IndStrategy(vec![
885 ("sma_10".to_string(), Indicator::Sma(10)),
886 ("sma_20".to_string(), Indicator::Sma(20)),
887 ]),
888 1.0,
889 )
890 .add(
891 IndStrategy(vec![
892 ("sma_20".to_string(), Indicator::Sma(20)), ("rsi_14".to_string(), Indicator::Rsi(14)),
894 ]),
895 1.0,
896 )
897 .build();
898
899 let indicators = ensemble.required_indicators();
900 assert_eq!(indicators.len(), 3);
901 assert!(indicators.iter().any(|(k, _)| k == "sma_10"));
902 assert!(indicators.iter().any(|(k, _)| k == "sma_20"));
903 assert!(indicators.iter().any(|(k, _)| k == "rsi_14"));
904 }
905
906 #[test]
907 fn test_required_indicators_keeps_same_key_different_params() {
908 use crate::backtesting::strategy::MacdSignal;
909
910 let ensemble = EnsembleStrategy::new("test")
911 .add(MacdSignal::new(12, 26, 9), 1.0)
912 .add(MacdSignal::new(5, 35, 5), 1.0)
913 .build();
914
915 let indicators = ensemble.required_indicators();
916 let macd_indicators: Vec<&Indicator> = indicators
917 .iter()
918 .filter(|(k, _)| k == "macd")
919 .map(|(_, ind)| ind)
920 .collect();
921 assert_eq!(
922 macd_indicators.len(),
923 2,
924 "both MacdSignal variants must survive dedup: {macd_indicators:?}"
925 );
926 }
927}