wickra_core/indicators/
historical_volatility.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
37pub struct HistoricalVolatility {
38 period: usize,
39 trading_periods: usize,
40 prev_price: Option<f64>,
41 window: VecDeque<f64>,
43 sum: f64,
44 sum_sq: f64,
45 last: Option<f64>,
46}
47
48impl HistoricalVolatility {
49 pub fn new(period: usize, trading_periods: usize) -> Result<Self> {
61 if period == 0 || trading_periods == 0 {
62 return Err(Error::PeriodZero);
63 }
64 if period < 2 {
65 return Err(Error::InvalidPeriod {
66 message: "historical volatility period must be >= 2",
67 });
68 }
69 Ok(Self {
70 period,
71 trading_periods,
72 prev_price: None,
73 window: VecDeque::with_capacity(period),
74 sum: 0.0,
75 sum_sq: 0.0,
76 last: None,
77 })
78 }
79
80 pub const fn periods(&self) -> (usize, usize) {
82 (self.period, self.trading_periods)
83 }
84
85 pub const fn value(&self) -> Option<f64> {
87 self.last
88 }
89}
90
91impl Indicator for HistoricalVolatility {
92 type Input = f64;
93 type Output = f64;
94
95 fn update(&mut self, input: f64) -> Option<f64> {
96 if !input.is_finite() || input <= 0.0 {
104 return self.last;
105 }
106 let Some(prev) = self.prev_price else {
107 self.prev_price = Some(input);
108 return None;
109 };
110 self.prev_price = Some(input);
114
115 let log_return = (input / prev).ln();
116 if self.window.len() == self.period {
117 let old = self.window.pop_front().expect("window is non-empty");
118 self.sum -= old;
119 self.sum_sq -= old * old;
120 }
121 self.window.push_back(log_return);
122 self.sum += log_return;
123 self.sum_sq += log_return * log_return;
124 if self.window.len() < self.period {
125 return None;
126 }
127 let n = self.period as f64;
128 let mean = self.sum / n;
129 let variance = ((self.sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
131 let hv = variance.sqrt() * (self.trading_periods as f64).sqrt() * 100.0;
132 self.last = Some(hv);
133 Some(hv)
134 }
135
136 fn reset(&mut self) {
137 self.prev_price = None;
138 self.window.clear();
139 self.sum = 0.0;
140 self.sum_sq = 0.0;
141 self.last = None;
142 }
143
144 fn warmup_period(&self) -> usize {
145 self.period + 1
147 }
148
149 fn is_ready(&self) -> bool {
150 self.last.is_some()
151 }
152
153 fn name(&self) -> &'static str {
154 "HistoricalVolatility"
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::traits::BatchExt;
162 use approx::assert_relative_eq;
163
164 #[test]
165 fn new_rejects_zero_period() {
166 assert!(matches!(
167 HistoricalVolatility::new(0, 252),
168 Err(Error::PeriodZero)
169 ));
170 assert!(matches!(
171 HistoricalVolatility::new(20, 0),
172 Err(Error::PeriodZero)
173 ));
174 }
175
176 #[test]
180 fn accessors_and_metadata() {
181 let mut hv = HistoricalVolatility::new(20, 252).unwrap();
182 assert_eq!(hv.periods(), (20, 252));
183 assert_eq!(hv.name(), "HistoricalVolatility");
184 assert_eq!(hv.value(), None);
185 for i in 1..=hv.warmup_period() {
186 hv.update(100.0 + f64::from(u32::try_from(i).unwrap()));
187 }
188 assert!(hv.value().is_some());
189 }
190
191 #[test]
192 fn new_rejects_period_one() {
193 assert!(matches!(
194 HistoricalVolatility::new(1, 252),
195 Err(Error::InvalidPeriod { .. })
196 ));
197 }
198
199 #[test]
200 fn first_emission_at_warmup_period() {
201 let mut hv = HistoricalVolatility::new(5, 252).unwrap();
202 assert_eq!(hv.warmup_period(), 6);
203 let out = hv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
204 for v in out.iter().take(5) {
205 assert!(v.is_none());
206 }
207 assert!(out[5].is_some());
208 }
209
210 #[test]
211 fn constant_series_yields_zero() {
212 let mut hv = HistoricalVolatility::new(10, 252).unwrap();
214 let out = hv.batch(&[100.0; 40]);
215 for v in out.iter().skip(10).flatten() {
216 assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
217 }
218 }
219
220 #[test]
221 fn geometric_series_yields_zero() {
222 let mut hv = HistoricalVolatility::new(10, 252).unwrap();
230 let prices: Vec<f64> = (0..40).map(|i| 100.0 * 1.01_f64.powi(i)).collect();
231 let out = hv.batch(&prices);
232 for v in out.iter().skip(10).flatten() {
233 assert_relative_eq!(*v, 0.0, epsilon = 1e-6);
234 }
235 }
236
237 #[test]
238 fn output_is_non_negative() {
239 let mut hv = HistoricalVolatility::new(20, 252).unwrap();
240 let prices: Vec<f64> = (1..=200)
241 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
242 .collect();
243 for v in hv.batch(&prices).into_iter().flatten() {
244 assert!(v >= 0.0, "volatility must be non-negative, got {v}");
245 }
246 }
247
248 #[test]
249 fn ignores_non_finite_input() {
250 let mut hv = HistoricalVolatility::new(5, 252).unwrap();
251 let out = hv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
252 let last = *out.last().unwrap();
253 assert!(last.is_some());
254 assert_eq!(hv.update(f64::NAN), last);
255 assert_eq!(hv.update(f64::INFINITY), last);
256 }
257
258 #[test]
263 fn skips_non_positive_prices() {
264 let mut hv = HistoricalVolatility::new(5, 252).unwrap();
265 let warmup_prices = (1..=20).map(f64::from).collect::<Vec<_>>();
267 let warmup = hv.batch(&warmup_prices);
268 let baseline = warmup
269 .last()
270 .copied()
271 .flatten()
272 .expect("warmed up by index 5");
273
274 assert_eq!(hv.update(-5.0), Some(baseline));
279 assert_eq!(hv.update(0.0), Some(baseline));
280
281 let mut control = hv.clone();
285 let after_real = hv.update(21.0).expect("ready");
286 assert_eq!(control.update(21.0).expect("ready"), after_real);
287 }
288
289 #[test]
290 fn reset_clears_state() {
291 let mut hv = HistoricalVolatility::new(5, 252).unwrap();
292 hv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
293 assert!(hv.is_ready());
294 hv.reset();
295 assert!(!hv.is_ready());
296 assert_eq!(hv.update(1.0), None);
297 }
298
299 #[test]
300 fn batch_equals_streaming() {
301 let prices: Vec<f64> = (1..=120)
302 .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
303 .collect();
304 let batch = HistoricalVolatility::new(20, 252).unwrap().batch(&prices);
305 let mut b = HistoricalVolatility::new(20, 252).unwrap();
306 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
307 assert_eq!(batch, streamed);
308 }
309}