Skip to main content

kestrel_chartkit/indicator/
volatility_regime.rs

1use super::bollinger::BollingerBands;
2use super::volatility_indicators::KeltnerChannelEngine;
3use super::{Indicator, IndicatorAlert, IndicatorOutput};
4use crate::model::Bar;
5use std::collections::HashMap;
6
7#[cfg(feature = "serde")]
8use serde::{Deserialize, Serialize};
9
10/// Volatility Regime Classification State.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
12#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
13pub enum VolatilityState {
14    Squeeze,
15    #[default]
16    Normal,
17    Expansion,
18}
19
20/// Volatility Regime & Bollinger Squeeze Detector Engine.
21#[derive(Debug, Clone)]
22pub struct VolatilityRegimeDetector {
23    period: usize,
24    bb: BollingerBands,
25    keltner: KeltnerChannelEngine,
26    state: VolatilityState,
27}
28
29impl VolatilityRegimeDetector {
30    pub fn new(period: usize, bb_mult: f64, kc_mult: f64) -> Self {
31        Self {
32            period: period.max(1),
33            bb: BollingerBands::new(period, bb_mult),
34            keltner: KeltnerChannelEngine::new(period, 10, kc_mult),
35            state: VolatilityState::Normal,
36        }
37    }
38
39    pub fn with_defaults() -> Self {
40        Self::new(20, 2.0, 1.5)
41    }
42
43    pub fn state(&self) -> VolatilityState {
44        self.state
45    }
46}
47
48impl Indicator for VolatilityRegimeDetector {
49    fn name(&self) -> &str {
50        "volatility_regime"
51    }
52
53    fn warmup_period(&self) -> usize {
54        self.period.max(10)
55    }
56
57    fn reset(&mut self) {
58        self.bb.reset();
59        self.keltner.reset();
60        self.state = VolatilityState::Normal;
61    }
62
63    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
64        let bb_out = self.bb.on_bar(bar);
65        let kc_out = self.keltner.on_bar(bar);
66        let (Some(bb_out), Some(kc_out)) = (bb_out, kc_out) else {
67            return None;
68        };
69
70        let bb_upper = bb_out.extra.get("upper").copied().unwrap_or(bb_out.value);
71        let bb_lower = bb_out.extra.get("lower").copied().unwrap_or(bb_out.value);
72
73        let kc_upper = kc_out.extra.get("upper").copied().unwrap_or(bb_upper);
74        let kc_lower = kc_out.extra.get("lower").copied().unwrap_or(bb_lower);
75
76        // Squeeze when Bollinger Bands are completely inside Keltner Channel
77        let is_squeeze = bb_upper <= kc_upper && bb_lower >= kc_lower;
78        // Expansion when BB bandwidth expands beyond 1.5x Keltner width
79        let bb_width = bb_upper - bb_lower;
80        let kc_width = (kc_upper - kc_lower).max(1e-8);
81        let is_expansion = bb_width > kc_width * 1.3;
82
83        self.state = if is_squeeze {
84            VolatilityState::Squeeze
85        } else if is_expansion {
86            VolatilityState::Expansion
87        } else {
88            VolatilityState::Normal
89        };
90
91        let state_code = match self.state {
92            VolatilityState::Squeeze => -1.0,
93            VolatilityState::Normal => 0.0,
94            VolatilityState::Expansion => 1.0,
95        };
96
97        let mut extra = HashMap::new();
98        extra.insert("bb_width".to_string(), bb_width);
99        extra.insert("kc_width".to_string(), kc_width);
100        extra.insert("squeeze".to_string(), if is_squeeze { 1.0 } else { 0.0 });
101
102        Some(IndicatorOutput::with_extra(state_code, extra))
103    }
104
105    fn alerts(&self) -> Vec<IndicatorAlert> {
106        let mut alerts = Vec::new();
107        if self.state == VolatilityState::Squeeze {
108            alerts.push(IndicatorAlert::new(
109                "volatility",
110                "Bollinger Squeeze in Effect",
111                0.7,
112            ));
113        } else if self.state == VolatilityState::Expansion {
114            alerts.push(IndicatorAlert::new(
115                "volatility",
116                "Volatility Expansion Triggered",
117                0.8,
118            ));
119        }
120        alerts
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_volatility_regime() {
130        let mut vr = VolatilityRegimeDetector::with_defaults();
131        let mut out = None;
132        for i in 0..30 {
133            let b = Bar::new(i, 100.0, 105.0, 95.0, 100.0 + (i % 2) as f64, 1000.0);
134            out = vr.on_bar(&b);
135        }
136        assert!(out.is_some());
137    }
138}