//! Cycle indicators — Hilbert Transform-based cycle analysis (Ehlers). //! //! Based on John Ehlers' Discrete Hilbert Transform as implemented in TA-Lib. //! Reference: "Cybernetic Analysis for Stocks and Futures" by J.F. Ehlers //! //! All HT functions share a 63-bar lookback period. use std::f64::consts::PI; /// Number of leading bars that are set to NaN / zero. pub const HT_LOOKBACK: usize = 63; /// Shared output from the core Hilbert Transform computation. pub struct HtCore { pub trendline: Vec, pub dc_period: Vec, pub dc_phase: Vec, pub inphase: Vec, pub quadrature: Vec, pub trend_mode: Vec, } /// Run the full Hilbert Transform pipeline on a slice of close prices. pub fn compute_ht_core(prices: &[f64]) -> HtCore { let n = prices.len(); let mut trendline = vec![f64::NAN; n]; let mut dc_period = vec![f64::NAN; n]; let mut dc_phase = vec![f64::NAN; n]; let mut inphase = vec![f64::NAN; n]; let mut quadrature = vec![f64::NAN; n]; let mut trend_mode = vec![0i32; n]; if n <= HT_LOOKBACK { return HtCore { trendline, dc_period, dc_phase, inphase, quadrature, trend_mode, }; } // Step 1: Smooth the price series (4-bar weighted average) let mut smooth = vec![0.0f64; n]; for i in 0..n { smooth[i] = if i >= 3 { (4.0 * prices[i] + 3.0 * prices[i - 1] + 2.0 * prices[i - 2] + prices[i - 3]) / 10.0 } else { prices[i] }; } // Step 2: Full Hilbert Transform pipeline let mut detrender = vec![0.0f64; n]; let mut q1 = vec![0.0f64; n]; let mut i1 = vec![0.0f64; n]; let mut ji = vec![0.0f64; n]; let mut jq = vec![0.0f64; n]; let mut i2 = vec![0.0f64; n]; let mut q2 = vec![0.0f64; n]; let mut re = vec![0.0f64; n]; let mut im = vec![0.0f64; n]; let mut period = vec![0.0f64; n]; let mut smooth_period = vec![0.0f64; n]; let mut phase = vec![0.0f64; n]; for i in 6..n { let prev_period = period[i - 1]; // Alpha coefficient for HT filters depends on the current period estimate let alpha = 0.075 * prev_period + 0.54; // Discrete Hilbert Transform of smooth price (detrender) detrender[i] = (0.0962 * smooth[i] + 0.5769 * smooth[i - 2] - 0.5769 * smooth[i - 4] - 0.0962 * smooth[i - 6]) * alpha; // Q1: HT of detrender if i >= 12 { q1[i] = (0.0962 * detrender[i] + 0.5769 * detrender[i - 2] - 0.5769 * detrender[i - 4] - 0.0962 * detrender[i - 6]) * alpha; } // I1: delayed detrender if i >= 9 { i1[i] = detrender[i - 3]; } // jI: HT of I1 if i >= 15 { ji[i] = (0.0962 * i1[i] + 0.5769 * i1[i - 2] - 0.5769 * i1[i - 4] - 0.0962 * i1[i - 6]) * alpha; } // jQ: HT of Q1 if i >= 18 { jq[i] = (0.0962 * q1[i] + 0.5769 * q1[i - 2] - 0.5769 * q1[i - 4] - 0.0962 * q1[i - 6]) * alpha; } // Phase components let i2_raw = i1[i] - jq[i]; let q2_raw = q1[i] + ji[i]; // EMA smoothing of I2 and Q2 let i2_prev = i2[i - 1]; let q2_prev = q2[i - 1]; i2[i] = 0.2 * i2_raw + 0.8 * i2_prev; q2[i] = 0.2 * q2_raw + 0.8 * q2_prev; // Cross-product for period estimation let re_raw = i2[i] * i2_prev + q2[i] * q2_prev; let im_raw = i2[i] * q2_prev - q2[i] * i2_prev; // EMA smoothing of Re and Im re[i] = 0.2 * re_raw + 0.8 * re[i - 1]; im[i] = 0.2 * im_raw + 0.8 * im[i - 1]; // Compute period from cross-product of consecutive phasors. let mut p = if re[i] != 0.0 && im[i] != 0.0 && re[i] > 0.0 { 2.0 * PI / (im[i] / re[i]).atan() } else { prev_period }; // Clamp period relative to previous if prev_period > 0.0 { if p > 1.5 * prev_period { p = 1.5 * prev_period; } if p < 0.67 * prev_period { p = 0.67 * prev_period; } } // Hard clamp to [6, 50] bars p = p.clamp(6.0, 50.0); // EMA smooth the period period[i] = 0.2 * p + 0.8 * prev_period; // Smooth the smoothed period once more smooth_period[i] = 0.33 * period[i] + 0.67 * smooth_period[i - 1]; // Phase from I1 and Q1 phase[i] = if i1[i] != 0.0 { q1[i].atan2(i1[i]) * 180.0 / PI } else if q1[i] > 0.0 { 90.0 } else if q1[i] < 0.0 { -90.0 } else { 0.0 }; // Write outputs once past lookback if i >= HT_LOOKBACK { dc_period[i] = smooth_period[i]; dc_phase[i] = phase[i]; inphase[i] = i1[i]; quadrature[i] = q1[i]; // Trend mode: cycle when SmoothPeriod >= 20, trend when < 20 trend_mode[i] = if smooth_period[i] < 20.0 { 1 } else { 0 }; } } // Trendline: average over the current dominant cycle period for i in HT_LOOKBACK..n { let sp = smooth_period[i]; let dc = (sp.round() as usize).max(1).min(i + 1); let sum: f64 = (0..dc).map(|j| smooth[i - j]).sum(); trendline[i] = sum / dc as f64; } HtCore { trendline, dc_period, dc_phase, inphase, quadrature, trend_mode, } } // --------------------------------------------------------------------------- // Public indicator functions // --------------------------------------------------------------------------- /// Hilbert Transform Instantaneous Trendline (Ehlers). /// Smooths price over the dominant cycle period. pub fn ht_trendline(close: &[f64]) -> Vec { compute_ht_core(close).trendline } /// Hilbert Transform Dominant Cycle Period in bars. pub fn ht_dcperiod(close: &[f64]) -> Vec { compute_ht_core(close).dc_period } /// Hilbert Transform Dominant Cycle Phase in degrees. pub fn ht_dcphase(close: &[f64]) -> Vec { compute_ht_core(close).dc_phase } /// Hilbert Transform Phasor components. Returns `(inphase, quadrature)`. pub fn ht_phasor(close: &[f64]) -> (Vec, Vec) { let core = compute_ht_core(close); (core.inphase, core.quadrature) } /// Hilbert Transform SineWave. Returns `(sine, leadsine)` where leadsine /// leads sine by 45 degrees. pub fn ht_sine(close: &[f64]) -> (Vec, Vec) { let n = close.len(); let core = compute_ht_core(close); let mut sine = vec![f64::NAN; n]; let mut lead_sine = vec![f64::NAN; n]; for i in HT_LOOKBACK..n { if !core.dc_phase[i].is_nan() { let phase_rad = core.dc_phase[i] * PI / 180.0; sine[i] = phase_rad.sin(); lead_sine[i] = (phase_rad + PI / 4.0).sin(); // 45-degree lead } } (sine, lead_sine) } /// Hilbert Transform Trend vs Cycle Mode: 1 = trending, 0 = cycling. pub fn ht_trendmode(close: &[f64]) -> Vec { compute_ht_core(close).trend_mode } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; /// Generate a simple sine wave for testing cycle detection. fn sine_wave(n: usize, period: f64) -> Vec { (0..n) .map(|i| 100.0 + 10.0 * (2.0 * PI * i as f64 / period).sin()) .collect() } /// Flat price series for baseline testing. fn flat_prices(n: usize) -> Vec { vec![100.0; n] } #[test] fn test_ht_trendline_length_and_lookback() { let close = sine_wave(200, 20.0); let result = ht_trendline(&close); assert_eq!(result.len(), close.len()); // First HT_LOOKBACK values must be NaN for v in &result[..HT_LOOKBACK] { assert!(v.is_nan(), "expected NaN in lookback region"); } // Values after lookback must be finite for v in &result[HT_LOOKBACK..] { assert!(v.is_finite(), "expected finite value after lookback"); } } #[test] fn test_ht_dcperiod_length_and_lookback() { let close = sine_wave(200, 20.0); let result = ht_dcperiod(&close); assert_eq!(result.len(), close.len()); for v in &result[..HT_LOOKBACK] { assert!(v.is_nan()); } // After lookback, period should be positive and finite for v in &result[HT_LOOKBACK..] { assert!(v.is_finite()); assert!(*v >= 6.0 && *v <= 50.0, "period {} out of [6,50]", v); } } #[test] fn test_ht_dcphase_length_and_lookback() { let close = sine_wave(200, 20.0); let result = ht_dcphase(&close); assert_eq!(result.len(), close.len()); for v in &result[..HT_LOOKBACK] { assert!(v.is_nan()); } for v in &result[HT_LOOKBACK..] { assert!(v.is_finite()); } } #[test] fn test_ht_phasor_dual_output() { let close = sine_wave(200, 20.0); let (inp, quad) = ht_phasor(&close); assert_eq!(inp.len(), close.len()); assert_eq!(quad.len(), close.len()); for v in &inp[..HT_LOOKBACK] { assert!(v.is_nan()); } for v in &quad[..HT_LOOKBACK] { assert!(v.is_nan()); } } #[test] fn test_ht_sine_dual_output() { let close = sine_wave(200, 20.0); let (s, ls) = ht_sine(&close); assert_eq!(s.len(), close.len()); assert_eq!(ls.len(), close.len()); for v in &s[..HT_LOOKBACK] { assert!(v.is_nan()); } // Sine values should be in [-1, 1] for v in &s[HT_LOOKBACK..] { assert!(v.is_finite()); assert!(*v >= -1.0 && *v <= 1.0, "sine {} out of [-1,1]", v); } for v in &ls[HT_LOOKBACK..] { assert!(v.is_finite()); assert!(*v >= -1.0 && *v <= 1.0, "leadsine {} out of [-1,1]", v); } } #[test] fn test_ht_trendmode_values() { let close = sine_wave(200, 20.0); let result = ht_trendmode(&close); assert_eq!(result.len(), close.len()); // All values must be 0 or 1 for v in &result { assert!(*v == 0 || *v == 1, "trend_mode {} not 0 or 1", v); } } #[test] fn test_short_input_all_nan() { let close = vec![100.0; HT_LOOKBACK]; // exactly HT_LOOKBACK, not enough let tl = ht_trendline(&close); assert!(tl.iter().all(|v| v.is_nan())); let dp = ht_dcperiod(&close); assert!(dp.iter().all(|v| v.is_nan())); } #[test] fn test_flat_prices_trendline_equals_price() { let close = flat_prices(200); let tl = ht_trendline(&close); // For a flat price, trendline after lookback should be very close to the price for v in &tl[HT_LOOKBACK..] { assert!( (v - 100.0).abs() < 1e-6, "trendline {} diverged from flat price", v ); } } }