1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct VolatilityConeOutput {
13 pub current: f64,
15 pub min: f64,
17 pub median: f64,
19 pub max: f64,
21 pub percentile: f64,
24}
25
26fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 {
28 let n = count as f64;
29 let mean = sum / n;
30 let variance = ((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
31 variance.sqrt()
32}
33
34#[derive(Debug, Clone)]
74pub struct VolatilityCone {
75 window: usize,
76 lookback: usize,
77 prev_close: Option<f64>,
78 returns: VecDeque<f64>,
80 ret_sum: f64,
81 ret_sum_sq: f64,
82 vols: VecDeque<f64>,
84 last: Option<VolatilityConeOutput>,
85}
86
87impl VolatilityCone {
88 pub fn new(window: usize, lookback: usize) -> Result<Self> {
98 if window == 0 || lookback == 0 {
99 return Err(Error::PeriodZero);
100 }
101 if window < 2 || lookback < 2 {
102 return Err(Error::InvalidPeriod {
103 message: "volatility cone window and lookback must both be >= 2",
104 });
105 }
106 Ok(Self {
107 window,
108 lookback,
109 prev_close: None,
110 returns: VecDeque::with_capacity(window),
111 ret_sum: 0.0,
112 ret_sum_sq: 0.0,
113 vols: VecDeque::with_capacity(lookback),
114 last: None,
115 })
116 }
117
118 pub const fn windows(&self) -> (usize, usize) {
120 (self.window, self.lookback)
121 }
122
123 pub const fn value(&self) -> Option<VolatilityConeOutput> {
125 self.last
126 }
127}
128
129impl Indicator for VolatilityCone {
130 type Input = Candle;
131 type Output = VolatilityConeOutput;
132
133 fn update(&mut self, candle: Candle) -> Option<VolatilityConeOutput> {
134 let price = candle.close;
135 if price <= 0.0 {
137 return self.last;
138 }
139 let Some(prev) = self.prev_close else {
140 self.prev_close = Some(price);
141 return None;
142 };
143 self.prev_close = Some(price);
144 let r = (price / prev).ln();
147
148 if self.returns.len() == self.window {
150 let old = self.returns.pop_front().expect("returns window non-empty");
151 self.ret_sum -= old;
152 self.ret_sum_sq -= old * old;
153 }
154 self.returns.push_back(r);
155 self.ret_sum += r;
156 self.ret_sum_sq += r * r;
157 if self.returns.len() < self.window {
158 return None;
159 }
160 let current = sample_stddev(self.ret_sum, self.ret_sum_sq, self.window);
161
162 if self.vols.len() == self.lookback {
164 self.vols.pop_front();
165 }
166 self.vols.push_back(current);
167 if self.vols.len() < self.lookback {
168 return None;
169 }
170
171 let mut sorted: Vec<f64> = self.vols.iter().copied().collect();
172 sorted.sort_by(f64::total_cmp);
173 let min = sorted[0];
174 let max = sorted[self.lookback - 1];
175 let mid = self.lookback / 2;
176 let median = if self.lookback % 2 == 1 {
177 sorted[mid]
178 } else {
179 f64::midpoint(sorted[mid - 1], sorted[mid])
180 };
181 let count_le = self.vols.iter().filter(|&&v| v <= current).count();
182 let percentile = count_le as f64 / self.lookback as f64 * 100.0;
183
184 let out = VolatilityConeOutput {
185 current,
186 min,
187 median,
188 max,
189 percentile,
190 };
191 self.last = Some(out);
192 Some(out)
193 }
194
195 fn reset(&mut self) {
196 self.prev_close = None;
197 self.returns.clear();
198 self.ret_sum = 0.0;
199 self.ret_sum_sq = 0.0;
200 self.vols.clear();
201 self.last = None;
202 }
203
204 fn warmup_period(&self) -> usize {
205 self.window + self.lookback
208 }
209
210 fn is_ready(&self) -> bool {
211 self.last.is_some()
212 }
213
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}