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    smooth_buf: Vec<f64>,
43    detrender_buf: Vec<f64>,
44    q1_buf: Vec<f64>,
45    i1_buf: Vec<f64>,
46    prev_i2: f64,
47    prev_q2: f64,
48    prev_re: f64,
49    prev_im: f64,
50    prev_period: f64,
51    ready: bool,
52}
53
54impl HtPhasor {
55    /// Construct a new Hilbert transform phasor.
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
61        buf.insert(0, v);
62        if buf.len() > cap {
63            buf.truncate(cap);
64        }
65    }
66}
67
68impl Indicator for HtPhasor {
69    type Input = f64;
70    type Output = HtPhasorOutput;
71
72    fn update(&mut self, input: f64) -> Option<HtPhasorOutput> {
73        if !input.is_finite() {
74            return None;
75        }
76
77        Self::push_front(&mut self.smooth_buf, input, 7);
78        if self.smooth_buf.len() < 7 {
79            return None;
80        }
81        let smooth = (4.0 * self.smooth_buf[0]
82            + 3.0 * self.smooth_buf[1]
83            + 2.0 * self.smooth_buf[2]
84            + self.smooth_buf[3])
85            / 10.0;
86
87        let period = self.prev_period.max(6.0).min(50.0);
88        let adj = 0.075 * period + 0.54;
89
90        let s0 = smooth;
91        let s2 = self.smooth_buf[2];
92        let s4 = self.smooth_buf[4];
93        let s6 = self.smooth_buf[6];
94        let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
95        Self::push_front(&mut self.detrender_buf, detrender, 7);
96        if self.detrender_buf.len() < 7 {
97            return None;
98        }
99
100        let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
101            - 0.5769 * self.detrender_buf[4]
102            - 0.0962 * self.detrender_buf[6])
103            * adj;
104        let i1 = self.detrender_buf[3];
105
106        Self::push_front(&mut self.q1_buf, q1, 7);
107        Self::push_front(&mut self.i1_buf, i1, 7);
108        if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
109            return None;
110        }
111
112        // Continue the dominant-cycle period adaptation so the next bar's `adj`
113        // coefficient tracks the cycle, exactly as TA-Lib's HT_PHASOR does.
114        let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
115            - 0.5769 * self.i1_buf[4]
116            - 0.0962 * self.i1_buf[6])
117            * adj;
118        let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
119            - 0.5769 * self.q1_buf[4]
120            - 0.0962 * self.q1_buf[6])
121            * adj;
122
123        let mut i2 = i1 - jq;
124        let mut q2 = q1 + ji;
125        i2 = 0.2 * i2 + 0.8 * self.prev_i2;
126        q2 = 0.2 * q2 + 0.8 * self.prev_q2;
127
128        let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
129        let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
130        re = 0.2 * re + 0.8 * self.prev_re;
131        im = 0.2 * im + 0.8 * self.prev_im;
132
133        self.prev_i2 = i2;
134        self.prev_q2 = q2;
135        self.prev_re = re;
136        self.prev_im = im;
137
138        let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
139            2.0 * PI / im.atan2(re)
140        } else {
141            self.prev_period
142        };
143        new_period = new_period.min(1.5 * self.prev_period);
144        new_period = new_period.max(0.67 * self.prev_period);
145        new_period = new_period.clamp(6.0, 50.0);
146        self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
147
148        self.ready = true;
149        Some(HtPhasorOutput {
150            inphase: i1,
151            quadrature: q1,
152        })
153    }
154
155    fn reset(&mut self) {
156        self.smooth_buf.clear();
157        self.detrender_buf.clear();
158        self.q1_buf.clear();
159        self.i1_buf.clear();
160        self.prev_i2 = 0.0;
161        self.prev_q2 = 0.0;
162        self.prev_re = 0.0;
163        self.prev_im = 0.0;
164        self.prev_period = 0.0;
165        self.ready = false;
166    }
167
168    fn warmup_period(&self) -> usize {
169        19
170    }
171
172    fn is_ready(&self) -> bool {
173        self.ready
174    }
175
176    fn name(&self) -> &'static str {
177        "HT_PHASOR"
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::traits::BatchExt;
185
186    fn sine_prices(n: usize) -> Vec<f64> {
187        (0..n)
188            .map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0)
189            .collect()
190    }
191
192    #[test]
193    fn accessors_and_metadata() {
194        let ht = HtPhasor::new();
195        assert_eq!(ht.warmup_period(), 19);
196        assert_eq!(ht.name(), "HT_PHASOR");
197        assert!(!ht.is_ready());
198    }
199
200    #[test]
201    fn emits_after_warmup_and_stays_finite() {
202        let mut ht = HtPhasor::new();
203        let out: Vec<Option<HtPhasorOutput>> = ht.batch(&sine_prices(120));
204        assert_eq!(out[0], None);
205        let first = out.iter().position(Option::is_some).expect("emits");
206        assert!(first <= 19, "first phasor at index {first}");
207        for o in out.into_iter().flatten() {
208            assert!(o.inphase.is_finite() && o.quadrature.is_finite());
209        }
210        assert!(ht.is_ready());
211    }
212
213    #[test]
214    fn ignores_non_finite_input() {
215        let mut ht = HtPhasor::new();
216        let _ = ht.batch(&sine_prices(120));
217        // A non-finite input is skipped and produces no value.
218        assert_eq!(ht.update(f64::NAN), None);
219    }
220
221    #[test]
222    fn batch_equals_streaming() {
223        let prices = sine_prices(150);
224        let mut a = HtPhasor::new();
225        let mut b = HtPhasor::new();
226        let batch = a.batch(&prices);
227        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
228        assert_eq!(batch, streamed);
229    }
230
231    #[test]
232    fn reset_clears_state() {
233        let mut ht = HtPhasor::new();
234        let _ = ht.batch(&sine_prices(120));
235        assert!(ht.is_ready());
236        ht.reset();
237        assert!(!ht.is_ready());
238        assert_eq!(ht.update(100.0), None);
239    }
240}