1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::ShiftedMoments;
7use crate::indicators::sorted_window;
8use crate::ohlcv::Candle;
9use crate::traits::Indicator;
10
11#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct VolatilityConeOutput {
15 pub current: f64,
17 pub min: f64,
19 pub median: f64,
21 pub max: f64,
23 pub percentile: f64,
26}
27
28#[derive(Debug, Clone)]
68pub struct VolatilityCone {
69 window: usize,
70 lookback: usize,
71 prev_close: Option<f64>,
72 returns: VecDeque<f64>,
74 ret_moments: ShiftedMoments,
75 vols: VecDeque<f64>,
77 scratch: Vec<f64>,
80 last: Option<VolatilityConeOutput>,
81}
82
83impl VolatilityCone {
84 pub fn new(window: usize, lookback: usize) -> Result<Self> {
94 if window == 0 || lookback == 0 {
95 return Err(Error::PeriodZero);
96 }
97 if window < 2 || lookback < 2 {
98 return Err(Error::InvalidPeriod {
99 message: "volatility cone window and lookback must both be >= 2",
100 });
101 }
102 Ok(Self {
103 window,
104 lookback,
105 prev_close: None,
106 returns: VecDeque::with_capacity(window),
107 ret_moments: ShiftedMoments::new(),
108 vols: VecDeque::with_capacity(lookback),
109 scratch: Vec::with_capacity(lookback),
110 last: None,
111 })
112 }
113
114 pub const fn windows(&self) -> (usize, usize) {
116 (self.window, self.lookback)
117 }
118
119 pub const fn value(&self) -> Option<VolatilityConeOutput> {
121 self.last
122 }
123}
124
125impl Indicator for VolatilityCone {
126 type Input = Candle;
127 type Output = VolatilityConeOutput;
128
129 fn update(&mut self, candle: Candle) -> Option<VolatilityConeOutput> {
130 let price = candle.close;
131 if price <= 0.0 {
133 return self.last;
134 }
135 let Some(prev) = self.prev_close else {
136 self.prev_close = Some(price);
137 return None;
138 };
139 self.prev_close = Some(price);
140 let r = (price / prev).ln();
143
144 if self.returns.len() == self.window {
146 let old = self.returns.pop_front().expect("returns window non-empty");
147 self.ret_moments.evict(old);
148 }
149 self.returns.push_back(r);
150 self.ret_moments.push(r);
151 if self.ret_moments.needs_reseed(self.window) {
152 self.ret_moments.reseed(self.returns.iter().copied());
153 }
154 if self.returns.len() < self.window {
155 return None;
156 }
157 let current = self.ret_moments.sample_variance(self.window).sqrt();
158
159 if self.vols.len() == self.lookback {
161 let oldest = self.vols.pop_front().expect("window is full");
162 sorted_window::remove(&mut self.scratch, oldest);
163 }
164 self.vols.push_back(current);
165 sorted_window::insert(&mut self.scratch, current);
166 if self.vols.len() < self.lookback {
167 return None;
168 }
169
170 let min = self.scratch[0];
171 let max = self.scratch[self.lookback - 1];
172 let mid = self.lookback / 2;
173 let median = if self.lookback % 2 == 1 {
174 self.scratch[mid]
175 } else {
176 f64::midpoint(self.scratch[mid - 1], self.scratch[mid])
177 };
178 let count_le = self.vols.iter().filter(|&&v| v <= current).count();
179 let percentile = count_le as f64 / self.lookback as f64 * 100.0;
180
181 let out = VolatilityConeOutput {
182 current,
183 min,
184 median,
185 max,
186 percentile,
187 };
188 self.last = Some(out);
189 Some(out)
190 }
191
192 fn reset(&mut self) {
193 self.prev_close = None;
194 self.returns.clear();
195 self.ret_moments.reset();
196 self.vols.clear();
197 self.scratch.clear();
198 self.last = None;
199 }
200
201 #[inline]
202 fn warmup_period(&self) -> usize {
203 self.window + self.lookback
206 }
207
208 #[inline]
209 fn is_ready(&self) -> bool {
210 self.last.is_some()
211 }
212
213 #[inline]
214 fn name(&self) -> &'static str {
215 "VolatilityCone"
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::traits::BatchExt;
223 use approx::assert_relative_eq;
224
225 fn close_candle(close: f64) -> Candle {
227 Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
228 }
229
230 #[test]
231 fn rejects_zero_window() {
232 assert!(matches!(VolatilityCone::new(0, 10), Err(Error::PeriodZero)));
233 assert!(matches!(VolatilityCone::new(10, 0), Err(Error::PeriodZero)));
234 }
235
236 #[test]
237 fn rejects_window_one() {
238 assert!(matches!(
239 VolatilityCone::new(1, 10),
240 Err(Error::InvalidPeriod { .. })
241 ));
242 assert!(matches!(
243 VolatilityCone::new(10, 1),
244 Err(Error::InvalidPeriod { .. })
245 ));
246 }
247
248 #[test]
249 fn accessors_and_metadata() {
250 let vc = VolatilityCone::new(20, 60).unwrap();
251 assert_eq!(vc.windows(), (20, 60));
252 assert_eq!(vc.warmup_period(), 80);
253 assert_eq!(vc.name(), "VolatilityCone");
254 assert!(!vc.is_ready());
255 assert_eq!(vc.value(), None);
256 }
257
258 #[test]
259 fn first_emission_at_warmup_period() {
260 let mut vc = VolatilityCone::new(2, 2).unwrap();
261 let prices = [100.0, 110.0, 121.0, 100.0, 105.0, 99.0];
262 let candles: Vec<Candle> = prices.iter().map(|p| close_candle(*p)).collect();
263 let out = vc.batch(&candles);
264 let warmup = vc.warmup_period(); assert_eq!(warmup, 4);
266 for v in out.iter().take(warmup - 1) {
267 assert!(v.is_none());
268 }
269 assert!(out[warmup - 1].is_some());
270 }
271
272 #[test]
273 fn known_value() {
274 let mut vc = VolatilityCone::new(2, 2).unwrap();
277 let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0]
278 .iter()
279 .map(|p| close_candle(*p))
280 .collect();
281 let out = vc.batch(&candles);
282 let r2 = (121.0_f64 / 110.0).ln();
283 let r3 = (100.0_f64 / 121.0).ln();
284 let vol2 = (r2 - r3).abs() / 2.0_f64.sqrt();
285 let o = out[3].unwrap();
286 assert_relative_eq!(o.current, vol2, epsilon = 1e-9);
287 assert_relative_eq!(o.min, 0.0, epsilon = 1e-9); assert_relative_eq!(o.max, vol2, epsilon = 1e-9);
289 assert_relative_eq!(o.median, vol2 / 2.0, epsilon = 1e-9);
290 assert_relative_eq!(o.percentile, 100.0, epsilon = 1e-9);
291 }
292
293 #[test]
294 fn odd_lookback_median_is_middle() {
295 let mut vc = VolatilityCone::new(2, 3).unwrap();
297 let candles: Vec<Candle> = [100.0, 101.0, 103.0, 100.0, 104.0, 99.0, 106.0]
298 .iter()
299 .map(|p| close_candle(*p))
300 .collect();
301 let out = vc.batch(&candles);
302 let o = out.last().unwrap().unwrap();
303 assert!(o.min <= o.median && o.median <= o.max);
304 }
305
306 #[test]
307 fn envelope_brackets_current() {
308 let mut vc = VolatilityCone::new(10, 30).unwrap();
309 let candles: Vec<Candle> = (0..200)
310 .map(|i| close_candle(100.0 + (f64::from(i) * 0.3).sin() * 12.0))
311 .collect();
312 for o in vc.batch(&candles).into_iter().flatten() {
313 assert!(o.min <= o.current && o.current <= o.max);
314 assert!(o.min <= o.median && o.median <= o.max);
315 assert!(o.percentile > 0.0 && o.percentile <= 100.0);
316 }
317 }
318
319 #[test]
320 fn constant_series_yields_zero_cone() {
321 let mut vc = VolatilityCone::new(5, 5).unwrap();
322 let candles: Vec<Candle> = (0..40).map(|_| close_candle(100.0)).collect();
323 for o in vc.batch(&candles).into_iter().flatten() {
324 assert_relative_eq!(o.current, 0.0, epsilon = 1e-12);
325 assert_relative_eq!(o.min, 0.0, epsilon = 1e-12);
326 assert_relative_eq!(o.max, 0.0, epsilon = 1e-12);
327 assert_relative_eq!(o.median, 0.0, epsilon = 1e-12);
328 assert_relative_eq!(o.percentile, 100.0, epsilon = 1e-12);
329 }
330 }
331
332 #[test]
333 fn skips_non_positive_close() {
334 let mut vc = VolatilityCone::new(2, 2).unwrap();
335 let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0]
336 .iter()
337 .map(|p| close_candle(*p))
338 .collect();
339 let warmup = vc.batch(&candles);
340 let baseline = warmup.last().copied().flatten().expect("warmed up");
341 assert_eq!(vc.update(close_candle(0.0)), Some(baseline));
343 let mut control = vc.clone();
345 let after = vc.update(close_candle(105.0)).expect("ready");
346 assert_eq!(control.update(close_candle(105.0)).expect("ready"), after);
347 }
348
349 #[test]
350 fn skips_non_positive_before_first_close() {
351 let mut vc = VolatilityCone::new(2, 2).unwrap();
352 assert_eq!(vc.update(close_candle(0.0)), None);
353 assert_eq!(vc.update(close_candle(100.0)), None);
354 }
355
356 #[test]
357 fn reset_clears_state() {
358 let mut vc = VolatilityCone::new(2, 2).unwrap();
359 let candles: Vec<Candle> = [100.0, 110.0, 121.0, 100.0, 105.0]
360 .iter()
361 .map(|p| close_candle(*p))
362 .collect();
363 vc.batch(&candles);
364 assert!(vc.is_ready());
365 vc.reset();
366 assert!(!vc.is_ready());
367 assert_eq!(vc.value(), None);
368 assert_eq!(vc.update(close_candle(100.0)), None);
369 }
370
371 #[test]
372 fn batch_equals_streaming() {
373 let candles: Vec<Candle> = (0..200)
374 .map(|i| close_candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
375 .collect();
376 let batch = VolatilityCone::new(10, 30).unwrap().batch(&candles);
377 let mut b = VolatilityCone::new(10, 30).unwrap();
378 let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
379 assert_eq!(batch, streamed);
380 }
381}