From 1ce4781d3e059e14696cd4a351f61e4242062767 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:24:18 -0500 Subject: [PATCH] Accuracy: PMT to Excel's last bits via ln_1p/exp_m1 PMT computed (1 + rate)^nper with powf, which rounds 1 + rate first and drops the low bits of a small rate. Over 400 synthetic probes that left PMT up to about 1000 ULPs from Excel (59 bit-exact); the ln_1p/exp_m1 form is within 4 ULPs (244 bit-exact). A loan schedule guarded by IF(p - SUM(principal) > 0, ...) turns on those bits: in 3000 synthetic schedules main disagreed with Excel on the number of live periods in 1382, the fix in 823. Also follows Excel at the edges: a rate of -1 or less is #NUM!, a huge nper gives -pv*rate instead of #NUM!, a tiny rate no longer collapses, and a small negative rate keeps its payment. IPMT, PPMT, CUMIPMT and CUMPRINC pick this up through pmt_calc. FV and PV are unchanged: Excel's own FV/PV sit further from the exact value than the current powf form does. The tests allow the one-ULP libm difference between platforms. --- crates/calc/src/tests.rs | 37 ++++++++++++++++++++ crates/functions/src/financial.rs | 58 +++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 622ed6cc9..e71015213 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1781,3 +1781,40 @@ fn text_before_after_empty_instance_defaults_to_one() { t.set("A4", "=TEXTBEFORE(\"a-b\",\"-\",0)"); assert_eq!(t.get("A4"), Value::Error(CellError::Value)); } + +#[test] +fn loan_schedule_residue_keeps_excels_sign() { + // A synthetic monthly loan schedule: interest on the opening balance, principal = PMT - interest, + // each later period guarded by IF(p - SUM(principal so far) > 0, ...). After the 12th payment + // Excel is left with 2^-30 > 0, so it takes a 13th, tiny-interest period. That last bit comes + // from PMT; the old (1 + rate)^nper form left -2.8e-8 and returned "" instead. + let mut t = T::new(); + t.set("B1", "4653300"); + t.set("B2", "1"); + t.set("B3", "0.141"); + let col = |i: u8| char::from(b'A' + i).to_string(); + t.set("C8", "=$B$1*($B$3/12)"); + t.set("C7", "=PMT($B$3/12,$B$2*12,-$B$1)-C8"); + t.set("D8", "=($B1-C7)*($B$3/12)"); + t.set("D7", "=PMT($B$3/12,$B$2*12,-$B$1)-D8"); + for i in 4..16 { + let (c, p) = (col(i), col(i - 1)); + t.set(&format!("{c}8"), &format!("=IF($B1-SUM($C7:{p}7)>0,($B1-SUM($C7:{p}7))*($B$3/12),\"\")")); + t.set(&format!("{c}7"), &format!("=IF($B1-SUM($C7:{p}7)>0,PMT($B$3/12,$B$2*12,-$B$1)-{c}8,\"\")")); + } + t.set("N4", "=IF($B$1-SUM($C7:N7)>0,$B$1-SUM($C7:N7),\"\")"); + // Excel's values (Value2) for the same synthetic inputs. + // The leftover (2^-30 in Excel) is 12 payments' worth of rounding, so a one-ULP libm difference + // moves it by as much as its own size: only its sign is checked, and the 13th period must exist. + let positive = |cell: &str| match t.get(cell) { + Value::Number(x) => assert!(x > 0.0 && x < 1e-6, "{cell}: {x}"), + other => panic!("{cell}: expected a small positive number, got {other:?}"), + }; + positive("N4"); + positive("O8"); + match t.get("O7") { + Value::Number(x) => assert!((x - 418025.37230211904).abs() < 1e-3, "O7: {x}"), + other => panic!("O7: expected a number, got {other:?}"), + } + assert_eq!(t.get("P7"), Value::Text("".into())); +} diff --git a/crates/functions/src/financial.rs b/crates/functions/src/financial.rs index af28326e7..043c09ed6 100644 --- a/crates/functions/src/financial.rs +++ b/crates/functions/src/financial.rs @@ -13,12 +13,31 @@ use crate::{Arg, Ctx, FnSpec, VAR}; // Time value of money // --------------------------------------------------------------------------------------------- +/// The payment per period. `(1 + rate)^nper` comes from `nper * ln_1p(rate)` and `p - 1` from +/// `exp_m1`: rounding `1 + rate` first throws away the low bits of a small rate, which left PMT up to +/// about 1000 ULPs from Excel (observed over 400 synthetic probes; this form is within 4). A loan +/// schedule's last `IF(p - SUM(principal) > 0, ...)` turns on those bits. Excel gives #NUM! for a +/// rate of -1 or less, and -pv*rate (no divide) once `(1 + rate)^nper` overflows. fn pmt_calc(rate: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 { if rate == 0.0 { return -(pv + fv) / nper; } - let p = (1.0 + rate).powf(nper); - -(rate * (fv + pv * p)) / ((1.0 + rate * typ) * (p - 1.0)) + if rate <= -1.0 { + return f64::NAN; + } + let l = nper * rate.ln_1p(); + let m = l.exp_m1(); + let k = 1.0 + rate * typ; + if m > 0.0 { + // (fv + pv*p) / (p - 1) = pv + (pv + fv) / (p - 1), without the cancellation in p - 1. + -(pv + (pv + fv) / m) * rate / k + } else if m > -0.5 { + // p near 1 from below: fv + pv*p = (pv + fv) + pv*m keeps the small terms that rounding p would drop. + -(rate * (pv + fv + pv * m)) / (k * m) + } else { + // p well below 1: m nears -1 and pv*m would swallow pv + fv, so use p itself. + -(rate * (fv + pv * l.exp())) / (k * m) + } } fn fv_calc(rate: f64, nper: f64, pmt: f64, pv: f64, typ: f64) -> f64 { @@ -1790,6 +1809,41 @@ mod tests { is_err(ev("CUMIPMT", vec![n(0.01), n(360.0), n(125000.0), n(1.0), n(1.0), n(2.0)]), CellError::Num); } + /// PMT within about 4 ULPs of Excel's (Value2 of synthetic probes; libm differs by an ULP across + /// platforms). The old `(1 + rate)^nper` form was 25 to 91 ULPs off on these, enough to flip a loan schedule's final `IF(p - SUM(r) > 0, ...)`. + #[test] + fn pmt_matches_excel_within_a_few_ulps() { + for (rate, nper, pv, fv, typ, excel) in [ + (0.141 / 12.0, 12.0, -4653300.0, 0.0, 0.0, 418025.37230211904), + (0.005716666666666666, 26.0, 42678411.0, -26010.62, 0.0, -1770235.3828507778), + (0.012164, 29.0, 16722649.0, 0.0, 1.0, -679516.0784762775), + (0.009916666666666666, 5.0, 8590842.8, 0.0, 0.0, -1769620.32624324), + (0.011158333333333333, 36.0, 21457108.0, 49218.26, 1.0, -720095.3912155787), + (-0.5, 2.5, 100.0, 0.0, 0.0, -10.736861692729645), + ] { + let got = ev("PMT", vec![n(rate), n(nper), n(pv), n(fv), n(typ)]); + close_tol(got, excel, 1e-15); + } + } + + /// Edge cases observed in Excel: a rate of -1 or less is #NUM!, a huge nper leaves -pv*rate, and a + /// tiny rate no longer loses its low digits in 1 + rate. + #[test] + fn pmt_edge_cases_follow_excel() { + is_err(ev("PMT", vec![n(-2.0), n(3.0), n(100.0)]), CellError::Num); + is_err(ev("PMT", vec![n(-1.0), n(3.0), n(100.0)]), CellError::Num); + is_err(ev("PMT", vec![n(-2.0), n(2.5), n(100.0)]), CellError::Num); + assert_eq!(ev("PMT", vec![n(0.1), n(10000.0), n(100.0)]), Value::Number(-10.0)); + assert_eq!(ev("PMT", vec![n(5.0), n(1000.0), n(100.0)]), Value::Number(-500.0)); + assert_eq!(ev("PMT", vec![n(1e-300), n(10.0), n(100.0)]), Value::Number(-10.0)); + close_tol(ev("PMT", vec![n(1e-12), n(10.0), n(100.0)]), -10.000000000055001, 1e-15); + close_tol(ev("PMT", vec![n(-0.999), n(5.0), n(100.0)]), -9.990_000_000_000_092e-14, 1e-15); + close_tol(ev("PMT", vec![n(0.05), n(-10.0), n(100.0)]), 7.950_457_496_545_667, 1e-15); + close_tol(ev("PMT", vec![n(-1e-17), n(1.0), n(1e18), n(-1e18)]), 10.0, 1e-9); + close_tol(ev("PMT", vec![n(-0.5), n(60.0), n(1e18)]), -0.433_680_868_994_201_8, 1e-6); + close_tol(ev("PMT", vec![n(0.05), n(10.0), n(100.0), n(50.0), n(1.0)]), -16.119701185541427, 1e-15); + } + #[test] fn cash_flows() { close_tol(ev("NPV", vec![n(0.1), n(-10000.0), n(3000.0), n(4200.0), n(6800.0)]), 1188.443412, 1e-9);