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)]
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 let is_squeeze = bb_upper <= kc_upper && bb_lower >= kc_lower;
93 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}