Skip to main content

wickra_core/indicators/
regime_label.rs

1//! Regime Label — volatility-quantile classification of the current bar.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::ShiftedMoments;
7use crate::indicators::rolling_quantile::quantile_sorted;
8use crate::traits::Indicator;
9
10/// Regime Label — a discrete `{−1, 0, +1}` classification of the current
11/// volatility regime by where the latest rolling volatility falls within its
12/// own recent distribution.
13///
14/// ```text
15/// σₜ    = sample stddev of the last `vol_period` log returns
16/// q1,q3 = 25th / 75th percentile of the last `lookback` σ readings
17/// label = −1 if σₜ < q1   (calm regime)
18///         +1 if σₜ > q3   (stressed regime)
19///          0 otherwise    (normal regime)
20/// ```
21///
22/// This is the canonical rolling-volatility-quantile regime split: rather than
23/// thresholding absolute volatility (which is not comparable across instruments
24/// or epochs), it asks whether *today's* volatility is unusually low or high
25/// **relative to its own recent history**. `−1` is a calm regime, `+1` a
26/// stressed / high-volatility regime, `0` the normal middle. Because the latest
27/// reading is included in its own reference window, a freshly elevated
28/// volatility prints `+1` until the window catches up to the new level — it
29/// flags the *transition*, not just the absolute level. When the recent
30/// volatilities are all equal (`q1 == q3`, e.g. a constant drift) there is no
31/// spread to classify against and the label is `0`.
32///
33/// Each `update` is `O(vol_period + lookback log lookback)`. Non-finite and
34/// non-positive prices are ignored.
35///
36/// # Example
37///
38/// ```
39/// use wickra_core::{Indicator, RegimeLabel};
40///
41/// let mut indicator = RegimeLabel::new(5, 20).unwrap();
42/// let mut last = None;
43/// for i in 0..60 {
44///     last = indicator.update(100.0 + (f64::from(i) * 0.5).sin());
45/// }
46/// assert!(last.is_some());
47/// ```
48#[derive(Debug, Clone)]
49pub struct RegimeLabel {
50    vol_period: usize,
51    lookback: usize,
52    prev_price: Option<f64>,
53    /// Trailing window of the last `vol_period` log returns.
54    ret_window: VecDeque<f64>,
55    ret_moments: ShiftedMoments,
56    /// Trailing window of the last `lookback` volatility readings.
57    vol_window: VecDeque<f64>,
58    /// Reusable scratch buffer for the quantile sort.
59    scratch: Vec<f64>,
60    last: Option<f64>,
61}
62
63impl RegimeLabel {
64    /// Construct a new Regime Label classifier.
65    ///
66    /// `vol_period` is the window for the rolling volatility; `lookback` is the
67    /// window of volatility readings whose quartiles set the regime bands.
68    ///
69    /// # Errors
70    /// Returns [`Error::InvalidPeriod`] if `vol_period < 2` (the sample standard
71    /// deviation needs at least two returns) or if `lookback < 2` (the quartile
72    /// split needs at least two readings).
73    pub fn new(vol_period: usize, lookback: usize) -> Result<Self> {
74        if vol_period < 2 {
75            return Err(Error::InvalidPeriod {
76                message: "regime label needs vol_period >= 2",
77            });
78        }
79        if vol_period > crate::error::MAX_PERIOD {
80            return Err(Error::InvalidPeriod {
81                message: crate::error::PERIOD_ABOVE_MAX,
82            });
83        }
84        if lookback < 2 {
85            return Err(Error::InvalidPeriod {
86                message: "regime label needs lookback >= 2",
87            });
88        }
89        if lookback > crate::error::MAX_PERIOD {
90            return Err(Error::InvalidPeriod {
91                message: crate::error::PERIOD_ABOVE_MAX,
92            });
93        }
94        Ok(Self {
95            vol_period,
96            lookback,
97            prev_price: None,
98            ret_window: VecDeque::with_capacity(vol_period),
99            ret_moments: ShiftedMoments::new(),
100            vol_window: VecDeque::with_capacity(lookback),
101            scratch: Vec::with_capacity(lookback),
102            last: None,
103        })
104    }
105
106    /// Configured `(vol_period, lookback)`.
107    pub const fn params(&self) -> (usize, usize) {
108        (self.vol_period, self.lookback)
109    }
110}
111
112impl Indicator for RegimeLabel {
113    type Input = f64;
114    type Output = f64;
115
116    fn update(&mut self, input: f64) -> Option<f64> {
117        if !input.is_finite() || input <= 0.0 {
118            return None;
119        }
120        let Some(prev) = self.prev_price else {
121            self.prev_price = Some(input);
122            return None;
123        };
124        self.prev_price = Some(input);
125        let r = (input / prev).ln();
126        // Roll the return window and its running moments.
127        if self.ret_window.len() == self.vol_period {
128            let old = self.ret_window.pop_front().expect("non-empty");
129            self.ret_moments.evict(old);
130        }
131        self.ret_window.push_back(r);
132        self.ret_moments.push(r);
133        if self.ret_moments.needs_reseed(self.vol_period) {
134            self.ret_moments.reseed(self.ret_window.iter().copied());
135        }
136        if self.ret_window.len() < self.vol_period {
137            return None;
138        }
139        let vol = self.ret_moments.sample_variance(self.vol_period).sqrt();
140        // Roll the volatility window.
141        if self.vol_window.len() == self.lookback {
142            self.vol_window.pop_front();
143        }
144        self.vol_window.push_back(vol);
145        if self.vol_window.len() < self.lookback {
146            return None;
147        }
148        // Classify the latest volatility against the quartiles of the window.
149        self.scratch.clear();
150        self.scratch.extend(self.vol_window.iter().copied());
151        self.scratch.sort_by(f64::total_cmp);
152        let q1 = quantile_sorted(&self.scratch, 0.25);
153        let q3 = quantile_sorted(&self.scratch, 0.75);
154        let label = if vol < q1 {
155            -1.0
156        } else if vol > q3 {
157            1.0
158        } else {
159            0.0
160        };
161        self.last = Some(label);
162        Some(label)
163    }
164
165    fn reset(&mut self) {
166        self.prev_price = None;
167        self.ret_window.clear();
168        self.ret_moments.reset();
169        self.vol_window.clear();
170        self.scratch.clear();
171        self.last = None;
172    }
173
174    #[inline]
175    fn warmup_period(&self) -> usize {
176        // One price seeds `prev`, `vol_period` returns yield the first vol, then
177        // `lookback` vols fill the regime window.
178        self.vol_period + self.lookback
179    }
180
181    #[inline]
182    fn is_ready(&self) -> bool {
183        self.last.is_some()
184    }
185
186    #[inline]
187    fn name(&self) -> &'static str {
188        "RegimeLabel"
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::traits::BatchExt;
196
197    #[test]
198    fn rejects_bad_periods() {
199        assert!(matches!(
200            RegimeLabel::new(1, 20),
201            Err(Error::InvalidPeriod { .. })
202        ));
203        assert!(matches!(
204            RegimeLabel::new(5, 1),
205            Err(Error::InvalidPeriod { .. })
206        ));
207    }
208
209    #[test]
210    fn accessors_and_metadata() {
211        let rl = RegimeLabel::new(5, 20).unwrap();
212        assert_eq!(rl.params(), (5, 20));
213        assert_eq!(rl.warmup_period(), 25);
214        assert_eq!(rl.name(), "RegimeLabel");
215        assert!(!rl.is_ready());
216    }
217
218    #[test]
219    fn detects_stressed_regime_on_volatility_spike() {
220        // Calm warmup, then a burst of large moves: the elevated volatility
221        // prints +1 while the lookback window still holds the calm readings.
222        let mut rl = RegimeLabel::new(4, 8).unwrap();
223        let mut prices: Vec<f64> = (0..24)
224            .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 0.2)
225            .collect();
226        let mut base = *prices.last().unwrap();
227        for i in 0..8 {
228            base *= if i % 2 == 0 { 1.08 } else { 0.93 };
229            prices.push(base);
230        }
231        let out = rl.batch(&prices);
232        assert!(
233            out.iter().flatten().any(|&v| v == 1.0),
234            "expected a stressed (+1) regime label"
235        );
236    }
237
238    #[test]
239    fn detects_calm_regime_after_volatility_drop() {
240        // Volatile warmup, then a calm tail: the depressed volatility prints -1.
241        let mut rl = RegimeLabel::new(4, 8).unwrap();
242        let mut prices: Vec<f64> = Vec::new();
243        let mut base = 100.0;
244        for i in 0..24 {
245            base *= if i % 2 == 0 { 1.05 } else { 0.96 };
246            prices.push(base);
247        }
248        for i in 0..12 {
249            prices.push(base + (f64::from(i) * 0.7).sin() * 0.05);
250        }
251        let out = rl.batch(&prices);
252        assert!(
253            out.iter().flatten().any(|&v| v == -1.0),
254            "expected a calm (-1) regime label"
255        );
256    }
257
258    #[test]
259    fn zero_volatility_is_neutral() {
260        // A constant price has exactly-zero returns => zero volatility on every
261        // window => q1 == q3 == 0 => neutral 0 throughout. (A geometric drift is
262        // *conceptually* constant-vol too, but floating-point rounding of the
263        // log returns leaves ~1e-16 dispersion, so the exactly-flat series is
264        // the clean way to pin the q1 == q3 branch.)
265        let mut rl = RegimeLabel::new(4, 8).unwrap();
266        for v in rl.batch(&[100.0; 40]).into_iter().flatten() {
267            assert_eq!(v, 0.0);
268        }
269    }
270
271    #[test]
272    fn output_is_ternary() {
273        let mut rl = RegimeLabel::new(5, 20).unwrap();
274        let prices: Vec<f64> = (0..300)
275            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * (1.0 + (f64::from(i) * 0.05).sin() * 5.0))
276            .collect();
277        for v in rl.batch(&prices).into_iter().flatten() {
278            assert!(v == -1.0 || v == 0.0 || v == 1.0, "non-ternary label {v}");
279        }
280    }
281
282    #[test]
283    fn ignores_non_finite_and_non_positive() {
284        let mut rl = RegimeLabel::new(4, 6).unwrap();
285        let prices: Vec<f64> = (0..40)
286            .map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 2.0)
287            .collect();
288        let out = rl.batch(&prices);
289        let last = *out.last().unwrap();
290        assert!(last.is_some());
291        assert_eq!(rl.update(f64::NAN), None);
292        assert_eq!(rl.update(-1.0), None);
293        assert_eq!(rl.update(0.0), None);
294    }
295
296    #[test]
297    fn reset_clears_state() {
298        let mut rl = RegimeLabel::new(4, 6).unwrap();
299        rl.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
300        assert!(rl.is_ready());
301        rl.reset();
302        assert!(!rl.is_ready());
303        assert_eq!(rl.update(1.0), None);
304    }
305
306    #[test]
307    fn batch_equals_streaming() {
308        let prices: Vec<f64> = (1..=160)
309            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 4.0)
310            .collect();
311        let batch = RegimeLabel::new(5, 20).unwrap().batch(&prices);
312        let mut b = RegimeLabel::new(5, 20).unwrap();
313        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
314        assert_eq!(batch, streamed);
315    }
316}