Skip to main content

portfolio_analyzer/sd2/
squeeze.rs

1//! # The squeeze clock
2//!
3//! A *squeeze* is a stretch of bars where price has gone unusually quiet:
4//! the Bollinger Bands (which measure how spread out closes are) have shrunk
5//! inside the Keltner Channels (which measure how far price usually travels
6//! per bar).  Quiet markets do not stay quiet forever.  When the bands burst
7//! back out, the squeeze has **fired**, and the move that follows is what SD
8//! tries to ride.
9//!
10//! This file is the finite-state machine from the paper, written once so that
11//! both the classic indicator (`classic.rs`) and SD v2 (`features.rs`) run on
12//! the very same clock.  The only thing that differs between them is *which*
13//! compression series and *which* "momentum flipped" series they feed in.
14//!
15//! ```text
16//!            comp rises above 0                comp drops below the
17//!   (none) ───────────────────────▶ SQUEEZE ─── highest tier reached ──▶ FIRED
18//!      ▲                               ▲                                   │
19//!      │        comp climbs again      │                                   │
20//!      │        ("unfire")             └───────────────────────────────────┤
21//!      │                                                                   │
22//!      └──── time runs out (τ > ⌈θ⌉) or momentum flips ◀── ACTIVE while ───┘
23//!                                                          neither happens
24//! ```
25
26/// Window used to learn "how long does a squeeze usually last here?".
27pub const THETA_WINDOW: usize = 125;
28
29/// Everything the clock knows at one bar.
30#[derive(Debug, Clone, Default)]
31pub struct ClockSeries {
32    pub highest_comp: Vec<u8>,
33    pub sq_start: Vec<bool>,
34    pub is_fired: Vec<bool>,
35    pub fired_start: Vec<bool>,
36    /// τ: bars since the squeeze fired (1 on the firing bar, 0 if not fired).
37    pub bars_since_fired: Vec<usize>,
38    /// Bars since the current compression lifecycle began.
39    pub bars_since_start: Vec<usize>,
40    pub mom_changed: Vec<bool>,
41    /// θ: the expected life of the move, in bars.
42    pub theta: Vec<f64>,
43    pub is_active: Vec<bool>,
44}
45
46/// Run the paper's state machine over a whole compression series.
47///
48/// * `comp[t]` is 0 (none), 1 (low), 2 (mid) or 3 (high compression).
49/// * `mom_flip[t]` is true when momentum changed direction on bar `t`.
50pub fn run_clock(comp: &[u8], mom_flip: &[bool]) -> ClockSeries {
51    let n = comp.len();
52    let mut s = ClockSeries {
53        highest_comp: vec![0; n],
54        sq_start: vec![false; n],
55        is_fired: vec![false; n],
56        fired_start: vec![false; n],
57        bars_since_fired: vec![0; n],
58        bars_since_start: vec![0; n],
59        mom_changed: vec![false; n],
60        theta: vec![0.0; n],
61        is_active: vec![false; n],
62    };
63    for i in 0..n {
64        let c = comp[i];
65        let c_prev = if i > 0 { comp[i - 1] } else { 0 };
66        let hc_prev = if i > 0 { s.highest_comp[i - 1] } else { 0 };
67        let start_base = c > 0 && c_prev == 0;
68        let unfired = c > c_prev && c_prev < hc_prev && hc_prev > 0;
69        s.sq_start[i] = start_base || unfired;
70        s.highest_comp[i] = if s.sq_start[i] || c > hc_prev { c } else { hc_prev };
71        s.is_fired[i] = c < s.highest_comp[i] && s.highest_comp[i] > 0;
72        s.fired_start[i] = s.is_fired[i] && (i == 0 || !s.is_fired[i - 1]);
73        s.bars_since_fired[i] = if s.fired_start[i] {
74            1
75        } else if s.is_fired[i] {
76            s.bars_since_fired[i - 1] + 1
77        } else {
78            0
79        };
80        s.bars_since_start[i] = if s.sq_start[i] {
81            1
82        } else if c > 0 || s.is_fired[i] {
83            if i > 0 { s.bars_since_start[i - 1] + 1 } else { 1 }
84        } else {
85            0
86        };
87        s.mom_changed[i] = if s.fired_start[i] {
88            false
89        } else if s.is_fired[i] && mom_flip[i] {
90            true
91        } else {
92            i > 0 && s.mom_changed[i - 1]
93        };
94    }
95
96    // θ: average squeeze length over the last 125 bars, per tier (paper §5).
97    // Prefix sums keep this O(n) instead of O(n · 125).
98    let prefix = |f: &dyn Fn(usize) -> bool| {
99        let mut p = vec![0.0; n + 1];
100        for i in 0..n {
101            p[i + 1] = p[i] + if f(i) { 1.0 } else { 0.0 };
102        }
103        p
104    };
105    let in_comp = prefix(&|i| comp[i] > 0);
106    let starts = prefix(&|i| s.sq_start[i]);
107    let at_mid = prefix(&|i| comp[i] == 2);
108    let into_mid = prefix(&|i| comp[i] == 2 && (i == 0 || comp[i - 1] != 2));
109    let at_low = prefix(&|i| comp[i] == 1);
110    let into_low = prefix(&|i| comp[i] == 1 && (i == 0 || comp[i - 1] != 1));
111    for i in 0..n {
112        let a = i.saturating_sub(THETA_WINDOW - 1);
113        let w = |p: &Vec<f64>| p[i + 1] - p[a];
114        let total = w(&in_comp) / w(&starts).max(1.0);
115        let mid = w(&at_mid) / w(&into_mid).max(1.0);
116        let low = w(&at_low) / w(&into_low).max(1.0);
117        s.theta[i] = match comp[i] {
118            2 if mid >= total / 3.0 => mid,
119            2 => low,
120            1 if low >= total / 3.0 => low,
121            1 => total,
122            _ => total,
123        };
124        s.is_active[i] = s.is_fired[i]
125            && s.bars_since_fired[i] <= s.theta[i].ceil() as usize
126            && !s.mom_changed[i];
127    }
128    s
129}
130
131/// Group C lexical code from (effective highest tier, current tier):
132/// 1=`***` 2=`**.` 3=`*..` 4=`...` 5=`**` 6=`*.` 7=`..` 8=`*` 9=`.` 0=none.
133pub fn group_c(eff_highest: u8, comp: u8) -> u8 {
134    match (eff_highest, comp) {
135        (3, 3) => 1,
136        (3, 2) => 2,
137        (3, 1) => 3,
138        (3, 0) => 4,
139        (2, 2) => 5,
140        (2, 1) => 6,
141        (2, 0) => 7,
142        (1, 1) => 8,
143        (1, 0) => 9,
144        _ => 0,
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn a_squeeze_fires_when_it_drops_below_its_highest_tier() {
154        let comp = [0, 1, 3, 3, 2, 0, 0];
155        let s = run_clock(&comp, &[false; 7]);
156        // Once fired, a squeeze stays "fired" until a new squeeze starts;
157        // it is θ and momentum that decide whether it is still *active*.
158        assert_eq!(s.is_fired, vec![false, false, false, false, true, true, true]);
159        assert_eq!(s.bars_since_fired, vec![0, 0, 0, 0, 1, 2, 3]);
160    }
161
162    #[test]
163    fn a_momentum_flip_after_firing_ends_the_active_window() {
164        let comp = [0, 3, 3, 1, 1, 1];
165        let flips = [false, false, false, false, true, false];
166        let s = run_clock(&comp, &flips);
167        assert!(s.is_active[3]);
168        assert!(!s.is_active[4] && !s.is_active[5]);
169    }
170}