Skip to main content

kestrel_chartkit/indicator/
multi_factor.rs

1use super::buy_sell_pressure::BuySellPressureEstimator;
2use super::rsi::Rsi;
3use super::trend_quality::TrendQualityScoreEngine;
4use super::volatility_regime::VolatilityRegimeDetector;
5use super::{Indicator, IndicatorAlert, IndicatorOutput};
6use crate::model::Bar;
7use std::collections::HashMap;
8
9/// Multi-Factor Market Score: trend, momentum and pressure combined, damped in a squeeze,
10/// `-1..=1`.
11///
12/// With every input over the same `period`:
13///
14/// - `trend_factor = trend_quality / 100` ([`TrendQualityScoreEngine`]);
15/// - `rsi_factor = (rsi - 50) / 50`, `rsi` the value of [`Rsi::with_period`]`(period)`: the
16///   EMA(3) line over the raw Wilder RSI, not the raw RSI;
17/// - `pressure_factor = pressure / 100` ([`BuySellPressureEstimator`]);
18/// - `volatility_factor`: the state code of [`VolatilityRegimeDetector`]`(period, 2, 1.5)`.
19///
20/// `raw = 0.35 · trend_factor + 0.25 · rsi_factor + 0.40 · pressure_factor`, halved while the
21/// volatility state is squeeze (`-1`), then clamped to `-1..=1`. The four factors are in `extra`
22/// under the names above. First output once all four inputs have one.
23#[derive(Debug, Clone)]
24pub struct MultiFactorMarketScore {
25    trend: TrendQualityScoreEngine,
26    rsi: Rsi,
27    pressure: BuySellPressureEstimator,
28    volatility: VolatilityRegimeDetector,
29}
30
31impl MultiFactorMarketScore {
32    pub fn new(period: usize) -> Self {
33        Self {
34            trend: TrendQualityScoreEngine::new(period),
35            rsi: Rsi::with_period(period),
36            pressure: BuySellPressureEstimator::new(period),
37            volatility: VolatilityRegimeDetector::new(period, 2.0, 1.5),
38        }
39    }
40
41    pub fn with_defaults() -> Self {
42        Self::new(14)
43    }
44}
45
46impl Indicator for MultiFactorMarketScore {
47    fn name(&self) -> &str {
48        "multi_factor"
49    }
50
51    fn warmup_period(&self) -> usize {
52        self.trend
53            .warmup_period()
54            .max(self.rsi.warmup_period())
55            .max(self.pressure.warmup_period())
56            .max(self.volatility.warmup_period())
57    }
58
59    fn reset(&mut self) {
60        self.trend.reset();
61        self.rsi.reset();
62        self.pressure.reset();
63        self.volatility.reset();
64    }
65
66    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
67        let trend_out = self.trend.on_bar(bar);
68        let rsi_out = self.rsi.on_bar(bar);
69        let pressure_out = self.pressure.on_bar(bar);
70        let vol_out = self.volatility.on_bar(bar);
71        let (Some(trend_out), Some(rsi_out), Some(pressure_out), Some(vol_out)) =
72            (trend_out, rsi_out, pressure_out, vol_out)
73        else {
74            return None;
75        };
76        let trend_score = trend_out.value / 100.0;
77        let rsi_norm = (rsi_out.value - 50.0) / 50.0;
78        let pressure_score = pressure_out.value / 100.0;
79
80        let vol_state = vol_out.value; // -1 = Squeeze, 0 = Normal, 1 = Expansion
81
82        // Weighted factor combination
83        let raw_composite = trend_score * 0.35 + rsi_norm * 0.25 + pressure_score * 0.40;
84
85        // Dampen composite during Squeeze
86        let final_score = if vol_state < 0.0 {
87            raw_composite * 0.5
88        } else {
89            raw_composite
90        }
91        .clamp(-1.0, 1.0);
92
93        let mut extra = HashMap::new();
94        extra.insert("trend_factor".to_string(), trend_score);
95        extra.insert("rsi_factor".to_string(), rsi_norm);
96        extra.insert("pressure_factor".to_string(), pressure_score);
97        extra.insert("volatility_factor".to_string(), vol_state);
98
99        Some(IndicatorOutput::with_extra(final_score, extra))
100    }
101
102    fn alerts(&self) -> Vec<IndicatorAlert> {
103        Vec::new()
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_multi_factor_market_score() {
113        let mut mf = MultiFactorMarketScore::with_defaults();
114        let mut out = None;
115        for i in 0..150 {
116            let b = Bar::new(i, 100.0, 105.0, 95.0, 100.0 + i as f64, 1000.0);
117            out = mf.on_bar(&b);
118        }
119        assert!(out.is_some());
120        let val = out.unwrap().value;
121        assert!((-1.0..=1.0).contains(&val));
122    }
123}