Skip to main content

finance_query/backtesting/strategy/
ensemble.rs

1//! Ensemble strategy that combines multiple strategies with configurable voting modes.
2//!
3//! An ensemble aggregates signals from multiple sub-strategies and resolves them
4//! using one of four voting modes.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use finance_query::backtesting::{EnsembleStrategy, EnsembleMode, SmaCrossover, RsiReversal, MacdSignal};
10//!
11//! let strategy = EnsembleStrategy::new("Multi-Signal")
12//!     .add(SmaCrossover::new(10, 50), 1.0)
13//!     .add(RsiReversal::default(), 0.5)
14//!     .add(MacdSignal::default(), 1.0)
15//!     .mode(EnsembleMode::WeightedMajority)
16//!     .build();
17//! ```
18
19use crate::indicators::Indicator;
20
21use super::{Signal, Strategy, StrategyContext};
22use crate::backtesting::signal::{SignalDirection, SignalStrength};
23
24/// Voting mode that determines how sub-strategy signals are combined.
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub enum EnsembleMode {
27    /// All active sub-strategies must agree on the same direction ("no-dissent" semantics).
28    ///
29    /// Strategies that return `Hold` abstain from the vote and do not block a consensus.
30    /// If any two *active* strategies disagree, the ensemble returns Hold.
31    /// The resulting signal strength is the average of all active strengths.
32    ///
33    /// **Note**: if you require *all* strategies (including abstainers) to explicitly
34    /// vote for the same direction, check `active_count == strategies.len()` yourself
35    /// and wrap this in a custom [`Strategy`] impl.
36    Unanimous,
37
38    /// Conviction-weighted vote: each vote is `weight × signal_strength`.
39    ///
40    /// All five directions (`Long`, `Short`, `Exit`, `ScaleIn`, `ScaleOut`) are
41    /// tallied independently; the highest score wins.
42    ///
43    /// **Strength denominator**: the output `signal_strength` is
44    /// `winner_score / Σ(all_weights)`, not `winner_score / Σ(active_scores)`.
45    /// Dividing by total potential prevents a lone weak voter from being
46    /// artificially amplified when the majority of sub-strategies abstain.
47    ///
48    /// **Scale fraction**: when `ScaleIn` or `ScaleOut` wins, the emitted
49    /// `scale_fraction` is the conviction-weighted average of all same-direction
50    /// voters' fractions, not the single highest-score contributor's fraction.
51    ///
52    /// **Position guard**: `Exit`, `ScaleIn`, and `ScaleOut` are only tallied
53    /// when a position is currently open. While flat they are discarded so they
54    /// cannot suppress entry signal strength. Their weights still count toward
55    /// the total-potential denominator.
56    ///
57    /// **Note on vote splitting**: `Exit` and `Short` are counted as independent
58    /// factions — if their combined intent would dominate but each falls below
59    /// `Long` individually, the ensemble may maintain a long position despite the
60    /// majority wanting to exit. Document your ensemble weights accordingly.
61    #[default]
62    WeightedMajority,
63
64    /// First non-Hold signal wins (strategies are evaluated in insertion order).
65    ///
66    /// **Note**: this gives a permanent priority advantage to strategies added
67    /// first via [`add`](EnsembleStrategy::add). Use [`StrongestSignal`](Self::StrongestSignal)
68    /// if you want insertion-order independence.
69    AnySignal,
70
71    /// The non-Hold signal with the highest `signal_strength` value wins.
72    StrongestSignal,
73}
74
75/// A strategy that aggregates signals from multiple sub-strategies.
76///
77/// Build with the fluent builder methods [`add`](Self::add), [`mode`](Self::mode),
78/// then finalise with [`build`](Self::build).
79///
80/// All six [`SignalDirection`](crate::backtesting::SignalDirection) variants are
81/// fully supported. In [`EnsembleMode::WeightedMajority`], `ScaleIn` and `ScaleOut`
82/// participate in the vote with the same position guard as `Exit` — they are only
83/// tallied when a position is open. In `Unanimous`, `AnySignal`, and
84/// `StrongestSignal` modes they are treated like any other non-Hold direction.
85pub struct EnsembleStrategy {
86    name: String,
87    strategies: Vec<(Box<dyn Strategy>, f64)>,
88    mode: EnsembleMode,
89}
90
91impl EnsembleStrategy {
92    /// Create a new ensemble with the given name.
93    ///
94    /// The default voting mode is [`EnsembleMode::WeightedMajority`].
95    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    /// Add a sub-strategy with the given weight.
104    ///
105    /// Weight is only meaningful for [`EnsembleMode::WeightedMajority`]; other
106    /// modes ignore it. Negative weights are treated as zero.
107    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    /// Set the voting mode.
113    pub fn mode(mut self, mode: EnsembleMode) -> Self {
114        self.mode = mode;
115        self
116    }
117
118    /// Finalise the ensemble. Returns `self` (all configuration happens in the
119    /// builder methods).
120    pub fn build(self) -> Self {
121        self
122    }
123
124    // ── voting helpers ────────────────────────────────────────────────────────
125
126    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        // Evaluated iteratively — no allocations in the hot path.
138        // As soon as any two active sub-strategies disagree we bail out early.
139        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(), // disagreement — short-circuit
161            }
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        // Denominator = sum of ALL strategy weights (total potential conviction).
183        // Prevents a lone weak voter from being artificially amplified when
184        // the majority of sub-strategies abstain.
185        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        // Σ(scale_fraction × score) for conviction-weighted average fraction.
197        let mut scale_in_frac_score = 0.0_f64;
198        let mut scale_out_frac_score = 0.0_f64;
199
200        // Track (signal, score) so we inherit metadata from the highest-conviction
201        // contributor, not merely the first one encountered.
202        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            // Vote score = static weight × dynamic conviction
213            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                // Exit, ScaleIn, ScaleOut require an open position — while flat
228                // they are discarded so they cannot suppress entry signal strength.
229                // Their weights still count toward total_potential (denominator).
230                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        // At least one strategy must have cast a non-Hold vote.
257        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        // Determine winner (strict majority across all five directions; ties → Hold)
264        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        // Strength = winner score / total potential (not just active votes).
303        sig.strength = SignalStrength::clamped(winner_score / total_potential);
304
305        // Replace inherited scale_fraction with the conviction-weighted average
306        // of all same-direction voters to avoid single-contributor size bias.
307        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    // A strategy that always emits the given direction
427    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); // avg of 1.0 and 0.6
557    }
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        // strength = 2.0 / 3.0
614        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        // Exit votes should not suppress Long conviction when there is no position.
714        let c = candles();
715        let ind = empty_indicators();
716        let ctx = make_ctx(&c, &ind); // position = None
717
718        // Exit weight would dominate if counted (3.0 vs Long 2.0), but while flat
719        // it must be discarded and Long should win.
720        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        // strength = 2.0 / 5.0 = 0.4 (exit weight counts in denominator even when
741        // its vote is discarded, correctly suppressing overconfidence)
742        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        // ScaleIn has the highest conviction score (3.0) while Long has 2.0.
762        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        // strength = 3.0 / 5.0
783        assert!((signal.strength.value() - 0.6).abs() < 1e-9);
784        // scale_fraction is the conviction-weighted average of all ScaleIn voters
785        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        // Strategy A: ScaleOut 10% with score 1.0 × 1.0 = 1.0
806        // Strategy B: ScaleOut 50% with score 1.0 × 1.0 = 1.0
807        // Weighted-average fraction = (0.10 × 1.0 + 0.50 × 1.0) / 2.0 = 0.30
808        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); // position = None
840
841        // ScaleIn would dominate (3.0 vs Long 2.0) but must be ignored while flat.
842        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        // strength = 2.0 / 5.0 = 0.4 (scale_in weight counts in denominator even
863        // when its vote is discarded while flat)
864        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)), // duplicate
893                    ("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}