--- a/src/core/periodic.rs +++ b/src/core/periodic.rs @@ -373,7 +373,7 @@ } rn.zip_mut_with(&diff, |x, &d| { - if d > 1e-6 { + if !d.is_finite() || d.abs() >= 1e-6 { *x = f64::NAN } }); @@ -405,7 +405,33 @@ + nper * pmt * &t2 * (rate * when + 1.) / rate + pmt * (&t1 - 1.0) * when / rate; - g / gp + let mut result = g / gp; + + // Near zero, the differences of powers in g and gp lose precision. + // Expand the annuity factor through r^4 and differentiate that series. + // At zero these give the exact limits n and n * (n - 1) / 2. + for (ref idx, result) in result.indexed_iter_mut() { + let r = rate[idx]; + let n = nper[idx]; + if r.abs() * n.abs().max(1.0) <= 1e-4 { + let t1 = (n - 1.0) * r / 2.0; + let t2 = t1 * ((n - 2.0) * r / 3.0); + let t3 = t2 * ((n - 3.0) * r / 4.0); + let t4 = t3 * ((n - 4.0) * r / 5.0); + let annuity = n * (1.0 + t1 + t2 + t3 + t4); + let d1 = (n - 2.0) * r / 3.0; + let d2 = d1 * ((n - 3.0) * r / 4.0); + let d3 = d2 * ((n - 4.0) * r / 5.0); + let derivative = n * (n - 1.0) / 2.0 * (1.0 + 2.0 * d1 + 3.0 * d2 + 4.0 * d3); + let timing = 1.0 + r * when[idx]; + let g = fv[idx] + pv[idx] * (1.0 + r * annuity) + pmt[idx] * timing * annuity; + let gp = pv[idx] * (annuity + r * derivative) + + pmt[idx] * (when[idx] * annuity + timing * derivative); + *result = g / gp; + } + } + + result } // http://westclintech.com/SQL-Server-Financial-Functions/SQL-Server-NFV-function --- a/tests/test_periodic.rs +++ b/tests/test_periodic.rs @@ -573,7 +573,72 @@ }) } +#[rstest] +fn test_rate_vec_rejects_unconverged_negative_step() { + Python::with_gil(|py| { + // The first element converges, but the second is still taking steps + // larger than the tolerance when the iteration limit is reached. + let actual: Vec> = pyxirr_call!( + py, + "rate", + ([12, 360], [-100.0, -1000.0], [1000.0, 200_000.0]), + py_dict!(py, "guess" => 0.5) + ); + assert_almost_eq!(actual[0].unwrap(), 0.029228540769158533, 1e-9); + assert!(actual[1].is_none()); + }); +} + +#[rstest] +fn test_rate_vec_zero_guess_nonzero_root() { + Python::with_gil(|py| { + // 1000 * (1 + rate) - 1100 = 0 has the unique solution 0.1. + let actual: Vec> = + pyxirr_call!(py, "rate", ([1], -1100.0, 1000.0), py_dict!(py, "guess" => 0.0)); + assert_almost_eq!(actual[0].unwrap(), 0.1, 1e-9); + }); +} + +#[rstest] +fn test_rate_vec_zero_guess_zero_root() { + Python::with_gil(|py| { + let actual: Vec> = + pyxirr_call!(py, "rate", ([12], -100.0, 1200.0), py_dict!(py, "guess" => 0.0)); + assert_eq!(actual, vec![Some(0.0)]); + }); +} + +#[rstest] +fn test_rate_vec_zero_guess_annuity_due() { + Python::with_gil(|py| { + // 1100 at time zero plus 1100 at time one has present value 2100 + // exactly when the period rate is 0.1. + let actual: Vec> = pyxirr_call!( + py, + "rate", + ([2], -1100.0, 2100.0), + py_dict!(py, "guess" => 0.0, "pmt_at_beginning" => true) + ); + assert_almost_eq!(actual[0].unwrap(), 0.1, 1e-9); + }); +} + // ------------ NFV ---------------- + +#[rstest] +fn test_rate_vec_zero_guess_transient_near_zero() { + Python::with_gil(|py| { + // An ordinary starting guess passes close to zero before reaching + // the negative root. Cancellation must not produce a false root. + let actual: Vec> = pyxirr_call!( + py, + "rate", + ([2], -497.48743718592965, 1000.0), + py_dict!(py, "pmt_at_beginning" => true) + ); + assert_almost_eq!(actual[0].unwrap(), -0.01, 1e-9); + }); +} #[rstest] fn test_nfv() {