Skip to main content

wickra_core/indicators/
ht_trendmode.rs

1//! Ehlers Hilbert Transform Trend vs Cycle Mode (`HT_TRENDMODE`).
2#![allow(clippy::manual_clamp)]
3
4use std::f64::consts::PI;
5
6use crate::traits::Indicator;
7
8/// Ehlers' Hilbert Transform Trend Mode (`HT_TRENDMODE`).
9///
10/// Runs the same adaptive Hilbert-transform engine as
11/// [`HilbertDominantCycle`](crate::HilbertDominantCycle), derives the dominant
12/// cycle phase, its sine / lead-sine, and an instantaneous trendline, then
13/// classifies the market into **trend mode (`1`)** or **cycle mode (`0`)**:
14///
15/// - it is a *cycle* shortly after the sine and lead-sine cross, while the phase
16///   advances at roughly the dominant-cycle rate;
17/// - it is a *trend* otherwise, and is forced to trend whenever price separates
18///   from the trendline by more than 1.5%.
19///
20/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
21/// `HT_TRENDMODE`. The output is `1.0` or `0.0`; the first value is emitted after
22/// ~50 inputs once the engine's moving-average chain has filled.
23///
24/// # Example
25///
26/// ```
27/// use wickra_core::{Indicator, HtTrendMode};
28///
29/// let mut ht = HtTrendMode::new();
30/// let mut last = None;
31/// for i in 0..120 {
32///     last = ht.update(100.0 + f64::from(i));
33/// }
34/// assert!(last.is_some());
35/// ```
36#[derive(Debug, Clone, Default)]
37pub struct HtTrendMode {
38    smooth_buf: Vec<f64>,
39    detrender_buf: Vec<f64>,
40    q1_buf: Vec<f64>,
41    i1_buf: Vec<f64>,
42    smooth_price: Vec<f64>,
43    prev_i2: f64,
44    prev_q2: f64,
45    prev_re: f64,
46    prev_im: f64,
47    prev_period: f64,
48    prev_smooth_period: f64,
49    // Trend-mode state.
50    prev_dc_phase: f64,
51    prev_sine: f64,
52    prev_lead_sine: f64,
53    days_in_trend: f64,
54    it1: f64,
55    it2: f64,
56    it3: f64,
57    count: usize,
58    last_value: Option<f64>,
59}
60
61impl HtTrendMode {
62    /// Construct a new Hilbert transform trend-mode classifier.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Current trend-mode flag (`1.0` trend, `0.0` cycle) if available.
68    pub const fn value(&self) -> Option<f64> {
69        self.last_value
70    }
71
72    fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
73        buf.insert(0, v);
74        if buf.len() > cap {
75            buf.truncate(cap);
76        }
77    }
78}
79
80impl Indicator for HtTrendMode {
81    type Input = f64;
82    type Output = f64;
83
84    #[allow(clippy::too_many_lines)]
85    fn update(&mut self, input: f64) -> Option<f64> {
86        if !input.is_finite() {
87            return self.last_value;
88        }
89        self.count += 1;
90
91        Self::push_front(&mut self.smooth_buf, input, 7);
92        if self.smooth_buf.len() < 7 {
93            return None;
94        }
95        let smooth = (4.0 * self.smooth_buf[0]
96            + 3.0 * self.smooth_buf[1]
97            + 2.0 * self.smooth_buf[2]
98            + self.smooth_buf[3])
99            / 10.0;
100        Self::push_front(&mut self.smooth_price, smooth, 50);
101
102        let period = self.prev_period.max(6.0).min(50.0);
103        let adj = 0.075 * period + 0.54;
104
105        let s0 = smooth;
106        let s2 = self.smooth_buf[2];
107        let s4 = self.smooth_buf[4];
108        let s6 = self.smooth_buf[6];
109        let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
110        Self::push_front(&mut self.detrender_buf, detrender, 7);
111        if self.detrender_buf.len() < 7 {
112            return None;
113        }
114
115        let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
116            - 0.5769 * self.detrender_buf[4]
117            - 0.0962 * self.detrender_buf[6])
118            * adj;
119        let i1 = self.detrender_buf[3];
120
121        Self::push_front(&mut self.q1_buf, q1, 7);
122        Self::push_front(&mut self.i1_buf, i1, 7);
123        if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
124            return None;
125        }
126
127        let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
128            - 0.5769 * self.i1_buf[4]
129            - 0.0962 * self.i1_buf[6])
130            * adj;
131        let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
132            - 0.5769 * self.q1_buf[4]
133            - 0.0962 * self.q1_buf[6])
134            * adj;
135
136        let mut i2 = i1 - jq;
137        let mut q2 = q1 + ji;
138        i2 = 0.2 * i2 + 0.8 * self.prev_i2;
139        q2 = 0.2 * q2 + 0.8 * self.prev_q2;
140
141        let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
142        let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
143        re = 0.2 * re + 0.8 * self.prev_re;
144        im = 0.2 * im + 0.8 * self.prev_im;
145
146        self.prev_i2 = i2;
147        self.prev_q2 = q2;
148        self.prev_re = re;
149        self.prev_im = im;
150
151        let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
152            2.0 * PI / im.atan2(re)
153        } else {
154            self.prev_period
155        };
156        new_period = new_period.min(1.5 * self.prev_period);
157        new_period = new_period.max(0.67 * self.prev_period);
158        new_period = new_period.clamp(6.0, 50.0);
159        self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
160        self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
161
162        let smooth_period = self.prev_smooth_period;
163        let dc_period = ((smooth_period + 0.5) as usize).clamp(1, self.smooth_price.len());
164
165        // Dominant-cycle phase over one cycle window.
166        let mut real_part = 0.0;
167        let mut imag_part = 0.0;
168        for i in 0..dc_period {
169            let angle = (i as f64) * 2.0 * PI / (dc_period as f64);
170            let sp = self.smooth_price[i];
171            real_part += angle.sin() * sp;
172            imag_part += angle.cos() * sp;
173        }
174        let dc_phase = compute_dc_phase(real_part, imag_part, smooth_period);
175
176        let sine = (dc_phase * PI / 180.0).sin();
177        let lead_sine = ((dc_phase + 45.0) * PI / 180.0).sin();
178
179        // Instantaneous trendline: average smoothed price over the cycle window,
180        // then a 4-3-2-1 weighted smoothing of that running average.
181        let mut trend_sum = 0.0;
182        for i in 0..dc_period {
183            trend_sum += self.smooth_price[i];
184        }
185        trend_sum /= dc_period as f64;
186        let trendline = (4.0 * trend_sum + 3.0 * self.it1 + 2.0 * self.it2 + self.it3) / 10.0;
187        self.it3 = self.it2;
188        self.it2 = self.it1;
189        self.it1 = trend_sum;
190
191        // Trend / cycle decision (assume trend, override to cycle).
192        let mut trend = 1.0_f64;
193
194        // A crossing of sine and lead-sine restarts the cycle clock.
195        if (sine > lead_sine && self.prev_sine <= self.prev_lead_sine)
196            || (sine < lead_sine && self.prev_sine >= self.prev_lead_sine)
197        {
198            self.days_in_trend = 0.0;
199            trend = 0.0;
200        }
201        self.days_in_trend += 1.0;
202        if self.days_in_trend < 0.5 * smooth_period {
203            trend = 0.0;
204        }
205
206        // Cycle mode while the phase advances at roughly the dominant-cycle rate.
207        let delta_phase = dc_phase - self.prev_dc_phase;
208        if smooth_period != 0.0
209            && delta_phase > 0.67 * 360.0 / smooth_period
210            && delta_phase < 1.5 * 360.0 / smooth_period
211        {
212            trend = 0.0;
213        }
214
215        // Force trend mode when price separates from the trendline.
216        if trendline != 0.0 && ((smooth - trendline) / trendline).abs() >= 0.015 {
217            trend = 1.0;
218        }
219
220        self.prev_dc_phase = dc_phase;
221        self.prev_sine = sine;
222        self.prev_lead_sine = lead_sine;
223
224        if self.count < 50 {
225            return None;
226        }
227        self.last_value = Some(trend);
228        Some(trend)
229    }
230
231    fn reset(&mut self) {
232        self.smooth_buf.clear();
233        self.detrender_buf.clear();
234        self.q1_buf.clear();
235        self.i1_buf.clear();
236        self.smooth_price.clear();
237        self.prev_i2 = 0.0;
238        self.prev_q2 = 0.0;
239        self.prev_re = 0.0;
240        self.prev_im = 0.0;
241        self.prev_period = 0.0;
242        self.prev_smooth_period = 0.0;
243        self.prev_dc_phase = 0.0;
244        self.prev_sine = 0.0;
245        self.prev_lead_sine = 0.0;
246        self.days_in_trend = 0.0;
247        self.it1 = 0.0;
248        self.it2 = 0.0;
249        self.it3 = 0.0;
250        self.count = 0;
251        self.last_value = None;
252    }
253
254    fn warmup_period(&self) -> usize {
255        50
256    }
257
258    fn is_ready(&self) -> bool {
259        self.last_value.is_some()
260    }
261
262    fn name(&self) -> &'static str {
263        "HT_TRENDMODE"
264    }
265}
266
267/// Recovers the dominant-cycle phase (degrees) from the real/imaginary parts of
268/// the one-cycle homodyne integration, then unwraps it into TA-Lib's
269/// `[-45, 315)` output range with the 4-bar smoother group-delay correction.
270///
271/// When `imag_part` is within `±0.001` of zero the `atan` is undefined, so the
272/// phase collapses to `±90°` by the sign of `real_part`.
273fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 {
274    let mut dc_phase = if imag_part.abs() > 0.001 {
275        (real_part / imag_part).atan().to_degrees()
276    } else if real_part < 0.0 {
277        -90.0
278    } else {
279        90.0
280    };
281    dc_phase += 90.0;
282    dc_phase += 360.0 / smooth_period;
283    if imag_part < 0.0 {
284        dc_phase += 180.0;
285    }
286    if dc_phase > 315.0 {
287        dc_phase -= 360.0;
288    }
289    dc_phase
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::traits::BatchExt;
296
297    /// A trending ramp followed by a clean cycle, so both modes are exercised.
298    fn mixed_prices() -> Vec<f64> {
299        let mut v = Vec::new();
300        for i in 0..150 {
301            v.push(100.0 + f64::from(i) * 0.8);
302        }
303        for i in 0..200 {
304            v.push(220.0 + (f64::from(i) * 0.45).sin() * 12.0);
305        }
306        v
307    }
308
309    #[test]
310    fn accessors_and_metadata() {
311        let ht = HtTrendMode::new();
312        assert_eq!(ht.warmup_period(), 50);
313        assert_eq!(ht.name(), "HT_TRENDMODE");
314        assert!(!ht.is_ready());
315        assert!(ht.value().is_none());
316    }
317
318    #[test]
319    fn near_zero_imaginary_collapses_to_signed_ninety() {
320        // A near-zero imaginary part makes atan(real/imag) undefined, so the phase
321        // collapses to +90 for non-negative real and -90 for negative real before
322        // the +90 offset and group-delay correction unwrap it.
323        let pos = compute_dc_phase(1.0, 0.0, 20.0);
324        let neg = compute_dc_phase(-1.0, 0.0, 20.0);
325        assert!((pos - 198.0).abs() < 1e-9);
326        assert!((neg - 18.0).abs() < 1e-9);
327        // The normal path still flows through atan.
328        let mid = compute_dc_phase(1.0, 1.0, 20.0);
329        assert!((mid - 153.0).abs() < 1e-9);
330    }
331
332    #[test]
333    fn emits_binary_flag_and_visits_both_modes() {
334        let mut ht = HtTrendMode::new();
335        let out: Vec<Option<f64>> = ht.batch(&mixed_prices());
336        assert_eq!(out[0], None);
337        assert!(ht.is_ready());
338        let mut saw_trend = false;
339        let mut saw_cycle = false;
340        for v in out.into_iter().flatten() {
341            assert!(v == 0.0 || v == 1.0, "trend mode must be binary, got {v}");
342            if v == 1.0 {
343                saw_trend = true;
344            } else {
345                saw_cycle = true;
346            }
347        }
348        assert!(saw_trend, "ramp segment should report trend mode");
349        assert!(saw_cycle, "cycle segment should report cycle mode");
350    }
351
352    #[test]
353    fn ignores_non_finite_input() {
354        let mut ht = HtTrendMode::new();
355        let _ = ht.batch(&mixed_prices());
356        let before = ht.value();
357        assert_eq!(ht.update(f64::NAN), before);
358    }
359
360    #[test]
361    fn batch_equals_streaming() {
362        let prices = mixed_prices();
363        let mut a = HtTrendMode::new();
364        let mut b = HtTrendMode::new();
365        let batch = a.batch(&prices);
366        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
367        assert_eq!(batch, streamed);
368    }
369
370    #[test]
371    fn reset_clears_state() {
372        let mut ht = HtTrendMode::new();
373        let _ = ht.batch(&mixed_prices());
374        assert!(ht.is_ready());
375        ht.reset();
376        assert!(!ht.is_ready());
377        assert_eq!(ht.update(100.0), None);
378    }
379}