Skip to main content

kestrel_chartkit/indicator/
volatility_indicators.rs

1use std::collections::{HashMap, VecDeque};
2
3use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
4use crate::model::Bar;
5
6/// True Range Indicator (raw price units).
7pub struct TrueRangeEngine {
8    prev_close: Option<f64>,
9    alerts: Vec<IndicatorAlert>,
10}
11
12impl TrueRangeEngine {
13    pub fn new() -> Self {
14        Self {
15            prev_close: None,
16            alerts: Vec::new(),
17        }
18    }
19}
20
21impl Default for TrueRangeEngine {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl Indicator for TrueRangeEngine {
28    fn name(&self) -> &str {
29        "true_range"
30    }
31
32    fn warmup_period(&self) -> usize {
33        1
34    }
35
36    fn reset(&mut self) {
37        self.prev_close = None;
38        self.alerts.clear();
39    }
40
41    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
42        let tr = if let Some(prev) = self.prev_close {
43            (bar.high - bar.low)
44                .max((bar.high - prev).abs())
45                .max((bar.low - prev).abs())
46        } else {
47            bar.high - bar.low
48        };
49        self.prev_close = Some(bar.close);
50
51        Some(IndicatorOutput::new(tr))
52    }
53
54    fn alerts(&self) -> Vec<IndicatorAlert> {
55        self.alerts.clone()
56    }
57}
58
59/// Keltner Channel Indicator (EMA Basis +/- Multiplier * ATR).
60#[derive(Debug, Clone)]
61pub struct KeltnerChannelEngine {
62    ema_period: usize,
63    atr_period: usize,
64    multiplier: f64,
65    closes: VecDeque<f64>,
66    trs: VecDeque<f64>,
67    prev_close: Option<f64>,
68    current_ema: Option<f64>,
69    alerts: Vec<IndicatorAlert>,
70}
71
72impl KeltnerChannelEngine {
73    pub fn new(ema_period: usize, atr_period: usize, multiplier: f64) -> Self {
74        Self {
75            ema_period,
76            atr_period,
77            multiplier,
78            closes: VecDeque::new(),
79            trs: VecDeque::new(),
80            prev_close: None,
81            current_ema: None,
82            alerts: Vec::new(),
83        }
84    }
85}
86
87impl Indicator for KeltnerChannelEngine {
88    fn name(&self) -> &str {
89        "keltner"
90    }
91
92    fn warmup_period(&self) -> usize {
93        self.ema_period.max(self.atr_period)
94    }
95
96    fn reset(&mut self) {
97        self.closes.clear();
98        self.trs.clear();
99        self.prev_close = None;
100        self.current_ema = None;
101        self.alerts.clear();
102    }
103
104    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
105        let tr = if let Some(prev) = self.prev_close {
106            (bar.high - bar.low)
107                .max((bar.high - prev).abs())
108                .max((bar.low - prev).abs())
109        } else {
110            bar.high - bar.low
111        };
112        self.prev_close = Some(bar.close);
113
114        self.closes.push_back(bar.close);
115        self.trs.push_back(tr);
116
117        let k = 2.0 / (self.ema_period as f64 + 1.0);
118        self.current_ema = match self.current_ema {
119            Some(prev_ema) => Some(bar.close * k + prev_ema * (1.0 - k)),
120            None => Some(bar.close),
121        };
122
123        if self.closes.len() > self.ema_period {
124            self.closes.pop_front();
125        }
126        if self.trs.len() > self.atr_period {
127            self.trs.pop_front();
128        }
129
130        self.alerts.clear();
131        if self.trs.len() < self.atr_period {
132            return None;
133        }
134
135        let basis = self.current_ema.unwrap_or(bar.close);
136        let atr: f64 = self.trs.iter().sum::<f64>() / self.atr_period as f64;
137        let upper = basis + self.multiplier * atr;
138        let lower = basis - self.multiplier * atr;
139
140        let mut extra = HashMap::new();
141        extra.insert("upper".to_string(), upper);
142        extra.insert("lower".to_string(), lower);
143        extra.insert("atr".to_string(), atr);
144
145        if bar.close > upper {
146            self.alerts.push(IndicatorAlert::new(
147                "keltner_upper_breakout",
148                format!("Price Above Upper Keltner Channel (${:.2})", upper),
149                0.80,
150            ));
151        } else if bar.close < lower {
152            self.alerts.push(IndicatorAlert::new(
153                "keltner_lower_breakout",
154                format!("Price Below Lower Keltner Channel (${:.2})", lower),
155                0.80,
156            ));
157        }
158
159        Some(IndicatorOutput::with_extra(basis, extra))
160    }
161
162    fn alerts(&self) -> Vec<IndicatorAlert> {
163        self.alerts.clone()
164    }
165}
166
167/// Donchian Channel Indicator (Highest High / Lowest Low over lookback).
168pub struct DonchianChannelEngine {
169    period: usize,
170    bars: VecDeque<Bar>,
171    alerts: Vec<IndicatorAlert>,
172}
173
174impl DonchianChannelEngine {
175    pub fn new(period: usize) -> Self {
176        Self {
177            period,
178            bars: VecDeque::new(),
179            alerts: Vec::new(),
180        }
181    }
182}
183
184impl Indicator for DonchianChannelEngine {
185    fn name(&self) -> &str {
186        "donchian"
187    }
188
189    fn warmup_period(&self) -> usize {
190        self.period
191    }
192
193    fn reset(&mut self) {
194        self.bars.clear();
195        self.alerts.clear();
196    }
197
198    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
199        self.bars.push_back(bar.clone());
200        if self.bars.len() > self.period {
201            self.bars.pop_front();
202        }
203
204        self.alerts.clear();
205        if self.bars.len() < self.period {
206            return None;
207        }
208
209        let upper = self.bars.iter().map(|b| b.high).fold(f64::MIN, f64::max);
210        let lower = self.bars.iter().map(|b| b.low).fold(f64::MAX, f64::min);
211        let basis = (upper + lower) / 2.0;
212
213        let mut extra = HashMap::new();
214        extra.insert("upper".to_string(), upper);
215        extra.insert("lower".to_string(), lower);
216        extra.insert("width".to_string(), upper - lower);
217
218        if (bar.high - upper).abs() < 1e-8 {
219            self.alerts.push(IndicatorAlert::new(
220                "donchian_new_high",
221                format!("{}-Period Donchian High: ${:.2}", self.period, upper),
222                0.85,
223            ));
224        } else if (bar.low - lower).abs() < 1e-8 {
225            self.alerts.push(IndicatorAlert::new(
226                "donchian_new_low",
227                format!("{}-Period Donchian Low: ${:.2}", self.period, lower),
228                0.85,
229            ));
230        }
231
232        Some(IndicatorOutput::with_extra(basis, extra))
233    }
234
235    fn alerts(&self) -> Vec<IndicatorAlert> {
236        self.alerts.clone()
237    }
238}
239
240/// Historical / Realized Volatility (Annualized Standard Deviation of Log Returns).
241pub struct HistoricalVolatilityEngine {
242    period: usize,
243    closes: VecDeque<f64>,
244    alerts: Vec<IndicatorAlert>,
245}
246
247impl HistoricalVolatilityEngine {
248    pub fn new(period: usize) -> Self {
249        Self {
250            period,
251            closes: VecDeque::new(),
252            alerts: Vec::new(),
253        }
254    }
255}
256
257impl Indicator for HistoricalVolatilityEngine {
258    fn name(&self) -> &str {
259        "historical_volatility"
260    }
261
262    fn warmup_period(&self) -> usize {
263        self.period + 1
264    }
265
266    fn reset(&mut self) {
267        self.closes.clear();
268        self.alerts.clear();
269    }
270
271    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
272        self.closes.push_back(bar.close);
273        if self.closes.len() > self.period + 1 {
274            self.closes.pop_front();
275        }
276
277        self.alerts.clear();
278        if self.closes.len() < self.period + 1 {
279            return None;
280        }
281
282        let mut log_returns = Vec::with_capacity(self.period);
283        for pair in self.closes.iter().collect::<Vec<_>>().windows(2) {
284            let prev = *pair[0];
285            let curr = *pair[1];
286            if prev > 0.0 && curr > 0.0 {
287                log_returns.push((curr / prev).ln());
288            } else {
289                log_returns.push(0.0);
290            }
291        }
292
293        let mean = log_returns.iter().sum::<f64>() / log_returns.len() as f64;
294        let variance = log_returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>()
295            / (log_returns.len() as f64 - 1.0).max(1.0);
296        let daily_std_dev = variance.sqrt();
297        let annualized_hv = daily_std_dev * (252.0f64).sqrt() * 100.0; // in %
298
299        Some(IndicatorOutput::new(annualized_hv))
300    }
301
302    fn alerts(&self) -> Vec<IndicatorAlert> {
303        self.alerts.clone()
304    }
305}
306
307/// Garman-Klass Volatility Estimator (OHLC Volatility).
308pub struct GarmanKlassVolatilityEngine {
309    period: usize,
310    bars: VecDeque<Bar>,
311    alerts: Vec<IndicatorAlert>,
312}
313
314impl GarmanKlassVolatilityEngine {
315    pub fn new(period: usize) -> Self {
316        Self {
317            period,
318            bars: VecDeque::new(),
319            alerts: Vec::new(),
320        }
321    }
322}
323
324impl Indicator for GarmanKlassVolatilityEngine {
325    fn name(&self) -> &str {
326        "garman_klass"
327    }
328
329    fn warmup_period(&self) -> usize {
330        self.period
331    }
332
333    fn reset(&mut self) {
334        self.bars.clear();
335        self.alerts.clear();
336    }
337
338    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
339        self.bars.push_back(bar.clone());
340        if self.bars.len() > self.period {
341            self.bars.pop_front();
342        }
343
344        self.alerts.clear();
345        if self.bars.len() < self.period {
346            return None;
347        }
348
349        let mut sum_var = 0.0f64;
350        for b in &self.bars {
351            if b.open > 0.0 && b.close > 0.0 && b.high > 0.0 && b.low > 0.0 {
352                let log_hl = (b.high / b.low).ln();
353                let log_co = (b.close / b.open).ln();
354                let bar_var = 0.5 * log_hl.powi(2) - (2.0 * (2.0f64).ln() - 1.0) * log_co.powi(2);
355                sum_var += bar_var.max(0.0);
356            }
357        }
358
359        let avg_var = sum_var / self.period as f64;
360        let annualized_gk = avg_var.sqrt() * (252.0f64).sqrt() * 100.0;
361
362        Some(IndicatorOutput::new(annualized_gk))
363    }
364
365    fn alerts(&self) -> Vec<IndicatorAlert> {
366        self.alerts.clone()
367    }
368}