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 params: &[
141 ParamDef {
142 name: "period",
143 default: "20",
144 description: "EMA Period",
145 },
146 ParamDef {
147 name: "multiplier",
148 default: "2.0",
149 description: "ATR Multiplier",
150 },
151 ],
152 formula_source: "https://www.investopedia.com/terms/k/keltnerchannel.asp",
153 formula_latex: r#"
154\[
155UC = EMA + (Multiplier \times ATR)
156\]
157"#,
158 gold_standard_file: "keltner.json",
159 category: "Classic",
160};