fin_primitives/signals/indicators/
volatility_compression.rs1use crate::error::FinError;
4use crate::signals::{BarInput, Signal, SignalValue};
5use rust_decimal::Decimal;
6use std::collections::VecDeque;
7
8pub struct VolatilityCompression {
32 name: String,
33 period: usize,
34 ranges: VecDeque<Decimal>,
35 sum: Decimal,
36 streak: u32,
37}
38
39impl VolatilityCompression {
40 pub fn new(name: impl Into<String>, period: usize) -> Result<Self, FinError> {
45 if period == 0 {
46 return Err(FinError::InvalidPeriod(period));
47 }
48 Ok(Self {
49 name: name.into(),
50 period,
51 ranges: VecDeque::with_capacity(period),
52 sum: Decimal::ZERO,
53 streak: 0,
54 })
55 }
56
57 pub fn streak(&self) -> u32 {
59 self.streak
60 }
61}
62
63impl Signal for VolatilityCompression {
64 fn name(&self) -> &str { &self.name }
65 fn period(&self) -> usize { self.period }
66 fn is_ready(&self) -> bool { self.ranges.len() >= self.period }
67
68 fn update(&mut self, bar: &BarInput) -> Result<SignalValue, FinError> {
69 let range = bar.range();
70
71 self.sum += range;
72 self.ranges.push_back(range);
73 if self.ranges.len() > self.period {
74 let removed = self.ranges.pop_front().unwrap();
75 self.sum -= removed;
76 }
77
78 if self.ranges.len() < self.period {
79 return Ok(SignalValue::Unavailable);
80 }
81
82 let avg = self.sum
83 .checked_div(Decimal::from(self.period as u32))
84 .ok_or(FinError::ArithmeticOverflow)?;
85
86 if range < avg {
87 self.streak += 1;
88 } else {
89 self.streak = 0;
90 }
91
92 #[allow(clippy::cast_possible_truncation)]
93 Ok(SignalValue::Scalar(Decimal::from(self.streak)))
94 }
95
96 fn reset(&mut self) {
97 self.ranges.clear();
98 self.sum = Decimal::ZERO;
99 self.streak = 0;
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use crate::ohlcv::OhlcvBar;
107 use crate::signals::Signal;
108 use crate::types::{NanoTimestamp, Price, Quantity, Symbol};
109 use rust_decimal_macros::dec;
110
111 fn bar(h: &str, l: &str) -> OhlcvBar {
112 let hp = Price::new(h.parse().unwrap()).unwrap();
113 let lp = Price::new(l.parse().unwrap()).unwrap();
114 OhlcvBar {
115 symbol: Symbol::new("X").unwrap(),
116 open: lp, high: hp, low: lp, close: hp,
117 volume: Quantity::zero(),
118 ts_open: NanoTimestamp::new(0),
119 ts_close: NanoTimestamp::new(1),
120 tick_count: 1,
121 }
122 }
123
124 #[test]
125 fn test_vc_invalid_period() {
126 assert!(VolatilityCompression::new("vc", 0).is_err());
127 }
128
129 #[test]
130 fn test_vc_unavailable_during_warmup() {
131 let mut vc = VolatilityCompression::new("vc", 3).unwrap();
132 for _ in 0..2 {
133 assert_eq!(vc.update_bar(&bar("110", "90")).unwrap(), SignalValue::Unavailable);
134 }
135 assert!(!vc.is_ready());
136 }
137
138 #[test]
139 fn test_vc_uniform_ranges_zero() {
140 let mut vc = VolatilityCompression::new("vc", 3).unwrap();
142 let mut last = SignalValue::Unavailable;
143 for _ in 0..5 {
144 last = vc.update_bar(&bar("110", "90")).unwrap(); }
146 assert_eq!(last, SignalValue::Scalar(dec!(0)));
147 }
148
149 #[test]
150 fn test_vc_compression_builds() {
151 let mut vc = VolatilityCompression::new("vc", 3).unwrap();
153 vc.update_bar(&bar("120", "80")).unwrap(); vc.update_bar(&bar("120", "80")).unwrap(); vc.update_bar(&bar("120", "80")).unwrap(); let r1 = vc.update_bar(&bar("105", "100")).unwrap(); let r2 = vc.update_bar(&bar("105", "100")).unwrap(); assert_eq!(r1, SignalValue::Scalar(dec!(1)));
160 assert_eq!(r2, SignalValue::Scalar(dec!(2)));
161 }
162
163 #[test]
164 fn test_vc_reset() {
165 let mut vc = VolatilityCompression::new("vc", 3).unwrap();
166 for _ in 0..3 { vc.update_bar(&bar("110", "90")).unwrap(); }
167 assert!(vc.is_ready());
168 vc.reset();
169 assert!(!vc.is_ready());
170 assert_eq!(vc.streak(), 0);
171 }
172}