Skip to main content

kestrel_chartkit/indicator/
adx.rs

1use std::collections::HashMap;
2
3use crate::model::Bar;
4
5use super::smoothing::{crossed_over, crossed_under, Ema, Rma};
6use super::{Indicator, IndicatorAlert, IndicatorOutput};
7
8#[derive(Debug, Clone)]
9pub struct Adx {
10    level_weak: f64,
11
12    prev_bar: Option<(f64, f64, f64)>,
13    tr_rma: Rma,
14    dm_plus_rma: Rma,
15    dm_minus_rma: Rma,
16    dx_rma: Rma,
17    adx_signal: Ema,
18
19    prev_di_plus: Option<f64>,
20    prev_di_minus: Option<f64>,
21    prev_adx_raw: Option<f64>,
22    bars_seen: usize,
23    warmup_period: usize,
24
25    alerts: AdxAlerts,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Default)]
29pub struct AdxAlerts {
30    pub bull_di_cross: bool,
31    pub bear_di_cross: bool,
32    pub adx_activated: bool,
33    pub adx_deactivated: bool,
34    pub regime_strength: f64,
35}
36
37impl Adx {
38    pub fn new(di_len: usize, adx_smooth: usize, sig_len: usize, level_weak: f64) -> Self {
39        Self {
40            level_weak,
41            prev_bar: None,
42            tr_rma: Rma::new(di_len),
43            dm_plus_rma: Rma::new(di_len),
44            dm_minus_rma: Rma::new(di_len),
45            dx_rma: Rma::new(adx_smooth),
46            adx_signal: Ema::new(sig_len),
47            prev_di_plus: None,
48            prev_di_minus: None,
49            prev_adx_raw: None,
50            bars_seen: 0,
51            warmup_period: di_len + adx_smooth,
52            alerts: AdxAlerts::default(),
53        }
54    }
55
56    pub fn with_defaults() -> Self {
57        // Matches the registry's "adx" catalog default (di_len=14, adx_smooth=14, sig_len=3,
58        // level_weak=20.0) -- sig_len was previously 14 here, silently diverging from the
59        // registry-built default.
60        Self::new(14, 14, 3, 20.0)
61    }
62
63    pub fn with_period(period: usize) -> Self {
64        Self::new(period, period, 14, 20.0)
65    }
66}
67
68impl Indicator for Adx {
69    fn name(&self) -> &str {
70        "adx"
71    }
72
73    fn warmup_period(&self) -> usize {
74        self.warmup_period
75    }
76
77    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
78        self.alerts = AdxAlerts::default();
79        self.bars_seen += 1;
80
81        let (prev_high, prev_low, prev_close) = match self.prev_bar {
82            None => {
83                self.prev_bar = Some((bar.high, bar.low, bar.close));
84                return None;
85            }
86            Some(p) => p,
87        };
88        self.prev_bar = Some((bar.high, bar.low, bar.close));
89
90        let up_move = bar.high - prev_high;
91        let down_move = prev_low - bar.low;
92        let dm_plus = if up_move > down_move && up_move > 0.0 {
93            up_move
94        } else {
95            0.0
96        };
97        let dm_minus = if down_move > up_move && down_move > 0.0 {
98            down_move
99        } else {
100            0.0
101        };
102        let tr = (bar.high - bar.low)
103            .max((bar.high - prev_close).abs())
104            .max((bar.low - prev_close).abs());
105
106        let (tr_avg, dm_plus_avg, dm_minus_avg) = match (
107            self.tr_rma.update(tr),
108            self.dm_plus_rma.update(dm_plus),
109            self.dm_minus_rma.update(dm_minus),
110        ) {
111            (Some(t), Some(p), Some(m)) => (t, p, m),
112            _ => return None,
113        };
114
115        let di_plus = if tr_avg != 0.0 {
116            100.0 * dm_plus_avg / tr_avg
117        } else {
118            0.0
119        };
120        let di_minus = if tr_avg != 0.0 {
121            100.0 * dm_minus_avg / tr_avg
122        } else {
123            0.0
124        };
125        let di_sum = di_plus + di_minus;
126        let dx = if di_sum != 0.0 {
127            100.0 * (di_plus - di_minus).abs() / di_sum
128        } else {
129            0.0
130        };
131
132        let adx_raw = self.dx_rma.update(dx)?;
133        let adx_line = self.adx_signal.update(adx_raw);
134
135        if let (Some(prev_plus), Some(prev_minus), Some(prev_adx)) =
136            (self.prev_di_plus, self.prev_di_minus, self.prev_adx_raw)
137        {
138            self.alerts.bull_di_cross = crossed_over(prev_plus, prev_minus, di_plus, di_minus);
139            self.alerts.bear_di_cross = crossed_under(prev_plus, prev_minus, di_plus, di_minus);
140            self.alerts.adx_activated =
141                crossed_over(prev_adx, self.level_weak, adx_raw, self.level_weak);
142            self.alerts.adx_deactivated =
143                crossed_under(prev_adx, self.level_weak, adx_raw, self.level_weak);
144            if self.alerts.adx_activated {
145                self.alerts.regime_strength =
146                    ((adx_raw - self.level_weak) / self.level_weak).clamp(0.0, 1.0);
147            } else if self.alerts.adx_deactivated {
148                self.alerts.regime_strength =
149                    ((self.level_weak - adx_raw) / self.level_weak).clamp(0.0, 1.0);
150            }
151        }
152        self.prev_di_plus = Some(di_plus);
153        self.prev_di_minus = Some(di_minus);
154        self.prev_adx_raw = Some(adx_raw);
155
156        let mut extra = HashMap::new();
157        extra.insert("signal".to_string(), adx_line);
158        extra.insert("di_plus".to_string(), di_plus);
159        extra.insert("di_minus".to_string(), di_minus);
160
161        Some(IndicatorOutput::with_extra(adx_raw, extra))
162    }
163
164    fn reset(&mut self) {
165        self.prev_bar = None;
166        self.tr_rma.reset();
167        self.dm_plus_rma.reset();
168        self.dm_minus_rma.reset();
169        self.dx_rma.reset();
170        self.adx_signal.reset();
171        self.prev_di_plus = None;
172        self.prev_di_minus = None;
173        self.prev_adx_raw = None;
174        self.bars_seen = 0;
175        self.alerts = AdxAlerts::default();
176    }
177
178    fn alerts(&self) -> Vec<IndicatorAlert> {
179        let a = self.alerts;
180        let mut out = Vec::new();
181        if a.bull_di_cross {
182            out.push(IndicatorAlert {
183                kind: "bull_di_cross".to_string(),
184                note: "ADX · DI BULL CROSS".to_string(),
185                strength: 1.0,
186            });
187        }
188        if a.bear_di_cross {
189            out.push(IndicatorAlert {
190                kind: "bear_di_cross".to_string(),
191                note: "ADX · DI BEAR CROSS".to_string(),
192                strength: 1.0,
193            });
194        }
195        if a.adx_activated {
196            out.push(IndicatorAlert {
197                kind: "adx_activated".to_string(),
198                note: "ADX · ADX ACTIVATED".to_string(),
199                strength: a.regime_strength,
200            });
201        }
202        if a.adx_deactivated {
203            out.push(IndicatorAlert {
204                kind: "adx_deactivated".to_string(),
205                note: "ADX · ADX DEACTIVATED".to_string(),
206                strength: a.regime_strength,
207            });
208        }
209        out
210    }
211}