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 Bands against a Keltner Channel.
21///
22/// Bollinger Bands over `period` closes at `bb_mult` population standard deviations
23/// ([`BollingerBands`]); a Keltner Channel with an EMA(`period`) basis and bands at `kc_mult`
24/// times the plain mean of the last 10 true ranges ([`KeltnerChannelEngine`]`(period, 10,
25/// kc_mult)`). Then
26///
27/// - squeeze (`value = -1`): both Bollinger bands inside the channel, `bb_upper <= kc_upper`
28///   and `bb_lower >= kc_lower`;
29/// - otherwise expansion (`value = 1`): `bb_width > 1.3 ยท kc_width`;
30/// - otherwise normal (`value = 0`).
31///
32/// Widths are `upper - lower`, the channel's at least `1e-8`; both are in `extra` (`bb_width`,
33/// `kc_width`), with `extra["squeeze"]` as 1 or 0. The two middles differ (SMA against EMA), so on
34/// a drifting series the bands can leave the channel on one side without the width test firing.
35/// First output once both have one: with bar `max(period, 10)`.
36#[derive(Debug, Clone)]
37pub struct VolatilityRegimeDetector {
38    period: usize,
39    bb: BollingerBands,
40    keltner: KeltnerChannelEngine,
41    state: VolatilityState,
42}
43
44impl VolatilityRegimeDetector {
45    pub fn new(period: usize, bb_mult: f64, kc_mult: f64) -> Self {
46        Self {
47            period: period.max(1),
48            bb: BollingerBands::new(period, bb_mult),
49            keltner: KeltnerChannelEngine::new(period, 10, kc_mult),
50            state: VolatilityState::Normal,
51        }
52    }
53
54    pub fn with_defaults() -> Self {
55        Self::new(20, 2.0, 1.5)
56    }
57
58    pub fn state(&self) -> VolatilityState {
59        self.state
60    }
61}
62
63impl Indicator for VolatilityRegimeDetector {
64    fn name(&self) -> &str {
65        "volatility_regime"
66    }
67
68    fn warmup_period(&self) -> usize {
69        self.period.max(10)
70    }
71
72    fn reset(&mut self) {
73        self.bb.reset();
74        self.keltner.reset();
75        self.state = VolatilityState::Normal;
76    }
77
78    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
79        let bb_out = self.bb.on_bar(bar);
80        let kc_out = self.keltner.on_bar(bar);
81        let (Some(bb_out), Some(kc_out)) = (bb_out, kc_out) else {
82            return None;
83        };
84
85        let bb_upper = bb_out.extra.get("upper").copied().unwrap_or(bb_out.value);
86        let bb_lower = bb_out.extra.get("lower").copied().unwrap_or(bb_out.value);
87
88        let kc_upper = kc_out.extra.get("upper").copied().unwrap_or(bb_upper);
89        let kc_lower = kc_out.extra.get("lower").copied().unwrap_or(bb_lower);
90
91        // Squeeze when Bollinger Bands are completely inside Keltner Channel
92        let is_squeeze = bb_upper <= kc_upper && bb_lower >= kc_lower;
93        // Expansion when the Bollinger width exceeds 1.3x the Keltner width
94        let bb_width = bb_upper - bb_lower;
95        let kc_width = (kc_upper - kc_lower).max(1e-8);
96        let is_expansion = bb_width > kc_width * 1.3;
97
98        self.state = if is_squeeze {
99            VolatilityState::Squeeze
100        } else if is_expansion {
101            VolatilityState::Expansion
102        } else {
103            VolatilityState::Normal
104        };
105
106        let state_code = match self.state {
107            VolatilityState::Squeeze => -1.0,
108            VolatilityState::Normal => 0.0,
109            VolatilityState::Expansion => 1.0,
110        };
111
112        let mut extra = HashMap::new();
113        extra.insert("bb_width".to_string(), bb_width);
114        extra.insert("kc_width".to_string(), kc_width);
115        extra.insert("squeeze".to_string(), if is_squeeze { 1.0 } else { 0.0 });
116
117        Some(IndicatorOutput::with_extra(state_code, extra))
118    }
119
120    fn alerts(&self) -> Vec<IndicatorAlert> {
121        let mut alerts = Vec::new();
122        if self.state == VolatilityState::Squeeze {
123            alerts.push(IndicatorAlert::new(
124                "volatility",
125                "Bollinger Squeeze in Effect",
126                0.7,
127            ));
128        } else if self.state == VolatilityState::Expansion {
129            alerts.push(IndicatorAlert::new(
130                "volatility",
131                "Volatility Expansion Triggered",
132                0.8,
133            ));
134        }
135        alerts
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_volatility_regime() {
145        let mut vr = VolatilityRegimeDetector::with_defaults();
146        let mut out = None;
147        for i in 0..30 {
148            let b = Bar::new(i, 100.0, 105.0, 95.0, 100.0 + (i % 2) as f64, 1000.0);
149            out = vr.on_bar(&b);
150        }
151        assert!(out.is_some());
152    }
153}