quantwave_core/indicators/
ttm_squeeze.rs1use crate::indicators::donchian::DonchianChannels;
2use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
3use crate::indicators::smoothing::SMA;
4use crate::indicators::statistics::{LinearRegression, StandardDeviation};
5use crate::indicators::volatility::ATR;
6use crate::traits::Next;
7
8#[derive(Debug, Clone)]
12pub struct TTMSqueeze {
13 sma: SMA,
14 stdev: StandardDeviation,
15 atr: ATR,
16 donchian: DonchianChannels,
17 linreg: LinearRegression,
18 multiplier_bb: f64,
19 multiplier_kc: f64,
20}
21
22impl TTMSqueeze {
23 pub fn new(period: usize, multiplier_bb: f64, multiplier_kc: f64) -> Self {
24 Self {
25 sma: SMA::new(period),
26 stdev: StandardDeviation::new(period),
27 atr: ATR::new(period),
28 donchian: DonchianChannels::new(period),
29 linreg: LinearRegression::new(period),
30 multiplier_bb,
31 multiplier_kc,
32 }
33 }
34}
35
36impl Next<(f64, f64, f64)> for TTMSqueeze {
38 type Output = (f64, bool);
39
40 fn next(&mut self, (high, low, close): (f64, f64, f64)) -> Self::Output {
41 let sma = self.sma.next(close);
42 let stdev = self.stdev.next(close);
43 let atr = self.atr.next((high, low, close));
44 let (_max_high, donchian_mid, _min_low) = self.donchian.next((high, low));
45
46 let bb_upper = sma + self.multiplier_bb * stdev;
48 let bb_lower = sma - self.multiplier_bb * stdev;
49
50 let kc_upper = sma + self.multiplier_kc * atr;
52 let kc_lower = sma - self.multiplier_kc * atr;
53
54 let is_squeezed = bb_upper < kc_upper && bb_lower > kc_lower;
56
57 let momentum_base = close - (donchian_mid + sma) / 2.0;
60 let histogram = self.linreg.next(momentum_base);
61
62 (histogram, is_squeezed)
63 }
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69 use proptest::prelude::*;
70 use serde::Deserialize;
71 use std::fs;
72 use std::path::Path;
73
74 #[derive(Debug, Deserialize)]
75 struct TTMCase {
76 high: Vec<f64>,
77 low: Vec<f64>,
78 close: Vec<f64>,
79 expected_histogram: Vec<f64>,
80 expected_squeezed: Vec<bool>,
81 }
82
83 #[test]
84 fn test_ttm_squeeze_gold_standard() {
85 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
86 let manifest_path = Path::new(&manifest_dir);
87 let path = manifest_path.join("tests/gold_standard/ttm_squeeze_20_2_15.json");
88 let path = if path.exists() {
89 path
90 } else {
91 manifest_path
92 .parent()
93 .unwrap()
94 .join("tests/gold_standard/ttm_squeeze_20_2_15.json")
95 };
96 let content = fs::read_to_string(path).unwrap();
97 let case: TTMCase = serde_json::from_str(&content).unwrap();
98
99 let mut ttm = TTMSqueeze::new(20, 2.0, 1.5);
100 for i in 0..case.high.len() {
101 let (hist, is_sq) = ttm.next((case.high[i], case.low[i], case.close[i]));
102 approx::assert_relative_eq!(hist, case.expected_histogram[i], epsilon = 1e-6);
103 assert_eq!(is_sq, case.expected_squeezed[i], "Mismatch at index {}", i);
104 }
105 }
106
107 fn ttm_batch(
108 data: Vec<(f64, f64, f64)>,
109 period: usize,
110 multiplier_bb: f64,
111 multiplier_kc: f64,
112 ) -> Vec<f64> {
113 let mut ttm = TTMSqueeze::new(period, multiplier_bb, multiplier_kc);
114 data.into_iter().map(|x| ttm.next(x).0).collect()
115 }
116
117 proptest! {
118 #[test]
119 fn test_ttm_parity(input in prop::collection::vec((0.0..100.0, 0.0..100.0, 0.0..100.0), 1..100)) {
120 let mut adj_input = Vec::with_capacity(input.len());
121 for (h, l, c) in input {
122 let h_f: f64 = h;
123 let l_f: f64 = l;
124 let c_f: f64 = c;
125 let high = h_f.max(l_f).max(c_f);
126 let low = l_f.min(h_f).min(c_f);
127 adj_input.push((high, low, c_f));
128 }
129
130 let period = 20;
131 let m_bb = 2.0;
132 let m_kc = 1.5;
133 let mut ttm = TTMSqueeze::new(period, m_bb, m_kc);
134 let mut streaming_results = Vec::with_capacity(adj_input.len());
135 for &val in &adj_input {
136 streaming_results.push(ttm.next(val).0);
137 }
138
139 let batch_results = ttm_batch(adj_input, period, m_bb, m_kc);
140
141 for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
142 approx::assert_relative_eq!(s, b, epsilon = 1e-6);
143 }
144 }
145 }
146
147 #[test]
148 fn test_ttm_squeeze_basic() {
149 let mut ttm = TTMSqueeze::new(20, 2.0, 1.5);
150
151 for i in 0..50 {
153 let val = 100.0 + (i as f64).sin() * 5.0;
154 let (hist, _squeezed) = ttm.next((val + 1.0, val - 1.0, val));
155 if i >= 19 {
156 assert!(!hist.is_nan());
157 }
158 }
159 }
160}
161
162pub const TTM_SQUEEZE_METADATA: IndicatorMetadata = IndicatorMetadata {
163 name: "TTM Squeeze",
164 description: "TTM Squeeze measures the relationship between Bollinger Bands and Keltner Channels to identify volatility consolidations.",
165 params: &[
166 ParamDef {
167 name: "bb_period",
168 default: "20",
169 description: "Bollinger Bands Period",
170 },
171 ParamDef {
172 name: "bb_mult",
173 default: "2.0",
174 description: "Bollinger Bands Multiplier",
175 },
176 ParamDef {
177 name: "kc_period",
178 default: "20",
179 description: "Keltner Channel Period",
180 },
181 ParamDef {
182 name: "kc_mult",
183 default: "1.5",
184 description: "Keltner Channel Multiplier",
185 },
186 ],
187 formula_source: "https://www.investopedia.com/articles/active-trading/110714/intro-ttm-squeeze-indicator.asp",
188 formula_latex: r#"
189\[
190\text{Squeeze} = BB_{width} < KC_{width}
191\]
192"#,
193 gold_standard_file: "ttm_squeeze.json",
194 category: "Classic",
195};