Skip to main content

wickra_core/indicators/
yang_zhang.rs

1//! Yang-Zhang Volatility (drift- and gap-robust OHLC estimator).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Yang-Zhang Volatility — combines overnight, open-to-close and
10/// Rogers-Satchell volatilities into a single drift- and gap-robust
11/// estimator.
12///
13/// Yang & Zhang (2000) showed that the three estimators below are
14/// independent under a driftless GBM with overnight gaps, so a convex
15/// combination of their (sample) variances has minimum estimation variance
16/// at a specific blending factor `k`:
17///
18/// ```text
19/// k        = 0.34 / (1.34 + (n + 1) / (n − 1))
20/// σ²_on    = sample_var(ln(O_t / C_{t-1})    over n bars)         // overnight
21/// σ²_oc    = sample_var(ln(C_t / O_t)        over n bars)         // open-to-close
22/// σ²_rs    = mean(ln(H/C)·ln(H/O) + ln(L/C)·ln(L/O) over n bars)  // Rogers-Satchell
23/// σ²_YZ    = σ²_on + k · σ²_oc + (1 − k) · σ²_rs
24/// out      = √max(σ²_YZ, 0) · √trading_periods · 100
25/// ```
26///
27/// The "sample" variance uses Bessel's correction (divisor `n − 1`), the
28/// same convention as [`HistoricalVolatility`](crate::HistoricalVolatility).
29///
30/// This is the gold-standard OHLC estimator for assets with both
31/// overnight gaps and intraday drift — equities, futures, and any
32/// market that doesn't trade continuously. For pure intraday data
33/// (where `C_{t-1} == O_t`), the overnight term vanishes and
34/// Rogers-Satchell alone is sufficient.
35///
36/// # Example
37///
38/// ```
39/// use wickra_core::{Candle, Indicator, YangZhangVolatility};
40///
41/// let mut indicator = YangZhangVolatility::new(20, 252).unwrap();
42/// let mut last = None;
43/// for i in 0..40 {
44///     let base = 100.0 + f64::from(i);
45///     let candle = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i))
46///         .unwrap();
47///     last = indicator.update(candle);
48/// }
49/// assert!(last.is_some());
50/// ```
51#[derive(Debug, Clone)]
52pub struct YangZhangVolatility {
53    period: usize,
54    trading_periods: usize,
55    k: f64,
56    prev_close: Option<f64>,
57    // Each window stores one f64 per bar in the rolling window.
58    overnight: VecDeque<f64>,
59    open_close: VecDeque<f64>,
60    rs_samples: VecDeque<f64>,
61    sum_on: f64,
62    sum_sq_on: f64,
63    sum_oc: f64,
64    sum_sq_oc: f64,
65    sum_rs: f64,
66    last: Option<f64>,
67}
68
69impl YangZhangVolatility {
70    /// Construct a Yang-Zhang Volatility estimator.
71    ///
72    /// `period` is the rolling window of bars; `trading_periods` is the
73    /// annualisation factor (`252` daily, `52` weekly, `12` monthly, or
74    /// `1` for raw per-bar volatility).
75    ///
76    /// # Errors
77    ///
78    /// Returns [`Error::PeriodZero`] if either parameter is `0`, or
79    /// [`Error::InvalidPeriod`] if `period < 2` (the sample variances
80    /// inside Yang-Zhang need at least two samples).
81    pub fn new(period: usize, trading_periods: usize) -> Result<Self> {
82        if period == 0 || trading_periods == 0 {
83            return Err(Error::PeriodZero);
84        }
85        if period < 2 {
86            return Err(Error::InvalidPeriod {
87                message: "Yang-Zhang period must be >= 2",
88            });
89        }
90        let n = period as f64;
91        let k = 0.34 / (1.34 + (n + 1.0) / (n - 1.0));
92        Ok(Self {
93            period,
94            trading_periods,
95            k,
96            prev_close: None,
97            overnight: VecDeque::with_capacity(period),
98            open_close: VecDeque::with_capacity(period),
99            rs_samples: VecDeque::with_capacity(period),
100            sum_on: 0.0,
101            sum_sq_on: 0.0,
102            sum_oc: 0.0,
103            sum_sq_oc: 0.0,
104            sum_rs: 0.0,
105            last: None,
106        })
107    }
108
109    /// Configured `(period, trading_periods)`.
110    pub const fn periods(&self) -> (usize, usize) {
111        (self.period, self.trading_periods)
112    }
113
114    /// Current value if available.
115    pub const fn value(&self) -> Option<f64> {
116        self.last
117    }
118
119    /// The Yang-Zhang blending factor `k` for this configuration.
120    pub const fn k(&self) -> f64 {
121        self.k
122    }
123}
124
125impl Indicator for YangZhangVolatility {
126    type Input = Candle;
127    type Output = f64;
128
129    fn update(&mut self, candle: Candle) -> Option<f64> {
130        // The overnight log-return needs the previous bar's close. On the
131        // first candle there is no previous close, so we only seed
132        // `prev_close` and return None without touching any window.
133        let Some(prev_c) = self.prev_close else {
134            self.prev_close = Some(candle.close);
135            return None;
136        };
137        self.prev_close = Some(candle.close);
138
139        // Per-bar samples. `Candle::new` guarantees finite, positive OHLC
140        // and the ordering invariants, so every ratio is well-defined.
141        let on_sample = (candle.open / prev_c).ln();
142        let oc_sample = (candle.close / candle.open).ln();
143        let log_hc = (candle.high / candle.close).ln();
144        let log_ho = (candle.high / candle.open).ln();
145        let log_lc = (candle.low / candle.close).ln();
146        let log_lo = (candle.low / candle.open).ln();
147        let rs_sample = log_hc.mul_add(log_ho, log_lc * log_lo);
148
149        // Roll the three windows.
150        if self.overnight.len() == self.period {
151            let old_on = self.overnight.pop_front().expect("window non-empty");
152            self.sum_on -= old_on;
153            self.sum_sq_on -= old_on * old_on;
154            let old_oc = self.open_close.pop_front().expect("window non-empty");
155            self.sum_oc -= old_oc;
156            self.sum_sq_oc -= old_oc * old_oc;
157            let old_rs = self.rs_samples.pop_front().expect("window non-empty");
158            self.sum_rs -= old_rs;
159        }
160        self.overnight.push_back(on_sample);
161        self.sum_on += on_sample;
162        self.sum_sq_on += on_sample * on_sample;
163        self.open_close.push_back(oc_sample);
164        self.sum_oc += oc_sample;
165        self.sum_sq_oc += oc_sample * oc_sample;
166        self.rs_samples.push_back(rs_sample);
167        self.sum_rs += rs_sample;
168
169        if self.overnight.len() < self.period {
170            return None;
171        }
172
173        let n = self.period as f64;
174        let mean_on = self.sum_on / n;
175        let mean_oc = self.sum_oc / n;
176        // Sample variances (Bessel's correction). Clamp to zero against
177        // FP cancellation noise.
178        let var_on = ((self.sum_sq_on - n * mean_on * mean_on) / (n - 1.0)).max(0.0);
179        let var_oc = ((self.sum_sq_oc - n * mean_oc * mean_oc) / (n - 1.0)).max(0.0);
180        // Rogers-Satchell mean: each per-bar sample is already >= 0 by
181        // construction, so the mean cannot be negative outside of FP.
182        let var_rs = (self.sum_rs / n).max(0.0);
183
184        let total = var_on + self.k * var_oc + (1.0 - self.k) * var_rs;
185        let sigma = total.max(0.0).sqrt();
186        let out = sigma * (self.trading_periods as f64).sqrt() * 100.0;
187        self.last = Some(out);
188        Some(out)
189    }
190
191    fn reset(&mut self) {
192        self.prev_close = None;
193        self.overnight.clear();
194        self.open_close.clear();
195        self.rs_samples.clear();
196        self.sum_on = 0.0;
197        self.sum_sq_on = 0.0;
198        self.sum_oc = 0.0;
199        self.sum_sq_oc = 0.0;
200        self.sum_rs = 0.0;
201        self.last = None;
202    }
203
204    fn warmup_period(&self) -> usize {
205        // One bar to seed `prev_close`, then `period` more bars to fill
206        // the rolling windows. First emit lands at index `period`, i.e.
207        // the `(period + 1)`-th input.
208        self.period + 1
209    }
210
211    fn is_ready(&self) -> bool {
212        self.last.is_some()
213    }
214
215    fn name(&self) -> &'static str {
216        "YangZhangVolatility"
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::traits::BatchExt;
224    use approx::assert_relative_eq;
225
226    fn candle(o: f64, h: f64, l: f64, c: f64, ts: i64) -> Candle {
227        Candle::new(o, h, l, c, 1.0, ts).unwrap()
228    }
229
230    #[test]
231    fn rejects_zero_period() {
232        assert!(matches!(
233            YangZhangVolatility::new(0, 252),
234            Err(Error::PeriodZero)
235        ));
236        assert!(matches!(
237            YangZhangVolatility::new(20, 0),
238            Err(Error::PeriodZero)
239        ));
240    }
241
242    #[test]
243    fn rejects_period_one() {
244        assert!(matches!(
245            YangZhangVolatility::new(1, 252),
246            Err(Error::InvalidPeriod { .. })
247        ));
248    }
249
250    #[test]
251    fn accessors_and_metadata() {
252        let yz = YangZhangVolatility::new(20, 252).unwrap();
253        assert_eq!(yz.periods(), (20, 252));
254        assert_eq!(yz.value(), None);
255        assert_eq!(yz.warmup_period(), 21);
256        assert_eq!(yz.name(), "YangZhangVolatility");
257        assert!(!yz.is_ready());
258
259        // k = 0.34 / (1.34 + 21/19) ≈ 0.139
260        let n = 20.0;
261        let expected_k = 0.34 / (1.34 + (n + 1.0) / (n - 1.0));
262        assert_relative_eq!(yz.k(), expected_k, epsilon = 1e-12);
263    }
264
265    #[test]
266    fn zero_movement_yields_zero() {
267        // O == H == L == C and constant across bars -> every per-bar sample
268        // is zero, all three variances are zero, output is zero.
269        let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect();
270        let mut yz = YangZhangVolatility::new(14, 1).unwrap();
271        for v in yz.batch(&candles).into_iter().flatten() {
272            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
273        }
274    }
275
276    #[test]
277    fn output_is_non_negative() {
278        let mut yz = YangZhangVolatility::new(14, 252).unwrap();
279        let candles: Vec<Candle> = (0..200)
280            .map(|i| {
281                let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
282                let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5;
283                let open = base - 0.1;
284                let close = base + 0.2;
285                candle(open, base + half, base - half, close, i64::from(i))
286            })
287            .collect();
288        for v in yz.batch(&candles).into_iter().flatten() {
289            assert!(v >= 0.0, "Yang-Zhang must be non-negative: {v}");
290        }
291    }
292
293    #[test]
294    fn annualisation_scales_by_sqrt_trading_periods() {
295        let candles: Vec<Candle> = (0..40)
296            .map(|i| {
297                let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
298                let half = 1.0 + (f64::from(i) * 0.2).cos().abs();
299                candle(
300                    base - 0.05,
301                    base + half,
302                    base - half,
303                    base + 0.3,
304                    i64::from(i),
305                )
306            })
307            .collect();
308        let raw = YangZhangVolatility::new(10, 1).unwrap().batch(&candles);
309        let annual = YangZhangVolatility::new(10, 252).unwrap().batch(&candles);
310        let scale = (252.0_f64).sqrt();
311        for (r, a) in raw.iter().zip(annual.iter()) {
312            assert_eq!(r.is_some(), a.is_some(), "warmup mismatch");
313            if let (Some(r), Some(a)) = (r, a) {
314                assert_relative_eq!(*a, r * scale, epsilon = 1e-9);
315            }
316        }
317    }
318
319    #[test]
320    fn first_emission_at_warmup_period() {
321        // period = 5 -> first ready at index 5 (the 6th candle): one bar
322        // seeds prev_close, the next 5 fill the rolling window.
323        let candles: Vec<Candle> = (0..20_i64)
324            .map(|i| {
325                let base = 100.0 + (i as f64 * 0.4).sin() * 3.0;
326                candle(base, base + 1.0, base - 1.0, base + 0.2, i)
327            })
328            .collect();
329        let mut yz = YangZhangVolatility::new(5, 1).unwrap();
330        assert_eq!(yz.warmup_period(), 6);
331        let out = yz.batch(&candles);
332        for v in out.iter().take(5) {
333            assert!(v.is_none(), "indicator must still be warming up");
334        }
335        assert!(
336            out[5].is_some(),
337            "first value lands at warmup_period - 1 = 5"
338        );
339    }
340
341    #[test]
342    fn batch_equals_streaming() {
343        let candles: Vec<Candle> = (0..80)
344            .map(|i| {
345                let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0;
346                let half = 1.0 + (f64::from(i) * 0.15).cos().abs();
347                candle(
348                    base - 0.05,
349                    base + half,
350                    base - half,
351                    base + 0.5,
352                    i64::from(i),
353                )
354            })
355            .collect();
356        let batch = YangZhangVolatility::new(14, 252).unwrap().batch(&candles);
357        let mut streamer = YangZhangVolatility::new(14, 252).unwrap();
358        let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect();
359        assert_eq!(batch, streamed);
360    }
361
362    #[test]
363    fn reset_clears_state() {
364        let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.5, i)).collect();
365        let mut yz = YangZhangVolatility::new(14, 252).unwrap();
366        yz.batch(&candles);
367        assert!(yz.is_ready());
368        yz.reset();
369        assert!(!yz.is_ready());
370        assert_eq!(yz.value(), None);
371        assert_eq!(yz.update(candles[0]), None);
372    }
373
374    #[test]
375    fn intraday_data_collapses_to_rs_only() {
376        // If `O_t == C_{t-1}` for every bar (perfect intraday continuity),
377        // the overnight log-return is zero and `var_on == 0`. If the
378        // open-to-close return is also constant across bars, `var_oc == 0`.
379        // Yang-Zhang then reduces to `(1-k) · var_rs`. The arithmetic
380        // checks out against the closed form.
381        //
382        // Construct a series where every bar opens at the previous close
383        // and has a constant intraday shape: O=10, H=11, L=9, C=10 every
384        // bar. Then ln(O_t/C_{t-1}) = 0, ln(C/O) = 0, and the RS sample
385        // is `2 · (ln(11/10) · ln(11/10))` (the ln(9/10)·ln(9/10) term
386        // matches numerically).
387        let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.0, i)).collect();
388        let mut yz = YangZhangVolatility::new(10, 1).unwrap();
389        let out = yz.batch(&candles);
390
391        let log_hc = (11.0_f64 / 10.0_f64).ln();
392        let log_ho = (11.0_f64 / 10.0_f64).ln();
393        let log_lc = (9.0_f64 / 10.0_f64).ln();
394        let log_lo = (9.0_f64 / 10.0_f64).ln();
395        let rs_sample = log_hc * log_ho + log_lc * log_lo;
396        let n = 10.0;
397        let k = 0.34 / (1.34 + (n + 1.0) / (n - 1.0));
398        // var_on = var_oc = 0 because every sample equals the mean (0).
399        let total = (1.0 - k) * rs_sample;
400        let expected = total.max(0.0).sqrt() * 100.0;
401
402        for v in out.iter().skip(11).flatten() {
403            assert_relative_eq!(*v, expected, epsilon = 1e-9);
404        }
405    }
406}