Skip to main content

wickra_core/indicators/
dpo.rs

1//! Detrended Price Oscillator.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Detrended Price Oscillator — strips the trend out of price to expose its
9/// shorter cycles.
10///
11/// Instead of comparing price to a *current* moving average, DPO compares a
12/// **past** price — shifted back by `period / 2 + 1` bars — to the moving
13/// average of the window:
14///
15/// ```text
16/// shift = period / 2 + 1
17/// DPO_t = price_{t − shift} − SMA(period)_t
18/// ```
19///
20/// Because the price is taken from roughly half a cycle back, the dominant
21/// trend cancels out and what remains oscillates around zero — making the
22/// peak-to-peak cycle length easy to read. DPO is **not** a momentum
23/// indicator and is not meant to track the latest bar.
24///
25/// # Example
26///
27/// ```
28/// use wickra_core::{Indicator, Dpo};
29///
30/// let mut indicator = Dpo::new(20).unwrap();
31/// let mut last = None;
32/// for i in 0..80 {
33///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 10.0);
34/// }
35/// assert!(last.is_some());
36/// ```
37#[derive(Debug, Clone)]
38pub struct Dpo {
39    period: usize,
40    shift: usize,
41    /// Window of the most recent `capacity` prices, oldest at the front.
42    capacity: usize,
43    window: VecDeque<f64>,
44    sum: f64,
45    last: Option<f64>,
46}
47
48impl Dpo {
49    /// Construct a new DPO with the given period.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`Error::PeriodZero`] if `period == 0`.
54    pub fn new(period: usize) -> Result<Self> {
55        if period == 0 {
56            return Err(Error::PeriodZero);
57        }
58        let shift = period / 2 + 1;
59        // The window must cover both the SMA (`period` prices) and the
60        // look-back (`shift + 1` prices: the current bar plus `shift` history).
61        let capacity = period.max(shift + 1);
62        Ok(Self {
63            period,
64            shift,
65            capacity,
66            window: VecDeque::with_capacity(capacity),
67            sum: 0.0,
68            last: None,
69        })
70    }
71
72    /// Configured period.
73    pub const fn period(&self) -> usize {
74        self.period
75    }
76
77    /// The look-back shift `period / 2 + 1`.
78    pub const fn shift(&self) -> usize {
79        self.shift
80    }
81
82    /// Current value if available.
83    pub const fn value(&self) -> Option<f64> {
84        self.last
85    }
86}
87
88impl Indicator for Dpo {
89    type Input = f64;
90    type Output = f64;
91
92    fn update(&mut self, input: f64) -> Option<f64> {
93        if !input.is_finite() {
94            // Non-finite input is ignored; the window is left untouched.
95            return self.last;
96        }
97        self.window.push_back(input);
98        self.sum += input;
99        let len = self.window.len();
100        if len > self.period {
101            // The price that just left the SMA window.
102            self.sum -= self.window[len - 1 - self.period];
103        }
104        if self.window.len() > self.capacity {
105            self.window.pop_front();
106        }
107        if self.window.len() < self.capacity {
108            return None;
109        }
110        let sma = self.sum / self.period as f64;
111        // `price_{t - shift}` — index counts back from the newest bar.
112        let shifted = self.window[self.window.len() - 1 - self.shift];
113        let dpo = shifted - sma;
114        self.last = Some(dpo);
115        Some(dpo)
116    }
117
118    fn reset(&mut self) {
119        self.window.clear();
120        self.sum = 0.0;
121        self.last = None;
122    }
123
124    fn warmup_period(&self) -> usize {
125        self.capacity
126    }
127
128    fn is_ready(&self) -> bool {
129        self.last.is_some()
130    }
131
132    fn name(&self) -> &'static str {
133        "DPO"
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::traits::BatchExt;
141    use approx::assert_relative_eq;
142
143    #[test]
144    fn new_rejects_zero_period() {
145        assert!(matches!(Dpo::new(0), Err(Error::PeriodZero)));
146    }
147
148    /// Cover the const accessors `period` / `value` (73-85) and the
149    /// Indicator-impl `name` body (132-134). `shift` is already covered
150    /// by `shift_is_half_period_plus_one`; `warmup_period` by
151    /// `reference_values`.
152    #[test]
153    fn accessors_and_metadata() {
154        let mut dpo = Dpo::new(20).unwrap();
155        assert_eq!(dpo.period(), 20);
156        assert_eq!(dpo.name(), "DPO");
157        assert_eq!(dpo.value(), None);
158        for i in 1..=dpo.warmup_period() {
159            dpo.update(f64::from(u32::try_from(i).unwrap()));
160        }
161        assert!(dpo.value().is_some());
162    }
163
164    #[test]
165    fn shift_is_half_period_plus_one() {
166        assert_eq!(Dpo::new(20).unwrap().shift(), 11);
167        assert_eq!(Dpo::new(4).unwrap().shift(), 3);
168    }
169
170    #[test]
171    fn reference_values() {
172        // DPO(4): shift = 3, capacity = max(4, 4) = 4.
173        // At input 4: window [1,2,3,4], SMA = 2.5, price[t-3] = 1 -> 1 - 2.5 = -1.5.
174        let mut dpo = Dpo::new(4).unwrap();
175        let out = dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
176        assert_eq!(dpo.warmup_period(), 4);
177        assert_eq!(out[0], None);
178        assert_eq!(out[2], None);
179        assert_relative_eq!(out[3].unwrap(), -1.5, epsilon = 1e-12);
180        assert_relative_eq!(out[4].unwrap(), -1.5, epsilon = 1e-12);
181        assert_relative_eq!(out[5].unwrap(), -1.5, epsilon = 1e-12);
182    }
183
184    #[test]
185    fn constant_series_yields_zero() {
186        // A flat series: the shifted price equals the SMA, so DPO is 0.
187        let mut dpo = Dpo::new(10).unwrap();
188        let out = dpo.batch(&[50.0; 40]);
189        for v in out.iter().skip(dpo.warmup_period() - 1).flatten() {
190            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
191        }
192    }
193
194    #[test]
195    fn ignores_non_finite_input() {
196        let mut dpo = Dpo::new(4).unwrap();
197        let out = dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
198        let last = *out.last().unwrap();
199        assert!(last.is_some());
200        assert_eq!(dpo.update(f64::NAN), last);
201        assert_eq!(dpo.update(f64::INFINITY), last);
202    }
203
204    #[test]
205    fn reset_clears_state() {
206        let mut dpo = Dpo::new(4).unwrap();
207        dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
208        assert!(dpo.is_ready());
209        dpo.reset();
210        assert!(!dpo.is_ready());
211        assert_eq!(dpo.update(1.0), None);
212    }
213
214    #[test]
215    fn batch_equals_streaming() {
216        let prices: Vec<f64> = (1..=80)
217            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 7.0)
218            .collect();
219        let batch = Dpo::new(20).unwrap().batch(&prices);
220        let mut b = Dpo::new(20).unwrap();
221        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
222        assert_eq!(batch, streamed);
223    }
224}