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    fn update(&mut self, input: f64) -> Option<f64> {
90        let rsi = self.rsi.update(input)?;
91        let s1 = self.ema1.update(rsi)?;
92        let s2 = self.ema2.update(s1)?;
93        let signal = self.signal.update(s2)?;
94        Some(s2 - signal)
95    }
96
97    fn reset(&mut self) {
98        self.rsi.reset();
99        self.ema1.reset();
100        self.ema2.reset();
101        self.signal.reset();
102    }
103
104    fn warmup_period(&self) -> usize {
105        self.warmup
106    }
107
108    fn is_ready(&self) -> bool {
109        self.signal.is_ready()
110    }
111
112    fn name(&self) -> &'static str {
113        "DerivativeOscillator"
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::traits::BatchExt;
121    use approx::assert_relative_eq;
122
123    #[test]
124    fn rejects_zero_periods() {
125        assert!(matches!(
126            DerivativeOscillator::new(0, 5, 3, 9),
127            Err(Error::PeriodZero)
128        ));
129        assert!(matches!(
130            DerivativeOscillator::new(14, 0, 3, 9),
131            Err(Error::PeriodZero)
132        ));
133        assert!(matches!(
134            DerivativeOscillator::new(14, 5, 0, 9),
135            Err(Error::PeriodZero)
136        ));
137        assert!(matches!(
138            DerivativeOscillator::new(14, 5, 3, 0),
139            Err(Error::PeriodZero)
140        ));
141    }
142
143    /// Cover the const accessor `warmup` and the Indicator-impl `warmup_period`
144    /// + `name`.
145    #[test]
146    fn accessors_and_metadata() {
147        let d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
148        // 14 + 5 + 3 + 9 - 2 = 29.
149        assert_eq!(d.warmup(), 29);
150        assert_eq!(d.warmup_period(), 29);
151        assert_eq!(d.name(), "DerivativeOscillator");
152    }
153
154    #[test]
155    fn first_emission_matches_warmup_period() {
156        let prices: Vec<f64> = (0..60)
157            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
158            .collect();
159        let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
160        let out = d.batch(&prices);
161        let warmup = d.warmup_period();
162        for (i, v) in out.iter().enumerate().take(warmup - 1) {
163            assert!(v.is_none(), "index {i} must be None during warmup");
164        }
165        assert!(
166            out[warmup - 1].is_some(),
167            "first value must land at warmup_period - 1"
168        );
169    }
170
171    #[test]
172    fn matches_manual_chain() {
173        // Equals RSI -> EMA -> EMA, minus the SMA signal of that line.
174        let prices: Vec<f64> = (0..80)
175            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
176            .collect();
177        let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
178        let mut rsi = Rsi::new(14).unwrap();
179        let mut e1 = Ema::new(5).unwrap();
180        let mut e2 = Ema::new(3).unwrap();
181        let mut sig = Sma::new(9).unwrap();
182        for (i, &p) in prices.iter().enumerate() {
183            let got = d.update(p);
184            let want = rsi
185                .update(p)
186                .and_then(|r| e1.update(r))
187                .and_then(|x| e2.update(x))
188                .and_then(|s2| sig.update(s2).map(|s| s2 - s));
189            assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
190            if let (Some(a), Some(b)) = (got, want) {
191                assert_relative_eq!(a, b, epsilon = 1e-9);
192            }
193        }
194    }
195
196    #[test]
197    fn reset_clears_state() {
198        let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
199        d.batch(&(0..60).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
200        assert!(d.is_ready());
201        d.reset();
202        assert!(!d.is_ready());
203        assert_eq!(d.update(1.0), None);
204    }
205
206    #[test]
207    fn batch_equals_streaming() {
208        let prices: Vec<f64> = (0..80)
209            .map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
210            .collect();
211        let mut a = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
212        let mut b = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
213        assert_eq!(
214            a.batch(&prices),
215            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
216        );
217    }
218}