Skip to main content

wickra_core/indicators/
derivative_oscillator.rs

1//! Derivative Oscillator (Constance Brown).
2
3use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::indicators::rsi::Rsi;
6use crate::indicators::sma::Sma;
7use crate::traits::Indicator;
8
9/// Derivative Oscillator — Constance Brown's double-smoothed RSI histogram.
10///
11/// The RSI is smoothed twice with EMAs, then a simple moving average of that
12/// double-smoothed line is subtracted as a signal, leaving a zero-centered
13/// histogram:
14///
15/// ```text
16/// rsi   = RSI(price, rsi_period)
17/// s1    = EMA(rsi, smooth1)
18/// s2    = EMA(s1,  smooth2)          // double-smoothed RSI
19/// signal = SMA(s2, signal_period)
20/// DerivativeOscillator = s2 - signal
21/// ```
22///
23/// The double EMA smoothing strips the RSI's high-frequency noise, and
24/// subtracting the SMA signal removes the residual level, so the result
25/// oscillates around zero: positive (and rising) bars mark accelerating bullish
26/// momentum, negative bars bearish. Brown's defaults are `rsi_period = 14`,
27/// `smooth1 = 5`, `smooth2 = 3`, `signal_period = 9`.
28///
29/// The first value lands after `rsi_period + smooth1 + smooth2 + signal_period − 2`
30/// inputs, the point at which the whole RSI → EMA → EMA → SMA chain is seeded.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{DerivativeOscillator, Indicator};
36///
37/// let mut indicator = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
38/// let mut last = None;
39/// for i in 0..120 {
40///     last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
41/// }
42/// assert!(last.is_some());
43/// ```
44#[derive(Debug, Clone)]
45pub struct DerivativeOscillator {
46    rsi: Rsi,
47    ema1: Ema,
48    ema2: Ema,
49    signal: Sma,
50    warmup: usize,
51}
52
53impl DerivativeOscillator {
54    /// Construct a Derivative Oscillator with the RSI, two EMA smoothing, and
55    /// SMA signal periods.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`Error::PeriodZero`] if any period is `0`.
60    pub fn new(
61        rsi_period: usize,
62        smooth1: usize,
63        smooth2: usize,
64        signal_period: usize,
65    ) -> Result<Self> {
66        if rsi_period == 0 || smooth1 == 0 || smooth2 == 0 || signal_period == 0 {
67            return Err(Error::PeriodZero);
68        }
69        Ok(Self {
70            rsi: Rsi::new(rsi_period)?,
71            ema1: Ema::new(smooth1)?,
72            ema2: Ema::new(smooth2)?,
73            signal: Sma::new(signal_period)?,
74            // RSI seeds at rsi_period + 1, then each stage adds (len - 1).
75            warmup: rsi_period + smooth1 + smooth2 + signal_period - 2,
76        })
77    }
78
79    /// Total warmup length (also returned by `warmup_period`).
80    pub const fn warmup(&self) -> usize {
81        self.warmup
82    }
83}
84
85impl Indicator for DerivativeOscillator {
86    type Input = f64;
87    type Output = f64;
88
89    #[inline]
90    fn update(&mut self, input: f64) -> Option<f64> {
91        let rsi = self.rsi.update(input)?;
92        let s1 = self.ema1.update(rsi)?;
93        let s2 = self.ema2.update(s1)?;
94        let signal = self.signal.update(s2)?;
95        Some(s2 - signal)
96    }
97
98    fn reset(&mut self) {
99        self.rsi.reset();
100        self.ema1.reset();
101        self.ema2.reset();
102        self.signal.reset();
103    }
104
105    #[inline]
106    fn warmup_period(&self) -> usize {
107        self.warmup
108    }
109
110    #[inline]
111    fn is_ready(&self) -> bool {
112        self.signal.is_ready()
113    }
114
115    #[inline]
116    fn name(&self) -> &'static str {
117        "DerivativeOscillator"
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::traits::BatchExt;
125    use approx::assert_relative_eq;
126
127    #[test]
128    fn rejects_zero_periods() {
129        assert!(matches!(
130            DerivativeOscillator::new(0, 5, 3, 9),
131            Err(Error::PeriodZero)
132        ));
133        assert!(matches!(
134            DerivativeOscillator::new(14, 0, 3, 9),
135            Err(Error::PeriodZero)
136        ));
137        assert!(matches!(
138            DerivativeOscillator::new(14, 5, 0, 9),
139            Err(Error::PeriodZero)
140        ));
141        assert!(matches!(
142            DerivativeOscillator::new(14, 5, 3, 0),
143            Err(Error::PeriodZero)
144        ));
145    }
146
147    /// Cover the const accessor `warmup` and the Indicator-impl `warmup_period`
148    /// + `name`.
149    #[test]
150    fn accessors_and_metadata() {
151        let d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
152        // 14 + 5 + 3 + 9 - 2 = 29.
153        assert_eq!(d.warmup(), 29);
154        assert_eq!(d.warmup_period(), 29);
155        assert_eq!(d.name(), "DerivativeOscillator");
156    }
157
158    #[test]
159    fn first_emission_matches_warmup_period() {
160        let prices: Vec<f64> = (0..60)
161            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
162            .collect();
163        let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
164        let out = d.batch(&prices);
165        let warmup = d.warmup_period();
166        for (i, v) in out.iter().enumerate().take(warmup - 1) {
167            assert!(v.is_none(), "index {i} must be None during warmup");
168        }
169        assert!(
170            out[warmup - 1].is_some(),
171            "first value must land at warmup_period - 1"
172        );
173    }
174
175    #[test]
176    fn matches_manual_chain() {
177        // Equals RSI -> EMA -> EMA, minus the SMA signal of that line.
178        let prices: Vec<f64> = (0..80)
179            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
180            .collect();
181        let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
182        let mut rsi = Rsi::new(14).unwrap();
183        let mut e1 = Ema::new(5).unwrap();
184        let mut e2 = Ema::new(3).unwrap();
185        let mut sig = Sma::new(9).unwrap();
186        for (i, &p) in prices.iter().enumerate() {
187            let got = d.update(p);
188            let want = rsi
189                .update(p)
190                .and_then(|r| e1.update(r))
191                .and_then(|x| e2.update(x))
192                .and_then(|s2| sig.update(s2).map(|s| s2 - s));
193            assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
194            if let (Some(a), Some(b)) = (got, want) {
195                assert_relative_eq!(a, b, epsilon = 1e-9);
196            }
197        }
198    }
199
200    #[test]
201    fn reset_clears_state() {
202        let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
203        d.batch(&(0..60).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
204        assert!(d.is_ready());
205        d.reset();
206        assert!(!d.is_ready());
207        assert_eq!(d.update(1.0), None);
208    }
209
210    #[test]
211    fn batch_equals_streaming() {
212        let prices: Vec<f64> = (0..80)
213            .map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
214            .collect();
215        let mut a = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
216        let mut b = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
217        assert_eq!(
218            a.batch(&prices),
219            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
220        );
221    }
222}