kestrel_chartkit/indicator/
volatility_regime.rs1use 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#[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#[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 let is_squeeze = bb_upper <= kc_upper && bb_lower >= kc_lower;
78 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}