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