quantwave_core/indicators/
sdo.rs1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::smoothing::EMA;
3use crate::traits::Next;
4use std::collections::VecDeque;
5
6#[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); }
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 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}