Skip to main content

wickra_core/indicators/
rogers_satchell.rs

1//! Rogers-Satchell Volatility (drift-free OHLC estimator).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::RollingSum;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Rogers-Satchell Volatility — a drift-free OHLC realised-volatility
11/// estimator.
12///
13/// Rogers, Satchell & Yoon (1994) extended the Garman-Klass framework to
14/// handle non-zero drift between bars without introducing the bias the
15/// Garman-Klass estimator picks up in trending markets. The per-bar sample
16/// is
17///
18/// ```text
19/// s_t = ln(H_t / C_t) · ln(H_t / O_t) + ln(L_t / C_t) · ln(L_t / O_t)
20/// ```
21///
22/// and the indicator returns the annualised square root of the rolling
23/// mean of `s_t`:
24///
25/// ```text
26/// out = sqrt(max(mean(s_t over `period`), 0)) · sqrt(trading_periods) · 100
27/// ```
28///
29/// The estimator is exact under a Brownian Motion with arbitrary drift —
30/// the drift component cancels out algebraically. Each per-bar sample is
31/// also guaranteed non-negative (both products contribute non-negative
32/// terms by construction: `H >= O,C` and `L <= O,C`), so the rolling mean
33/// cannot drift below zero except through FP cancellation.
34///
35/// # Example
36///
37/// ```
38/// use wickra_core::{Candle, Indicator, RogersSatchellVolatility};
39///
40/// let mut indicator = RogersSatchellVolatility::new(20, 252).unwrap();
41/// let mut last = None;
42/// for i in 0..40 {
43///     let base = 100.0 + f64::from(i);
44///     let candle = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i))
45///         .unwrap();
46///     last = indicator.update(candle);
47/// }
48/// assert!(last.is_some());
49/// ```
50#[derive(Debug, Clone)]
51pub struct RogersSatchellVolatility {
52    period: usize,
53    trading_periods: usize,
54    window: VecDeque<f64>,
55    sum: RollingSum,
56    last: Option<f64>,
57}
58
59impl RogersSatchellVolatility {
60    /// Construct a Rogers-Satchell Volatility estimator.
61    ///
62    /// `period` is the rolling window of bars; `trading_periods` is the
63    /// annualisation factor (`252` daily, `52` weekly, `12` monthly, or
64    /// `1` for raw per-bar volatility).
65    ///
66    /// # Errors
67    ///
68    /// Returns [`Error::PeriodZero`] if either parameter is `0`.
69    pub fn new(period: usize, trading_periods: usize) -> Result<Self> {
70        if period == 0 || trading_periods == 0 {
71            return Err(Error::PeriodZero);
72        }
73        Ok(Self {
74            period,
75            trading_periods,
76            window: VecDeque::with_capacity(period),
77            sum: RollingSum::new(),
78            last: None,
79        })
80    }
81
82    /// Configured `(period, trading_periods)`.
83    pub const fn periods(&self) -> (usize, usize) {
84        (self.period, self.trading_periods)
85    }
86
87    /// Current value if available.
88    pub const fn value(&self) -> Option<f64> {
89        self.last
90    }
91}
92
93impl Indicator for RogersSatchellVolatility {
94    type Input = Candle;
95    type Output = f64;
96
97    #[inline]
98    fn update(&mut self, candle: Candle) -> Option<f64> {
99        // `Candle::new` guarantees finite, positive OHLC with `high >=
100        // max(open, low, close)` and `low <= min(open, high, close)`. The
101        // factors below thus have predictable signs:
102        //   ln(H/C) >= 0,  ln(H/O) >= 0,  ln(L/C) <= 0,  ln(L/O) <= 0
103        // so both products are non-negative and the per-bar sample is
104        // guaranteed `>= 0` by construction.
105        let log_hc = (candle.high / candle.close).ln();
106        let log_ho = (candle.high / candle.open).ln();
107        let log_lc = (candle.low / candle.close).ln();
108        let log_lo = (candle.low / candle.open).ln();
109        let sample = log_hc.mul_add(log_ho, log_lc * log_lo);
110
111        if self.window.len() == self.period {
112            let old = self.window.pop_front().expect("window is non-empty");
113            self.sum.evict(old);
114        }
115        self.window.push_back(sample);
116        self.sum.push(sample);
117        if self.sum.needs_reseed(self.period) {
118            self.sum.reseed(self.window.iter().copied());
119        }
120
121        if self.window.len() < self.period {
122            return None;
123        }
124
125        let n = self.period as f64;
126        // The clamp absorbs FP cancellation; the mathematical value is
127        // already `>= 0` by the sign argument above.
128        let variance = (self.sum.value() / n).max(0.0);
129        let sigma = variance.sqrt();
130        let out = sigma * (self.trading_periods as f64).sqrt() * 100.0;
131        self.last = Some(out);
132        Some(out)
133    }
134
135    fn reset(&mut self) {
136        self.window.clear();
137        self.sum.reset();
138        self.last = None;
139    }
140
141    #[inline]
142    fn warmup_period(&self) -> usize {
143        self.period
144    }
145
146    #[inline]
147    fn is_ready(&self) -> bool {
148        self.last.is_some()
149    }
150
151    #[inline]
152    fn name(&self) -> &'static str {
153        "RogersSatchellVolatility"
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::traits::BatchExt;
161    use approx::assert_relative_eq;
162
163    fn candle(o: f64, h: f64, l: f64, c: f64, ts: i64) -> Candle {
164        Candle::new(o, h, l, c, 1.0, ts).unwrap()
165    }
166
167    #[test]
168    fn rejects_zero_period() {
169        assert!(matches!(
170            RogersSatchellVolatility::new(0, 252),
171            Err(Error::PeriodZero)
172        ));
173        assert!(matches!(
174            RogersSatchellVolatility::new(20, 0),
175            Err(Error::PeriodZero)
176        ));
177    }
178
179    #[test]
180    fn accessors_and_metadata() {
181        let rs = RogersSatchellVolatility::new(20, 252).unwrap();
182        assert_eq!(rs.periods(), (20, 252));
183        assert_eq!(rs.value(), None);
184        assert_eq!(rs.warmup_period(), 20);
185        assert_eq!(rs.name(), "RogersSatchellVolatility");
186        assert!(!rs.is_ready());
187    }
188
189    #[test]
190    fn zero_movement_yields_zero() {
191        let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect();
192        let mut rs = RogersSatchellVolatility::new(14, 1).unwrap();
193        for v in rs.batch(&candles).into_iter().flatten() {
194            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
195        }
196    }
197
198    #[test]
199    fn constant_bar_shape_yields_constant_sigma() {
200        // Each bar has identical OHLC -> per-bar sample is a constant `k`.
201        let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.5, i)).collect();
202        let log_hc = (11.0_f64 / 10.5_f64).ln();
203        let log_ho = (11.0_f64 / 10.0_f64).ln();
204        let log_lc = (9.0_f64 / 10.5_f64).ln();
205        let log_lo = (9.0_f64 / 10.0_f64).ln();
206        let k = log_hc * log_ho + log_lc * log_lo;
207        let expected = k.max(0.0).sqrt() * 100.0;
208
209        let mut rs = RogersSatchellVolatility::new(10, 1).unwrap();
210        let out = rs.batch(&candles);
211        for v in out.iter().skip(9).flatten() {
212            assert_relative_eq!(*v, expected, epsilon = 1e-9);
213        }
214    }
215
216    #[test]
217    fn output_is_non_negative() {
218        let mut rs = RogersSatchellVolatility::new(14, 252).unwrap();
219        let candles: Vec<Candle> = (0..200)
220            .map(|i| {
221                let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
222                let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5;
223                let open = base - 0.1;
224                let close = base + 0.2;
225                candle(open, base + half, base - half, close, i64::from(i))
226            })
227            .collect();
228        for v in rs.batch(&candles).into_iter().flatten() {
229            assert!(v >= 0.0, "Rogers-Satchell must be non-negative: {v}");
230        }
231    }
232
233    #[test]
234    fn annualisation_scales_by_sqrt_trading_periods() {
235        let candles: Vec<Candle> = (0..40)
236            .map(|i| {
237                let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
238                let half = 1.0 + (f64::from(i) * 0.2).cos().abs();
239                candle(base, base + half, base - half, base + 0.3, i64::from(i))
240            })
241            .collect();
242        let raw = RogersSatchellVolatility::new(10, 1)
243            .unwrap()
244            .batch(&candles);
245        let annual = RogersSatchellVolatility::new(10, 252)
246            .unwrap()
247            .batch(&candles);
248        let scale = (252.0_f64).sqrt();
249        for (r, a) in raw.iter().zip(annual.iter()) {
250            assert_eq!(r.is_some(), a.is_some(), "warmup mismatch");
251            if let (Some(r), Some(a)) = (r, a) {
252                assert_relative_eq!(*a, r * scale, epsilon = 1e-9);
253            }
254        }
255    }
256
257    #[test]
258    fn first_emission_at_warmup_period() {
259        let candles: Vec<Candle> = (0..20).map(|i| candle(10.0, 11.0, 9.0, 10.5, i)).collect();
260        let mut rs = RogersSatchellVolatility::new(5, 1).unwrap();
261        let out = rs.batch(&candles);
262        for v in out.iter().take(4) {
263            assert!(v.is_none());
264        }
265        assert!(out[4].is_some());
266    }
267
268    #[test]
269    fn batch_equals_streaming() {
270        let candles: Vec<Candle> = (0..80)
271            .map(|i| {
272                let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0;
273                let half = 1.0 + (f64::from(i) * 0.15).cos().abs();
274                candle(base, base + half, base - half, base + 0.5, i64::from(i))
275            })
276            .collect();
277        let batch = RogersSatchellVolatility::new(14, 252)
278            .unwrap()
279            .batch(&candles);
280        let mut streamer = RogersSatchellVolatility::new(14, 252).unwrap();
281        let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect();
282        assert_eq!(batch, streamed);
283    }
284
285    #[test]
286    fn reset_clears_state() {
287        let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.5, i)).collect();
288        let mut rs = RogersSatchellVolatility::new(14, 252).unwrap();
289        rs.batch(&candles);
290        assert!(rs.is_ready());
291        rs.reset();
292        assert!(!rs.is_ready());
293        assert_eq!(rs.value(), None);
294        assert_eq!(rs.update(candles[0]), None);
295    }
296}