Skip to main content

indicators/signal/
aggregator.rs

1//! Signal aggregator — combines all 11 indicator layers into a single trading signal.
2//!
3//! pure math, no exchange I/O.
4//!
5//! # Usage
6//! ```rust,ignore
7//! use indicators::{compute_signal, SignalStreak, IndicatorConfig};
8//!
9//! let cfg = IndicatorConfig::default();
10//! let mut streak = SignalStreak::new(cfg.signal_confirm_bars);
11//!
12//! // per-candle:
13//! let (raw, components) = compute_signal(close, &ind, &liq, &conf, &ms, &cfg, Some(&cvd), Some(&vol));
14//! if streak.update(raw) { /* confirmed signal */ }
15//! ```
16
17use std::collections::HashMap;
18
19use crate::error::IndicatorError;
20use crate::indicator::{Indicator, IndicatorOutput};
21use crate::indicator_config::IndicatorConfig;
22use crate::registry::param_usize;
23use crate::signal::confluence::{ConfluenceEngine, ConfluenceParams};
24use crate::signal::cvd::{CVDTracker, CvdParams};
25use crate::signal::engine::Indicators;
26use crate::signal::liquidity::{LiquidityParams, LiquidityProfile};
27use crate::signal::structure::{MarketStructure, StructureParams};
28use crate::signal::vol_regime::VolatilityPercentile;
29use crate::types::Candle;
30
31// ── Indicator wrapper ─────────────────────────────────────────────────────────
32
33/// Batch `Indicator` adapter for the full signal pipeline.
34///
35/// Constructs all sub-components with their default configurations, replays the
36/// candle slice through each, calls [`compute_signal`] per bar, and emits:
37/// - `signal`  — `+1` long, `-1` short, `0` neutral
38/// - `bull_score` / `bear_score` — raw confluence scores
39///
40/// For production use, prefer constructing the sub-components individually with
41/// tuned configs and calling `compute_signal` directly; this adapter is intended
42/// for backtesting and registry-driven workflows.
43#[derive(Debug, Clone)]
44pub struct SignalIndicator {
45    pub engine_cfg: IndicatorConfig,
46    pub conf_params: ConfluenceParams,
47    pub liq_params: LiquidityParams,
48    pub struct_params: StructureParams,
49    pub cvd_params: CvdParams,
50    pub signal_confirm_bars: usize,
51}
52
53impl SignalIndicator {
54    pub fn new(
55        engine_cfg: IndicatorConfig,
56        conf_params: ConfluenceParams,
57        liq_params: LiquidityParams,
58        struct_params: StructureParams,
59        cvd_params: CvdParams,
60        signal_confirm_bars: usize,
61    ) -> Self {
62        Self {
63            engine_cfg,
64            conf_params,
65            liq_params,
66            struct_params,
67            cvd_params,
68            signal_confirm_bars,
69        }
70    }
71    pub fn with_defaults() -> Self {
72        Self::new(
73            IndicatorConfig::default(),
74            ConfluenceParams::default(),
75            LiquidityParams::default(),
76            StructureParams::default(),
77            CvdParams::default(),
78            3,
79        )
80    }
81}
82
83impl Indicator for SignalIndicator {
84    fn name(&self) -> &'static str {
85        "Signal"
86    }
87    fn required_len(&self) -> usize {
88        // Engine training_period is the dominant warm-up requirement.
89        self.engine_cfg
90            .training_period
91            .max(self.conf_params.trend_len + 1)
92            .max(self.liq_params.period)
93            .max(self.cvd_params.div_lookback + 1)
94            .max(self.struct_params.swing_len * 4 + 10)
95    }
96    fn required_columns(&self) -> &[&'static str] {
97        &["open", "high", "low", "close", "volume"]
98    }
99
100    fn calculate(&self, candles: &[Candle]) -> Result<IndicatorOutput, IndicatorError> {
101        self.check_len(candles)?;
102        let cp = &self.conf_params;
103        let lp = &self.liq_params;
104        let sp = &self.struct_params;
105
106        let mut ind = Indicators::new(self.engine_cfg.clone());
107        let mut liq = LiquidityProfile::new(lp.period, lp.n_bins);
108        let mut conf = ConfluenceEngine::new(
109            cp.fast_len,
110            cp.slow_len,
111            cp.trend_len,
112            cp.rsi_len,
113            cp.adx_len,
114        );
115        let mut ms = MarketStructure::new(sp.swing_len, sp.atr_mult);
116        let mut cvd = CVDTracker::new(self.cvd_params.slope_bars, self.cvd_params.div_lookback);
117        let mut vol = VolatilityPercentile::new(100);
118        let mut streak = SignalStreak::new(self.signal_confirm_bars);
119
120        let n = candles.len();
121        let mut signal_out = vec![f64::NAN; n];
122        let mut bull_out = vec![f64::NAN; n];
123        let mut bear_out = vec![f64::NAN; n];
124
125        for (i, c) in candles.iter().enumerate() {
126            ind.update(c);
127            liq.update(c);
128            conf.update(c);
129            ms.update(c);
130            cvd.update(c);
131            vol.update(ind.atr);
132
133            let (raw, comps) = compute_signal(
134                c.close,
135                &ind,
136                &liq,
137                &conf,
138                &ms,
139                &self.engine_cfg,
140                Some(&cvd),
141                Some(&vol),
142            );
143            let confirmed = if streak.update(raw) { raw } else { 0 };
144            signal_out[i] = confirmed as f64;
145            bull_out[i] = comps.bull_score;
146            bear_out[i] = comps.bear_score;
147        }
148        Ok(IndicatorOutput::from_pairs([
149            ("signal", signal_out),
150            ("signal_bull_score", bull_out),
151            ("signal_bear_score", bear_out),
152        ]))
153    }
154}
155
156// ── Registry factory ──────────────────────────────────────────────────────────
157
158pub fn factory<S: ::std::hash::BuildHasher>(
159    params: &HashMap<String, String, S>,
160) -> Result<Box<dyn Indicator>, IndicatorError> {
161    let signal_confirm_bars = param_usize(params, "confirm_bars", 3)?;
162    Ok(Box::new(SignalIndicator::new(
163        IndicatorConfig::default(),
164        ConfluenceParams::default(),
165        LiquidityParams::default(),
166        StructureParams::default(),
167        CvdParams::default(),
168        signal_confirm_bars,
169    )))
170}
171
172// ── Signal components ─────────────────────────────────────────────────────────
173
174/// All per-layer votes and supporting diagnostic values.
175#[derive(Debug, Clone)]
176pub struct SignalComponents {
177    // Votes: +1 = bull, -1 = bear, 0 = neutral
178    pub v_vwap: i8,
179    pub v_ema: i8,
180    pub v_st: i8,
181    pub v_ts: i8,
182    pub v_liq: i8,
183    pub v_conf_bull: i8,
184    pub v_conf_bear: i8,
185    pub v_struct: i8,
186    pub v_cvd: i8,
187    pub v_ao: i8,
188    pub v_hurst: i8,
189    pub v_accel_bull: i8,
190    pub v_accel_bear: i8,
191    // Supporting values (for logging / debugging)
192    pub hurst: f64,
193    pub price_accel: f64,
194    pub bull_score: f64,
195    pub bear_score: f64,
196    pub conf_min_adj: f64,
197    pub liq_imbalance: f64,
198    pub liq_buy_pct: f64,
199    pub poc: Option<f64>,
200    pub struct_bias: i8,
201    pub fib618: Option<f64>,
202    pub fib_zone: &'static str,
203    pub fib_ok: bool,
204    pub bos: bool,
205    pub choch: bool,
206    pub ts_norm: f64,
207    pub dominance: f64,
208    pub cvd_slope: Option<f64>,
209    pub cvd_div: i8,
210    pub ao: f64,
211    pub ao_rising: bool,
212    pub wr_pct: f64,
213    pub mom_pct: f64,
214    pub wave_ok_long: bool,
215    pub wave_ok_short: bool,
216    pub mom_ok_long: bool,
217    pub mom_ok_short: bool,
218    pub vol_pct: Option<f64>,
219    pub vol_regime: Option<&'static str>,
220}
221
222// ── compute_signal ────────────────────────────────────────────────────────────
223
224/// Aggregate all layers into a single trading signal.
225///
226/// Returns `(signal, components)`:
227/// - `1`  → long
228/// - `-1` → short
229/// - `0`  → neutral / no trade
230pub fn compute_signal(
231    close: f64,
232    ind: &Indicators,
233    liq: &LiquidityProfile,
234    conf: &ConfluenceEngine,
235    ms: &MarketStructure,
236    cfg: &IndicatorConfig,
237    cvd: Option<&CVDTracker>,
238    vol: Option<&VolatilityPercentile>,
239) -> (i32, SignalComponents) {
240    let (Some(vwap), Some(ema), true) = (ind.vwap, ind.ema, ind.st.is_some()) else {
241        return (0, empty_components(ind, liq, conf, ms, cvd, vol));
242    };
243
244    // ── Layer votes ───────────────────────────────────────────────────────────
245    let v1 = if close > vwap { 1_i8 } else { -1 }; // L1 VWAP
246    let v2 = if close > ema { 1 } else { -1 }; // L2 EMA
247    let v3 = if ind.st_dir_pub == -1 { -1 } else { 1 }; // L3 SuperTrend
248    let v4 = if ind.ts_bullish { 1 } else { -1 }; // L4 TrendSpeed
249    let v5 = if liq.bullish() { 1 } else { -1 }; // L5 Liquidity
250
251    let conf_adj = vol.map_or(1.0, |v| v.conf_adj);
252    let adj_min = cfg.conf_min_score * conf_adj;
253    let v6_bull = if conf.bull_score >= adj_min { 1_i8 } else { -1 };
254    let v6_bear = if conf.bear_score >= adj_min { 1_i8 } else { -1 };
255
256    let v7 = ms.bias; // L7 Market Structure
257
258    let v8: i8 = cvd.map_or(0, |c| {
259        if c.divergence != 0 {
260            c.divergence
261        } else if c.bullish {
262            1
263        } else {
264            -1
265        }
266    }); // L8 CVD
267
268    let v9: i8 = if ind.highs.len() >= 34 {
269        if ind.ao_rising { 1 } else { -1 }
270    } else {
271        0
272    }; // L9 AO
273
274    let v10: i8 = if (ind.hurst - 0.5).abs() < 0.005 {
275        0
276    } else if ind.hurst >= cfg.hurst_threshold {
277        1
278    } else {
279        -1
280    }; // L10 Hurst
281
282    let (v11_bull, v11_bear): (i8, i8) = if ind.price_accel.abs() < 0.005 {
283        (0, 0)
284    } else {
285        (
286            if ind.price_accel > 0.0 { 1 } else { -1 },
287            if ind.price_accel < 0.0 { 1 } else { -1 },
288        )
289    }; // L11 PriceAccel
290
291    // Fibonacci zone gates
292    let fib_ok_long = !cfg.fib_zone_enabled || ms.in_discount || ms.fib500.is_none();
293    let fib_ok_short = !cfg.fib_zone_enabled || ms.in_premium || ms.fib500.is_none();
294
295    // ── Signal logic ──────────────────────────────────────────────────────────
296    let (bull, bear) = match cfg.signal_mode.as_str() {
297        "strict" => {
298            let bull = v1 == 1
299                && v2 == 1
300                && v3 == -1
301                && v4 == 1
302                && v5 == 1
303                && v6_bull == 1
304                && v7 == 1
305                && fib_ok_long
306                && (v8 == 1 || v8 == 0);
307            let bear = v1 == -1
308                && v2 == -1
309                && v3 == 1
310                && v4 == -1
311                && v5 == -1
312                && v6_bear == 1
313                && v7 == -1
314                && fib_ok_short
315                && (v8 == -1 || v8 == 0);
316            (bull, bear)
317        }
318        "majority" => {
319            let core_bull = v1 == 1 && v2 == 1 && v3 == -1 && v4 == 1;
320            let core_bear = v1 == -1 && v2 == -1 && v3 == 1 && v4 == -1;
321
322            let ext_bull = [
323                v5 == 1,
324                v6_bull == 1,
325                v7 == 1,
326                fib_ok_long,
327                v8 == 1,
328                v9 == 1,
329                ind.wave_ok_long,
330                ind.mom_ok_long,
331                v10 == 1,
332                v11_bull == 1,
333            ]
334            .iter()
335            .filter(|&&b| b)
336            .count();
337
338            let ext_bear = [
339                v5 == -1,
340                v6_bear == 1,
341                v7 == -1,
342                fib_ok_short,
343                v8 == -1,
344                v9 == -1,
345                ind.wave_ok_short,
346                ind.mom_ok_short,
347                v10 == 1,
348                v11_bear == 1,
349            ]
350            .iter()
351            .filter(|&&b| b)
352            .count();
353
354            (core_bull && ext_bull >= 2, core_bear && ext_bear >= 2)
355        }
356        _ => {
357            // "any" / default — core layers only
358            let bull = v1 == 1 && v2 == 1 && v3 == -1 && v4 == 1;
359            let bear = v1 == -1 && v2 == -1 && v3 == 1 && v4 == -1;
360            (bull, bear)
361        }
362    };
363
364    let fib_zone = if ms.in_discount {
365        "discount"
366    } else if ms.in_premium {
367        "premium"
368    } else {
369        "mid"
370    };
371
372    let comps = SignalComponents {
373        v_vwap: v1,
374        v_ema: v2,
375        v_st: v3,
376        v_ts: v4,
377        v_liq: v5,
378        v_conf_bull: v6_bull,
379        v_conf_bear: v6_bear,
380        v_struct: v7,
381        v_cvd: v8,
382        v_ao: v9,
383        v_hurst: v10,
384        v_accel_bull: v11_bull,
385        v_accel_bear: v11_bear,
386        hurst: ind.hurst,
387        price_accel: ind.price_accel,
388        bull_score: conf.bull_score,
389        bear_score: conf.bear_score,
390        conf_min_adj: adj_min,
391        liq_imbalance: liq.imbalance,
392        liq_buy_pct: liq.buy_pct * 100.0,
393        poc: liq.poc_price,
394        struct_bias: ms.bias,
395        fib618: ms.fib618,
396        fib_zone,
397        fib_ok: if bull { fib_ok_long } else { fib_ok_short },
398        bos: ms.bos,
399        choch: ms.choch,
400        ts_norm: ind.ts_norm,
401        dominance: ind.dominance,
402        cvd_slope: cvd.map(|c| c.cvd_slope),
403        cvd_div: cvd.map_or(0, |c| c.divergence),
404        ao: ind.ao,
405        ao_rising: ind.ao_rising,
406        wr_pct: ind.wr_pct,
407        mom_pct: ind.mom_pct,
408        wave_ok_long: ind.wave_ok_long,
409        wave_ok_short: ind.wave_ok_short,
410        mom_ok_long: ind.mom_ok_long,
411        mom_ok_short: ind.mom_ok_short,
412        vol_pct: vol.map(|v| v.vol_pct),
413        vol_regime: vol.map(|v| v.vol_regime),
414    };
415
416    if bull {
417        return (1, comps);
418    }
419    if bear {
420        return (-1, comps);
421    }
422    (0, comps)
423}
424
425fn empty_components(
426    ind: &Indicators,
427    liq: &LiquidityProfile,
428    conf: &ConfluenceEngine,
429    ms: &MarketStructure,
430    cvd: Option<&CVDTracker>,
431    vol: Option<&VolatilityPercentile>,
432) -> SignalComponents {
433    SignalComponents {
434        v_vwap: 0,
435        v_ema: 0,
436        v_st: 0,
437        v_ts: 0,
438        v_liq: 0,
439        v_conf_bull: 0,
440        v_conf_bear: 0,
441        v_struct: 0,
442        v_cvd: 0,
443        v_ao: 0,
444        v_hurst: 0,
445        v_accel_bull: 0,
446        v_accel_bear: 0,
447        hurst: ind.hurst,
448        price_accel: ind.price_accel,
449        bull_score: conf.bull_score,
450        bear_score: conf.bear_score,
451        conf_min_adj: 0.0,
452        liq_imbalance: liq.imbalance,
453        liq_buy_pct: liq.buy_pct * 100.0,
454        poc: liq.poc_price,
455        struct_bias: ms.bias,
456        fib618: ms.fib618,
457        fib_zone: "mid",
458        fib_ok: false,
459        bos: false,
460        choch: false,
461        ts_norm: 0.5,
462        dominance: 0.0,
463        cvd_slope: cvd.map(|c| c.cvd_slope),
464        cvd_div: 0,
465        ao: ind.ao,
466        ao_rising: false,
467        wr_pct: 0.5,
468        mom_pct: 0.5,
469        wave_ok_long: false,
470        wave_ok_short: false,
471        mom_ok_long: false,
472        mom_ok_short: false,
473        vol_pct: vol.map(|v| v.vol_pct),
474        vol_regime: vol.map(|v| v.vol_regime),
475    }
476}
477
478// ── SignalStreak ──────────────────────────────────────────────────────────────
479
480/// Confirmation filter — signal must agree for `required` consecutive bars.
481///
482/// `update()` returns `true` only when the streak reaches `required` and the
483/// signal is non-zero.
484pub struct SignalStreak {
485    required: usize,
486    direction: i32,
487    count: usize,
488}
489
490impl SignalStreak {
491    pub fn new(required: usize) -> Self {
492        Self {
493            required,
494            direction: 0,
495            count: 0,
496        }
497    }
498
499    /// Feed a raw signal (`+1`, `-1`, or `0`).
500    /// Returns `true` when streak reaches `required` and signal is non-zero.
501    pub fn update(&mut self, signal: i32) -> bool {
502        if signal != 0 && signal == self.direction {
503            self.count += 1;
504        } else {
505            self.direction = signal;
506            self.count = usize::from(signal != 0);
507        }
508        self.count >= self.required && signal != 0
509    }
510
511    pub fn reset(&mut self) {
512        self.direction = 0;
513        self.count = 0;
514    }
515    pub fn current_direction(&self) -> i32 {
516        self.direction
517    }
518    pub fn current_count(&self) -> usize {
519        self.count
520    }
521}
522
523// ── Tests ─────────────────────────────────────────────────────────────────────
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    // ── engine_cfg honored by the batch adapter ───────────────────────────────
530
531    /// Smooth uptrend with rising volume — enough bars to clear the 100-bar
532    /// engine warm-up so `compute_signal` actually emits non-neutral votes.
533    fn rising_candles(n: usize, base: f64) -> Vec<Candle> {
534        (0..n)
535            .map(|i| {
536                let c = base + i as f64 * 0.5;
537                Candle {
538                    time: i64::try_from(i).unwrap() * 60_000, // same UTC day
539                    open: c - 0.2,
540                    high: c + 0.3,
541                    low: c - 0.3,
542                    close: c,
543                    volume: 1_000.0 + (i % 10) as f64 * 50.0,
544                }
545            })
546            .collect()
547    }
548
549    fn signal_indicator_with(cfg: IndicatorConfig) -> SignalIndicator {
550        SignalIndicator {
551            engine_cfg: cfg,
552            conf_params: ConfluenceParams::default(),
553            liq_params: LiquidityParams::default(),
554            struct_params: StructureParams::default(),
555            cvd_params: CvdParams::default(),
556            signal_confirm_bars: 1,
557        }
558    }
559
560    /// Regression test for the engine_cfg-ignored bug: `calculate` used to pass
561    /// a fresh `IndicatorConfig::default()` to `compute_signal`, so tuning
562    /// `engine_cfg.signal_mode` had zero effect on the emitted column. Here the
563    /// default (`"majority"`) and a tuned (`"strict"`) config must produce a
564    /// *different* signal column on the same candles. Before the fix the two
565    /// columns were byte-for-byte identical and this assertion failed.
566    #[test]
567    fn calculate_honors_engine_cfg_signal_mode() {
568        let candles = rising_candles(200, 50.0);
569
570        let default_cfg = IndicatorConfig {
571            signal_confirm_bars: 1,
572            ..IndicatorConfig::default()
573        };
574        assert_eq!(
575            default_cfg.signal_mode, "majority",
576            "precondition: default mode is majority"
577        );
578
579        let strict_cfg = IndicatorConfig {
580            signal_mode: "strict".into(),
581            signal_confirm_bars: 1,
582            ..IndicatorConfig::default()
583        };
584
585        let majority = signal_indicator_with(default_cfg)
586            .calculate(&candles)
587            .unwrap();
588        let strict = signal_indicator_with(strict_cfg)
589            .calculate(&candles)
590            .unwrap();
591
592        let maj_sig = majority.get("signal").unwrap();
593        let strict_sig = strict.get("signal").unwrap();
594
595        // Lenient "majority" fires longs on this uptrend; "strict" demands every
596        // layer align and fires far fewer (or none) — so the columns differ.
597        let differs = maj_sig
598            .iter()
599            .zip(strict_sig.iter())
600            .any(|(a, b)| (a - b).abs() > f64::EPSILON);
601        assert!(
602            differs,
603            "engine_cfg.signal_mode was ignored: majority and strict produced \
604             identical signal columns (the pre-fix bug)"
605        );
606    }
607
608    /// Stronger, count-based companion to the test above: on a clean uptrend
609    /// the lenient `"any"` mode fires many longs while the all-layers-must-agree
610    /// `"strict"` mode fires none. The adapter can only honour that contrast if
611    /// it reads `engine_cfg.signal_mode` (the pre-fix code passed a hard-coded
612    /// default to `compute_signal`, so both modes returned the same column).
613    #[test]
614    fn calculate_strict_mode_suppresses_longs_that_any_mode_fires() {
615        let candles = rising_candles(200, 50.0);
616
617        let count_longs = |mode: &str| -> usize {
618            let out = signal_indicator_with(IndicatorConfig {
619                signal_mode: mode.into(),
620                signal_confirm_bars: 1,
621                ..IndicatorConfig::default()
622            })
623            .calculate(&candles)
624            .unwrap();
625            out.get("signal")
626                .unwrap()
627                .iter()
628                .filter(|&&v| (v - 1.0).abs() < f64::EPSILON)
629                .count()
630        };
631
632        let any_longs = count_longs("any");
633        let strict_longs = count_longs("strict");
634
635        assert!(
636            any_longs > 0,
637            "expected 'any' mode to fire longs on a 200-bar uptrend, got {any_longs}"
638        );
639        assert!(
640            strict_longs < any_longs,
641            "expected 'strict' mode ({strict_longs}) to fire fewer longs than \
642             'any' mode ({any_longs}); engine_cfg.signal_mode appears ignored"
643        );
644    }
645
646    // ── SignalStreak tests ────────────────────────────────────────────────────
647
648    #[test]
649    fn streak_fires_after_required_consecutive() {
650        let mut s = SignalStreak::new(3);
651        assert!(!s.update(1));
652        assert!(!s.update(1));
653        assert!(s.update(1)); // third consecutive → fires
654        assert!(s.update(1)); // keeps firing
655    }
656
657    #[test]
658    fn streak_resets_on_direction_change() {
659        let mut s = SignalStreak::new(2);
660        assert!(!s.update(1));
661        assert!(s.update(1)); // fires
662        assert!(!s.update(-1)); // direction changed → resets
663        assert!(s.update(-1)); // fires again
664    }
665
666    #[test]
667    fn streak_zero_signal_breaks_streak() {
668        let mut s = SignalStreak::new(2);
669        s.update(1);
670        s.update(0); // zero resets
671        assert!(!s.update(1)); // back to count=1
672    }
673
674    #[test]
675    fn streak_required_1_fires_immediately() {
676        let mut s = SignalStreak::new(1);
677        assert!(s.update(1));
678        assert!(s.update(-1));
679        assert!(!s.update(0));
680    }
681
682    #[test]
683    fn streak_tracks_direction_and_count() {
684        let mut s = SignalStreak::new(3);
685        s.update(1);
686        s.update(1);
687        assert_eq!(s.current_direction(), 1);
688        assert_eq!(s.current_count(), 2);
689        s.update(-1);
690        assert_eq!(s.current_direction(), -1);
691        assert_eq!(s.current_count(), 1);
692    }
693
694    #[test]
695    fn streak_reset_clears_state() {
696        let mut s = SignalStreak::new(2);
697        s.update(1);
698        s.update(1);
699        s.reset();
700        assert_eq!(s.current_count(), 0);
701        assert_eq!(s.current_direction(), 0);
702        assert!(!s.update(1)); // must rebuild from zero
703    }
704}