Skip to main content

egui_charts/studies/builtin/
dpo.rs

1//! Detrended Price Oscillator (DPO)
2//!
3//! The DPO removes the trend from the close by comparing it with a moving average shifted by
4//! half its length plus one bar, so that cycles shorter than the length stand out.
5//!
6//! # Formula
7//! ```text
8//! shift = length / 2 + 1   (integer division)
9//! non-centred: DPO = close - SMA(close, length)[shift]
10//! centred:     DPO = close[shift] - SMA(close, length), drawn `shift` bars back
11//! ```
12//! with conventional length 21, non-centred. The non-centred form is aligned with the current
13//! bar. The centred form compares each close with the average centred on it; its value is
14//! stored at the bar that completes the average and [`plot_offset`](Indicator::plot_offset)
15//! draws it `shift` bars back, so its last `shift` bars are blank. A value that would be drawn
16//! back over a bar skipped for a non-finite close is left out, as it would not lie under the close
17//! it uses.
18//!
19//! # Example
20//! ```
21//! # let bars: Vec<egui_charts::model::Bar> = (0..60).map(|i| { let p = 100.0 + (i as f64 * 0.3).sin() * 5.0; egui_charts::model::Bar::new(chrono::Utc::now(), p, p + 1.0, p - 1.0, p + 0.5, 1_000.0) }).collect();
22//! use egui_charts::studies::{DetrendedPriceOscillator, Indicator};
23//!
24//! let mut dpo = DetrendedPriceOscillator::new(21);
25//! dpo.calculate(&bars);
26//! assert!(dpo.values().value(0, 31).is_some());
27//!
28//! let mut centred = DetrendedPriceOscillator::new(21).with_centered(true);
29//! centred.calculate(&bars);
30//! assert_eq!(centred.plot_offset(0), -11);
31//! ```
32
33use super::over_finite_bars;
34use crate::model::Bar;
35use crate::studies::palette;
36use crate::studies::stepper::{rolling_mean, update_window, usable_behind};
37use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
38use egui::Color32;
39
40/// Detrended Price Oscillator, aligned with the current bar or centred on the average.
41#[derive(Clone)]
42pub struct DetrendedPriceOscillator {
43    period: usize,
44    centered: bool,
45    values: IndicatorValues,
46    color: Color32,
47    visible: bool,
48}
49
50impl DetrendedPriceOscillator {
51    /// Create a non-centred DPO over `period` bars (conventionally 21).
52    pub fn new(period: usize) -> Self {
53        Self {
54            period: period.max(1),
55            centered: false,
56            values: IndicatorValues::new(),
57            color: palette::DARK_GREEN,
58            visible: true,
59        }
60    }
61
62    /// Compare each close with the average centred on it, drawn back by the shift.
63    pub fn with_centered(mut self, centered: bool) -> Self {
64        self.centered = centered;
65        self
66    }
67
68    /// Set the plot color.
69    pub fn with_color(mut self, color: Color32) -> Self {
70        self.color = color;
71        self
72    }
73
74    fn shift(&self) -> usize {
75        self.period / 2 + 1
76    }
77
78    /// The values over `data`, every bar of which has a finite close.
79    fn finite_compute(&self, data: &[Bar]) -> IndicatorValues {
80        let closes: Vec<f64> = data.iter().map(|b| b.close).collect();
81        let average = rolling_mean(&closes, self.period);
82        (0..data.len())
83            .map(|i| {
84                IndicatorValue::Single(match i.checked_sub(self.shift()) {
85                    Some(back) if self.centered => closes[back] - average[i],
86                    Some(back) => closes[i] - average[back],
87                    None => f64::NAN,
88                })
89            })
90            .collect()
91    }
92}
93
94/// Construct with the conventional default parameters.
95impl Default for DetrendedPriceOscillator {
96    fn default() -> Self {
97        Self::new(21)
98    }
99}
100
101impl DetrendedPriceOscillator {
102    fn replay(&self, data: &[Bar]) -> IndicatorValues {
103        let mut values = over_finite_bars(
104            data,
105            |b| b.close.is_finite(),
106            |data| self.finite_compute(data),
107        );
108        if self.centered {
109            // Drawn back over a skipped bar, a value would sit beside the close it uses.
110            let mut skipped = None;
111            values = (0..data.len())
112                .map(|i| {
113                    if !data[i].close.is_finite() {
114                        skipped = Some(i);
115                    }
116                    let near = skipped.is_some_and(|s| i - s <= self.shift());
117                    IndicatorValue::Single(if near {
118                        f64::NAN
119                    } else {
120                        values.value(0, i).unwrap_or(f64::NAN)
121                    })
122                })
123                .collect();
124        }
125        values
126    }
127}
128
129impl Indicator for DetrendedPriceOscillator {
130    fn source(&self) -> Option<crate::studies::StudySource> {
131        Some(crate::studies::StudySource::Price(
132            crate::model::PriceSource::Close,
133        ))
134    }
135
136    crate::studies::inputs::study_inputs!(self;
137        period: length("Length", 1, usize::MAX),
138        centered: toggle("Centered")
139    ; {});
140
141    fn plot_offset(&self, _line: usize) -> isize {
142        if self.centered {
143            -(self.shift().min(isize::MAX as usize) as isize)
144        } else {
145            0
146        }
147    }
148
149    fn value_format(&self) -> crate::scales::StudyFormat {
150        crate::scales::StudyFormat::Price
151    }
152
153    fn name(&self) -> &str {
154        "DPO"
155    }
156
157    fn desc(&self) -> &str {
158        "Detrended Price Oscillator - Removes trend to identify cycles"
159    }
160
161    fn calculate(&mut self, data: &[Bar]) {
162        self.update(data, 0);
163    }
164
165    fn update(&mut self, data: &[Bar], from: usize) {
166        let mut values = std::mem::take(&mut self.values);
167        update_window(
168            &mut values,
169            data,
170            from,
171            usable_behind(
172                self.period.saturating_sub(1).saturating_add(self.shift()),
173                |b| b.close.is_finite(),
174            ),
175            |data| self.replay(data),
176        );
177        self.values = values;
178    }
179
180    fn is_incremental(&self) -> bool {
181        true
182    }
183
184    fn values(&self) -> &IndicatorValues {
185        &self.values
186    }
187
188    fn colors(&self) -> Vec<Color32> {
189        vec![self.color]
190    }
191
192    fn set_colors(&mut self, colors: Vec<Color32>) {
193        if !colors.is_empty() {
194            self.color = colors[0];
195        }
196    }
197
198    fn pane(&self) -> crate::studies::StudyPane {
199        crate::studies::StudyPane {
200            levels: vec![(0.0, palette::GRAY, "Zero")],
201            ..Default::default()
202        }
203    }
204
205    fn is_overlay(&self) -> bool {
206        false // Separate pane
207    }
208
209    fn is_visible(&self) -> bool {
210        self.visible
211    }
212
213    fn set_visible(&mut self, visible: bool) {
214        self.visible = visible;
215    }
216
217    fn clone_box(&self) -> Box<dyn Indicator> {
218        Box::new(self.clone())
219    }
220
221    fn line_names(&self) -> Vec<String> {
222        vec![format!("DPO({})", self.period)]
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use chrono::{TimeZone, Utc};
230
231    /// With length 1 the shift is 1 and the average is the close, so a centred value is drawn
232    /// under the close one bar before the one completing it. Nothing is drawn under a skipped bar,
233    /// nor under the bar before it, whose value the skipped bar would complete.
234    #[test]
235    fn centred_values_stay_under_their_close_past_a_skipped_bar() {
236        let bars: Vec<Bar> = [0.0, 1.0, 4.0, f64::NAN, 9.0, 16.0]
237            .iter()
238            .enumerate()
239            .map(|(i, &c)| {
240                Bar::new(
241                    Utc.timestamp_opt(i as i64 * 60, 0).unwrap(),
242                    c,
243                    c,
244                    c,
245                    c,
246                    1.0,
247                )
248            })
249            .collect();
250        let mut dpo = DetrendedPriceOscillator::new(1).with_centered(true);
251        dpo.calculate(&bars);
252        let drawn: Vec<_> = (0..5).map(|bar| dpo.plot_value(0, bar)).collect();
253        assert_eq!(drawn, [Some(-1.0), Some(-3.0), None, None, Some(-7.0)]);
254    }
255}