wickra_core/indicators/
dx.rs1use crate::error::{Error, Result};
4use crate::indicators::adx::directional_movement;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
36pub struct Dx {
37 period: usize,
38 prev: Option<Candle>,
39 plus_dm_seed: f64,
40 minus_dm_seed: f64,
41 tr_seed: f64,
42 seed_count: usize,
43 plus_dm_smooth: Option<f64>,
44 minus_dm_smooth: Option<f64>,
45 tr_smooth: Option<f64>,
46}
47
48impl Dx {
49 pub fn new(period: usize) -> Result<Self> {
52 if period == 0 {
53 return Err(Error::PeriodZero);
54 }
55 if period > crate::error::MAX_PERIOD {
56 return Err(Error::InvalidPeriod {
57 message: crate::error::PERIOD_ABOVE_MAX,
58 });
59 }
60 Ok(Self {
61 period,
62 prev: None,
63 plus_dm_seed: 0.0,
64 minus_dm_seed: 0.0,
65 tr_seed: 0.0,
66 seed_count: 0,
67 plus_dm_smooth: None,
68 minus_dm_smooth: None,
69 tr_smooth: None,
70 })
71 }
72
73 pub const fn period(&self) -> usize {
75 self.period
76 }
77}
78
79impl Indicator for Dx {
80 type Input = Candle;
81 type Output = f64;
82
83 fn update(&mut self, candle: Candle) -> Option<f64> {
84 let Some(prev) = self.prev else {
85 self.prev = Some(candle);
86 return None;
87 };
88 self.prev = Some(candle);
89
90 let (plus_dm, minus_dm) = directional_movement(&prev, &candle);
91 let tr = candle.true_range(Some(prev.close));
92 let n = self.period as f64;
93
94 let (plus_v, minus_v, tr_v) = if let (Some(p), Some(m), Some(t)) =
95 (self.plus_dm_smooth, self.minus_dm_smooth, self.tr_smooth)
96 {
97 let p_new = p - p / n + plus_dm;
98 let m_new = m - m / n + minus_dm;
99 let t_new = t - t / n + tr;
100 self.plus_dm_smooth = Some(p_new);
101 self.minus_dm_smooth = Some(m_new);
102 self.tr_smooth = Some(t_new);
103 (p_new, m_new, t_new)
104 } else {
105 self.plus_dm_seed += plus_dm;
106 self.minus_dm_seed += minus_dm;
107 self.tr_seed += tr;
108 self.seed_count += 1;
109 if self.seed_count < self.period {
110 return None;
111 }
112 self.plus_dm_smooth = Some(self.plus_dm_seed);
113 self.minus_dm_smooth = Some(self.minus_dm_seed);
114 self.tr_smooth = Some(self.tr_seed);
115 (self.plus_dm_seed, self.minus_dm_seed, self.tr_seed)
116 };
117
118 let (plus_di, minus_di) = if tr_v == 0.0 {
119 (0.0, 0.0)
120 } else {
121 (100.0 * plus_v / tr_v, 100.0 * minus_v / tr_v)
122 };
123 let di_sum = plus_di + minus_di;
124 let dx = if di_sum == 0.0 {
125 0.0
126 } else {
127 100.0 * (plus_di - minus_di).abs() / di_sum
128 };
129 Some(dx)
130 }
131
132 fn reset(&mut self) {
133 self.prev = None;
134 self.plus_dm_seed = 0.0;
135 self.minus_dm_seed = 0.0;
136 self.tr_seed = 0.0;
137 self.seed_count = 0;
138 self.plus_dm_smooth = None;
139 self.minus_dm_smooth = None;
140 self.tr_smooth = None;
141 }
142
143 #[inline]
144 fn warmup_period(&self) -> usize {
145 self.period + 1
146 }
147
148 #[inline]
149 fn is_ready(&self) -> bool {
150 self.tr_smooth.is_some()
151 }
152
153 #[inline]
154 fn name(&self) -> &'static str {
155 "DX"
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::traits::BatchExt;
163 use approx::assert_relative_eq;
164
165 fn c(h: f64, l: f64, cl: f64) -> Candle {
166 Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
167 }
168
169 #[test]
170 fn rejects_zero_period() {
171 assert!(matches!(Dx::new(0), Err(Error::PeriodZero)));
172 }
173
174 #[test]
175 fn accessors_report_config() {
176 let dx = Dx::new(7).unwrap();
177 assert_eq!(dx.period(), 7);
178 assert_eq!(dx.name(), "DX");
179 assert_eq!(dx.warmup_period(), 8);
180 assert!(!dx.is_ready());
181 }
182
183 #[test]
184 fn warmup_period_matches_the_first_emitted_value() {
185 let candles: Vec<Candle> = (0..12)
189 .map(|i| {
190 let x = f64::from(i);
191 c(11.0 + x, 9.0 + 0.5 * x, 10.0 + x)
192 })
193 .collect();
194 for period in 1..=5 {
195 let mut dx = Dx::new(period).unwrap();
196 let out: Vec<Option<f64>> = dx.batch(&candles);
197 let first = out.iter().position(Option::is_some).unwrap();
198 assert_eq!(first + 1, dx.warmup_period());
199 }
200 }
201
202 #[test]
203 fn strong_trend_drives_dx_high() {
204 let candles: Vec<Candle> = (0..12)
206 .map(|i| {
207 let base = 100.0 + f64::from(i) * 2.0;
208 c(base + 1.0, base - 0.5, base + 0.5)
209 })
210 .collect();
211 let mut dx = Dx::new(3).unwrap();
212 let out: Vec<Option<f64>> = dx.batch(&candles);
213 assert_eq!(out[0], None);
214 assert!(out[3].is_some());
215 let last = out.into_iter().flatten().last().unwrap();
216 assert!(last > 50.0 && last <= 100.0);
217 assert!(dx.is_ready());
218 }
219
220 #[test]
221 fn flat_market_returns_zero() {
222 let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
224 let mut dx = Dx::new(3).unwrap();
225 let last = dx.batch(&candles).into_iter().flatten().last().unwrap();
226 assert_relative_eq!(last, 0.0, epsilon = 1e-12);
227 }
228
229 #[test]
230 fn balanced_directional_movement_is_low() {
231 let candles: Vec<Candle> = (0..30)
234 .map(|i| {
235 let base = if i % 2 == 0 { 100.0 } else { 101.0 };
236 c(base + 1.0, base - 1.0, base)
237 })
238 .collect();
239 let mut dx = Dx::new(5).unwrap();
240 let last = dx.batch(&candles).into_iter().flatten().last().unwrap();
241 assert!((0.0..=100.0).contains(&last));
242 }
243
244 #[test]
245 fn reset_restores_initial_state() {
246 let candles: Vec<Candle> = (0..6)
247 .map(|i| {
248 let base = 100.0 + f64::from(i) * 2.0;
249 c(base + 1.0, base - 0.5, base + 0.5)
250 })
251 .collect();
252 let mut dx = Dx::new(3).unwrap();
253 let _ = dx.batch(&candles);
254 assert!(dx.is_ready());
255 dx.reset();
256 assert!(!dx.is_ready());
257 assert_eq!(dx.update(candles[0]), None);
258 }
259}