Skip to main content

wickra_core/indicators/
ht_dcphase.rs

1//! Ehlers Hilbert Transform Dominant Cycle Phase (`HT_DCPHASE`).
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 Dominant Cycle Phase (`HT_DCPHASE`).
10///
11/// Runs the same adaptive Hilbert-transform engine as
12/// [`HilbertDominantCycle`](crate::HilbertDominantCycle) to recover the dominant
13/// cycle period, then measures the **phase angle** of that cycle (in degrees) by
14/// correlating the smoothed price over one dominant-cycle window against a unit
15/// phasor. The phase advances roughly linearly through a clean cycle and stalls
16/// in a trend, which is the basis of Ehlers' trend-versus-cycle detection.
17///
18/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
19/// `HT_DCPHASE`. The first value is emitted after ~50 inputs, once the engine's
20/// moving-average chain has filled.
21///
22/// # Example
23///
24/// ```
25/// use wickra_core::{Indicator, HtDcPhase};
26///
27/// let mut ht = HtDcPhase::new();
28/// let mut last = None;
29/// for i in 0..120 {
30///     last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
31/// }
32/// assert!(last.is_some());
33/// ```
34#[derive(Debug, Clone, Default)]
35pub struct HtDcPhase {
36    // Raw input window for the 4-bar WMA.
37    price_buf: Vec<f64>,
38    // WMA-smoothed price history feeding the Hilbert detrender taps.
39    smooth_buf: Vec<f64>,
40    detrender_buf: Vec<f64>,
41    q1_buf: Vec<f64>,
42    i1_buf: Vec<f64>,
43    // Longer history of the 4-bar smoothed price, used to integrate the phase
44    // over one dominant-cycle window (up to 50 bars).
45    smooth_price: Vec<f64>,
46    prev_i2: f64,
47    prev_q2: f64,
48    prev_re: f64,
49    prev_im: f64,
50    prev_period: f64,
51    prev_smooth_period: f64,
52    count: usize,
53    last_value: Option<f64>,
54}
55
56impl HtDcPhase {
57    /// Construct a new Hilbert transform dominant-cycle phase estimator.
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Current dominant-cycle phase (degrees) if available.
63    pub const fn value(&self) -> Option<f64> {
64        self.last_value
65    }
66
67    fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
68        buf.insert(0, v);
69        if buf.len() > cap {
70            buf.truncate(cap);
71        }
72    }
73}
74
75impl Indicator for HtDcPhase {
76    type Input = f64;
77    type Output = f64;
78
79    fn update(&mut self, input: f64) -> Option<f64> {
80        if !input.is_finite() {
81            return None;
82        }
83        self.count += 1;
84
85        Self::push_front(&mut self.price_buf, input, 4);
86        if self.price_buf.len() < 4 {
87            return None;
88        }
89        let smooth = (4.0 * self.price_buf[0]
90            + 3.0 * self.price_buf[1]
91            + 2.0 * self.price_buf[2]
92            + self.price_buf[3])
93            / 10.0;
94        Self::push_front(&mut self.smooth_buf, smooth, 7);
95        Self::push_front(&mut self.smooth_price, smooth, MAX_DC_PERIOD);
96
97        let period = self.prev_period.max(6.0).min(50.0);
98        let adj = 0.075 * period + 0.54;
99
100        if self.smooth_buf.len() < 7 {
101            return None;
102        }
103        let s0 = smooth;
104        let s2 = self.smooth_buf[2];
105        let s4 = self.smooth_buf[4];
106        let s6 = self.smooth_buf[6];
107        let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
108        Self::push_front(&mut self.detrender_buf, detrender, 7);
109        if self.detrender_buf.len() < 7 {
110            return None;
111        }
112
113        let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
114            - 0.5769 * self.detrender_buf[4]
115            - 0.0962 * self.detrender_buf[6])
116            * adj;
117        let i1 = self.detrender_buf[3];
118
119        Self::push_front(&mut self.q1_buf, q1, 7);
120        Self::push_front(&mut self.i1_buf, i1, 7);
121        if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
122            return None;
123        }
124
125        let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
126            - 0.5769 * self.i1_buf[4]
127            - 0.0962 * self.i1_buf[6])
128            * adj;
129        let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
130            - 0.5769 * self.q1_buf[4]
131            - 0.0962 * self.q1_buf[6])
132            * adj;
133
134        let mut i2 = i1 - jq;
135        let mut q2 = q1 + ji;
136        i2 = 0.2 * i2 + 0.8 * self.prev_i2;
137        q2 = 0.2 * q2 + 0.8 * self.prev_q2;
138
139        let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
140        let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
141        re = 0.2 * re + 0.8 * self.prev_re;
142        im = 0.2 * im + 0.8 * self.prev_im;
143
144        self.prev_i2 = i2;
145        self.prev_q2 = q2;
146        self.prev_re = re;
147        self.prev_im = im;
148
149        let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
150            2.0 * PI / im.atan2(re)
151        } else {
152            self.prev_period
153        };
154        new_period = new_period.min(1.5 * self.prev_period);
155        new_period = new_period.max(0.67 * self.prev_period);
156        new_period = new_period.clamp(6.0, 50.0);
157        self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
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
164        // Integrate the smoothed price over one dominant-cycle window against a
165        // unit phasor to recover the instantaneous dominant-cycle phase.
166        let smooth_period = self.prev_smooth_period;
167        let dc_period = (smooth_period + 0.5) as usize;
168        let dc_period = dc_period.clamp(1, self.smooth_price.len());
169        let mut real_part = 0.0;
170        let mut imag_part = 0.0;
171        for (&(sin, cos), &sp) in dc_phasor::phasor(dc_period).iter().zip(&self.smooth_price) {
172            real_part += sin * sp;
173            imag_part += cos * sp;
174        }
175
176        let dc_phase = compute_dc_phase(real_part, imag_part, smooth_period);
177
178        self.last_value = Some(dc_phase);
179        Some(dc_phase)
180    }
181
182    fn reset(&mut self) {
183        self.price_buf.clear();
184        self.smooth_buf.clear();
185        self.detrender_buf.clear();
186        self.q1_buf.clear();
187        self.i1_buf.clear();
188        self.smooth_price.clear();
189        self.prev_i2 = 0.0;
190        self.prev_q2 = 0.0;
191        self.prev_re = 0.0;
192        self.prev_im = 0.0;
193        self.prev_period = 0.0;
194        self.prev_smooth_period = 0.0;
195        self.count = 0;
196        self.last_value = None;
197    }
198
199    #[inline]
200    fn warmup_period(&self) -> usize {
201        50
202    }
203
204    #[inline]
205    fn is_ready(&self) -> bool {
206        self.last_value.is_some()
207    }
208
209    #[inline]
210    fn name(&self) -> &'static str {
211        "HT_DCPHASE"
212    }
213}
214
215/// Recovers the dominant-cycle phase (degrees) from the real/imaginary parts of
216/// the one-cycle homodyne integration, then unwraps it into TA-Lib's
217/// `[-45, 315)` output range with the 4-bar smoother group-delay correction.
218///
219/// When `imag_part` is within `±0.001` of zero the `atan` is undefined, so the
220/// phase collapses to `±90°` by the sign of `real_part`.
221fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 {
222    let mut dc_phase = if imag_part.abs() > 0.001 {
223        (real_part / imag_part).atan().to_degrees()
224    } else if real_part < 0.0 {
225        -90.0
226    } else {
227        90.0
228    };
229    dc_phase += 90.0;
230    // Compensate the group delay of the 4-bar weighted smoother.
231    dc_phase += 360.0 / smooth_period;
232    if imag_part < 0.0 {
233        dc_phase += 180.0;
234    }
235    if dc_phase > 315.0 {
236        dc_phase -= 360.0;
237    }
238    dc_phase
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::traits::BatchExt;
245
246    fn sine_prices(n: usize) -> Vec<f64> {
247        (0..n)
248            .map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0)
249            .collect()
250    }
251
252    #[test]
253    fn accessors_and_metadata() {
254        let ht = HtDcPhase::new();
255        assert_eq!(ht.warmup_period(), 50);
256        assert_eq!(ht.name(), "HT_DCPHASE");
257        assert!(!ht.is_ready());
258    }
259
260    #[test]
261    fn near_zero_imaginary_collapses_to_signed_ninety() {
262        // A near-zero imaginary part makes atan(real/imag) undefined, so the phase
263        // collapses to +90 for non-negative real and -90 for negative real before
264        // the +90 offset and group-delay correction unwrap it.
265        let pos = compute_dc_phase(1.0, 0.0, 20.0);
266        let neg = compute_dc_phase(-1.0, 0.0, 20.0);
267        assert!((pos - 198.0).abs() < 1e-9);
268        assert!((neg - 18.0).abs() < 1e-9);
269        // The normal path still flows through atan.
270        let mid = compute_dc_phase(1.0, 1.0, 20.0);
271        assert!((mid - 153.0).abs() < 1e-9);
272    }
273
274    #[test]
275    fn emits_after_warmup_within_phase_band() {
276        let mut ht = HtDcPhase::new();
277        let out: Vec<Option<f64>> = ht.batch(&sine_prices(200));
278        assert_eq!(out[0], None);
279        assert!(ht.is_ready());
280        for v in out.into_iter().flatten() {
281            assert!(v.is_finite(), "phase must be finite");
282            assert!((-360.0..=360.0).contains(&v), "phase {v} outside band");
283        }
284    }
285
286    #[test]
287    fn ignores_non_finite_input() {
288        let mut ht = HtDcPhase::new();
289        let _ = ht.batch(&sine_prices(120));
290        let before = ht.value();
291        assert_eq!(ht.update(f64::NAN), None);
292        // The rejected input must not have disturbed the state.
293        assert_eq!(ht.value(), before);
294    }
295
296    #[test]
297    fn batch_equals_streaming() {
298        let prices = sine_prices(200);
299        let mut a = HtDcPhase::new();
300        let mut b = HtDcPhase::new();
301        let batch = a.batch(&prices);
302        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
303        assert_eq!(batch, streamed);
304    }
305
306    #[test]
307    fn reset_clears_state() {
308        let mut ht = HtDcPhase::new();
309        let _ = ht.batch(&sine_prices(120));
310        assert!(ht.is_ready());
311        ht.reset();
312        assert!(!ht.is_ready());
313        assert_eq!(ht.update(100.0), None);
314    }
315
316    use crate::traits::BatchNanExt;
317    use approx::assert_relative_eq;
318
319    #[test]
320    fn first_value_lands_exactly_at_warmup() {
321        let mut ht = HtDcPhase::new();
322        let out = ht.batch(&sine_prices(120));
323        let warmup = ht.warmup_period();
324        assert!(out[..warmup - 1].iter().all(Option::is_none));
325        assert!(out[warmup - 1].is_some());
326    }
327
328    #[test]
329    fn reset_replays_identically() {
330        let prices = sine_prices(150);
331        let fresh = HtDcPhase::new().batch(&prices);
332        let mut ht = HtDcPhase::new();
333        let first = ht.batch(&prices);
334        ht.reset();
335        let second = ht.batch(&prices);
336        assert_eq!(first, fresh);
337        assert_eq!(second, fresh);
338    }
339
340    #[test]
341    fn batch_nan_paths_match_streaming_bitwise() {
342        let prices = sine_prices(150);
343        let mut out = vec![0.0; prices.len()];
344        HtDcPhase::new().batch_nan_into(&prices, &mut out);
345        let nan = HtDcPhase::new().batch_nan(&prices);
346        let fast = HtDcPhase::new().batch_fast(&prices);
347        let mut stream = HtDcPhase::new();
348        let expected: Vec<u64> = prices
349            .iter()
350            .map(|&p| stream.update(p).unwrap_or(f64::NAN).to_bits())
351            .collect();
352        assert!(out.iter().zip(&expected).all(|(v, e)| v.to_bits() == *e));
353        assert!(nan.iter().zip(&expected).all(|(v, e)| v.to_bits() == *e));
354        assert!(fast.iter().zip(&expected).all(|(v, e)| v.to_bits() == *e));
355    }
356
357    #[test]
358    fn wma_of_raw_inputs_feeds_detrender_taps() {
359        let mut ht = HtDcPhase::new();
360        // After exactly 4 inputs the WMA is (4*40 + 3*30 + 2*20 + 10) / 10 = 30.
361        for p in [10.0, 20.0, 30.0, 40.0] {
362            assert_eq!(ht.update(p), None);
363        }
364        assert_eq!(ht.smooth_buf, vec![30.0]);
365        assert_eq!(ht.smooth_price, vec![30.0]);
366
367        // Spike of 10 at index 7 in a zero series: smoothed values 4, 3, 2 at
368        // indices 7, 8, 9, so the smooth history is [2, 3, 4, 0, 0, 0, 0].
369        // adj = 0.075*6 + 0.54 = 0.99 and the detrender reads the smoothed taps:
370        //   (0.0962*2 + 0.5769*4 - 0.5769*0 - 0.0962*0) * 0.99 = 2.475.
371        let mut ht = HtDcPhase::new();
372        let mut series = [0.0; 10];
373        series[7] = 10.0;
374        let _ = ht.batch(&series);
375        assert_eq!(ht.smooth_buf, vec![2.0, 3.0, 4.0, 0.0, 0.0, 0.0, 0.0]);
376        assert_eq!(ht.detrender_buf.len(), 1);
377        assert_relative_eq!(ht.detrender_buf[0], 2.475, epsilon = 1e-12);
378    }
379
380    #[test]
381    fn dc_phase_quadrant_and_wrap_hand_computed() {
382        // real = 1, imag = -1, period 20: atan(-1) = -45; -45 + 90 + 360/20 = 63;
383        // imag < 0 adds 180 -> 243 (no wrap, 243 <= 315).
384        assert_relative_eq!(compute_dc_phase(1.0, -1.0, 20.0), 243.0, epsilon = 1e-9);
385        // real = -1, imag = -1, period 20: atan(1) = 45; 45 + 90 + 18 = 153;
386        // imag < 0 adds 180 -> 333 > 315, so it wraps to 333 - 360 = -27.
387        assert_relative_eq!(compute_dc_phase(-1.0, -1.0, 20.0), -27.0, epsilon = 1e-9);
388        // real = 0, imag = 0, period 6: degenerate -> 90; 90 + 90 + 60 = 240.
389        assert_relative_eq!(compute_dc_phase(0.0, 0.0, 6.0), 240.0, epsilon = 1e-12);
390    }
391
392    #[test]
393    fn zero_series_phase_converges_to_degenerate_value() {
394        // Zero input: real = imag = 0 so the phase is 90 + 90 + 360/period, and
395        // the smoothed period converges on the lower clamp of 6 -> 240 degrees.
396        let mut ht = HtDcPhase::new();
397        let out = ht.batch(&[0.0; 400]);
398        assert!(out.iter().flatten().all(|v| v.is_finite()));
399        assert_relative_eq!(ht.value().unwrap(), 240.0, epsilon = 1e-6);
400        // A non-zero flat series stays finite.
401        let mut ht = HtDcPhase::new();
402        let out = ht.batch(&[100.0; 400]);
403        assert_eq!(
404            out.iter().flatten().filter(|v| v.is_finite()).count(),
405            400 - 49
406        );
407    }
408}