1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::ShiftedMoments;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10#[derive(Debug, Clone)]
53pub struct YangZhangVolatility {
54 period: usize,
55 trading_periods: usize,
56 k: f64,
57 prev_close: Option<f64>,
58 overnight: VecDeque<f64>,
60 open_close: VecDeque<f64>,
61 rs_samples: VecDeque<f64>,
62 on_moments: ShiftedMoments,
63 oc_moments: ShiftedMoments,
64 sum_rs: f64,
65 last: Option<f64>,
66}
67
68impl YangZhangVolatility {
69 pub fn new(period: usize, trading_periods: usize) -> Result<Self> {
81 if period == 0 || trading_periods == 0 {
82 return Err(Error::PeriodZero);
83 }
84 if period < 2 {
85 return Err(Error::InvalidPeriod {
86 message: "Yang-Zhang period must be >= 2",
87 });
88 }
89 if period > crate::error::MAX_PERIOD {
90 return Err(Error::InvalidPeriod {
91 message: crate::error::PERIOD_ABOVE_MAX,
92 });
93 }
94 let n = period as f64;
95 let k = 0.34 / (1.34 + (n + 1.0) / (n - 1.0));
96 Ok(Self {
97 period,
98 trading_periods,
99 k,
100 prev_close: None,
101 overnight: VecDeque::with_capacity(period),
102 open_close: VecDeque::with_capacity(period),
103 rs_samples: VecDeque::with_capacity(period),
104 on_moments: ShiftedMoments::new(),
105 oc_moments: ShiftedMoments::new(),
106 sum_rs: 0.0,
107 last: None,
108 })
109 }
110
111 pub const fn periods(&self) -> (usize, usize) {
113 (self.period, self.trading_periods)
114 }
115
116 pub const fn value(&self) -> Option<f64> {
118 self.last
119 }
120
121 pub const fn k(&self) -> f64 {
123 self.k
124 }
125}
126
127impl Indicator for YangZhangVolatility {
128 type Input = Candle;
129 type Output = f64;
130
131 fn update(&mut self, candle: Candle) -> Option<f64> {
132 let Some(prev_c) = self.prev_close else {
136 self.prev_close = Some(candle.close);
137 return None;
138 };
139 self.prev_close = Some(candle.close);
140
141 let on_sample = (candle.open / prev_c).ln();
144 let oc_sample = (candle.close / candle.open).ln();
145 let log_hc = (candle.high / candle.close).ln();
146 let log_ho = (candle.high / candle.open).ln();
147 let log_lc = (candle.low / candle.close).ln();
148 let log_lo = (candle.low / candle.open).ln();
149 let rs_sample = log_hc.mul_add(log_ho, log_lc * log_lo);
150
151 if self.overnight.len() == self.period {
153 let old_on = self.overnight.pop_front().expect("window non-empty");
154 self.on_moments.evict(old_on);
155 let old_oc = self.open_close.pop_front().expect("window non-empty");
156 self.oc_moments.evict(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.on_moments.push(on_sample);
162 self.open_close.push_back(oc_sample);
163 self.oc_moments.push(oc_sample);
164 if self.on_moments.needs_reseed(self.period) {
165 self.on_moments.reseed(self.overnight.iter().copied());
166 self.oc_moments.reseed(self.open_close.iter().copied());
167 }
168 self.rs_samples.push_back(rs_sample);
169 self.sum_rs += rs_sample;
170
171 if self.overnight.len() < self.period {
172 return None;
173 }
174
175 let n = self.period as f64;
176 let var_on = self.on_moments.sample_variance(self.period);
179 let var_oc = self.oc_moments.sample_variance(self.period);
180 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.on_moments.reset();
197 self.oc_moments.reset();
198 self.sum_rs = 0.0;
199 self.last = None;
200 }
201
202 #[inline]
203 fn warmup_period(&self) -> usize {
204 self.period + 1
208 }
209
210 #[inline]
211 fn is_ready(&self) -> bool {
212 self.last.is_some()
213 }
214
215 #[inline]
216 fn name(&self) -> &'static str {
217 "YangZhangVolatility"
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use crate::traits::BatchExt;
225 use approx::assert_relative_eq;
226
227 fn candle(o: f64, h: f64, l: f64, c: f64, ts: i64) -> Candle {
228 Candle::new(o, h, l, c, 1.0, ts).unwrap()
229 }
230
231 #[test]
232 fn rejects_zero_period() {
233 assert!(matches!(
234 YangZhangVolatility::new(0, 252),
235 Err(Error::PeriodZero)
236 ));
237 assert!(matches!(
238 YangZhangVolatility::new(20, 0),
239 Err(Error::PeriodZero)
240 ));
241 }
242
243 #[test]
244 fn rejects_period_one() {
245 assert!(matches!(
246 YangZhangVolatility::new(1, 252),
247 Err(Error::InvalidPeriod { .. })
248 ));
249 }
250
251 #[test]
252 fn accessors_and_metadata() {
253 let yz = YangZhangVolatility::new(20, 252).unwrap();
254 assert_eq!(yz.periods(), (20, 252));
255 assert_eq!(yz.value(), None);
256 assert_eq!(yz.warmup_period(), 21);
257 assert_eq!(yz.name(), "YangZhangVolatility");
258 assert!(!yz.is_ready());
259
260 let n = 20.0;
262 let expected_k = 0.34 / (1.34 + (n + 1.0) / (n - 1.0));
263 assert_relative_eq!(yz.k(), expected_k, epsilon = 1e-12);
264 }
265
266 #[test]
267 fn zero_movement_yields_zero() {
268 let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect();
271 let mut yz = YangZhangVolatility::new(14, 1).unwrap();
272 for v in yz.batch(&candles).into_iter().flatten() {
273 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
274 }
275 }
276
277 #[test]
278 fn output_is_non_negative() {
279 let mut yz = YangZhangVolatility::new(14, 252).unwrap();
280 let candles: Vec<Candle> = (0..200)
281 .map(|i| {
282 let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
283 let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5;
284 let open = base - 0.1;
285 let close = base + 0.2;
286 candle(open, base + half, base - half, close, i64::from(i))
287 })
288 .collect();
289 for v in yz.batch(&candles).into_iter().flatten() {
290 assert!(v >= 0.0, "Yang-Zhang must be non-negative: {v}");
291 }
292 }
293
294 #[test]
295 fn annualisation_scales_by_sqrt_trading_periods() {
296 let candles: Vec<Candle> = (0..40)
297 .map(|i| {
298 let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
299 let half = 1.0 + (f64::from(i) * 0.2).cos().abs();
300 candle(
301 base - 0.05,
302 base + half,
303 base - half,
304 base + 0.3,
305 i64::from(i),
306 )
307 })
308 .collect();
309 let raw = YangZhangVolatility::new(10, 1).unwrap().batch(&candles);
310 let annual = YangZhangVolatility::new(10, 252).unwrap().batch(&candles);
311 let scale = (252.0_f64).sqrt();
312 for (r, a) in raw.iter().zip(annual.iter()) {
313 assert_eq!(r.is_some(), a.is_some(), "warmup mismatch");
314 if let (Some(r), Some(a)) = (r, a) {
315 assert_relative_eq!(*a, r * scale, epsilon = 1e-9);
316 }
317 }
318 }
319
320 #[test]
321 fn first_emission_at_warmup_period() {
322 let candles: Vec<Candle> = (0..20_i64)
325 .map(|i| {
326 let base = 100.0 + (i as f64 * 0.4).sin() * 3.0;
327 candle(base, base + 1.0, base - 1.0, base + 0.2, i)
328 })
329 .collect();
330 let mut yz = YangZhangVolatility::new(5, 1).unwrap();
331 assert_eq!(yz.warmup_period(), 6);
332 let out = yz.batch(&candles);
333 for v in out.iter().take(5) {
334 assert!(v.is_none(), "indicator must still be warming up");
335 }
336 assert!(
337 out[5].is_some(),
338 "first value lands at warmup_period - 1 = 5"
339 );
340 }
341
342 #[test]
343 fn batch_equals_streaming() {
344 let candles: Vec<Candle> = (0..80)
345 .map(|i| {
346 let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0;
347 let half = 1.0 + (f64::from(i) * 0.15).cos().abs();
348 candle(
349 base - 0.05,
350 base + half,
351 base - half,
352 base + 0.5,
353 i64::from(i),
354 )
355 })
356 .collect();
357 let batch = YangZhangVolatility::new(14, 252).unwrap().batch(&candles);
358 let mut streamer = YangZhangVolatility::new(14, 252).unwrap();
359 let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect();
360 assert_eq!(batch, streamed);
361 }
362
363 #[test]
364 fn reset_clears_state() {
365 let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.5, i)).collect();
366 let mut yz = YangZhangVolatility::new(14, 252).unwrap();
367 yz.batch(&candles);
368 assert!(yz.is_ready());
369 yz.reset();
370 assert!(!yz.is_ready());
371 assert_eq!(yz.value(), None);
372 assert_eq!(yz.update(candles[0]), None);
373 }
374
375 #[test]
376 fn intraday_data_collapses_to_rs_only() {
377 let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.0, i)).collect();
389 let mut yz = YangZhangVolatility::new(10, 1).unwrap();
390 let out = yz.batch(&candles);
391
392 let log_hc = (11.0_f64 / 10.0_f64).ln();
393 let log_ho = (11.0_f64 / 10.0_f64).ln();
394 let log_lc = (9.0_f64 / 10.0_f64).ln();
395 let log_lo = (9.0_f64 / 10.0_f64).ln();
396 let rs_sample = log_hc * log_ho + log_lc * log_lo;
397 let n = 10.0;
398 let k = 0.34 / (1.34 + (n + 1.0) / (n - 1.0));
399 let total = (1.0 - k) * rs_sample;
401 let expected = total.max(0.0).sqrt() * 100.0;
402
403 for v in out.iter().skip(11).flatten() {
404 assert_relative_eq!(*v, expected, epsilon = 1e-9);
405 }
406 }
407}