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 None;
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    #[inline]
193    fn warmup_period(&self) -> usize {
194        50
195    }
196
197    #[inline]
198    fn is_ready(&self) -> bool {
199        self.last_value.is_some()
200    }
201
202    #[inline]
203    fn name(&self) -> &'static str {
204        "HT_DCPHASE"
205    }
206}
207
208/// Recovers the dominant-cycle phase (degrees) from the real/imaginary parts of
209/// the one-cycle homodyne integration, then unwraps it into TA-Lib's
210/// `[-45, 315)` output range with the 4-bar smoother group-delay correction.
211///
212/// When `imag_part` is within `±0.001` of zero the `atan` is undefined, so the
213/// phase collapses to `±90°` by the sign of `real_part`.
214fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 {
215    let mut dc_phase = if imag_part.abs() > 0.001 {
216        (real_part / imag_part).atan().to_degrees()
217    } else if real_part < 0.0 {
218        -90.0
219    } else {
220        90.0
221    };
222    dc_phase += 90.0;
223    // Compensate the group delay of the 4-bar weighted smoother.
224    dc_phase += 360.0 / smooth_period;
225    if imag_part < 0.0 {
226        dc_phase += 180.0;
227    }
228    if dc_phase > 315.0 {
229        dc_phase -= 360.0;
230    }
231    dc_phase
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::traits::BatchExt;
238
239    fn sine_prices(n: usize) -> Vec<f64> {
240        (0..n)
241            .map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0)
242            .collect()
243    }
244
245    #[test]
246    fn accessors_and_metadata() {
247        let ht = HtDcPhase::new();
248        assert_eq!(ht.warmup_period(), 50);
249        assert_eq!(ht.name(), "HT_DCPHASE");
250        assert!(!ht.is_ready());
251    }
252
253    #[test]
254    fn near_zero_imaginary_collapses_to_signed_ninety() {
255        // A near-zero imaginary part makes atan(real/imag) undefined, so the phase
256        // collapses to +90 for non-negative real and -90 for negative real before
257        // the +90 offset and group-delay correction unwrap it.
258        let pos = compute_dc_phase(1.0, 0.0, 20.0);
259        let neg = compute_dc_phase(-1.0, 0.0, 20.0);
260        assert!((pos - 198.0).abs() < 1e-9);
261        assert!((neg - 18.0).abs() < 1e-9);
262        // The normal path still flows through atan.
263        let mid = compute_dc_phase(1.0, 1.0, 20.0);
264        assert!((mid - 153.0).abs() < 1e-9);
265    }
266
267    #[test]
268    fn emits_after_warmup_within_phase_band() {
269        let mut ht = HtDcPhase::new();
270        let out: Vec<Option<f64>> = ht.batch(&sine_prices(200));
271        assert_eq!(out[0], None);
272        assert!(ht.is_ready());
273        for v in out.into_iter().flatten() {
274            assert!(v.is_finite(), "phase must be finite");
275            assert!((-360.0..=360.0).contains(&v), "phase {v} outside band");
276        }
277    }
278
279    #[test]
280    fn ignores_non_finite_input() {
281        let mut ht = HtDcPhase::new();
282        let _ = ht.batch(&sine_prices(120));
283        let before = ht.value();
284        assert_eq!(ht.update(f64::NAN), None);
285        // The rejected input must not have disturbed the state.
286        assert_eq!(ht.value(), before);
287    }
288
289    #[test]
290    fn batch_equals_streaming() {
291        let prices = sine_prices(200);
292        let mut a = HtDcPhase::new();
293        let mut b = HtDcPhase::new();
294        let batch = a.batch(&prices);
295        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
296        assert_eq!(batch, streamed);
297    }
298
299    #[test]
300    fn reset_clears_state() {
301        let mut ht = HtDcPhase::new();
302        let _ = ht.batch(&sine_prices(120));
303        assert!(ht.is_ready());
304        ht.reset();
305        assert!(!ht.is_ready());
306        assert_eq!(ht.update(100.0), None);
307    }
308}