Skip to main content

quantwave_core/indicators/
keltner.rs

1use 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        // Typical price = (H+L+C)/3
127        // bar 1: H=12, L=8, C=10 -> TP=10. ATR=4 (since TR=4). EMA=10.
128        // Upper = 10 + 2*4 = 18. Lower = 10 - 2*4 = 2.
129
130        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};