quantwave_core/indicators/
keltner.rs1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::indicators::smoothing::EMA;
3use crate::indicators::volatility::ATR;
4use crate::traits::Next;
5
6#[derive(Debug, Clone)]
7pub struct KeltnerChannels {
8 ema: EMA,
9 atr: ATR,
10 multiplier: f64,
11}
12
13impl KeltnerChannels {
14 pub fn new(ema_period: usize, atr_period: usize, multiplier: f64) -> Self {
15 Self {
16 ema: EMA::new(ema_period),
17 atr: ATR::new(atr_period),
18 multiplier,
19 }
20 }
21}
22
23impl Next<(f64, f64, f64)> for KeltnerChannels {
24 type Output = (f64, f64, f64);
25
26 fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
27 let typical_price = (high + low + close) / 3.0;
28 let middle = self.ema.next(typical_price);
29 let atr = self.atr.next((high, low, close));
30
31 let upper = middle + self.multiplier * atr;
32 let lower = middle - self.multiplier * atr;
33
34 (upper, middle, lower)
35 }
36}
37
38#[cfg(test)]
39mod tests {
40 use super::*;
41 use proptest::prelude::*;
42 use serde::Deserialize;
43 use std::fs;
44 use std::path::Path;
45
46 #[derive(Debug, Deserialize)]
47 struct KeltnerCase {
48 high: Vec<f64>,
49 low: Vec<f64>,
50 close: Vec<f64>,
51 expected_upper: Vec<f64>,
52 expected_middle: Vec<f64>,
53 expected_lower: Vec<f64>,
54 }
55
56 #[test]
57 fn test_keltner_gold_standard() {
58 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
59 let manifest_path = Path::new(&manifest_dir);
60 let path = manifest_path.join("tests/gold_standard/keltner_20_20_15.json");
61 let path = if path.exists() {
62 path
63 } else {
64 manifest_path
65 .parent()
66 .unwrap()
67 .join("tests/gold_standard/keltner_20_20_15.json")
68 };
69 let content = fs::read_to_string(path).unwrap();
70 let case: KeltnerCase = serde_json::from_str(&content).unwrap();
71
72 let mut kc = KeltnerChannels::new(20, 20, 1.5);
73 for i in 0..case.high.len() {
74 let (u, m, l) = kc.next((case.high[i], case.low[i], case.close[i]));
75 approx::assert_relative_eq!(u, case.expected_upper[i], epsilon = 1e-6);
76 approx::assert_relative_eq!(m, case.expected_middle[i], epsilon = 1e-6);
77 approx::assert_relative_eq!(l, case.expected_lower[i], epsilon = 1e-6);
78 }
79 }
80
81 fn keltner_batch(
82 data: Vec<(f64, f64, f64)>,
83 ema_period: usize,
84 atr_period: usize,
85 multiplier: f64,
86 ) -> Vec<(f64, f64, f64)> {
87 let mut kc = KeltnerChannels::new(ema_period, atr_period, multiplier);
88 data.into_iter().map(|x| kc.next(x)).collect()
89 }
90
91 proptest! {
92 #[test]
93 fn test_keltner_parity(input in prop::collection::vec((0.0..100.0, 0.0..100.0, 0.0..100.0), 1..100)) {
94 let mut adj_input = Vec::with_capacity(input.len());
95 for (h, l, c) in input {
96 let h_f: f64 = h;
97 let l_f: f64 = l;
98 let c_f: f64 = c;
99 let high = h_f.max(l_f).max(c_f);
100 let low = l_f.min(h_f).min(c_f);
101 adj_input.push((high, low, c_f));
102 }
103
104 let ema_period = 20;
105 let atr_period = 20;
106 let multiplier = 1.5;
107 let mut kc = KeltnerChannels::new(ema_period, atr_period, multiplier);
108 let mut streaming_results = Vec::with_capacity(adj_input.len());
109 for &val in &adj_input {
110 streaming_results.push(kc.next(val));
111 }
112
113 let batch_results = keltner_batch(adj_input, ema_period, atr_period, multiplier);
114
115 for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
116 approx::assert_relative_eq!(s.0, b.0, epsilon = 1e-6);
117 approx::assert_relative_eq!(s.1, b.1, epsilon = 1e-6);
118 approx::assert_relative_eq!(s.2, b.2, epsilon = 1e-6);
119 }
120 }
121 }
122
123 #[test]
124 fn test_keltner_basic() {
125 let mut kc = KeltnerChannels::new(3, 3, 2.0);
126 let (upper, middle, lower) = kc.next((12.0, 8.0, 10.0));
131 approx::assert_relative_eq!(middle, 10.0);
132 approx::assert_relative_eq!(upper, 18.0);
133 approx::assert_relative_eq!(lower, 2.0);
134 }
135}
136
137pub const KELTNER_METADATA: IndicatorMetadata = IndicatorMetadata {
138 name: "Keltner Channels",
139 description: "Keltner Channels are volatility-based envelopes set above and below an exponential moving average.",
140 usage: "Use as volatility-adjusted envelope bands around an EMA. When Keltner Channels contract inside Bollinger Bands (the Squeeze), a high-energy breakout move is typically imminent.",
141 keywords: &["volatility", "trend", "breakout", "channels", "classic"],
142 ehlers_summary: "Keltner Channels, updated by Linda Raschke in the 1980s from Chester Keltner original design, use ATR to set channel width around an EMA. Unlike Bollinger Bands which use standard deviation, ATR-based channels adapt to average bar range rather than statistical volatility, producing smoother and more stable channel boundaries. — StockCharts ChartSchool",
143 params: &[
144 ParamDef {
145 name: "period",
146 default: "20",
147 description: "EMA Period",
148 },
149 ParamDef {
150 name: "multiplier",
151 default: "2.0",
152 description: "ATR Multiplier",
153 },
154 ],
155 formula_source: "https://www.investopedia.com/terms/k/keltnerchannel.asp",
156 formula_latex: r#"
157\[
158UC = EMA + (Multiplier \times ATR)
159\]
160"#,
161 gold_standard_file: "keltner.json",
162 category: "Classic",
163};