Skip to main content

fin_primitives/signals/indicators/
volatility_compression.rs

1//! Volatility Compression indicator.
2
3use crate::error::FinError;
4use crate::signals::{BarInput, Signal, SignalValue};
5use rust_decimal::Decimal;
6use std::collections::VecDeque;
7
8/// Volatility Compression — consecutive count of bars where the bar's range is below
9/// the rolling average range over the last `period` bars.
10///
11/// Outputs:
12/// - **+N**: N consecutive bars where range < rolling avg (compression building).
13/// - **0**: current bar has range >= rolling avg (compression broken).
14///
15/// This is useful for detecting squeeze setups: prolonged compression often precedes
16/// a volatility expansion breakout.
17///
18/// Returns [`SignalValue::Unavailable`] until `period` bars have been seen.
19///
20/// # Errors
21/// Returns [`FinError::InvalidPeriod`] if `period == 0`.
22///
23/// # Example
24/// ```rust
25/// use fin_primitives::signals::indicators::VolatilityCompression;
26/// use fin_primitives::signals::Signal;
27///
28/// let vc = VolatilityCompression::new("vc", 14).unwrap();
29/// assert_eq!(vc.period(), 14);
30/// ```
31pub struct VolatilityCompression {
32    name: String,
33    period: usize,
34    ranges: VecDeque<Decimal>,
35    sum: Decimal,
36    streak: u32,
37}
38
39impl VolatilityCompression {
40    /// Constructs a new `VolatilityCompression`.
41    ///
42    /// # Errors
43    /// Returns [`FinError::InvalidPeriod`] if `period == 0`.
44    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    /// Returns the current compression streak count.
58    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        // Equal ranges → avg = range → 0 bars below avg → streak stays 0
141        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(); // range=20 every bar
145        }
146        assert_eq!(last, SignalValue::Scalar(dec!(0)));
147    }
148
149    #[test]
150    fn test_vc_compression_builds() {
151        // 3 wide bars, then narrow bars to build compression
152        let mut vc = VolatilityCompression::new("vc", 3).unwrap();
153        vc.update_bar(&bar("120", "80")).unwrap(); // range=40
154        vc.update_bar(&bar("120", "80")).unwrap(); // range=40
155        vc.update_bar(&bar("120", "80")).unwrap(); // range=40, now avg=40
156        // narrow bars: range=5 < avg=40 → compression
157        let r1 = vc.update_bar(&bar("105", "100")).unwrap(); // streak=1
158        let r2 = vc.update_bar(&bar("105", "100")).unwrap(); // streak=2
159        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}