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