test: cover the cold paths in the TA-Lib parity batch (100% patch) (#150)
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.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user