wickra_core/indicators/
regime_label.rs1use 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#[derive(Debug, Clone)]
49pub struct RegimeLabel {
50 vol_period: usize,
51 lookback: usize,
52 prev_price: Option<f64>,
53 ret_window: VecDeque<f64>,
55 ret_moments: ShiftedMoments,
56 vol_window: VecDeque<f64>,
58 scratch: Vec<f64>,
60 last: Option<f64>,
61}
62
63impl RegimeLabel {
64 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 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 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 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 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 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 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 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 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}