diff --git a/crates/wickra-core/src/indicators/ht_dcphase.rs b/crates/wickra-core/src/indicators/ht_dcphase.rs index c66e09c8..e1d6bfcd 100644 --- a/crates/wickra-core/src/indicators/ht_dcphase.rs +++ b/crates/wickra-core/src/indicators/ht_dcphase.rs @@ -167,22 +167,7 @@ impl Indicator for HtDcPhase { imag_part += angle.cos() * sp; } - let mut dc_phase = if imag_part.abs() > 0.001 { - (real_part / imag_part).atan().to_degrees() - } else if real_part < 0.0 { - -90.0 - } else { - 90.0 - }; - dc_phase += 90.0; - // Compensate the group delay of the 4-bar weighted smoother. - dc_phase += 360.0 / smooth_period; - if imag_part < 0.0 { - dc_phase += 180.0; - } - if dc_phase > 315.0 { - dc_phase -= 360.0; - } + let dc_phase = compute_dc_phase(real_part, imag_part, smooth_period); self.last_value = Some(dc_phase); Some(dc_phase) @@ -217,6 +202,32 @@ impl Indicator for HtDcPhase { } } +/// Recovers the dominant-cycle phase (degrees) from the real/imaginary parts of +/// the one-cycle homodyne integration, then unwraps it into TA-Lib's +/// `[-45, 315)` output range with the 4-bar smoother group-delay correction. +/// +/// When `imag_part` is within `±0.001` of zero the `atan` is undefined, so the +/// phase collapses to `±90°` by the sign of `real_part`. +fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 { + let mut dc_phase = if imag_part.abs() > 0.001 { + (real_part / imag_part).atan().to_degrees() + } else if real_part < 0.0 { + -90.0 + } else { + 90.0 + }; + dc_phase += 90.0; + // Compensate the group delay of the 4-bar weighted smoother. + dc_phase += 360.0 / smooth_period; + if imag_part < 0.0 { + dc_phase += 180.0; + } + if dc_phase > 315.0 { + dc_phase -= 360.0; + } + dc_phase +} + #[cfg(test)] mod tests { use super::*; @@ -236,6 +247,20 @@ mod tests { assert!(!ht.is_ready()); } + #[test] + fn near_zero_imaginary_collapses_to_signed_ninety() { + // A near-zero imaginary part makes atan(real/imag) undefined, so the phase + // collapses to +90 for non-negative real and -90 for negative real before + // the +90 offset and group-delay correction unwrap it. + let pos = compute_dc_phase(1.0, 0.0, 20.0); + let neg = compute_dc_phase(-1.0, 0.0, 20.0); + assert!((pos - 198.0).abs() < 1e-9); + assert!((neg - 18.0).abs() < 1e-9); + // The normal path still flows through atan. + let mid = compute_dc_phase(1.0, 1.0, 20.0); + assert!((mid - 153.0).abs() < 1e-9); + } + #[test] fn emits_after_warmup_within_phase_band() { let mut ht = HtDcPhase::new(); diff --git a/crates/wickra-core/src/indicators/ht_trendmode.rs b/crates/wickra-core/src/indicators/ht_trendmode.rs index 238b5263..568d7ae6 100644 --- a/crates/wickra-core/src/indicators/ht_trendmode.rs +++ b/crates/wickra-core/src/indicators/ht_trendmode.rs @@ -171,21 +171,7 @@ impl Indicator for HtTrendMode { real_part += angle.sin() * sp; imag_part += angle.cos() * sp; } - let mut dc_phase = if imag_part.abs() > 0.001 { - (real_part / imag_part).atan().to_degrees() - } else if real_part < 0.0 { - -90.0 - } else { - 90.0 - }; - dc_phase += 90.0; - dc_phase += 360.0 / smooth_period; - if imag_part < 0.0 { - dc_phase += 180.0; - } - if dc_phase > 315.0 { - dc_phase -= 360.0; - } + let dc_phase = compute_dc_phase(real_part, imag_part, smooth_period); let sine = (dc_phase * PI / 180.0).sin(); let lead_sine = ((dc_phase + 45.0) * PI / 180.0).sin(); @@ -278,6 +264,31 @@ impl Indicator for HtTrendMode { } } +/// Recovers the dominant-cycle phase (degrees) from the real/imaginary parts of +/// the one-cycle homodyne integration, then unwraps it into TA-Lib's +/// `[-45, 315)` output range with the 4-bar smoother group-delay correction. +/// +/// When `imag_part` is within `±0.001` of zero the `atan` is undefined, so the +/// phase collapses to `±90°` by the sign of `real_part`. +fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 { + let mut dc_phase = if imag_part.abs() > 0.001 { + (real_part / imag_part).atan().to_degrees() + } else if real_part < 0.0 { + -90.0 + } else { + 90.0 + }; + dc_phase += 90.0; + dc_phase += 360.0 / smooth_period; + if imag_part < 0.0 { + dc_phase += 180.0; + } + if dc_phase > 315.0 { + dc_phase -= 360.0; + } + dc_phase +} + #[cfg(test)] mod tests { use super::*; @@ -304,6 +315,20 @@ mod tests { assert!(ht.value().is_none()); } + #[test] + fn near_zero_imaginary_collapses_to_signed_ninety() { + // A near-zero imaginary part makes atan(real/imag) undefined, so the phase + // collapses to +90 for non-negative real and -90 for negative real before + // the +90 offset and group-delay correction unwrap it. + let pos = compute_dc_phase(1.0, 0.0, 20.0); + let neg = compute_dc_phase(-1.0, 0.0, 20.0); + assert!((pos - 198.0).abs() < 1e-9); + assert!((neg - 18.0).abs() < 1e-9); + // The normal path still flows through atan. + let mid = compute_dc_phase(1.0, 1.0, 20.0); + assert!((mid - 153.0).abs() < 1e-9); + } + #[test] fn emits_binary_flag_and_visits_both_modes() { let mut ht = HtTrendMode::new(); diff --git a/crates/wickra-core/src/indicators/sar_ext.rs b/crates/wickra-core/src/indicators/sar_ext.rs index bc4b8c54..bdf6338b 100644 --- a/crates/wickra-core/src/indicators/sar_ext.rs +++ b/crates/wickra-core/src/indicators/sar_ext.rs @@ -276,10 +276,14 @@ mod tests { #[test] fn rejects_invalid_params() { - // Non-positive / non-finite acceleration terms. + // Non-positive acceleration terms. assert!(SarExt::new(0.0, 0.0, 0.0, 0.02, 0.2, 0.02, 0.02, 0.2).is_err()); assert!(SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.0, 0.02, 0.2).is_err()); assert!(SarExt::new(0.0, 0.0, 0.30, 0.02, 0.2, 0.02, 0.02, 0.2).is_err()); + // Non-finite acceleration terms hit the finite guard in `Accel::validate`, + // on both the long and the short schedule. + assert!(SarExt::new(0.0, 0.0, f64::NAN, 0.02, 0.2, 0.02, 0.02, 0.2).is_err()); + assert!(SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.02, f64::INFINITY, 0.2).is_err()); // Bad start value / offset. assert!(SarExt::new(f64::NAN, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).is_err()); assert!(SarExt::new(0.0, -1.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).is_err());