1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
52pub struct YangZhangVolatility {
53 period: usize,
54 trading_periods: usize,
55 k: f64,
56 prev_close: Option<f64>,
57 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 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 pub const fn periods(&self) -> (usize, usize) {
111 (self.period, self.trading_periods)
112 }
113
114 pub const fn value(&self) -> Option<f64> {
116 self.last
117 }
118
119 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 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 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 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 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 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 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 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 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 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 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 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}