Skip to main content

quantwave_core/indicators/
ttm_squeeze.rs

1use 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/// TTM Squeeze Indicator
9/// Combines Bollinger Bands and Keltner Channels to identify volatility compression.
10/// Includes a momentum histogram based on linear regression.
11#[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
36/// Output of TTM Squeeze: (Momentum Histogram, Is Squeezed)
37impl 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        // Bollinger Bands
47        let bb_upper = sma + self.multiplier_bb * stdev;
48        let bb_lower = sma - self.multiplier_bb * stdev;
49
50        // Keltner Channels (using SMA of close as midline for TTM Squeeze standard)
51        let kc_upper = sma + self.multiplier_kc * atr;
52        let kc_lower = sma - self.multiplier_kc * atr;
53
54        // Squeeze Status
55        let is_squeezed = bb_upper < kc_upper && bb_lower > kc_lower;
56
57        // Momentum Histogram
58        // Value = Close - (DonchianMidpoint + SMAClose) / 2
59        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    /// quantwave-qkft blast radius: TTM Squeeze's Bollinger leg is a
118    /// `StandardDeviation`. Batch and chunked-streaming must stay bit-identical
119    /// for both the histogram and the boolean squeeze flag (which is a
120    /// threshold comparison and therefore the more brittle of the two).
121    #[test]
122    fn ttm_batch_streaming_bitwise_parity() {
123        let bars: Vec<(f64, f64, f64)> = (0..500)
124            .map(|i| {
125                let c = 2980.0 + (i as f64 * 0.31).sin() * 40.0;
126                (c + 6.0, c - 6.0, c)
127            })
128            .collect();
129
130        let mut batch = TTMSqueeze::new(20, 2.0, 1.5);
131        let batch_out: Vec<(f64, bool)> = bars.iter().map(|&b| batch.next(b)).collect();
132
133        for chunk in [1usize, 7, 64] {
134            let mut streaming = TTMSqueeze::new(20, 2.0, 1.5);
135            let mut out = Vec::with_capacity(bars.len());
136            for part in bars.chunks(chunk) {
137                for &b in part {
138                    out.push(streaming.next(b));
139                }
140            }
141            for (i, (b, s)) in batch_out.iter().zip(out.iter()).enumerate() {
142                assert_eq!(b.0.to_bits(), s.0.to_bits(), "hist bar {i} chunk {chunk}");
143                assert_eq!(b.1, s.1, "squeeze bar {i} chunk {chunk}");
144            }
145        }
146    }
147
148    proptest! {
149        #[test]
150        fn test_ttm_parity(input in prop::collection::vec((0.0..100.0, 0.0..100.0, 0.0..100.0), 1..100)) {
151            let mut adj_input = Vec::with_capacity(input.len());
152            for (h, l, c) in input {
153                let h_f: f64 = h;
154                let l_f: f64 = l;
155                let c_f: f64 = c;
156                let high = h_f.max(l_f).max(c_f);
157                let low = l_f.min(h_f).min(c_f);
158                adj_input.push((high, low, c_f));
159            }
160
161            let period = 20;
162            let m_bb = 2.0;
163            let m_kc = 1.5;
164            let mut ttm = TTMSqueeze::new(period, m_bb, m_kc);
165            let mut streaming_results = Vec::with_capacity(adj_input.len());
166            for &val in &adj_input {
167                streaming_results.push(ttm.next(val).0);
168            }
169
170            let batch_results = ttm_batch(adj_input, period, m_bb, m_kc);
171
172            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
173                approx::assert_relative_eq!(s, b, epsilon = 1e-6);
174            }
175        }
176    }
177
178    #[test]
179    fn test_ttm_squeeze_basic() {
180        let mut ttm = TTMSqueeze::new(20, 2.0, 1.5);
181
182        // Feed some data to warm up
183        for i in 0..50 {
184            let val = 100.0 + (i as f64).sin() * 5.0;
185            let (hist, _squeezed) = ttm.next((val + 1.0, val - 1.0, val));
186            if i >= 19 {
187                assert!(!hist.is_nan());
188            }
189        }
190    }
191}
192
193pub const TTM_SQUEEZE_METADATA: IndicatorMetadata = IndicatorMetadata {
194    name: "TTM Squeeze",
195    description: "TTM Squeeze measures the relationship between Bollinger Bands and Keltner Channels to identify volatility consolidations.",
196    usage: "Use to identify periods of compressed volatility (Bollinger Bands inside Keltner Channels) followed by high-energy breakouts. The momentum histogram direction at squeeze release indicates trade direction.",
197    keywords: &["volatility", "momentum", "breakout", "squeeze", "classic"],
198    ehlers_summary: "The TTM Squeeze, developed by John Carter, identifies market consolidation by detecting when Bollinger Bands contract inside Keltner Channels — a squeeze condition indicating coiling energy. When the bands expand back outside the Keltner Channels, the squeeze releases and a momentum histogram shows the expected breakout direction. — Mastering the Trade, John Carter",
199    params: &[
200        ParamDef {
201            name: "period",
202            default: "20",
203            description: "Bollinger Bands / Keltner Channel period",
204        },
205        ParamDef {
206            name: "multiplier_bb",
207            default: "2.0",
208            description: "Bollinger Bands Multiplier",
209        },
210        ParamDef {
211            name: "multiplier_kc",
212            default: "1.5",
213            description: "Keltner Channel Multiplier",
214        },
215    ],
216    formula_source: "https://www.investopedia.com/articles/active-trading/110714/intro-ttm-squeeze-indicator.asp",
217    formula_latex: r#"
218\[
219\text{Squeeze} = BB_{width} < KC_{width}
220\]
221"#,
222    gold_standard_file: "ttm_squeeze.json",
223    category: "Classic",
224};