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