Skip to main content

wickra_core/indicators/
ht_phasor.rs

1//! Ehlers Hilbert Transform Phasor components (`HT_PHASOR`).
2#![allow(clippy::manual_clamp)]
3
4use std::f64::consts::PI;
5
6use crate::traits::Indicator;
7
8/// In-phase and quadrature components of the Hilbert transform phasor.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct HtPhasorOutput {
11    /// In-phase component (`I1`).
12    pub inphase: f64,
13    /// Quadrature component (`Q1`).
14    pub quadrature: f64,
15}
16
17/// Ehlers' Hilbert Transform Phasor (`HT_PHASOR`).
18///
19/// Runs the same adaptive Hilbert-transform engine as
20/// [`HilbertDominantCycle`](crate::HilbertDominantCycle) but reports the raw
21/// in-phase (`I1`) and quadrature (`Q1`) components of the analytic signal rather
22/// than the recovered cycle period. The two components are 90° out of phase, so
23/// their ratio tracks the instantaneous phase of the dominant cycle.
24///
25/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
26/// `HT_PHASOR`. The first value is emitted once the transform's tap buffers fill.
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{Indicator, HtPhasor};
32///
33/// let mut ht = HtPhasor::new();
34/// let mut last = None;
35/// for i in 0..120 {
36///     last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
37/// }
38/// assert!(last.is_some());
39/// ```
40#[derive(Debug, Clone, Default)]
41pub struct HtPhasor {
42    // Raw input window for the 4-bar WMA.
43    price_buf: Vec<f64>,
44    // WMA-smoothed price history feeding the Hilbert detrender taps.
45    smooth_buf: Vec<f64>,
46    detrender_buf: Vec<f64>,
47    q1_buf: Vec<f64>,
48    i1_buf: Vec<f64>,
49    prev_i2: f64,
50    prev_q2: f64,
51    prev_re: f64,
52    prev_im: f64,
53    prev_period: f64,
54    ready: bool,
55}
56
57impl HtPhasor {
58    /// Construct a new Hilbert transform phasor.
59    pub fn new() -> Self {
60        Self::default()
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 HtPhasor {
72    type Input = f64;
73    type Output = HtPhasorOutput;
74
75    fn update(&mut self, input: f64) -> Option<HtPhasorOutput> {
76        if !input.is_finite() {
77            return None;
78        }
79
80        Self::push_front(&mut self.price_buf, input, 4);
81        if self.price_buf.len() < 4 {
82            return None;
83        }
84        let smooth = (4.0 * self.price_buf[0]
85            + 3.0 * self.price_buf[1]
86            + 2.0 * self.price_buf[2]
87            + self.price_buf[3])
88            / 10.0;
89        Self::push_front(&mut self.smooth_buf, smooth, 7);
90
91        let period = self.prev_period.max(6.0).min(50.0);
92        let adj = 0.075 * period + 0.54;
93
94        if self.smooth_buf.len() < 7 {
95            return None;
96        }
97        let s0 = smooth;
98        let s2 = self.smooth_buf[2];
99        let s4 = self.smooth_buf[4];
100        let s6 = self.smooth_buf[6];
101        let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
102        Self::push_front(&mut self.detrender_buf, detrender, 7);
103        if self.detrender_buf.len() < 7 {
104            return None;
105        }
106
107        let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
108            - 0.5769 * self.detrender_buf[4]
109            - 0.0962 * self.detrender_buf[6])
110            * adj;
111        let i1 = self.detrender_buf[3];
112
113        Self::push_front(&mut self.q1_buf, q1, 7);
114        Self::push_front(&mut self.i1_buf, i1, 7);
115        if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
116            return None;
117        }
118
119        // Continue the dominant-cycle period adaptation so the next bar's `adj`
120        // coefficient tracks the cycle, exactly as TA-Lib's HT_PHASOR does.
121        let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
122            - 0.5769 * self.i1_buf[4]
123            - 0.0962 * self.i1_buf[6])
124            * adj;
125        let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
126            - 0.5769 * self.q1_buf[4]
127            - 0.0962 * self.q1_buf[6])
128            * adj;
129
130        let mut i2 = i1 - jq;
131        let mut q2 = q1 + ji;
132        i2 = 0.2 * i2 + 0.8 * self.prev_i2;
133        q2 = 0.2 * q2 + 0.8 * self.prev_q2;
134
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        new_period = new_period.min(1.5 * self.prev_period);
151        new_period = new_period.max(0.67 * self.prev_period);
152        new_period = new_period.clamp(6.0, 50.0);
153        self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
154
155        self.ready = true;
156        Some(HtPhasorOutput {
157            inphase: i1,
158            quadrature: q1,
159        })
160    }
161
162    fn reset(&mut self) {
163        self.price_buf.clear();
164        self.smooth_buf.clear();
165        self.detrender_buf.clear();
166        self.q1_buf.clear();
167        self.i1_buf.clear();
168        self.prev_i2 = 0.0;
169        self.prev_q2 = 0.0;
170        self.prev_re = 0.0;
171        self.prev_im = 0.0;
172        self.prev_period = 0.0;
173        self.ready = false;
174    }
175
176    #[inline]
177    fn warmup_period(&self) -> usize {
178        22
179    }
180
181    #[inline]
182    fn is_ready(&self) -> bool {
183        self.ready
184    }
185
186    #[inline]
187    fn name(&self) -> &'static str {
188        "HT_PHASOR"
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::traits::BatchExt;
196
197    fn sine_prices(n: usize) -> Vec<f64> {
198        (0..n)
199            .map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0)
200            .collect()
201    }
202
203    #[test]
204    fn accessors_and_metadata() {
205        let ht = HtPhasor::new();
206        assert_eq!(ht.warmup_period(), 22);
207        assert_eq!(ht.name(), "HT_PHASOR");
208        assert!(!ht.is_ready());
209    }
210
211    #[test]
212    fn emits_after_warmup_and_stays_finite() {
213        let mut ht = HtPhasor::new();
214        let out: Vec<Option<HtPhasorOutput>> = ht.batch(&sine_prices(120));
215        assert_eq!(out[0], None);
216        let first = out.iter().position(Option::is_some).expect("emits");
217        assert!(first <= 21, "first phasor at index {first}");
218        for o in out.into_iter().flatten() {
219            assert!(o.inphase.is_finite() && o.quadrature.is_finite());
220        }
221        assert!(ht.is_ready());
222    }
223
224    #[test]
225    fn ignores_non_finite_input() {
226        let mut ht = HtPhasor::new();
227        let _ = ht.batch(&sine_prices(120));
228        // A non-finite input is skipped and produces no value.
229        assert_eq!(ht.update(f64::NAN), None);
230    }
231
232    #[test]
233    fn batch_equals_streaming() {
234        let prices = sine_prices(150);
235        let mut a = HtPhasor::new();
236        let mut b = HtPhasor::new();
237        let batch = a.batch(&prices);
238        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
239        assert_eq!(batch, streamed);
240    }
241
242    #[test]
243    fn reset_clears_state() {
244        let mut ht = HtPhasor::new();
245        let _ = ht.batch(&sine_prices(120));
246        assert!(ht.is_ready());
247        ht.reset();
248        assert!(!ht.is_ready());
249        assert_eq!(ht.update(100.0), None);
250    }
251
252    use approx::assert_relative_eq;
253
254    #[test]
255    fn first_value_lands_exactly_at_warmup() {
256        // 4 inputs fill the WMA, 7 smoothed values the detrender taps, 7
257        // detrender values the I/Q taps and 7 more of those the second Hilbert
258        // pass: 3 + 6 + 6 + 6 = 21 -> first value at index 21.
259        let mut ht = HtPhasor::new();
260        let out = ht.batch(&sine_prices(60));
261        let warmup = ht.warmup_period();
262        assert_eq!(warmup, 22);
263        assert!(out[..warmup - 1].iter().all(Option::is_none));
264        assert!(out[warmup - 1].is_some());
265    }
266
267    #[test]
268    fn reset_replays_identically() {
269        let prices = sine_prices(150);
270        let fresh = HtPhasor::new().batch(&prices);
271        let mut ht = HtPhasor::new();
272        let first = ht.batch(&prices);
273        ht.reset();
274        let second = ht.batch(&prices);
275        assert_eq!(first, fresh);
276        assert_eq!(second, fresh);
277    }
278
279    #[test]
280    fn wma_of_raw_inputs_feeds_detrender_taps() {
281        let mut ht = HtPhasor::new();
282        // After exactly 4 inputs the WMA is (4*40 + 3*30 + 2*20 + 10) / 10 = 30.
283        for p in [10.0, 20.0, 30.0, 40.0] {
284            assert_eq!(ht.update(p), None);
285        }
286        assert_eq!(ht.smooth_buf, vec![30.0]);
287
288        // Spike of 10 at index 7 in a zero series: smoothed values 4, 3, 2 at
289        // indices 7, 8, 9, so the smooth history is [2, 3, 4, 0, 0, 0, 0].
290        // adj = 0.075*6 + 0.54 = 0.99 and the detrender reads the smoothed taps:
291        //   (0.0962*2 + 0.5769*4 - 0.5769*0 - 0.0962*0) * 0.99 = 2.475.
292        let mut ht = HtPhasor::new();
293        let mut series = [0.0; 10];
294        series[7] = 10.0;
295        let _ = ht.batch(&series);
296        assert_eq!(ht.smooth_buf, vec![2.0, 3.0, 4.0, 0.0, 0.0, 0.0, 0.0]);
297        assert_eq!(ht.detrender_buf.len(), 1);
298        assert_relative_eq!(ht.detrender_buf[0], 2.475, epsilon = 1e-12);
299    }
300
301    #[test]
302    fn inphase_is_detrender_three_bars_back() {
303        // I1 is the detrender delayed by 3 bars; Q1 is the Hilbert transform of
304        // the detrender history scaled by the same adj.
305        let mut ht = HtPhasor::new();
306        let prices = sine_prices(60);
307        let _ = ht.batch(&prices[..59]);
308        let out = ht.update(prices[59]).unwrap();
309        assert_eq!(out.inphase.to_bits(), ht.detrender_buf[3].to_bits());
310        assert_eq!(out.quadrature.to_bits(), ht.q1_buf[0].to_bits());
311    }
312
313    #[test]
314    fn constant_input_stays_finite_and_period_clamps_to_six() {
315        // Zero input: every I/Q term is exactly 0, re == im == 0, and the period
316        // falls back and is clamped up to 6 by the EMA chain.
317        let mut ht = HtPhasor::new();
318        let out = ht.batch(&[0.0; 200]);
319        assert!(out
320            .iter()
321            .flatten()
322            .all(|o| o.inphase.abs().to_bits() == 0 && o.quadrature.abs().to_bits() == 0));
323        assert_relative_eq!(ht.prev_period, 6.0, epsilon = 1e-9);
324        let mut ht = HtPhasor::new();
325        let out = ht.batch(&[100.0; 200]);
326        assert!(out
327            .iter()
328            .flatten()
329            .all(|o| o.inphase.is_finite() && o.quadrature.is_finite()));
330        assert_eq!(out.iter().flatten().count(), 200 - 21);
331    }
332}