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    smooth_buf: Vec<f64>,
37    detrender_buf: Vec<f64>,
38    q1_buf: Vec<f64>,
39    i1_buf: Vec<f64>,
40    // Longer history of the 4-bar smoothed price, used to integrate the phase
41    // over one dominant-cycle window (up to 50 bars).
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    count: usize,
50    last_value: Option<f64>,
51}
52
53impl HtDcPhase {
54    /// Construct a new Hilbert transform dominant-cycle phase estimator.
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Current dominant-cycle phase (degrees) if available.
60    pub const fn value(&self) -> Option<f64> {
61        self.last_value
62    }
63
64    fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
65        buf.insert(0, v);
66        if buf.len() > cap {
67            buf.truncate(cap);
68        }
69    }
70}
71
72impl Indicator for HtDcPhase {
73    type Input = f64;
74    type Output = f64;
75
76    fn update(&mut self, input: f64) -> Option<f64> {
77        if !input.is_finite() {
78            return None;
79        }
80        self.count += 1;
81
82        Self::push_front(&mut self.smooth_buf, input, 7);
83        if self.smooth_buf.len() < 7 {
84            return None;
85        }
86        let smooth = (4.0 * self.smooth_buf[0]
87            + 3.0 * self.smooth_buf[1]
88            + 2.0 * self.smooth_buf[2]
89            + self.smooth_buf[3])
90            / 10.0;
91        Self::push_front(&mut self.smooth_price, smooth, MAX_DC_PERIOD);
92
93        let period = self.prev_period.max(6.0).min(50.0);
94        let adj = 0.075 * period + 0.54;
95
96        let s0 = smooth;
97        let s2 = self.smooth_buf[2];
98        let s4 = self.smooth_buf[4];
99        let s6 = self.smooth_buf[6];
100        let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
101        Self::push_front(&mut self.detrender_buf, detrender, 7);
102        if self.detrender_buf.len() < 7 {
103            return None;
104        }
105
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        let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
119            - 0.5769 * self.i1_buf[4]
120            - 0.0962 * self.i1_buf[6])
121            * adj;
122        let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
123            - 0.5769 * self.q1_buf[4]
124            - 0.0962 * self.q1_buf[6])
125            * adj;
126
127        let mut i2 = i1 - jq;
128        let mut q2 = q1 + ji;
129        i2 = 0.2 * i2 + 0.8 * self.prev_i2;
130        q2 = 0.2 * q2 + 0.8 * self.prev_q2;
131
132        let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
133        let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
134        re = 0.2 * re + 0.8 * self.prev_re;
135        im = 0.2 * im + 0.8 * self.prev_im;
136
137        self.prev_i2 = i2;
138        self.prev_q2 = q2;
139        self.prev_re = re;
140        self.prev_im = im;
141
142        let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
143            2.0 * PI / im.atan2(re)
144        } else {
145            self.prev_period
146        };
147        new_period = new_period.min(1.5 * self.prev_period);
148        new_period = new_period.max(0.67 * self.prev_period);
149        new_period = new_period.clamp(6.0, 50.0);
150        self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
151        self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
152
153        if self.count < 50 {
154            return None;
155        }
156
157        // Integrate the smoothed price over one dominant-cycle window against a
158        // unit phasor to recover the instantaneous dominant-cycle phase.
159        let smooth_period = self.prev_smooth_period;
160        let dc_period = (smooth_period + 0.5) as usize;
161        let dc_period = dc_period.clamp(1, self.smooth_price.len());
162        let mut real_part = 0.0;
163        let mut imag_part = 0.0;
164        for (&(sin, cos), &sp) in dc_phasor::phasor(dc_period).iter().zip(&self.smooth_price) {
165            real_part += sin * sp;
166            imag_part += cos * sp;
167        }
168
169        let dc_phase = compute_dc_phase(real_part, imag_part, smooth_period);
170
171        self.last_value = Some(dc_phase);
172        Some(dc_phase)
173    }
174
175    fn reset(&mut self) {
176        self.smooth_buf.clear();
177        self.detrender_buf.clear();
178        self.q1_buf.clear();
179        self.i1_buf.clear();
180        self.smooth_price.clear();
181        self.prev_i2 = 0.0;
182        self.prev_q2 = 0.0;
183        self.prev_re = 0.0;
184        self.prev_im = 0.0;
185        self.prev_period = 0.0;
186        self.prev_smooth_period = 0.0;
187        self.count = 0;
188        self.last_value = None;
189    }
190
191    #[inline]
192    fn warmup_period(&self) -> usize {
193        50
194    }
195
196    #[inline]
197    fn is_ready(&self) -> bool {
198        self.last_value.is_some()
199    }
200
201    #[inline]
202    fn name(&self) -> &'static str {
203        "HT_DCPHASE"
204    }
205}
206
207/// Recovers the dominant-cycle phase (degrees) from the real/imaginary parts of
208/// the one-cycle homodyne integration, then unwraps it into TA-Lib's
209/// `[-45, 315)` output range with the 4-bar smoother group-delay correction.
210///
211/// When `imag_part` is within `±0.001` of zero the `atan` is undefined, so the
212/// phase collapses to `±90°` by the sign of `real_part`.
213fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 {
214    let mut dc_phase = if imag_part.abs() > 0.001 {
215        (real_part / imag_part).atan().to_degrees()
216    } else if real_part < 0.0 {
217        -90.0
218    } else {
219        90.0
220    };
221    dc_phase += 90.0;
222    // Compensate the group delay of the 4-bar weighted smoother.
223    dc_phase += 360.0 / smooth_period;
224    if imag_part < 0.0 {
225        dc_phase += 180.0;
226    }
227    if dc_phase > 315.0 {
228        dc_phase -= 360.0;
229    }
230    dc_phase
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::traits::BatchExt;
237
238    fn sine_prices(n: usize) -> Vec<f64> {
239        (0..n)
240            .map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0)
241            .collect()
242    }
243
244    #[test]
245    fn accessors_and_metadata() {
246        let ht = HtDcPhase::new();
247        assert_eq!(ht.warmup_period(), 50);
248        assert_eq!(ht.name(), "HT_DCPHASE");
249        assert!(!ht.is_ready());
250    }
251
252    #[test]
253    fn near_zero_imaginary_collapses_to_signed_ninety() {
254        // A near-zero imaginary part makes atan(real/imag) undefined, so the phase
255        // collapses to +90 for non-negative real and -90 for negative real before
256        // the +90 offset and group-delay correction unwrap it.
257        let pos = compute_dc_phase(1.0, 0.0, 20.0);
258        let neg = compute_dc_phase(-1.0, 0.0, 20.0);
259        assert!((pos - 198.0).abs() < 1e-9);
260        assert!((neg - 18.0).abs() < 1e-9);
261        // The normal path still flows through atan.
262        let mid = compute_dc_phase(1.0, 1.0, 20.0);
263        assert!((mid - 153.0).abs() < 1e-9);
264    }
265
266    #[test]
267    fn emits_after_warmup_within_phase_band() {
268        let mut ht = HtDcPhase::new();
269        let out: Vec<Option<f64>> = ht.batch(&sine_prices(200));
270        assert_eq!(out[0], None);
271        assert!(ht.is_ready());
272        for v in out.into_iter().flatten() {
273            assert!(v.is_finite(), "phase must be finite");
274            assert!((-360.0..=360.0).contains(&v), "phase {v} outside band");
275        }
276    }
277
278    #[test]
279    fn ignores_non_finite_input() {
280        let mut ht = HtDcPhase::new();
281        let _ = ht.batch(&sine_prices(120));
282        let before = ht.value();
283        assert_eq!(ht.update(f64::NAN), None);
284        // The rejected input must not have disturbed the state.
285        assert_eq!(ht.value(), before);
286    }
287
288    #[test]
289    fn batch_equals_streaming() {
290        let prices = sine_prices(200);
291        let mut a = HtDcPhase::new();
292        let mut b = HtDcPhase::new();
293        let batch = a.batch(&prices);
294        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
295        assert_eq!(batch, streamed);
296    }
297
298    #[test]
299    fn reset_clears_state() {
300        let mut ht = HtDcPhase::new();
301        let _ = ht.batch(&sine_prices(120));
302        assert!(ht.is_ready());
303        ht.reset();
304        assert!(!ht.is_ready());
305        assert_eq!(ht.update(100.0), None);
306    }
307}