quant-indicators 0.7.0

Pure indicator math library for trading — MA, RSI, Bollinger, MACD, ATR, HRP
Documentation
//! Detrended Oscillator indicator.
//!
//! Measures how far price has moved from its Hull MA trend,
//! normalised by ATR so the result is dimensionless (no price units).
//!
//! # Formula
//!
//! DetrendedOscillator = (close - HMA(n)) / ATR(m)
//!
//! A value of +1.0 means price is 1 ATR above the HMA trend.
//! A value of -1.0 means price is 1 ATR below the HMA trend.

use quant_primitives::Candle;
use rust_decimal::Decimal;

use crate::atr::Atr;
use crate::error::IndicatorError;
use crate::hull::HullMa;
use crate::indicator::Indicator;
use crate::series::Series;

/// Detrended Oscillator: (close - HMA) / ATR.
///
/// Pure function — no I/O, no state between calls.
#[derive(Debug, Clone)]
pub struct DetrendedOscillator {
    hma: HullMa,
    atr: Atr,
    name: String,
}

impl DetrendedOscillator {
    /// Create a new Detrended Oscillator.
    ///
    /// # Parameters
    /// - `hma_period`: period for the Hull Moving Average (trend baseline)
    /// - `atr_period`: period for ATR (volatility normalisation)
    ///
    /// # Errors
    ///
    /// Returns `InvalidParameter` if either period is invalid.
    pub fn new(hma_period: usize, atr_period: usize) -> Result<Self, IndicatorError> {
        let hma = HullMa::new(hma_period)?;
        let atr = Atr::new(atr_period)?;
        Ok(Self {
            name: format!("DetrendedOsc(HMA={},ATR={})", hma_period, atr_period),
            hma,
            atr,
        })
    }
}

impl Indicator for DetrendedOscillator {
    fn name(&self) -> &str {
        &self.name
    }

    fn warmup_period(&self) -> usize {
        // Need enough candles for both HMA and ATR to produce output.
        // HMA warmup >= ATR warmup in typical configs (period + sqrt_period > atr_period + 1)
        // but we take the max to be safe.
        self.hma.warmup_period().max(self.atr.warmup_period())
    }

    fn compute(&self, candles: &[Candle]) -> Result<Series, IndicatorError> {
        let required = self.warmup_period();
        if candles.len() < required {
            return Err(IndicatorError::InsufficientData {
                required,
                actual: candles.len(),
            });
        }

        let hma_series = self.hma.compute(candles)?;
        let atr_series = self.atr.compute(candles)?;

        // HMA and ATR produce series of different lengths; align on the shorter one.
        // Both are suffix-aligned (last candle is always the last value).
        let hma_vals = hma_series.values();
        let atr_vals = atr_series.values();

        // Take the shorter length (both are anchored to the tail of candles).
        let len = hma_vals.len().min(atr_vals.len());

        // Align: take the last `len` values from each.
        let hma_tail = &hma_vals[hma_vals.len() - len..];
        let atr_tail = &atr_vals[atr_vals.len() - len..];

        // Candles are aligned to the same tail.
        let candle_tail = &candles[candles.len() - len..];

        let mut values = Vec::with_capacity(len);
        for i in 0..len {
            let close = candle_tail[i].close();
            let hma = hma_tail[i].1;
            let atr = atr_tail[i].1;
            let ts = candle_tail[i].timestamp();

            if atr == Decimal::ZERO {
                // Flat series — oscillator is 0 (price equals trend, no volatility)
                values.push((ts, Decimal::ZERO));
            } else {
                values.push((ts, (close - hma) / atr));
            }
        }

        Ok(Series::new(values))
    }
}

#[cfg(test)]
#[path = "detrended_tests.rs"]
mod tests;