Skip to main content

kestrel_chartkit/indicator/
trend_quality.rs

1use super::adx::Adx;
2use super::efficiency::LegEfficiencyEngine;
3use super::moving_averages::EmaEngine;
4use super::volume_indicators::RvolEngine;
5use super::{Indicator, IndicatorAlert, IndicatorOutput};
6use crate::model::Bar;
7use std::collections::HashMap;
8
9/// Trend Quality Score Engine (0..100).
10/// Formula: Score = Direction * Efficiency * Persistence * Strength * Participation
11#[derive(Debug, Clone)]
12pub struct TrendQualityScoreEngine {
13    ema: EmaEngine,
14    efficiency: LegEfficiencyEngine,
15    adx: Adx,
16    rvol: RvolEngine,
17    prev_ema: Option<f64>,
18}
19
20impl TrendQualityScoreEngine {
21    pub fn new(period: usize) -> Self {
22        Self {
23            ema: EmaEngine::new(period),
24            efficiency: LegEfficiencyEngine::new(period),
25            adx: Adx::with_period(period),
26            rvol: RvolEngine::new(period),
27            prev_ema: None,
28        }
29    }
30
31    pub fn with_defaults() -> Self {
32        Self::new(14)
33    }
34}
35
36impl Indicator for TrendQualityScoreEngine {
37    fn name(&self) -> &str {
38        "trend_quality"
39    }
40
41    fn warmup_period(&self) -> usize {
42        self.ema
43            .warmup_period()
44            .max(self.efficiency.warmup_period())
45            .max(self.adx.warmup_period())
46            .max(self.rvol.warmup_period())
47    }
48
49    fn reset(&mut self) {
50        self.ema.reset();
51        self.efficiency.reset();
52        self.adx.reset();
53        self.rvol.reset();
54        self.prev_ema = None;
55    }
56
57    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
58        let ema_out = self.ema.on_bar(bar);
59        let efficiency_out = self.efficiency.on_bar(bar);
60        let adx_out = self.adx.on_bar(bar);
61        let rvol_out = self.rvol.on_bar(bar);
62        let (Some(ema_out), Some(efficiency_out), Some(adx_out), Some(rvol_out)) =
63            (ema_out, efficiency_out, adx_out, rvol_out)
64        else {
65            return None;
66        };
67        let ema_val = ema_out.value;
68        let eff_val = efficiency_out.value;
69        let rvol_val = rvol_out.value;
70
71        let slope = match self.prev_ema {
72            Some(prev) => (ema_val - prev) / prev.max(1e-8),
73            None => 0.0,
74        };
75        self.prev_ema = Some(ema_val);
76
77        let direction = if slope > 0.0 { 1.0 } else { -1.0 };
78        let strength = (adx_out.value / 50.0).clamp(0.0, 1.0); // ADX normalized
79        let participation = (rvol_val / 2.0).clamp(0.2, 1.0); // RVOL normalized
80
81        let raw_score = direction * eff_val * strength * participation * 100.0;
82        let quality_score = raw_score.clamp(-100.0, 100.0);
83
84        let mut extra = HashMap::new();
85        extra.insert("direction".to_string(), direction);
86        extra.insert("efficiency".to_string(), eff_val);
87        extra.insert("strength".to_string(), strength);
88        extra.insert("participation".to_string(), participation);
89
90        Some(IndicatorOutput::with_extra(quality_score, extra))
91    }
92
93    fn alerts(&self) -> Vec<IndicatorAlert> {
94        Vec::new()
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn test_trend_quality_score() {
104        let mut tq = TrendQualityScoreEngine::with_defaults();
105        let mut out = None;
106        for i in 0..100 {
107            let b = Bar::new(i, 100.0, 105.0, 95.0, 100.0 + i as f64, 1000.0);
108            out = tq.on_bar(&b);
109        }
110        assert!(out.is_some());
111    }
112}