mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Merge PR #2393
This commit is contained in:
@@ -1856,3 +1856,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()));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user