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    // Raw input window for the 4-bar WMA.
40    price_buf: Vec<f64>,
41    // WMA-smoothed price history feeding the Hilbert detrender taps.
42    smooth_buf: Vec<f64>,
43    detrender_buf: Vec<f64>,
44    q1_buf: Vec<f64>,
45    i1_buf: Vec<f64>,
46    smooth_price: Vec<f64>,
47    // Raw input history the instantaneous trendline averages (TA-Lib sums
48    // `inReal`, not the smoothed price).
49    raw_price: Vec<f64>,
50    prev_i2: f64,
51    prev_q2: f64,
52    prev_re: f64,
53    prev_im: f64,
54    prev_period: f64,
55    prev_smooth_period: f64,
56    // Trend-mode state.
57    prev_dc_phase: f64,
58    prev_sine: f64,
59    prev_lead_sine: f64,
60    days_in_trend: f64,
61    it1: f64,
62    it2: f64,
63    it3: f64,
64    count: usize,
65    last_value: Option<f64>,
66}
67
68impl HtTrendMode {
69    /// Construct a new Hilbert transform trend-mode classifier.
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Current trend-mode flag (`1.0` trend, `0.0` cycle) if available.
75    pub const fn value(&self) -> Option<f64> {
76        self.last_value
77    }
78
79    fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
80        buf.insert(0, v);
81        if buf.len() > cap {
82            buf.truncate(cap);
83        }
84    }
85}
86
87impl Indicator for HtTrendMode {
88    type Input = f64;
89    type Output = f64;
90
91    #[allow(clippy::too_many_lines)]
92    fn update(&mut self, input: f64) -> Option<f64> {
93        if !input.is_finite() {
94            return None;
95        }
96        self.count += 1;
97
98        Self::push_front(&mut self.raw_price, input, MAX_DC_PERIOD);
99        Self::push_front(&mut self.price_buf, input, 4);
100        if self.price_buf.len() < 4 {
101            return None;
102        }
103        let smooth = (4.0 * self.price_buf[0]
104            + 3.0 * self.price_buf[1]
105            + 2.0 * self.price_buf[2]
106            + self.price_buf[3])
107            / 10.0;
108        Self::push_front(&mut self.smooth_buf, smooth, 7);
109        Self::push_front(&mut self.smooth_price, smooth, MAX_DC_PERIOD);
110
111        let period = self.prev_period.max(6.0).min(50.0);
112        let adj = 0.075 * period + 0.54;
113
114        if self.smooth_buf.len() < 7 {
115            return None;
116        }
117        let s0 = smooth;
118        let s2 = self.smooth_buf[2];
119        let s4 = self.smooth_buf[4];
120        let s6 = self.smooth_buf[6];
121        let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
122        Self::push_front(&mut self.detrender_buf, detrender, 7);
123        if self.detrender_buf.len() < 7 {
124            return None;
125        }
126
127        let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
128            - 0.5769 * self.detrender_buf[4]
129            - 0.0962 * self.detrender_buf[6])
130            * adj;
131        let i1 = self.detrender_buf[3];
132
133        Self::push_front(&mut self.q1_buf, q1, 7);
134        Self::push_front(&mut self.i1_buf, i1, 7);
135        if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
136            return None;
137        }
138
139        let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
140            - 0.5769 * self.i1_buf[4]
141            - 0.0962 * self.i1_buf[6])
142            * adj;
143        let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
144            - 0.5769 * self.q1_buf[4]
145            - 0.0962 * self.q1_buf[6])
146            * adj;
147
148        let mut i2 = i1 - jq;
149        let mut q2 = q1 + ji;
150        i2 = 0.2 * i2 + 0.8 * self.prev_i2;
151        q2 = 0.2 * q2 + 0.8 * self.prev_q2;
152
153        let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
154        let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
155        re = 0.2 * re + 0.8 * self.prev_re;
156        im = 0.2 * im + 0.8 * self.prev_im;
157
158        self.prev_i2 = i2;
159        self.prev_q2 = q2;
160        self.prev_re = re;
161        self.prev_im = im;
162
163        let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
164            2.0 * PI / im.atan2(re)
165        } else {
166            self.prev_period
167        };
168        new_period = new_period.min(1.5 * self.prev_period);
169        new_period = new_period.max(0.67 * self.prev_period);
170        new_period = new_period.clamp(6.0, 50.0);
171        self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
172        self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
173
174        let smooth_period = self.prev_smooth_period;
175        let dc_period = ((smooth_period + 0.5) as usize).clamp(1, self.smooth_price.len());
176
177        // Dominant-cycle phase over one cycle window.
178        let mut real_part = 0.0;
179        let mut imag_part = 0.0;
180        for (&(sin, cos), &sp) in dc_phasor::phasor(dc_period).iter().zip(&self.smooth_price) {
181            real_part += sin * sp;
182            imag_part += cos * sp;
183        }
184        let dc_phase = compute_dc_phase(real_part, imag_part, smooth_period);
185
186        let sine = (dc_phase * PI / 180.0).sin();
187        let lead_sine = ((dc_phase + 45.0) * PI / 180.0).sin();
188
189        // Instantaneous trendline: average raw price over the cycle window,
190        // then a 4-3-2-1 weighted smoothing of that running average.
191        let mut trend_sum = 0.0;
192        for &price in &self.raw_price[..dc_period] {
193            trend_sum += price;
194        }
195        trend_sum /= dc_period as f64;
196        let trendline = (4.0 * trend_sum + 3.0 * self.it1 + 2.0 * self.it2 + self.it3) / 10.0;
197        self.it3 = self.it2;
198        self.it2 = self.it1;
199        self.it1 = trend_sum;
200
201        // Trend / cycle decision (assume trend, override to cycle).
202        let mut trend = 1.0_f64;
203
204        // A crossing of sine and lead-sine restarts the cycle clock.
205        if (sine > lead_sine && self.prev_sine <= self.prev_lead_sine)
206            || (sine < lead_sine && self.prev_sine >= self.prev_lead_sine)
207        {
208            self.days_in_trend = 0.0;
209            trend = 0.0;
210        }
211        self.days_in_trend += 1.0;
212        if self.days_in_trend < 0.5 * smooth_period {
213            trend = 0.0;
214        }
215
216        // Cycle mode while the phase advances at roughly the dominant-cycle rate.
217        let delta_phase = dc_phase - self.prev_dc_phase;
218        if smooth_period != 0.0
219            && delta_phase > 0.67 * 360.0 / smooth_period
220            && delta_phase < 1.5 * 360.0 / smooth_period
221        {
222            trend = 0.0;
223        }
224
225        // Force trend mode when price separates from the trendline.
226        if trendline != 0.0 && ((smooth - trendline) / trendline).abs() >= 0.015 {
227            trend = 1.0;
228        }
229
230        self.prev_dc_phase = dc_phase;
231        self.prev_sine = sine;
232        self.prev_lead_sine = lead_sine;
233
234        if self.count < 50 {
235            return None;
236        }
237        self.last_value = Some(trend);
238        Some(trend)
239    }
240
241    fn reset(&mut self) {
242        self.price_buf.clear();
243        self.smooth_buf.clear();
244        self.detrender_buf.clear();
245        self.q1_buf.clear();
246        self.i1_buf.clear();
247        self.smooth_price.clear();
248        self.raw_price.clear();
249        self.prev_i2 = 0.0;
250        self.prev_q2 = 0.0;
251        self.prev_re = 0.0;
252        self.prev_im = 0.0;
253        self.prev_period = 0.0;
254        self.prev_smooth_period = 0.0;
255        self.prev_dc_phase = 0.0;
256        self.prev_sine = 0.0;
257        self.prev_lead_sine = 0.0;
258        self.days_in_trend = 0.0;
259        self.it1 = 0.0;
260        self.it2 = 0.0;
261        self.it3 = 0.0;
262        self.count = 0;
263        self.last_value = None;
264    }
265
266    #[inline]
267    fn warmup_period(&self) -> usize {
268        50
269    }
270
271    #[inline]
272    fn is_ready(&self) -> bool {
273        self.last_value.is_some()
274    }
275
276    #[inline]
277    fn name(&self) -> &'static str {
278        "HT_TRENDMODE"
279    }
280}
281
282/// Recovers the dominant-cycle phase (degrees) from the real/imaginary parts of
283/// the one-cycle homodyne integration, then unwraps it into TA-Lib's
284/// `[-45, 315)` output range with the 4-bar smoother group-delay correction.
285///
286/// When `imag_part` is within `±0.001` of zero the `atan` is undefined, so the
287/// phase collapses to `±90°` by the sign of `real_part`.
288fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 {
289    let mut dc_phase = if imag_part.abs() > 0.001 {
290        (real_part / imag_part).atan().to_degrees()
291    } else if real_part < 0.0 {
292        -90.0
293    } else {
294        90.0
295    };
296    dc_phase += 90.0;
297    dc_phase += 360.0 / smooth_period;
298    if imag_part < 0.0 {
299        dc_phase += 180.0;
300    }
301    if dc_phase > 315.0 {
302        dc_phase -= 360.0;
303    }
304    dc_phase
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::traits::BatchExt;
311
312    /// A trending ramp followed by a clean cycle, so both modes are exercised.
313    fn mixed_prices() -> Vec<f64> {
314        let mut v = Vec::new();
315        for i in 0..150 {
316            v.push(100.0 + f64::from(i) * 0.8);
317        }
318        for i in 0..200 {
319            v.push(220.0 + (f64::from(i) * 0.45).sin() * 12.0);
320        }
321        v
322    }
323
324    #[test]
325    fn accessors_and_metadata() {
326        let ht = HtTrendMode::new();
327        assert_eq!(ht.warmup_period(), 50);
328        assert_eq!(ht.name(), "HT_TRENDMODE");
329        assert!(!ht.is_ready());
330        assert!(ht.value().is_none());
331    }
332
333    #[test]
334    fn near_zero_imaginary_collapses_to_signed_ninety() {
335        // A near-zero imaginary part makes atan(real/imag) undefined, so the phase
336        // collapses to +90 for non-negative real and -90 for negative real before
337        // the +90 offset and group-delay correction unwrap it.
338        let pos = compute_dc_phase(1.0, 0.0, 20.0);
339        let neg = compute_dc_phase(-1.0, 0.0, 20.0);
340        assert!((pos - 198.0).abs() < 1e-9);
341        assert!((neg - 18.0).abs() < 1e-9);
342        // The normal path still flows through atan.
343        let mid = compute_dc_phase(1.0, 1.0, 20.0);
344        assert!((mid - 153.0).abs() < 1e-9);
345    }
346
347    #[test]
348    fn emits_binary_flag_and_visits_both_modes() {
349        let mut ht = HtTrendMode::new();
350        let out: Vec<Option<f64>> = ht.batch(&mixed_prices());
351        assert_eq!(out[0], None);
352        assert!(ht.is_ready());
353        let mut saw_trend = false;
354        let mut saw_cycle = false;
355        for v in out.into_iter().flatten() {
356            assert!(v == 0.0 || v == 1.0, "trend mode must be binary, got {v}");
357            if v == 1.0 {
358                saw_trend = true;
359            } else {
360                saw_cycle = true;
361            }
362        }
363        assert!(saw_trend, "ramp segment should report trend mode");
364        assert!(saw_cycle, "cycle segment should report cycle mode");
365    }
366
367    #[test]
368    fn ignores_non_finite_input() {
369        let mut ht = HtTrendMode::new();
370        let _ = ht.batch(&mixed_prices());
371        let before = ht.value();
372        assert_eq!(ht.update(f64::NAN), None);
373        // The rejected input must not have disturbed the state.
374        assert_eq!(ht.value(), before);
375    }
376
377    #[test]
378    fn batch_equals_streaming() {
379        let prices = mixed_prices();
380        let mut a = HtTrendMode::new();
381        let mut b = HtTrendMode::new();
382        let batch = a.batch(&prices);
383        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
384        assert_eq!(batch, streamed);
385    }
386
387    #[test]
388    fn reset_clears_state() {
389        let mut ht = HtTrendMode::new();
390        let _ = ht.batch(&mixed_prices());
391        assert!(ht.is_ready());
392        ht.reset();
393        assert!(!ht.is_ready());
394        assert_eq!(ht.update(100.0), None);
395    }
396
397    use crate::traits::BatchNanExt;
398    use approx::assert_relative_eq;
399
400    fn sine_prices(n: u32) -> Vec<f64> {
401        (0..n)
402            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
403            .collect()
404    }
405
406    #[test]
407    fn first_value_lands_exactly_at_warmup() {
408        let mut ht = HtTrendMode::new();
409        let out = ht.batch(&mixed_prices());
410        let warmup = ht.warmup_period();
411        assert!(out[..warmup - 1].iter().all(Option::is_none));
412        assert!(out[warmup - 1].is_some());
413    }
414
415    #[test]
416    fn reset_replays_identically() {
417        let prices = mixed_prices();
418        let fresh = HtTrendMode::new().batch(&prices);
419        let mut ht = HtTrendMode::new();
420        let first = ht.batch(&prices);
421        ht.reset();
422        let second = ht.batch(&prices);
423        assert_eq!(first, fresh);
424        assert_eq!(second, fresh);
425    }
426
427    #[test]
428    fn batch_nan_paths_match_streaming_bitwise() {
429        let prices = mixed_prices();
430        let mut out = vec![0.0; prices.len()];
431        HtTrendMode::new().batch_nan_into(&prices, &mut out);
432        let nan = HtTrendMode::new().batch_nan(&prices);
433        let fast = HtTrendMode::new().batch_fast(&prices);
434        let mut stream = HtTrendMode::new();
435        let expected: Vec<u64> = prices
436            .iter()
437            .map(|&p| stream.update(p).unwrap_or(f64::NAN).to_bits())
438            .collect();
439        assert!(out.iter().zip(&expected).all(|(v, e)| v.to_bits() == *e));
440        assert!(nan.iter().zip(&expected).all(|(v, e)| v.to_bits() == *e));
441        assert!(fast.iter().zip(&expected).all(|(v, e)| v.to_bits() == *e));
442    }
443
444    #[test]
445    fn wma_of_raw_inputs_feeds_detrender_taps() {
446        let mut ht = HtTrendMode::new();
447        // After exactly 4 inputs the WMA is (4*40 + 3*30 + 2*20 + 10) / 10 = 30,
448        // while the raw history keeps the inputs newest first.
449        for p in [10.0, 20.0, 30.0, 40.0] {
450            assert_eq!(ht.update(p), None);
451        }
452        assert_eq!(ht.smooth_buf, vec![30.0]);
453        assert_eq!(ht.raw_price, vec![40.0, 30.0, 20.0, 10.0]);
454
455        // Spike of 10 at index 7 in a zero series: smoothed values 4, 3, 2 at
456        // indices 7, 8, 9, so the smooth history is [2, 3, 4, 0, 0, 0, 0].
457        // adj = 0.075*6 + 0.54 = 0.99 and the detrender reads the smoothed taps:
458        //   (0.0962*2 + 0.5769*4 - 0.5769*0 - 0.0962*0) * 0.99 = 2.475.
459        let mut ht = HtTrendMode::new();
460        let mut series = [0.0; 10];
461        series[7] = 10.0;
462        let _ = ht.batch(&series);
463        assert_eq!(ht.smooth_buf, vec![2.0, 3.0, 4.0, 0.0, 0.0, 0.0, 0.0]);
464        assert_eq!(ht.detrender_buf.len(), 1);
465        assert_relative_eq!(ht.detrender_buf[0], 2.475, epsilon = 1e-12);
466    }
467
468    #[test]
469    fn trendline_averages_raw_not_smoothed_price() {
470        // On a unit-slope ramp p[k] = 100 + k the 4-bar WMA lags by exactly one
471        // bar: (4k + 3(k-1) + 2(k-2) + (k-3)) / 10 = k - 1. So the mean of the
472        // raw prices over the dominant-cycle window exceeds the mean of the
473        // smoothed prices by exactly 1.0, which tells the two averages apart.
474        let prices: Vec<f64> = (0..120).map(|k| 100.0 + f64::from(k)).collect();
475        let mut ht = HtTrendMode::new();
476        let _ = ht.batch(&prices);
477        let dc_period = ((ht.prev_smooth_period + 0.5) as usize).clamp(1, ht.smooth_price.len());
478        let n = prices.len();
479        let window = &prices[n - dc_period..];
480        let dc = dc_period as f64;
481        let raw_mean = window.iter().sum::<f64>() / dc;
482        let smooth_mean = ht.smooth_price[..dc_period].iter().sum::<f64>() / dc;
483        // `it1` stores the latest trendline average.
484        assert_relative_eq!(ht.it1, raw_mean, epsilon = 1e-9);
485        assert_relative_eq!(raw_mean - smooth_mean, 1.0, epsilon = 1e-9);
486        // The raw window is exactly the newest `dc_period` inputs, newest first.
487        assert!(ht
488            .raw_price
489            .iter()
490            .zip(prices.iter().rev())
491            .all(|(a, b)| a.to_bits() == b.to_bits()));
492        assert_eq!(ht.raw_price.len(), MAX_DC_PERIOD);
493    }
494
495    #[test]
496    fn steady_ramp_reports_trend_and_clean_cycle_reports_cycle() {
497        // Strong ramp: price separates from the trendline by more than 1.5%,
498        // forcing trend mode on every emitted bar once the engine settles.
499        let ramp: Vec<f64> = (0..200).map(|k| 100.0 * 1.02_f64.powi(k)).collect();
500        let mut ht = HtTrendMode::new();
501        let out = ht.batch(&ramp);
502        assert!(out[100..]
503            .iter()
504            .flatten()
505            .all(|v| v.to_bits() == 1.0_f64.to_bits()));
506        // Clean cycle with a small amplitude: cycle mode must appear.
507        let mut ht = HtTrendMode::new();
508        let out = ht.batch(&sine_prices(300));
509        assert!(out
510            .iter()
511            .flatten()
512            .any(|v| v.to_bits() == 0.0_f64.to_bits()));
513    }
514
515    #[test]
516    fn zero_series_skips_trendline_separation_and_settles_in_trend() {
517        // All zero: the trendline is exactly 0, so the separation test is
518        // skipped; the phase is constant (no crossing, no phase advance), so
519        // after days_in_trend passes half the period the flag is trend (1).
520        let mut ht = HtTrendMode::new();
521        let out = ht.batch(&[0.0; 200]);
522        assert_eq!(out.iter().flatten().count(), 200 - 49);
523        assert_eq!(ht.value(), Some(1.0));
524    }
525
526    #[test]
527    fn dc_phase_quadrant_and_wrap_hand_computed() {
528        // real = 1, imag = -1, period 20: atan(-1) = -45; -45 + 90 + 18 = 63;
529        // imag < 0 adds 180 -> 243.
530        assert_relative_eq!(compute_dc_phase(1.0, -1.0, 20.0), 243.0, epsilon = 1e-9);
531        // real = -1, imag = -1: 45 + 90 + 18 + 180 = 333 > 315 -> -27.
532        assert_relative_eq!(compute_dc_phase(-1.0, -1.0, 20.0), -27.0, epsilon = 1e-9);
533    }
534}