Skip to main content

quantwave_core/indicators/
sdo.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::smoothing::EMA;
3use crate::traits::Next;
4use std::collections::VecDeque;
5
6/// Stochastic Distance Oscillator (SDO)
7///
8/// Based on Vitali Apirine's article "The Stochastic Distance Oscillator" (TASC June 2023).
9/// The SDO is a momentum study that shows the magnitude of the current distance relative
10/// to the maximum-minimum distance range over a set period.
11#[derive(Debug, Clone)]
12pub struct SDO {
13    lookback_period: usize,
14    period: usize,
15    ema: EMA,
16    prices: VecDeque<f64>,
17    distances: VecDeque<f64>,
18}
19
20impl SDO {
21    pub fn new(lookback_period: usize, period: usize, ema_pds: usize) -> Self {
22        Self {
23            lookback_period,
24            period,
25            ema: EMA::new(ema_pds),
26            prices: VecDeque::with_capacity(period + 1),
27            distances: VecDeque::with_capacity(lookback_period),
28        }
29    }
30}
31
32impl Default for SDO {
33    fn default() -> Self {
34        Self::new(200, 12, 3)
35    }
36}
37
38impl Next<f64> for SDO {
39    type Output = f64;
40
41    fn next(&mut self, input: f64) -> Self::Output {
42        self.prices.push_back(input);
43        if self.prices.len() > self.period + 1 {
44            self.prices.pop_front();
45        }
46
47        if self.prices.len() <= self.period {
48            return 0.0;
49        }
50
51        let prev_price = self.prices[0];
52        let dist = (input - prev_price).abs();
53        
54        self.distances.push_back(dist);
55        if self.distances.len() > self.lookback_period {
56            self.distances.pop_front();
57        }
58
59        let mut max_dist = f64::MIN;
60        let mut min_dist = f64::MAX;
61
62        for &d in self.distances.iter() {
63            if d > max_dist {
64                max_dist = d;
65            }
66            if d < min_dist {
67                min_dist = d;
68            }
69        }
70
71        let mut ddo = 0.0;
72        let range = max_dist - min_dist;
73        if range > 0.0 {
74            ddo = (dist - min_dist) / range;
75        }
76
77        let dd_val = if input > prev_price {
78            ddo
79        } else if input < prev_price {
80            -ddo
81        } else {
82            0.0
83        };
84
85        self.ema.next(dd_val) * 100.0
86    }
87}
88
89pub const SDO_METADATA: IndicatorMetadata = IndicatorMetadata {
90    name: "Stochastic Distance Oscillator",
91    description: "A momentum indicator based on the classic stochastic oscillator applied to price distances.",
92    usage: "Identify bull and bear trend changes through overbought (+40) and oversold (-40) levels. Suitable for both trending and ranging markets.",
93    keywords: &["momentum", "stochastic", "oscillator", "apirine", "trend"],
94    ehlers_summary: "The Stochastic Distance Oscillator (SDO) by Vitali Apirine adapts the stochastic formula to measure the current price distance relative to its historical range. By smoothing this relative distance with an EMA, it provides a cleaner momentum signal that identifies potential trend reversals when crossing extreme thresholds.",
95    params: &[
96        ParamDef {
97            name: "lookback_period",
98            default: "200",
99            description: "Range lookback for stochastic calculation",
100        },
101        ParamDef {
102            name: "period",
103            default: "12",
104            description: "Distance calculation period",
105        },
106        ParamDef {
107            name: "ema_pds",
108            default: "3",
109            description: "Smoothing EMA period",
110        },
111    ],
112    formula_source: "https://traders.com/Documentation/FEEDbk_docs/2023/06/TradersTips.html",
113    formula_latex: r#"
114\[
115Dist = |Price_t - Price_{t-n}|
116\]
117\[
118DVal = \frac{Dist - \min(Dist_{lookback})}{\max(Dist_{lookback}) - \min(Dist_{lookback})}
119\]
120\[
121DDVal = \begin{cases} DVal & \text{if } Price_t > Price_{t-n} \\ -DVal & \text{if } Price_t < Price_{t-n} \\ 0 & \text{otherwise} \end{cases}
122\]
123\[
124SDO = EMA(DDVal, smoothing) \times 100
125\]
126"#,
127    gold_standard_file: "sdo.json",
128    category: "Momentum",
129};
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::traits::Next;
135    use proptest::prelude::*;
136
137    #[test]
138    fn test_sdo_basic() {
139        let mut sdo = SDO::new(200, 12, 3);
140        for i in 0..250 {
141            let val = sdo.next(100.0 + i as f64);
142            assert!(!val.is_nan());
143            if i > 12 {
144                assert!(val >= 0.0); // Trending up
145            }
146        }
147    }
148
149    proptest! {
150        #[test]
151        fn test_sdo_parity(
152            inputs in prop::collection::vec(1.0..100.0, 250..300),
153        ) {
154            let lookback = 200;
155            let period = 12;
156            let ema_pds = 3;
157            let mut sdo_obj = SDO::new(lookback, period, ema_pds);
158            let streaming_results: Vec<f64> = inputs.iter().map(|&x| sdo_obj.next(x)).collect();
159
160            // Batch implementation
161            let mut batch_results = Vec::with_capacity(inputs.len());
162            let mut ema_obj = EMA::new(ema_pds);
163            let mut distances = Vec::new();
164
165            for (i, &input) in inputs.iter().enumerate() {
166                if i < period {
167                    batch_results.push(0.0);
168                    continue;
169                }
170
171                let prev_price = inputs[i - period];
172                let dist = (input - prev_price).abs();
173                distances.push(dist);
174
175                let start = if distances.len() > lookback { distances.len() - lookback } else { 0 };
176                let current_window = &distances[start..];
177                
178                let mut max_dist = f64::MIN;
179                let mut min_dist = f64::MAX;
180                for &d in current_window {
181                    if d > max_dist { max_dist = d; }
182                    if d < min_dist { min_dist = d; }
183                }
184
185                let mut ddo = 0.0;
186                let range = max_dist - min_dist;
187                if range > 0.0 {
188                    ddo = (dist - min_dist) / range;
189                }
190
191                let dd_val = if input > prev_price {
192                    ddo
193                } else if input < prev_price {
194                    -ddo
195                } else {
196                    0.0
197                };
198
199                batch_results.push(ema_obj.next(dd_val) * 100.0);
200            }
201
202            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
203                approx::assert_relative_eq!(s, b, epsilon = 1e-10);
204            }
205        }
206    }
207}