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