1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
//! 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;