Skip to main content

wickra_core/indicators/
hilbert_dominant_cycle.rs

1//! Ehlers Hilbert Transform Dominant Cycle period estimator.
2#![allow(clippy::manual_clamp)]
3
4use std::f64::consts::PI;
5
6use crate::traits::Indicator;
7
8/// Ehlers' Hilbert Transform–based Dominant Cycle period estimator.
9///
10/// Decomposes price into in-phase and quadrature components via Ehlers'
11/// truncated Hilbert transform, then derives the instantaneous phase. The
12/// dominant cycle period is recovered from the phase rate of change and
13/// median-smoothed. From *Rocket Science for Traders* (Ehlers 2001, ch. 7),
14/// implementation aligned with the formulation used in TA-Lib's `HT_DCPERIOD`.
15///
16/// The output is clamped to the band `[6, 50]` bars, which Ehlers identifies
17/// as the meaningful tradable cycle range. The estimator emits its first
18/// value after ~50 inputs as the moving-average chain fills.
19///
20/// # Example
21///
22/// ```
23/// use wickra_core::{Indicator, HilbertDominantCycle};
24///
25/// let mut ht = HilbertDominantCycle::new();
26/// let mut last = None;
27/// for i in 0..200 {
28///     last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
29/// }
30/// assert!(last.is_some());
31/// ```
32#[derive(Debug, Clone, Default)]
33pub struct HilbertDominantCycle {
34    // Rolling 7-tap smoother input buffer.
35    smooth_buf: Vec<f64>,
36    // Detrender / Q1 / I1 ring history (need 6 prior).
37    detrender_buf: Vec<f64>,
38    q1_buf: Vec<f64>,
39    i1_buf: Vec<f64>,
40    // Smoothed I/Q lines for phase computation.
41    prev_i2: f64,
42    prev_q2: f64,
43    prev_re: f64,
44    prev_im: f64,
45    prev_period: f64,
46    prev_smooth_period: f64,
47    count: usize,
48    last_value: Option<f64>,
49}
50
51impl HilbertDominantCycle {
52    /// Construct a new dominant cycle estimator.
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Current period estimate if available.
58    pub const fn value(&self) -> Option<f64> {
59        self.last_value
60    }
61}
62
63impl Indicator for HilbertDominantCycle {
64    type Input = f64;
65    type Output = f64;
66
67    fn update(&mut self, input: f64) -> Option<f64> {
68        if !input.is_finite() {
69            return self.last_value;
70        }
71        self.count += 1;
72
73        // 4-bar weighted moving average of the input (smoothed price).
74        // Ehlers: (4*x[0] + 3*x[1] + 2*x[2] + x[3]) / 10.
75        Self::push_front(&mut self.smooth_buf, input, 7);
76        if self.smooth_buf.len() < 4 {
77            return None;
78        }
79        let smooth = (4.0 * self.smooth_buf[0]
80            + 3.0 * self.smooth_buf[1]
81            + 2.0 * self.smooth_buf[2]
82            + self.smooth_buf[3])
83            / 10.0;
84
85        // Adaptive coefficient based on the previous period estimate.
86        let period = self.prev_period.max(6.0).min(50.0);
87        let adj = 0.075 * period + 0.54;
88
89        // We need the smooth buffer to hold ≥ 7 samples for the Hilbert taps.
90        if self.smooth_buf.len() < 7 {
91            return None;
92        }
93
94        // Ehlers' Hilbert transform of `smooth` (using current + 2/4/6 lags).
95        let s0 = smooth;
96        let s2 = self.smooth_buf[2];
97        let s4 = self.smooth_buf[4];
98        let s6 = self.smooth_buf[6];
99        let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
100        Self::push_front(&mut self.detrender_buf, detrender, 7);
101
102        if self.detrender_buf.len() < 7 {
103            return None;
104        }
105        // In-phase and quadrature components.
106        let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
107            - 0.5769 * self.detrender_buf[4]
108            - 0.0962 * self.detrender_buf[6])
109            * adj;
110        let i1 = self.detrender_buf[3];
111
112        Self::push_front(&mut self.q1_buf, q1, 7);
113        Self::push_front(&mut self.i1_buf, i1, 7);
114        if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
115            return None;
116        }
117
118        // Advance the phase 90 deg via a second Hilbert pass.
119        let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
120            - 0.5769 * self.i1_buf[4]
121            - 0.0962 * self.i1_buf[6])
122            * adj;
123        let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
124            - 0.5769 * self.q1_buf[4]
125            - 0.0962 * self.q1_buf[6])
126            * adj;
127
128        // Phasor smoothing.
129        let mut i2 = i1 - jq;
130        let mut q2 = q1 + ji;
131        i2 = 0.2 * i2 + 0.8 * self.prev_i2;
132        q2 = 0.2 * q2 + 0.8 * self.prev_q2;
133
134        // Homodyne discriminator.
135        let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
136        let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
137        re = 0.2 * re + 0.8 * self.prev_re;
138        im = 0.2 * im + 0.8 * self.prev_im;
139
140        self.prev_i2 = i2;
141        self.prev_q2 = q2;
142        self.prev_re = re;
143        self.prev_im = im;
144
145        let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
146            2.0 * PI / im.atan2(re)
147        } else {
148            self.prev_period
149        };
150        // Rate-of-change clamp per Ehlers.
151        new_period = new_period.min(1.5 * self.prev_period);
152        new_period = new_period.max(0.67 * self.prev_period);
153        new_period = new_period.clamp(6.0, 50.0);
154
155        // EMA smoothing of the period.
156        self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
157        // Second smoothing step (TA-Lib uses 0.33/0.67).
158        self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
159
160        if self.count < 50 {
161            return None;
162        }
163        self.last_value = Some(self.prev_smooth_period);
164        Some(self.prev_smooth_period)
165    }
166
167    fn reset(&mut self) {
168        self.smooth_buf.clear();
169        self.detrender_buf.clear();
170        self.q1_buf.clear();
171        self.i1_buf.clear();
172        self.prev_i2 = 0.0;
173        self.prev_q2 = 0.0;
174        self.prev_re = 0.0;
175        self.prev_im = 0.0;
176        self.prev_period = 0.0;
177        self.prev_smooth_period = 0.0;
178        self.count = 0;
179        self.last_value = None;
180    }
181
182    fn warmup_period(&self) -> usize {
183        50
184    }
185
186    fn is_ready(&self) -> bool {
187        self.last_value.is_some()
188    }
189
190    fn name(&self) -> &'static str {
191        "HilbertDominantCycle"
192    }
193}
194
195impl HilbertDominantCycle {
196    /// Push `v` at the front of `buf`, capping the length at `cap`.
197    fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
198        buf.insert(0, v);
199        if buf.len() > cap {
200            buf.truncate(cap);
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::traits::BatchExt;
209
210    #[test]
211    fn accessors_and_metadata() {
212        let mut ht = HilbertDominantCycle::new();
213        assert_eq!(ht.warmup_period(), 50);
214        assert_eq!(ht.name(), "HilbertDominantCycle");
215        assert!(!ht.is_ready());
216        assert!(ht.value().is_none());
217        for i in 0..120 {
218            ht.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
219        }
220        assert!(ht.is_ready());
221        assert!(ht.value().is_some());
222    }
223
224    #[test]
225    fn output_within_clamp_band() {
226        let mut ht = HilbertDominantCycle::new();
227        let prices: Vec<f64> = (0..200)
228            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
229            .collect();
230        let out = ht.batch(&prices);
231        for v in out.iter().flatten() {
232            assert!((6.0..=50.0).contains(v), "period {v} outside [6, 50]");
233        }
234    }
235
236    #[test]
237    fn batch_equals_streaming() {
238        let prices: Vec<f64> = (0..200)
239            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
240            .collect();
241        let mut a = HilbertDominantCycle::new();
242        let mut b = HilbertDominantCycle::new();
243        let batch = a.batch(&prices);
244        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
245        assert_eq!(batch, streamed);
246    }
247
248    #[test]
249    fn ignores_non_finite_input() {
250        let mut ht = HilbertDominantCycle::new();
251        let prices: Vec<f64> = (0..120)
252            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
253            .collect();
254        ht.batch(&prices);
255        let before = ht.value();
256        assert!(before.is_some());
257        assert_eq!(ht.update(f64::NAN), before);
258    }
259
260    #[test]
261    fn reset_clears_state() {
262        let mut ht = HilbertDominantCycle::new();
263        let prices: Vec<f64> = (0..120)
264            .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
265            .collect();
266        ht.batch(&prices);
267        assert!(ht.is_ready());
268        ht.reset();
269        assert!(!ht.is_ready());
270        assert!(ht.value().is_none());
271    }
272}