1use 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#[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 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
156pub 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#[derive(Debug, Clone)]
176pub struct SignalComponents {
177 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 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
222pub 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 let v1 = if close > vwap { 1_i8 } else { -1 }; let v2 = if close > ema { 1 } else { -1 }; let v3 = if ind.st_dir_pub == -1 { -1 } else { 1 }; let v4 = if ind.ts_bullish { 1 } else { -1 }; let v5 = if liq.bullish() { 1 } else { -1 }; 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; 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 }); let v9: i8 = if ind.highs.len() >= 34 {
269 if ind.ao_rising { 1 } else { -1 }
270 } else {
271 0
272 }; 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 }; 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 }; 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 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 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
478pub 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 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#[cfg(test)]
526mod tests {
527 use super::*;
528
529 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, 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 #[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 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 #[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 #[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)); assert!(s.update(1)); }
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)); assert!(!s.update(-1)); assert!(s.update(-1)); }
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); assert!(!s.update(1)); }
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)); }
704}