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