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