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: a signed product of four trend factors, `-100..=100`.
10///
11/// All four inputs run on every bar with the same `period`:
12///
13/// - `direction`: `+1` if the EMA(`period`) of the close (first-sample seeded, see
14///   [`EmaEngine`]) rose against the previous bar's, `ema > prev`; otherwise `-1`. A flat EMA
15///   counts as `-1`, and so does the first output, which has no previous value to compare with.
16/// - `efficiency`: the Kaufman Efficiency Ratio over `period` ([`LegEfficiencyEngine`]), `0..=1`.
17/// - `strength`: `clamp(ADX / 50, 0, 1)`, the ADX from [`Adx::with_period`]`(period)`: Wilder DI
18///   and ADX smoothing both over `period`.
19/// - `participation`: `clamp(RVOL / 2, 0.2, 1)` with [`RvolEngine`]`(period)`.
20///
21/// `value = clamp(direction · efficiency · strength · participation · 100, -100, 100)`, the four
22/// factors in `extra` under their names. There is no separate persistence factor.
23///
24/// First output once all four inputs have one, which the ADX sets: with bar `2 · period`.
25#[derive(Debug, Clone)]
26pub struct TrendQualityScoreEngine {
27    ema: EmaEngine,
28    efficiency: LegEfficiencyEngine,
29    adx: Adx,
30    rvol: RvolEngine,
31    prev_ema: Option<f64>,
32}
33
34impl TrendQualityScoreEngine {
35    pub fn new(period: usize) -> Self {
36        Self {
37            ema: EmaEngine::new(period),
38            efficiency: LegEfficiencyEngine::new(period),
39            adx: Adx::with_period(period),
40            rvol: RvolEngine::new(period),
41            prev_ema: None,
42        }
43    }
44
45    pub fn with_defaults() -> Self {
46        Self::new(14)
47    }
48}
49
50impl Indicator for TrendQualityScoreEngine {
51    fn name(&self) -> &str {
52        "trend_quality"
53    }
54
55    fn warmup_period(&self) -> usize {
56        self.ema
57            .warmup_period()
58            .max(self.efficiency.warmup_period())
59            .max(self.adx.warmup_period())
60            .max(self.rvol.warmup_period())
61    }
62
63    fn reset(&mut self) {
64        self.ema.reset();
65        self.efficiency.reset();
66        self.adx.reset();
67        self.rvol.reset();
68        self.prev_ema = None;
69    }
70
71    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
72        let ema_out = self.ema.on_bar(bar);
73        let efficiency_out = self.efficiency.on_bar(bar);
74        let adx_out = self.adx.on_bar(bar);
75        let rvol_out = self.rvol.on_bar(bar);
76        let (Some(ema_out), Some(efficiency_out), Some(adx_out), Some(rvol_out)) =
77            (ema_out, efficiency_out, adx_out, rvol_out)
78        else {
79            return None;
80        };
81        let ema_val = ema_out.value;
82        let eff_val = efficiency_out.value;
83        let rvol_val = rvol_out.value;
84
85        let slope = match self.prev_ema {
86            Some(prev) => (ema_val - prev) / prev.max(1e-8),
87            None => 0.0,
88        };
89        self.prev_ema = Some(ema_val);
90
91        let direction = if slope > 0.0 { 1.0 } else { -1.0 };
92        let strength = (adx_out.value / 50.0).clamp(0.0, 1.0); // ADX normalized
93        let participation = (rvol_val / 2.0).clamp(0.2, 1.0); // RVOL normalized
94
95        let raw_score = direction * eff_val * strength * participation * 100.0;
96        let quality_score = raw_score.clamp(-100.0, 100.0);
97
98        let mut extra = HashMap::new();
99        extra.insert("direction".to_string(), direction);
100        extra.insert("efficiency".to_string(), eff_val);
101        extra.insert("strength".to_string(), strength);
102        extra.insert("participation".to_string(), participation);
103
104        Some(IndicatorOutput::with_extra(quality_score, extra))
105    }
106
107    fn alerts(&self) -> Vec<IndicatorAlert> {
108        Vec::new()
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_trend_quality_score() {
118        let mut tq = TrendQualityScoreEngine::with_defaults();
119        let mut out = None;
120        for i in 0..100 {
121            let b = Bar::new(i, 100.0, 105.0, 95.0, 100.0 + i as f64, 1000.0);
122            out = tq.on_bar(&b);
123        }
124        assert!(out.is_some());
125    }
126}