From f71b3b6b499b826a2b9aef52dad7b89b61a028b8 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Wed, 3 Jun 2026 02:48:11 +0200 Subject: [PATCH] test: cover the cold paths in the TA-Lib parity batch (100% patch) (#150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #148 merged at **99.67%** patch coverage — `codecov/patch` flagged seven by-construction-rare lines in three of the new indicators that no test exercised. This brings the batch back to 100%. **`ht_dcphase` / `ht_trendmode`** (6 lines) — the dominant-cycle phase recovery guards against a near-zero imaginary part (where `atan(real/imag)` is undefined) by collapsing to ±90° on the sign of the real part. That branch is unreachable with realistic price data. Extracted the phase-unwrap arithmetic into a private `compute_dc_phase(real, imag, smooth_period)` helper — a pure refactor with byte-identical output — and unit-tested it directly with crafted `(real, imag)` pairs, covering both the ±90 collapse and the normal `atan` path. **`sar_ext`** (1 line) — `Accel::validate`'s non-finite guard was only ever hit for non-positive terms, never non-finite ones, despite the test comment claiming both. Added `NaN` / `infinity` cases on the long and short acceleration schedules. No behaviour or public-API change. Locally: `cargo test -p wickra-core` (2578 + 297 doctests) and `clippy --workspace -D warnings` all green. --- .../wickra-core/src/indicators/ht_dcphase.rs | 57 +++++++++++++------ .../src/indicators/ht_trendmode.rs | 55 +++++++++++++----- crates/wickra-core/src/indicators/sar_ext.rs | 6 +- 3 files changed, 86 insertions(+), 32 deletions(-) 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());