Skip to main content

quant_indicators/
detrended.rs

1//! Detrended Oscillator indicator.
2//!
3//! Measures how far price has moved from its Hull MA trend,
4//! normalised by ATR so the result is dimensionless (no price units).
5//!
6//! # Formula
7//!
8//! DetrendedOscillator = (close - HMA(n)) / ATR(m)
9//!
10//! A value of +1.0 means price is 1 ATR above the HMA trend.
11//! A value of -1.0 means price is 1 ATR below the HMA trend.
12
13use quant_primitives::Candle;
14use rust_decimal::Decimal;
15
16use crate::atr::Atr;
17use crate::error::IndicatorError;
18use crate::hull::HullMa;
19use crate::indicator::Indicator;
20use crate::series::Series;
21
22/// Detrended Oscillator: (close - HMA) / ATR.
23///
24/// Pure function — no I/O, no state between calls.
25#[derive(Debug, Clone)]
26pub struct DetrendedOscillator {
27    hma: HullMa,
28    atr: Atr,
29    name: String,
30}
31
32impl DetrendedOscillator {
33    /// Create a new Detrended Oscillator.
34    ///
35    /// # Parameters
36    /// - `hma_period`: period for the Hull Moving Average (trend baseline)
37    /// - `atr_period`: period for ATR (volatility normalisation)
38    ///
39    /// # Errors
40    ///
41    /// Returns `InvalidParameter` if either period is invalid.
42    pub fn new(hma_period: usize, atr_period: usize) -> Result<Self, IndicatorError> {
43        let hma = HullMa::new(hma_period)?;
44        let atr = Atr::new(atr_period)?;
45        Ok(Self {
46            name: format!("DetrendedOsc(HMA={},ATR={})", hma_period, atr_period),
47            hma,
48            atr,
49        })
50    }
51}
52
53impl Indicator for DetrendedOscillator {
54    fn name(&self) -> &str {
55        &self.name
56    }
57
58    fn warmup_period(&self) -> usize {
59        // Need enough candles for both HMA and ATR to produce output.
60        // HMA warmup >= ATR warmup in typical configs (period + sqrt_period > atr_period + 1)
61        // but we take the max to be safe.
62        self.hma.warmup_period().max(self.atr.warmup_period())
63    }
64
65    fn compute(&self, candles: &[Candle]) -> Result<Series, IndicatorError> {
66        let required = self.warmup_period();
67        if candles.len() < required {
68            return Err(IndicatorError::InsufficientData {
69                required,
70                actual: candles.len(),
71            });
72        }
73
74        let hma_series = self.hma.compute(candles)?;
75        let atr_series = self.atr.compute(candles)?;
76
77        // HMA and ATR produce series of different lengths; align on the shorter one.
78        // Both are suffix-aligned (last candle is always the last value).
79        let hma_vals = hma_series.values();
80        let atr_vals = atr_series.values();
81
82        // Take the shorter length (both are anchored to the tail of candles).
83        let len = hma_vals.len().min(atr_vals.len());
84
85        // Align: take the last `len` values from each.
86        let hma_tail = &hma_vals[hma_vals.len() - len..];
87        let atr_tail = &atr_vals[atr_vals.len() - len..];
88
89        // Candles are aligned to the same tail.
90        let candle_tail = &candles[candles.len() - len..];
91
92        let mut values = Vec::with_capacity(len);
93        for i in 0..len {
94            let close = candle_tail[i].close();
95            let hma = hma_tail[i].1;
96            let atr = atr_tail[i].1;
97            let ts = candle_tail[i].timestamp();
98
99            if atr == Decimal::ZERO {
100                // Flat series — oscillator is 0 (price equals trend, no volatility)
101                values.push((ts, Decimal::ZERO));
102            } else {
103                values.push((ts, (close - hma) / atr));
104            }
105        }
106
107        Ok(Series::new(values))
108    }
109}
110
111#[cfg(test)]
112#[path = "detrended_tests.rs"]
113mod tests;