Skip to main content

finance_solution/stocks/ta/
adx.rs

1//! # Average Directional Index (ADX) / +DI / −DI / DX
2//!
3//! Wilder directional movement system (period \(N\), classic **14**):
4//!
5//! ```text
6//! +DM = up-move if up > down and up > 0 else 0
7//! −DM = down-move if down > up and down > 0 else 0
8//! TR  = true range
9//! Smooth TR, +DM, −DM with Wilder (seed = SMA of first N, then Wilder)
10//! +DI = 100 * smooth(+DM) / smooth(TR)
11//! −DI = 100 * smooth(−DM) / smooth(TR)
12//! DX  = 100 * |+DI − −DI| / (+DI + −DI)
13//! ADX = Wilder smooth of DX (first ADX = SMA of first N DX values)
14//! ```
15//!
16//! Warm-up: first bar has no prior close (TR = H−L; DM from first change needs bar 1).  
17//! First DI/DX at index `N−1` after N TR/DM samples; first ADX after N DX values
18//! (index roughly `2N−2` on a continuous path).
19//!
20//! ---
21//!
22//! ## Trading perspective
23//!
24//! | Signal | Habit (classic, not a rule) |
25//! |--------|-----------------------------|
26//! | ADX rising / \> ~25 | Trend strength — trend systems “allowed” |
27//! | ADX low / falling | Range — oscillators / mean-reversion more natural |
28//! | +DI \> −DI | Bullish directional bias |
29//! | −DI \> +DI | Bearish directional bias |
30//! | DI cross | Direction change screen (filter with ADX level) |
31//!
32//! **ADX is not direction** — only strength. Always read **+DI / −DI** (or price structure)
33//! for side.
34//!
35//! ## vs ATR / Supertrend / MACD
36//!
37//! | | ADX/DI | ATR | Supertrend | MACD |
38//! |--|--------|-----|------------|------|
39//! | Measures | Trend *strength* + DI direction | Volatility size | Trail stop / side | Momentum of closes |
40//! | Good at | Regime filter | Stops / size | In/out of trend | Timing / hist flips |
41//!
42//! ## Pairs well with
43//!
44//! - **Supertrend / SAR / Donchian** — take breakouts only if ADX confirms trend.
45//! - **RSI / WillR / CCI** — fade extremes only if ADX is weak (range regime).
46//! - **Moving averages** — DI side + MA slope agreement.
47//!
48//! ---
49//!
50//! ## Engineering
51//!
52//! [`AdxParams`] → [`adx`] / [`AdxState`] → [`adx_solution`].  
53//! Batch uses [`AdxState`] end-to-end. After seeds, each push is **O(1)**.  
54//! Smoothing uses the same **average-seed then Wilder** style as this crate’s ATR
55//! (SMA of first N samples, then \((prev·(N−1)+x)/N\)).
56//!
57//! ## Word problem
58//!
59//! > Can ADX be defined on a series shorter than \(2N−1\) bars?
60//!
61//! Usually **no** for a full ADX value (need N DM/TR seeds then N DX for ADX seed).
62//!
63//! ```
64//! use finance_solution::stocks::ta::{adx, AdxParams};
65//! let n = 40usize;
66//! let h: Vec<_> = (0..n).map(|i| 101.0 + i as f64 * 0.2).collect();
67//! let l: Vec<_> = (0..n).map(|i| 99.0 + i as f64 * 0.2).collect();
68//! let c: Vec<_> = (0..n).map(|i| 100.0 + i as f64 * 0.2).collect();
69//! let s = adx(&h, &l, &c, AdxParams::period_14()).unwrap();
70//! assert!(s.adx.iter().any(|x| x.is_some()));
71//! assert!(s.plus_di.iter().any(|x| x.is_some()));
72//! ```
73
74use crate::stocks::ta::common::{opt_cell, require_hlc, true_range};
75use crate::util::error::{require_finite, FinanceError, FinanceResult};
76use crate::util::primitives::PeriodLength;
77use crate::{columns_with_strings, print_table_locale_opt};
78
79/// ADX / DI Wilder period.
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
81pub struct AdxParams {
82    pub period: usize,
83}
84
85impl AdxParams {
86    pub const fn new(period: usize) -> Self {
87        Self { period }
88    }
89
90    pub const fn period_14() -> Self {
91        Self { period: 14 }
92    }
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
96pub struct ValidatedAdx {
97    params: AdxParams,
98}
99
100impl ValidatedAdx {
101    pub fn new(params: AdxParams) -> FinanceResult<Self> {
102        PeriodLength::new(params.period)?;
103        Ok(Self { params })
104    }
105
106    pub fn params(self) -> AdxParams {
107        self.params
108    }
109
110    pub fn compute(self, high: &[f64], low: &[f64], close: &[f64]) -> FinanceResult<AdxSeries> {
111        adx_validated(high, low, close, self)
112    }
113}
114
115#[derive(Clone, Debug, PartialEq)]
116pub struct AdxSeries {
117    pub plus_di: Vec<Option<f64>>,
118    pub minus_di: Vec<Option<f64>>,
119    pub dx: Vec<Option<f64>>,
120    pub adx: Vec<Option<f64>>,
121    pub params: AdxParams,
122}
123
124impl AdxSeries {
125    pub fn last_adx(&self) -> Option<f64> {
126        self.adx.iter().rev().find_map(|x| *x)
127    }
128}
129
130/// One-bar ADX pack (any field may still be warming up).
131#[derive(Clone, Copy, Debug, PartialEq)]
132pub struct AdxBarOutput {
133    pub plus_di: Option<f64>,
134    pub minus_di: Option<f64>,
135    pub dx: Option<f64>,
136    pub adx: Option<f64>,
137}
138
139/// Incremental ADX / DI / DX.
140#[derive(Clone, Debug)]
141pub struct AdxState {
142    params: AdxParams,
143    prev_high: Option<f64>,
144    prev_low: Option<f64>,
145    prev_close: Option<f64>,
146    /// Seed buffers until length == period.
147    seed_tr: Vec<f64>,
148    seed_pdm: Vec<f64>,
149    seed_mdm: Vec<f64>,
150    atr: Option<f64>,
151    pdm: Option<f64>,
152    mdm: Option<f64>,
153    seed_dx: Vec<f64>,
154    adx: Option<f64>,
155    last: Option<AdxBarOutput>,
156}
157
158impl AdxState {
159    pub fn new(params: AdxParams) -> FinanceResult<Self> {
160        PeriodLength::new(params.period)?;
161        Ok(Self {
162            params,
163            prev_high: None,
164            prev_low: None,
165            prev_close: None,
166            seed_tr: Vec::with_capacity(params.period),
167            seed_pdm: Vec::with_capacity(params.period),
168            seed_mdm: Vec::with_capacity(params.period),
169            atr: None,
170            pdm: None,
171            mdm: None,
172            seed_dx: Vec::with_capacity(params.period),
173            adx: None,
174            last: None,
175        })
176    }
177
178    pub fn from_history(
179        params: AdxParams,
180        high: &[f64],
181        low: &[f64],
182        close: &[f64],
183    ) -> FinanceResult<Self> {
184        let mut s = Self::new(params)?;
185        let _ = s.push_bars(high, low, close)?;
186        Ok(s)
187    }
188
189    pub fn params(&self) -> AdxParams {
190        self.params
191    }
192
193    pub fn reset(&mut self) {
194        self.prev_high = None;
195        self.prev_low = None;
196        self.prev_close = None;
197        self.seed_tr.clear();
198        self.seed_pdm.clear();
199        self.seed_mdm.clear();
200        self.atr = None;
201        self.pdm = None;
202        self.mdm = None;
203        self.seed_dx.clear();
204        self.adx = None;
205        self.last = None;
206    }
207
208    pub fn push(&mut self, high: f64, low: f64, close: f64) -> FinanceResult<AdxBarOutput> {
209        require_finite("high", high)?;
210        require_finite("low", low)?;
211        require_finite("close", close)?;
212        if high < low {
213            return Err(FinanceError::InvalidCashflow {
214                message: "high must be >= low for each bar",
215            });
216        }
217        let period = self.params.period;
218        let pf = period as f64;
219
220        let (plus_dm, minus_dm) = match (self.prev_high, self.prev_low) {
221            (Some(ph), Some(pl)) => {
222                let up = high - ph;
223                let down = pl - low;
224                let pdm = if up > down && up > 0.0 { up } else { 0.0 };
225                let mdm = if down > up && down > 0.0 { down } else { 0.0 };
226                (pdm, mdm)
227            }
228            _ => (0.0, 0.0),
229        };
230        let tr = true_range(high, low, self.prev_close);
231
232        let mut plus_di = None;
233        let mut minus_di = None;
234        let mut dx = None;
235        let mut adx_out = None;
236
237        let smoothed = if self.atr.is_none() {
238            self.seed_tr.push(tr);
239            self.seed_pdm.push(plus_dm);
240            self.seed_mdm.push(minus_dm);
241            if self.seed_tr.len() == period {
242                let atr = self.seed_tr.iter().sum::<f64>() / pf;
243                let pdm = self.seed_pdm.iter().sum::<f64>() / pf;
244                let mdm = self.seed_mdm.iter().sum::<f64>() / pf;
245                self.atr = Some(atr);
246                self.pdm = Some(pdm);
247                self.mdm = Some(mdm);
248                Some((atr, pdm, mdm))
249            } else {
250                None
251            }
252        } else {
253            let atr = (self.atr.unwrap() * (pf - 1.0) + tr) / pf;
254            let pdm = (self.pdm.unwrap() * (pf - 1.0) + plus_dm) / pf;
255            let mdm = (self.mdm.unwrap() * (pf - 1.0) + minus_dm) / pf;
256            self.atr = Some(atr);
257            self.pdm = Some(pdm);
258            self.mdm = Some(mdm);
259            Some((atr, pdm, mdm))
260        };
261
262        if let Some((atr, pdm, mdm)) = smoothed {
263            if atr > 0.0 {
264                let pdi = 100.0 * pdm / atr;
265                let mdi = 100.0 * mdm / atr;
266                plus_di = Some(pdi);
267                minus_di = Some(mdi);
268                let den = pdi + mdi;
269                if den > 0.0 {
270                    let d = 100.0 * (pdi - mdi).abs() / den;
271                    dx = Some(d);
272                    if self.adx.is_none() {
273                        self.seed_dx.push(d);
274                        if self.seed_dx.len() == period {
275                            let a = self.seed_dx.iter().sum::<f64>() / pf;
276                            self.adx = Some(a);
277                            adx_out = Some(a);
278                        }
279                    } else {
280                        let a = (self.adx.unwrap() * (pf - 1.0) + d) / pf;
281                        self.adx = Some(a);
282                        adx_out = Some(a);
283                    }
284                }
285            }
286        }
287
288        self.prev_high = Some(high);
289        self.prev_low = Some(low);
290        self.prev_close = Some(close);
291
292        let out = AdxBarOutput {
293            plus_di,
294            minus_di,
295            dx,
296            adx: adx_out,
297        };
298        self.last = Some(out);
299        Ok(out)
300    }
301
302    pub fn push_bars(
303        &mut self,
304        high: &[f64],
305        low: &[f64],
306        close: &[f64],
307    ) -> FinanceResult<Vec<AdxBarOutput>> {
308        require_hlc(high, low, close)?;
309        let mut out = Vec::with_capacity(close.len());
310        for i in 0..close.len() {
311            out.push(self.push(high[i], low[i], close[i])?);
312        }
313        Ok(out)
314    }
315
316    pub fn last(&self) -> Option<AdxBarOutput> {
317        self.last
318    }
319}
320
321pub fn adx(
322    high: &[f64],
323    low: &[f64],
324    close: &[f64],
325    params: AdxParams,
326) -> FinanceResult<AdxSeries> {
327    ValidatedAdx::new(params)?.compute(high, low, close)
328}
329
330fn adx_validated(
331    high: &[f64],
332    low: &[f64],
333    close: &[f64],
334    eng: ValidatedAdx,
335) -> FinanceResult<AdxSeries> {
336    let mut st = AdxState::new(eng.params)?;
337    let bars = st.push_bars(high, low, close)?;
338    let n = bars.len();
339    let mut plus_di = vec![None; n];
340    let mut minus_di = vec![None; n];
341    let mut dx = vec![None; n];
342    let mut adx = vec![None; n];
343    for (i, b) in bars.into_iter().enumerate() {
344        plus_di[i] = b.plus_di;
345        minus_di[i] = b.minus_di;
346        dx[i] = b.dx;
347        adx[i] = b.adx;
348    }
349    Ok(AdxSeries {
350        plus_di,
351        minus_di,
352        dx,
353        adx,
354        params: eng.params,
355    })
356}
357
358#[derive(Clone, Debug)]
359pub struct AdxSolution {
360    series: AdxSeries,
361    close: Vec<f64>,
362    formula: String,
363}
364
365impl AdxSolution {
366    pub fn series(&self) -> &AdxSeries {
367        &self.series
368    }
369    pub fn formula(&self) -> &str {
370        &self.formula
371    }
372
373    pub fn print_table(&self) {
374        self.print_table_locale_opt(None, None);
375    }
376
377    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
378        self.print_table_locale_opt(Some(locale), Some(precision));
379    }
380
381    fn print_table_locale_opt(
382        &self,
383        locale: Option<&num_format::Locale>,
384        precision: Option<usize>,
385    ) {
386        let columns = columns_with_strings(&[
387            ("period", "i", true),
388            ("close", "f", true),
389            ("plus_di", "f", true),
390            ("minus_di", "f", true),
391            ("dx", "f", true),
392            ("adx", "f", true),
393        ]);
394        let data = self
395            .close
396            .iter()
397            .enumerate()
398            .map(|(i, c)| {
399                vec![
400                    i.to_string(),
401                    c.to_string(),
402                    opt_cell(self.series.plus_di[i]),
403                    opt_cell(self.series.minus_di[i]),
404                    opt_cell(self.series.dx[i]),
405                    opt_cell(self.series.adx[i]),
406                ]
407            })
408            .collect();
409        print_table_locale_opt(&columns, data, locale, precision);
410    }
411}
412
413/// # Examples
414/// ```
415/// use finance_solution::stocks::ta::{adx_solution, AdxParams};
416/// let n = 50usize;
417/// let h: Vec<_> = (0..n).map(|i| 101.0 + i as f64 * 0.1).collect();
418/// let l: Vec<_> = (0..n).map(|i| 99.0 + i as f64 * 0.1).collect();
419/// let c: Vec<_> = (0..n).map(|i| 100.0 + i as f64 * 0.1).collect();
420/// let sol = adx_solution(&h, &l, &c, AdxParams::period_14()).unwrap();
421/// assert!(sol.formula().contains("Wilder"));
422/// ```
423pub fn adx_solution(
424    high: &[f64],
425    low: &[f64],
426    close: &[f64],
427    params: AdxParams,
428) -> FinanceResult<AdxSolution> {
429    let series = adx(high, low, close, params)?;
430    Ok(AdxSolution {
431        series,
432        close: close.to_vec(),
433        formula: format!(
434            "Wilder ADX/DI period={}: +DI/-DI from DM & TR; DX; ADX=Wilder(DX)",
435            params.period
436        ),
437    })
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    fn rising_path(n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
445        let h: Vec<_> = (0..n).map(|i| 101.0 + i as f64 * 0.3).collect();
446        let l: Vec<_> = (0..n).map(|i| 99.0 + i as f64 * 0.3).collect();
447        let c: Vec<_> = (0..n).map(|i| 100.0 + i as f64 * 0.3).collect();
448        (h, l, c)
449    }
450
451    #[test]
452    fn produces_adx_on_long_path() {
453        let (h, l, c) = rising_path(50);
454        let s = adx(&h, &l, &c, AdxParams::period_14()).unwrap();
455        assert!(s.adx.iter().filter(|x| x.is_some()).count() > 5);
456        assert!(s.plus_di.iter().any(|x| x.is_some()));
457        // Strong uptrend: +DI should dominate when defined
458        if let (Some(p), Some(m)) = (s.plus_di[49], s.minus_di[49]) {
459            assert!(p > m, "+DI={p} −DI={m}");
460        }
461        let a = s.last_adx().unwrap();
462        assert!(a >= 0.0 && a <= 100.0, "adx={a}");
463    }
464
465    #[test]
466    fn di_before_adx() {
467        let (h, l, c) = rising_path(30);
468        let s = adx(&h, &l, &c, AdxParams::period_14()).unwrap();
469        let first_di = s.plus_di.iter().position(|x| x.is_some()).unwrap();
470        let first_adx = s.adx.iter().position(|x| x.is_some()).unwrap();
471        assert!(first_di < first_adx);
472    }
473
474    #[test]
475    fn state_parity() {
476        let (h, l, c) = rising_path(45);
477        let p = AdxParams::period_14();
478        let batch = adx(&h, &l, &c, p).unwrap();
479        let mut st = AdxState::new(p).unwrap();
480        for i in 0..c.len() {
481            let o = st.push(h[i], l[i], c[i]).unwrap();
482            match (o.plus_di, batch.plus_di[i]) {
483                (None, None) => {}
484                (Some(a), Some(b)) => assert!((a - b).abs() < 1e-9, "pdi {i}"),
485                other => panic!("pdi {i}: {other:?}"),
486            }
487            match (o.adx, batch.adx[i]) {
488                (None, None) => {}
489                (Some(a), Some(b)) => assert!((a - b).abs() < 1e-9, "adx {i}"),
490                other => panic!("adx {i}: {other:?}"),
491            }
492        }
493    }
494
495    #[test]
496    fn high_lt_low_err() {
497        assert!(adx(&[1.0], &[2.0], &[1.5], AdxParams::period_14()).is_err());
498    }
499}