Skip to main content

wickra_core/indicators/
ttm_squeeze.rs

1//! TTM Squeeze (John Carter).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::atr::Atr;
7use crate::indicators::bollinger::BollingerBands;
8use crate::indicators::sma::Sma;
9use crate::ohlcv::Candle;
10use crate::traits::Indicator;
11
12/// TTM Squeeze output.
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct TtmSqueezeOutput {
15    /// `1.0` while the squeeze is *on* (Bollinger Bands sit inside the Keltner
16    /// Channel), `0.0` otherwise. The squeeze releases — the signal flips back
17    /// to `0.0` — when volatility expands and BB pierce KC.
18    pub squeeze: f64,
19    /// Detrended momentum: linear-regression endpoint of
20    /// `close − (midpoint(highest_high, lowest_low, period) + SMA(close, period)) / 2`.
21    /// Histogram-like reading that swings positive in a breakout up, negative
22    /// in a breakout down; trade direction on the squeeze release follows the
23    /// sign of `momentum`.
24    pub momentum: f64,
25}
26
27/// TTM Squeeze (John Carter): a Bollinger-vs-Keltner volatility squeeze paired
28/// with a detrended-close momentum reading.
29///
30/// Carter's setup detects coiled markets (low realised volatility relative to
31/// ATR) and the *direction* of the breakout when they uncoil:
32///
33/// ```text
34/// squeeze  = 1.0 if BollingerBands(period, bb_mult)
35///                ⊂ KeltnerChannels-like(SMA(period), ATR(period), kc_mult)
36///            else 0.0
37///
38/// hl_mid   = (max(high, period) + min(low, period)) / 2
39/// detrend  = close − (hl_mid + SMA(close, period)) / 2
40/// momentum = LinearRegression(detrend, period)        // endpoint
41/// ```
42///
43/// The "Keltner-like" envelope here uses an *SMA* centerline (not the EMA of
44/// typical price that [`Keltner`](crate::Keltner) uses) plus an ATR offset,
45/// exactly as Carter's original publication and every chart-vendor
46/// implementation define it. Common parameters: `period = 20`, `bb_mult = 2.0`,
47/// `kc_mult = 1.5`.
48///
49/// # Example
50///
51/// ```
52/// use wickra_core::{Candle, Indicator, TtmSqueeze};
53///
54/// let mut indicator = TtmSqueeze::new(20, 2.0, 1.5).unwrap();
55/// let mut last = None;
56/// for i in 0..40 {
57///     let base = 100.0 + f64::from(i);
58///     let candle =
59///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
60///     last = indicator.update(candle);
61/// }
62/// assert!(last.is_some());
63/// ```
64#[derive(Debug, Clone)]
65pub struct TtmSqueeze {
66    period: usize,
67    kc_mult: f64,
68    bb: BollingerBands,
69    sma_close: Sma,
70    atr: Atr,
71    highs: VecDeque<f64>,
72    lows: VecDeque<f64>,
73    closes: VecDeque<f64>,
74    // Pre-computed OLS constants over `x = 0..period − 1`.
75    sum_x: f64,
76    denom: f64,
77}
78
79impl TtmSqueeze {
80    /// # Errors
81    /// Returns [`Error::PeriodZero`] if `period == 0` and
82    /// [`Error::NonPositiveMultiplier`] if either multiplier is not strictly
83    /// positive and finite. `period >= 2` is required for the linear-regression
84    /// momentum component.
85    pub fn new(period: usize, bb_mult: f64, kc_mult: f64) -> Result<Self> {
86        if period < 2 {
87            return Err(Error::InvalidPeriod {
88                message: "TTM squeeze needs period >= 2 for the momentum regression",
89            });
90        }
91        if period > crate::error::MAX_PERIOD {
92            return Err(Error::InvalidPeriod {
93                message: crate::error::PERIOD_ABOVE_MAX,
94            });
95        }
96        if !bb_mult.is_finite() || bb_mult <= 0.0 || !kc_mult.is_finite() || kc_mult <= 0.0 {
97            return Err(Error::NonPositiveMultiplier);
98        }
99        let n = period as f64;
100        let sum_x = n * (n - 1.0) / 2.0;
101        let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
102        Ok(Self {
103            period,
104            kc_mult,
105            bb: BollingerBands::new(period, bb_mult)?,
106            sma_close: Sma::new(period)?,
107            atr: Atr::new(period)?,
108            highs: VecDeque::with_capacity(period),
109            lows: VecDeque::with_capacity(period),
110            closes: VecDeque::with_capacity(period),
111            sum_x,
112            denom: n * sum_xx - sum_x * sum_x,
113        })
114    }
115
116    /// John Carter's classic configuration: `period = 20`, `bb_mult = 2.0`,
117    /// `kc_mult = 1.5`.
118    pub fn classic() -> Self {
119        Self::new(20, 2.0, 1.5).expect("classic TTM Squeeze parameters are valid")
120    }
121
122    /// Configured `(period, bb_mult, kc_mult)`.
123    pub fn parameters(&self) -> (usize, f64, f64) {
124        (self.period, self.bb.multiplier(), self.kc_mult)
125    }
126}
127
128impl Indicator for TtmSqueeze {
129    type Input = Candle;
130    type Output = TtmSqueezeOutput;
131
132    fn update(&mut self, candle: Candle) -> Option<TtmSqueezeOutput> {
133        if self.highs.len() == self.period {
134            self.highs.pop_front();
135            self.lows.pop_front();
136            self.closes.pop_front();
137        }
138        self.highs.push_back(candle.high);
139        self.lows.push_back(candle.low);
140        self.closes.push_back(candle.close);
141
142        // Feed all three sub-indicators unconditionally so they warm up in
143        // lock-step. ATR returns its first value at bar `period` (Wilder
144        // seeds), the SMA and BB on bar `period` as well.
145        let bb = self.bb.update(candle.close);
146        let mid = self.sma_close.update(candle.close);
147        let atr = self.atr.update(candle);
148        let (bb, mid, atr) = (bb?, mid?, atr?);
149
150        let kc_upper = mid + self.kc_mult * atr;
151        let kc_lower = mid - self.kc_mult * atr;
152        let squeeze = f64::from(bb.upper <= kc_upper && bb.lower >= kc_lower);
153
154        // Detrended close. The reference forms it as the deviation of close
155        // from the average of the rolling high-low midpoint and the SMA of
156        // close, then runs a linear regression of that series.
157        let hi = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
158        let lo = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
159        let hl_mid = f64::midpoint(hi, lo);
160        // Build the detrended window over the closes currently in `closes`.
161        // We need all `period` closes to fit the regression, which is
162        // guaranteed once `bb` / `mid` are ready.
163        let baseline = f64::midpoint(hl_mid, mid);
164        let mut sum_y = 0.0;
165        let mut sum_xy = 0.0;
166        for (i, &c) in self.closes.iter().enumerate() {
167            let y = c - baseline;
168            let x = i as f64;
169            sum_y += y;
170            sum_xy += x * y;
171        }
172        let n = self.period as f64;
173        let slope = (n * sum_xy - self.sum_x * sum_y) / self.denom;
174        let intercept = (sum_y - slope * self.sum_x) / n;
175        let momentum = intercept + slope * (n - 1.0);
176
177        Some(TtmSqueezeOutput { squeeze, momentum })
178    }
179
180    fn reset(&mut self) {
181        self.bb.reset();
182        self.sma_close.reset();
183        self.atr.reset();
184        self.highs.clear();
185        self.lows.clear();
186        self.closes.clear();
187    }
188
189    #[inline]
190    fn warmup_period(&self) -> usize {
191        self.period
192    }
193
194    #[inline]
195    fn is_ready(&self) -> bool {
196        self.bb.is_ready() && self.sma_close.is_ready() && self.atr.is_ready()
197    }
198
199    #[inline]
200    fn name(&self) -> &'static str {
201        "TtmSqueeze"
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::traits::BatchExt;
209    use approx::assert_relative_eq;
210
211    fn c(h: f64, l: f64, cl: f64) -> Candle {
212        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
213    }
214
215    #[test]
216    fn rejects_invalid_period() {
217        assert!(TtmSqueeze::new(0, 2.0, 1.5).is_err());
218        assert!(TtmSqueeze::new(1, 2.0, 1.5).is_err());
219    }
220
221    #[test]
222    fn rejects_non_positive_multipliers() {
223        assert!(matches!(
224            TtmSqueeze::new(20, 0.0, 1.5),
225            Err(Error::NonPositiveMultiplier)
226        ));
227        assert!(matches!(
228            TtmSqueeze::new(20, 2.0, -1.0),
229            Err(Error::NonPositiveMultiplier)
230        ));
231        assert!(matches!(
232            TtmSqueeze::new(20, f64::NAN, 1.5),
233            Err(Error::NonPositiveMultiplier)
234        ));
235    }
236
237    #[test]
238    fn accessors_and_metadata() {
239        let s = TtmSqueeze::classic();
240        let (p, b, k) = s.parameters();
241        assert_eq!(p, 20);
242        assert_relative_eq!(b, 2.0, epsilon = 1e-12);
243        assert_relative_eq!(k, 1.5, epsilon = 1e-12);
244        assert_eq!(s.warmup_period(), 20);
245        assert_eq!(s.name(), "TtmSqueeze");
246    }
247
248    #[test]
249    fn flat_market_has_zero_momentum() {
250        let candles: Vec<Candle> = (0..30).map(|_| c(10.0, 10.0, 10.0)).collect();
251        let mut s = TtmSqueeze::new(20, 2.0, 1.5).unwrap();
252        let last = s.batch(&candles).into_iter().flatten().last().unwrap();
253        assert_relative_eq!(last.momentum, 0.0, epsilon = 1e-9);
254        // With zero volatility both BB and KC collapse to a point, so the
255        // squeeze is trivially "on".
256        assert_relative_eq!(last.squeeze, 1.0, epsilon = 1e-12);
257    }
258
259    #[test]
260    fn batch_equals_streaming() {
261        let candles: Vec<Candle> = (0..40)
262            .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
263            .collect();
264        let mut a = TtmSqueeze::new(20, 2.0, 1.5).unwrap();
265        let mut b = TtmSqueeze::new(20, 2.0, 1.5).unwrap();
266        assert_eq!(
267            a.batch(&candles),
268            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
269        );
270    }
271
272    #[test]
273    fn reset_clears_state() {
274        let candles: Vec<Candle> = (0..30)
275            .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
276            .collect();
277        let mut s = TtmSqueeze::classic();
278        s.batch(&candles);
279        assert!(s.is_ready());
280        s.reset();
281        assert!(!s.is_ready());
282        assert_eq!(s.update(candles[0]), None);
283    }
284
285    /// Squeeze fires only after `period` candles, never before.
286    #[test]
287    fn warmup_returns_none() {
288        let mut s = TtmSqueeze::new(20, 2.0, 1.5).unwrap();
289        for i in 0..19 {
290            let base = 100.0 + f64::from(i);
291            assert!(s.update(c(base + 1.0, base - 1.0, base)).is_none());
292        }
293        assert!(s.update(c(121.0, 119.0, 120.0)).is_some());
294    }
295
296    /// Squeeze flag is binary — `0.0` or `1.0`.
297    #[test]
298    fn squeeze_is_binary() {
299        let candles: Vec<Candle> = (0..60)
300            .map(|i| {
301                let m = 100.0 + (f64::from(i) * 0.4).sin() * 2.0;
302                c(m + 1.0, m - 1.0, m)
303            })
304            .collect();
305        let mut s = TtmSqueeze::new(20, 2.0, 1.5).unwrap();
306        for o in s.batch(&candles).into_iter().flatten() {
307            assert!(o.squeeze == 0.0 || o.squeeze == 1.0);
308        }
309    }
310}