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