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 cfg = IndicatorConfig::default();
119        let mut streak = SignalStreak::new(self.signal_confirm_bars);
120
121        let n = candles.len();
122        let mut signal_out = vec![f64::NAN; n];
123        let mut bull_out = vec![f64::NAN; n];
124        let mut bear_out = vec![f64::NAN; n];
125
126        for (i, c) in candles.iter().enumerate() {
127            ind.update(c);
128            liq.update(c);
129            conf.update(c);
130            ms.update(c);
131            cvd.update(c);
132            vol.update(ind.atr);
133
134            let (raw, comps) = compute_signal(
135                c.close,
136                &ind,
137                &liq,
138                &conf,
139                &ms,
140                &cfg,
141                Some(&cvd),
142                Some(&vol),
143            );
144            let confirmed = if streak.update(raw) { raw } else { 0 };
145            signal_out[i] = confirmed as f64;
146            bull_out[i] = comps.bull_score;
147            bear_out[i] = comps.bear_score;
148        }
149        Ok(IndicatorOutput::from_pairs([
150            ("signal", signal_out),
151            ("signal_bull_score", bull_out),
152            ("signal_bear_score", bear_out),
153        ]))
154    }
155}
156
157// ── Registry factory ──────────────────────────────────────────────────────────
158
159pub fn factory<S: ::std::hash::BuildHasher>(
160    params: &HashMap<String, String, S>,
161) -> Result<Box<dyn Indicator>, IndicatorError> {
162    let signal_confirm_bars = param_usize(params, "confirm_bars", 3)?;
163    Ok(Box::new(SignalIndicator::new(
164        IndicatorConfig::default(),
165        ConfluenceParams::default(),
166        LiquidityParams::default(),
167        StructureParams::default(),
168        CvdParams::default(),
169        signal_confirm_bars,
170    )))
171}
172
173// ── Signal components ─────────────────────────────────────────────────────────
174
175/// All per-layer votes and supporting diagnostic values.
176#[derive(Debug, Clone)]
177pub struct SignalComponents {
178    // Votes: +1 = bull, -1 = bear, 0 = neutral
179    pub v_vwap: i8,
180    pub v_ema: i8,
181    pub v_st: i8,
182    pub v_ts: i8,
183    pub v_liq: i8,
184    pub v_conf_bull: i8,
185    pub v_conf_bear: i8,
186    pub v_struct: i8,
187    pub v_cvd: i8,
188    pub v_ao: i8,
189    pub v_hurst: i8,
190    pub v_accel_bull: i8,
191    pub v_accel_bear: i8,
192    // Supporting values (for logging / debugging)
193    pub hurst: f64,
194    pub price_accel: f64,
195    pub bull_score: f64,
196    pub bear_score: f64,
197    pub conf_min_adj: f64,
198    pub liq_imbalance: f64,
199    pub liq_buy_pct: f64,
200    pub poc: Option<f64>,
201    pub struct_bias: i8,
202    pub fib618: Option<f64>,
203    pub fib_zone: &'static str,
204    pub fib_ok: bool,
205    pub bos: bool,
206    pub choch: bool,
207    pub ts_norm: f64,
208    pub dominance: f64,
209    pub cvd_slope: Option<f64>,
210    pub cvd_div: i8,
211    pub ao: f64,
212    pub ao_rising: bool,
213    pub wr_pct: f64,
214    pub mom_pct: f64,
215    pub wave_ok_long: bool,
216    pub wave_ok_short: bool,
217    pub mom_ok_long: bool,
218    pub mom_ok_short: bool,
219    pub vol_pct: Option<f64>,
220    pub vol_regime: Option<&'static str>,
221}
222
223// ── compute_signal ────────────────────────────────────────────────────────────
224
225/// Aggregate all layers into a single trading signal.
226///
227/// Returns `(signal, components)`:
228/// - `1`  → long
229/// - `-1` → short
230/// - `0`  → neutral / no trade
231pub fn compute_signal(
232    close: f64,
233    ind: &Indicators,
234    liq: &LiquidityProfile,
235    conf: &ConfluenceEngine,
236    ms: &MarketStructure,
237    cfg: &IndicatorConfig,
238    cvd: Option<&CVDTracker>,
239    vol: Option<&VolatilityPercentile>,
240) -> (i32, SignalComponents) {
241    if ind.vwap.is_none() || ind.ema.is_none() || ind.st.is_none() {
242        return (0, empty_components(ind, liq, conf, ms, cvd, vol));
243    }
244
245    let vwap = ind.vwap.unwrap();
246    let ema = ind.ema.unwrap();
247
248    // ── Layer votes ───────────────────────────────────────────────────────────
249    let v1 = if close > vwap { 1_i8 } else { -1 }; // L1 VWAP
250    let v2 = if close > ema { 1 } else { -1 }; // L2 EMA
251    let v3 = if ind.st_dir_pub == -1 { -1 } else { 1 }; // L3 SuperTrend
252    let v4 = if ind.ts_bullish { 1 } else { -1 }; // L4 TrendSpeed
253    let v5 = if liq.bullish() { 1 } else { -1 }; // L5 Liquidity
254
255    let conf_adj = vol.map_or(1.0, |v| v.conf_adj);
256    let adj_min = cfg.conf_min_score * conf_adj;
257    let v6_bull = if conf.bull_score >= adj_min { 1_i8 } else { -1 };
258    let v6_bear = if conf.bear_score >= adj_min { 1_i8 } else { -1 };
259
260    let v7 = ms.bias; // L7 Market Structure
261
262    let v8: i8 = cvd.map_or(0, |c| {
263        if c.divergence != 0 {
264            c.divergence
265        } else if c.bullish {
266            1
267        } else {
268            -1
269        }
270    }); // L8 CVD
271
272    let v9: i8 = if ind.highs.len() >= 34 {
273        if ind.ao_rising { 1 } else { -1 }
274    } else {
275        0
276    }; // L9 AO
277
278    let v10: i8 = if (ind.hurst - 0.5).abs() < 0.005 {
279        0
280    } else if ind.hurst >= cfg.hurst_threshold {
281        1
282    } else {
283        -1
284    }; // L10 Hurst
285
286    let (v11_bull, v11_bear): (i8, i8) = if ind.price_accel.abs() < 0.005 {
287        (0, 0)
288    } else {
289        (
290            if ind.price_accel > 0.0 { 1 } else { -1 },
291            if ind.price_accel < 0.0 { 1 } else { -1 },
292        )
293    }; // L11 PriceAccel
294
295    // Fibonacci zone gates
296    let fib_ok_long = !cfg.fib_zone_enabled || ms.in_discount || ms.fib500.is_none();
297    let fib_ok_short = !cfg.fib_zone_enabled || ms.in_premium || ms.fib500.is_none();
298
299    // ── Signal logic ──────────────────────────────────────────────────────────
300    let (bull, bear) = match cfg.signal_mode.as_str() {
301        "strict" => {
302            let bull = v1 == 1
303                && v2 == 1
304                && v3 == -1
305                && v4 == 1
306                && v5 == 1
307                && v6_bull == 1
308                && v7 == 1
309                && fib_ok_long
310                && (v8 == 1 || v8 == 0);
311            let bear = v1 == -1
312                && v2 == -1
313                && v3 == 1
314                && v4 == -1
315                && v5 == -1
316                && v6_bear == 1
317                && v7 == -1
318                && fib_ok_short
319                && (v8 == -1 || v8 == 0);
320            (bull, bear)
321        }
322        "majority" => {
323            let core_bull = v1 == 1 && v2 == 1 && v3 == -1 && v4 == 1;
324            let core_bear = v1 == -1 && v2 == -1 && v3 == 1 && v4 == -1;
325
326            let ext_bull = [
327                v5 == 1,
328                v6_bull == 1,
329                v7 == 1,
330                fib_ok_long,
331                v8 == 1,
332                v9 == 1,
333                ind.wave_ok_long,
334                ind.mom_ok_long,
335                v10 == 1,
336                v11_bull == 1,
337            ]
338            .iter()
339            .filter(|&&b| b)
340            .count();
341
342            let ext_bear = [
343                v5 == -1,
344                v6_bear == 1,
345                v7 == -1,
346                fib_ok_short,
347                v8 == -1,
348                v9 == -1,
349                ind.wave_ok_short,
350                ind.mom_ok_short,
351                v10 == 1,
352                v11_bear == 1,
353            ]
354            .iter()
355            .filter(|&&b| b)
356            .count();
357
358            (core_bull && ext_bull >= 2, core_bear && ext_bear >= 2)
359        }
360        _ => {
361            // "any" / default — core layers only
362            let bull = v1 == 1 && v2 == 1 && v3 == -1 && v4 == 1;
363            let bear = v1 == -1 && v2 == -1 && v3 == 1 && v4 == -1;
364            (bull, bear)
365        }
366    };
367
368    let fib_zone = if ms.in_discount {
369        "discount"
370    } else if ms.in_premium {
371        "premium"
372    } else {
373        "mid"
374    };
375
376    let comps = SignalComponents {
377        v_vwap: v1,
378        v_ema: v2,
379        v_st: v3,
380        v_ts: v4,
381        v_liq: v5,
382        v_conf_bull: v6_bull,
383        v_conf_bear: v6_bear,
384        v_struct: v7,
385        v_cvd: v8,
386        v_ao: v9,
387        v_hurst: v10,
388        v_accel_bull: v11_bull,
389        v_accel_bear: v11_bear,
390        hurst: ind.hurst,
391        price_accel: ind.price_accel,
392        bull_score: conf.bull_score,
393        bear_score: conf.bear_score,
394        conf_min_adj: adj_min,
395        liq_imbalance: liq.imbalance,
396        liq_buy_pct: liq.buy_pct * 100.0,
397        poc: liq.poc_price,
398        struct_bias: ms.bias,
399        fib618: ms.fib618,
400        fib_zone,
401        fib_ok: if bull { fib_ok_long } else { fib_ok_short },
402        bos: ms.bos,
403        choch: ms.choch,
404        ts_norm: ind.ts_norm,
405        dominance: ind.dominance,
406        cvd_slope: cvd.map(|c| c.cvd_slope),
407        cvd_div: cvd.map_or(0, |c| c.divergence),
408        ao: ind.ao,
409        ao_rising: ind.ao_rising,
410        wr_pct: ind.wr_pct,
411        mom_pct: ind.mom_pct,
412        wave_ok_long: ind.wave_ok_long,
413        wave_ok_short: ind.wave_ok_short,
414        mom_ok_long: ind.mom_ok_long,
415        mom_ok_short: ind.mom_ok_short,
416        vol_pct: vol.map(|v| v.vol_pct),
417        vol_regime: vol.map(|v| v.vol_regime),
418    };
419
420    if bull {
421        return (1, comps);
422    }
423    if bear {
424        return (-1, comps);
425    }
426    (0, comps)
427}
428
429fn empty_components(
430    ind: &Indicators,
431    liq: &LiquidityProfile,
432    conf: &ConfluenceEngine,
433    ms: &MarketStructure,
434    cvd: Option<&CVDTracker>,
435    vol: Option<&VolatilityPercentile>,
436) -> SignalComponents {
437    SignalComponents {
438        v_vwap: 0,
439        v_ema: 0,
440        v_st: 0,
441        v_ts: 0,
442        v_liq: 0,
443        v_conf_bull: 0,
444        v_conf_bear: 0,
445        v_struct: 0,
446        v_cvd: 0,
447        v_ao: 0,
448        v_hurst: 0,
449        v_accel_bull: 0,
450        v_accel_bear: 0,
451        hurst: ind.hurst,
452        price_accel: ind.price_accel,
453        bull_score: conf.bull_score,
454        bear_score: conf.bear_score,
455        conf_min_adj: 0.0,
456        liq_imbalance: liq.imbalance,
457        liq_buy_pct: liq.buy_pct * 100.0,
458        poc: liq.poc_price,
459        struct_bias: ms.bias,
460        fib618: ms.fib618,
461        fib_zone: "mid",
462        fib_ok: false,
463        bos: false,
464        choch: false,
465        ts_norm: 0.5,
466        dominance: 0.0,
467        cvd_slope: cvd.map(|c| c.cvd_slope),
468        cvd_div: 0,
469        ao: ind.ao,
470        ao_rising: false,
471        wr_pct: 0.5,
472        mom_pct: 0.5,
473        wave_ok_long: false,
474        wave_ok_short: false,
475        mom_ok_long: false,
476        mom_ok_short: false,
477        vol_pct: vol.map(|v| v.vol_pct),
478        vol_regime: vol.map(|v| v.vol_regime),
479    }
480}
481
482// ── SignalStreak ──────────────────────────────────────────────────────────────
483
484/// Confirmation filter — signal must agree for `required` consecutive bars.
485///
486/// `update()` returns `true` only when the streak reaches `required` and the
487/// signal is non-zero.
488pub struct SignalStreak {
489    required: usize,
490    direction: i32,
491    count: usize,
492}
493
494impl SignalStreak {
495    pub fn new(required: usize) -> Self {
496        Self {
497            required,
498            direction: 0,
499            count: 0,
500        }
501    }
502
503    /// Feed a raw signal (`+1`, `-1`, or `0`).
504    /// Returns `true` when streak reaches `required` and signal is non-zero.
505    pub fn update(&mut self, signal: i32) -> bool {
506        if signal != 0 && signal == self.direction {
507            self.count += 1;
508        } else {
509            self.direction = signal;
510            self.count = usize::from(signal != 0);
511        }
512        self.count >= self.required && signal != 0
513    }
514
515    pub fn reset(&mut self) {
516        self.direction = 0;
517        self.count = 0;
518    }
519    pub fn current_direction(&self) -> i32 {
520        self.direction
521    }
522    pub fn current_count(&self) -> usize {
523        self.count
524    }
525}
526
527// ── Tests ─────────────────────────────────────────────────────────────────────
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    // ── SignalStreak tests ────────────────────────────────────────────────────
534
535    #[test]
536    fn streak_fires_after_required_consecutive() {
537        let mut s = SignalStreak::new(3);
538        assert!(!s.update(1));
539        assert!(!s.update(1));
540        assert!(s.update(1)); // third consecutive → fires
541        assert!(s.update(1)); // keeps firing
542    }
543
544    #[test]
545    fn streak_resets_on_direction_change() {
546        let mut s = SignalStreak::new(2);
547        assert!(!s.update(1));
548        assert!(s.update(1)); // fires
549        assert!(!s.update(-1)); // direction changed → resets
550        assert!(s.update(-1)); // fires again
551    }
552
553    #[test]
554    fn streak_zero_signal_breaks_streak() {
555        let mut s = SignalStreak::new(2);
556        s.update(1);
557        s.update(0); // zero resets
558        assert!(!s.update(1)); // back to count=1
559    }
560
561    #[test]
562    fn streak_required_1_fires_immediately() {
563        let mut s = SignalStreak::new(1);
564        assert!(s.update(1));
565        assert!(s.update(-1));
566        assert!(!s.update(0));
567    }
568
569    #[test]
570    fn streak_tracks_direction_and_count() {
571        let mut s = SignalStreak::new(3);
572        s.update(1);
573        s.update(1);
574        assert_eq!(s.current_direction(), 1);
575        assert_eq!(s.current_count(), 2);
576        s.update(-1);
577        assert_eq!(s.current_direction(), -1);
578        assert_eq!(s.current_count(), 1);
579    }
580
581    #[test]
582    fn streak_reset_clears_state() {
583        let mut s = SignalStreak::new(2);
584        s.update(1);
585        s.update(1);
586        s.reset();
587        assert_eq!(s.current_count(), 0);
588        assert_eq!(s.current_direction(), 0);
589        assert!(!s.update(1)); // must rebuild from zero
590    }
591}