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/// Composite Multi-Factor Market Score Engine (-1.0 .. +1.0).
10/// Combines Trend Quality, Momentum (RSI), Volume/Pressure, and Volatility Regime.
11#[derive(Debug, Clone)]
12pub struct MultiFactorMarketScore {
13    trend: TrendQualityScoreEngine,
14    rsi: Rsi,
15    pressure: BuySellPressureEstimator,
16    volatility: VolatilityRegimeDetector,
17}
18
19impl MultiFactorMarketScore {
20    pub fn new(period: usize) -> Self {
21        Self {
22            trend: TrendQualityScoreEngine::new(period),
23            rsi: Rsi::with_period(period),
24            pressure: BuySellPressureEstimator::new(period),
25            volatility: VolatilityRegimeDetector::new(period, 2.0, 1.5),
26        }
27    }
28
29    pub fn with_defaults() -> Self {
30        Self::new(14)
31    }
32}
33
34impl Indicator for MultiFactorMarketScore {
35    fn name(&self) -> &str {
36        "multi_factor"
37    }
38
39    fn warmup_period(&self) -> usize {
40        self.trend
41            .warmup_period()
42            .max(self.rsi.warmup_period())
43            .max(self.pressure.warmup_period())
44            .max(self.volatility.warmup_period())
45    }
46
47    fn reset(&mut self) {
48        self.trend.reset();
49        self.rsi.reset();
50        self.pressure.reset();
51        self.volatility.reset();
52    }
53
54    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
55        let trend_out = self.trend.on_bar(bar);
56        let rsi_out = self.rsi.on_bar(bar);
57        let pressure_out = self.pressure.on_bar(bar);
58        let vol_out = self.volatility.on_bar(bar);
59        let (Some(trend_out), Some(rsi_out), Some(pressure_out), Some(vol_out)) =
60            (trend_out, rsi_out, pressure_out, vol_out)
61        else {
62            return None;
63        };
64        let trend_score = trend_out.value / 100.0;
65        let rsi_norm = (rsi_out.value - 50.0) / 50.0;
66        let pressure_score = pressure_out.value / 100.0;
67
68        let vol_state = vol_out.value; // -1 = Squeeze, 0 = Normal, 1 = Expansion
69
70        // Weighted factor combination
71        let raw_composite = trend_score * 0.35 + rsi_norm * 0.25 + pressure_score * 0.40;
72
73        // Dampen composite during Squeeze
74        let final_score = if vol_state < 0.0 {
75            raw_composite * 0.5
76        } else {
77            raw_composite
78        }
79        .clamp(-1.0, 1.0);
80
81        let mut extra = HashMap::new();
82        extra.insert("trend_factor".to_string(), trend_score);
83        extra.insert("rsi_factor".to_string(), rsi_norm);
84        extra.insert("pressure_factor".to_string(), pressure_score);
85        extra.insert("volatility_factor".to_string(), vol_state);
86
87        Some(IndicatorOutput::with_extra(final_score, extra))
88    }
89
90    fn alerts(&self) -> Vec<IndicatorAlert> {
91        Vec::new()
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn test_multi_factor_market_score() {
101        let mut mf = MultiFactorMarketScore::with_defaults();
102        let mut out = None;
103        for i in 0..150 {
104            let b = Bar::new(i, 100.0, 105.0, 95.0, 100.0 + i as f64, 1000.0);
105            out = mf.on_bar(&b);
106        }
107        assert!(out.is_some());
108        let val = out.unwrap().value;
109        assert!((-1.0..=1.0).contains(&val));
110    }
111}