From 341ae6848e63f35d2e7ff53725b5c8bc110062ba Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:43 +0000 Subject: [PATCH] refactor(functions): move financial tests to src/financial/tests.rs --- crates/functions/src/financial.rs | 325 +----------------------- crates/functions/src/financial/tests.rs | 322 +++++++++++++++++++++++ 2 files changed, 323 insertions(+), 324 deletions(-) create mode 100644 crates/functions/src/financial/tests.rs diff --git a/crates/functions/src/financial.rs b/crates/functions/src/financial.rs index 55c570c06..22c926185 100644 --- a/crates/functions/src/financial.rs +++ b/crates/functions/src/financial.rs @@ -1774,327 +1774,4 @@ pub(crate) fn specs() -> Vec { } #[cfg(test)] -mod tests { - use crate::Arg; - use crate::util::testutil::*; - use gridcraft_core::{CellError, DateSystem, Value, date::serial_from_ymd}; - - fn d(y: i64, m: i64, dd: i64) -> Arg { - n(serial_from_ymd(DateSystem::D1900, y, m, dd).unwrap()) - } - - #[test] - fn tvm() { - close_tol(ev("PMT", vec![n(0.08 / 12.0), n(10.0), n(10000.0)]), -1037.032089, 1e-9); - close_tol(ev("PMT", vec![n(0.06 / 12.0), n(216.0), n(0.0), n(50000.0)]), -129.0811609, 1e-9); - close(ev("PMT", vec![n(0.0), n(10.0), n(1000.0)]), -100.0); - is_err(ev("PMT", vec![n(0.1), n(0.0), n(1000.0)]), CellError::Num); - close_tol(ev("FV", vec![n(0.06 / 12.0), n(10.0), n(-200.0), n(-500.0), n(1.0)]), 2581.403374, 1e-9); - close_tol(ev("FV", vec![n(0.01), n(12.0), n(-1000.0)]), 12682.50301, 1e-9); - close(ev("FV", vec![n(0.0), n(10.0), n(-100.0)]), 1000.0); - close_tol(ev("PV", vec![n(0.08 / 12.0), n(240.0), n(500.0)]), -59777.14585, 1e-9); - close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0), n(1.0)]), 59.6738657, 1e-8); - close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0)]), 60.0821229, 1e-8); - close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0)]), -9.57859404, 1e-8); - close_tol(ev("IPMT", vec![n(0.1 / 12.0), n(1.0), n(36.0), n(8000.0)]), -66.66666667, 1e-9); - close_tol(ev("IPMT", vec![n(0.1), n(3.0), n(3.0), n(8000.0)]), -292.4471299, 1e-9); - is_err(ev("IPMT", vec![n(0.1), n(4.0), n(3.0), n(8000.0)]), CellError::Num); - close_tol(ev("PPMT", vec![n(0.1 / 12.0), n(1.0), n(24.0), n(2000.0)]), -75.62318601, 1e-9); - close_tol(ev("PPMT", vec![n(0.08), n(10.0), n(10.0), n(200000.0)]), -27598.05346, 1e-9); - close_tol(ev("RATE", vec![n(48.0), n(-200.0), n(8000.0)]), 0.0077014725, 1e-7); - close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -11135.23213, 1e-9); - close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -937.5, 1e-9); - close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -934.1071234, 1e-9); - close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -68.27827118, 1e-9); - is_err(ev("CUMIPMT", vec![n(0.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), CellError::Num); - 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(1e-300), n(10.0), n(1e12)]), -1e11, 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); - close_tol(ev("NPV", vec![n(0.08), rf(col(&[8000.0, 9200.0, 10000.0, 12000.0, 14500.0]))]), 41922.06155, 1e-9); - is_err(ev("NPV", vec![n(-1.0), n(1.0)]), CellError::Div0); - let flows = col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0]); - close_tol(ev("IRR", vec![rf(flows)]), -0.021244848, 1e-7); - close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0, 26000.0]))]), 0.086630948, 1e-7); - close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0])), n(-0.1)]), -0.44350694, 1e-7); - is_err(ev("IRR", vec![rf(col(&[1.0, 2.0]))]), CellError::Num); - let vals = col(&[-10000.0, 2750.0, 4250.0, 3250.0, 2750.0]); - let dates = col(&[39448.0, 39508.0, 39751.0, 39859.0, 39904.0]); - close_tol(ev("XNPV", vec![n(0.09), rf(vals.clone()), rf(dates.clone())]), 2086.647602, 1e-9); - close_tol(ev("XIRR", vec![rf(vals.clone()), rf(dates)]), 0.373362535, 1e-7); - is_err(ev("XNPV", vec![n(0.09), rf(vals), rf(col(&[1.0, 2.0]))]), CellError::Num); - let m = col(&[-120000.0, 39000.0, 30000.0, 21000.0, 37000.0, 46000.0]); - close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6); - close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7); - is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0); - // Excel 365 gives -1 when every flow is negative: the positive flows are worth nothing. - close_tol(ev("MIRR", vec![rf(col(&[-10000.0, -3400.0, -6500.0, -1000.0])), n(0.07), n(0.01)]), -1.0, 1e-12); - close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089); - close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584); - is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num); - close_tol(ev("NOMINAL", vec![n(0.053543), n(4.0)]), 0.052500319, 1e-8); - close_tol(ev("ISPMT", vec![n(0.1 / 12.0), n(0.0), n(36.0), n(8000000.0)]), -66666.66667, 1e-9); - is_err(ev("ISPMT", vec![n(0.1), n(1.0), n(0.0), n(1.0)]), CellError::Div0); - close_tol(ev("PDURATION", vec![n(0.025), n(2000.0), n(2200.0)]), 3.859866163, 1e-9); - is_err(ev("PDURATION", vec![n(0.0), n(2000.0), n(2200.0)]), CellError::Num); - close_tol(ev("RRI", vec![n(96.0), n(10000.0), n(11000.0)]), 0.0009933, 1e-4); - close(ev("DOLLARDE", vec![n(1.02), n(16.0)]), 1.125); - close(ev("DOLLARDE", vec![n(1.1), n(32.0)]), 1.3125); - close(ev("DOLLARFR", vec![n(1.125), n(16.0)]), 1.02); - close(ev("DOLLARFR", vec![n(1.125), n(32.0)]), 1.04); - is_err(ev("DOLLARDE", vec![n(1.02), n(0.0)]), CellError::Div0); - is_err(ev("DOLLARDE", vec![n(1.02), n(-1.0)]), CellError::Num); - } - - #[test] - fn depreciation() { - close(ev("SLN", vec![n(30000.0), n(7500.0), n(10.0)]), 2250.0); - is_err(ev("SLN", vec![n(30000.0), n(7500.0), n(0.0)]), CellError::Div0); - close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(1.0)]), 4090.909091, 1e-9); - close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(10.0)]), 409.0909091, 1e-9); - let dbv = |p: f64| ev("DB", vec![n(1000000.0), n(100000.0), n(6.0), n(p), n(7.0)]); - close_tol(dbv(1.0), 186083.3333, 1e-9); - close_tol(dbv(2.0), 259639.4167, 1e-9); - close_tol(dbv(3.0), 176814.4428, 1e-9); - close_tol(dbv(7.0), 15845.09847, 1e-9); - is_err(dbv(8.0), CellError::Num); - close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(3650.0), n(1.0)]), 1.315068493, 1e-8); - close(ev("DDB", vec![n(2400.0), n(300.0), n(120.0), n(1.0), n(2.0)]), 40.0); - close(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(1.0), n(2.0)]), 480.0); - close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(2.0), n(1.5)]), 306.0, 1e-12); - close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(10.0)]), 22.1225472, 1e-8); - is_err(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(11.0)]), CellError::Num); - close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(3650.0), n(0.0), n(1.0)]), 1.315068493, 1e-8); - close(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(0.0), n(1.0)]), 40.0); - close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(1.0)]), 480.0); - close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0)]), 396.3060533, 1e-8); - close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0), n(1.5)]), 311.8089367, 1e-8); - close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(0.875), n(1.5)]), 315.0); - // Over the whole life the total depreciation is cost - salvage. - close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(10.0)]), 2100.0); - is_err(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(5.0), n(4.0)]), CellError::Num); - close_tol(ev("AMORLINC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 360.0, 1e-9); - close(ev("AMORDEGRC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 776.0); - } - - #[test] - fn coupons() { - let args = |f: &str| ev(f, vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(1.0)]); - close(args("COUPDAYBS"), 71.0); - close(args("COUPDAYS"), 181.0); - close(args("COUPDAYSNC"), 110.0); - close(args("COUPNCD"), 40678.0); - close(args("COUPPCD"), 40497.0); - close(args("COUPNUM"), 2.0); - close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(0.0)]), 180.0); - close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(4.0), n(3.0)]), 91.25); - close(ev("COUPNUM", vec![d(2007, 1, 25), d(2008, 11, 15), n(2.0), n(1.0)]), 4.0); - is_err(ev("COUPNUM", vec![d(2012, 1, 25), d(2011, 11, 15), n(2.0)]), CellError::Num); - is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(3.0)]), CellError::Num); - is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(5.0)]), CellError::Num); - // End-of-month maturity keeps month-end coupon dates. - close(ev("COUPPCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2010, 11, 30).unwrap()); - close(ev("COUPNCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2011, 5, 31).unwrap()); - } - - #[test] - fn securities() { - close_tol(ev("DISC", vec![d(2007, 1, 25), d(2007, 6, 15), n(97.975), n(100.0), n(1.0)]), 0.052420213, 1e-8); - close_tol(ev("PRICEDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(0.0525), n(100.0), n(2.0)]), 99.79583333, 1e-9); - close_tol(ev("YIELDDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(99.795), n(100.0), n(2.0)]), 0.052823, 1e-5); - close_tol(ev("INTRATE", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(1014420.0), n(2.0)]), 0.05768, 1e-9); - close_tol(ev("RECEIVED", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(0.0575), n(2.0)]), 1014584.654, 1e-9); - is_err(ev("DISC", vec![d(2008, 2, 15), d(2008, 1, 15), n(97.0), n(100.0)]), CellError::Num); - close_tol(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.09)]), 98.45, 1e-12); - close_tol(ev("TBILLYIELD", vec![d(2008, 3, 31), d(2008, 6, 1), n(98.45)]), 0.091417, 1e-6); - close_tol(ev("TBILLEQ", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.0914)]), 0.094151, 1e-6); - is_err(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2009, 6, 1), n(0.09)]), CellError::Num); - close_tol(ev("ACCRINTM", vec![d(2008, 4, 1), d(2008, 6, 15), n(0.1), n(1000.0), n(3.0)]), 20.54794521, 1e-9); - is_err(ev("ACCRINTM", vec![d(2008, 6, 15), d(2008, 4, 1), n(0.1)]), CellError::Num); - close_tol(ev("ACCRINT", vec![d(2008, 3, 1), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0)]), 16.66666667, 1e-9); - close_tol(ev("ACCRINT", vec![d(2008, 3, 5), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0), b(false)]), 15.55555556, 1e-9); - close_tol(ev("PRICEMAT", vec![d(2008, 2, 15), d(2008, 4, 13), d(2007, 11, 11), n(0.061), n(0.061), n(0.0)]), 99.98449888, 1e-9); - close_tol(ev("YIELDMAT", vec![d(2008, 3, 15), d(2008, 11, 3), d(2007, 11, 8), n(0.0625), n(100.0123), n(0.0)]), 0.060954, 1e-6); - close_tol(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]), 94.63436, 1e-7); - close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2016, 11, 15), n(0.0575), n(95.04287), n(100.0), n(2.0), n(0.0)]), 0.065, 1e-6); - is_err(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(-0.01), n(0.065), n(100.0), n(2.0)]), CellError::Num); - close_tol(ev("DURATION", vec![d(2018, 7, 1), d(2048, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 10.9191453, 1e-8); - close_tol(ev("MDURATION", vec![d(2008, 1, 1), d(2016, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 5.73567, 1e-6); - // A one-coupon bond uses the closed form for yield; PRICE and YIELD invert each other. - let p = ev("PRICE", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(0.04), n(100.0), n(2.0)]); - let pv = match p { - Value::Number(x) => x, - _ => panic!(), - }; - close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(pv), n(100.0), n(2.0)]), 0.04, 1e-9); - } - - fn number(v: Value) -> f64 { - match v { - Value::Number(x) => x, - other => panic!("expected a number, got {other:?}"), - } - } - - #[test] - fn odd_periods() { - // The worked examples on Microsoft's function pages (results as printed there). - let first = |x: f64, rest: &[f64]| { - let mut a = vec![d(2008, 11, 11), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(x)]; - a.extend(rest.iter().map(|v| n(*v))); - a - }; - close_tol(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0])), 113.60, 0.005 / 113.6); - close_tol(ev("ODDFYIELD", first(0.0575, &[84.50, 100.0, 2.0, 0.0])), 0.0772, 0.00005); - let lp = vec![d(2008, 2, 7), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0), n(0.0)]; - close_tol(ev("ODDLPRICE", lp), 99.88, 0.005 / 99.88); - let ly = vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(99.875), n(100.0), n(2.0), n(0.0)]; - close_tol(ev("ODDLYIELD", ly), 0.04519, 0.000005); - - // Yield → price → yield, for a short and a long odd first period and a long odd last period. - for (issue, basis) in [(d(2008, 10, 15), 0.0), (d(2007, 1, 20), 1.0), (d(2008, 1, 31), 3.0)] { - let args = |x: f64| vec![d(2008, 11, 11), d(2021, 3, 1), issue.clone(), d(2009, 3, 1), n(0.06), n(x), n(100.0), n(2.0), n(basis)]; - let p = number(ev("ODDFPRICE", args(0.05))); - close_tol(ev("ODDFYIELD", args(p)), 0.05, 1e-9); - } - let last = |x: f64| vec![d(2009, 2, 1), d(2009, 12, 15), d(2008, 3, 15), n(0.04), n(x), n(100.0), n(4.0), n(1.0)]; - let p = number(ev("ODDLPRICE", last(0.07))); - close_tol(ev("ODDLYIELD", last(p)), 0.07, 1e-9); - - // Argument checks: frequency, basis, date order, negative yield, non-positive price. - is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 3.0, 1.0])), CellError::Num); - is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 5.0])), CellError::Num); - is_err(ev("ODDFPRICE", first(0.0785, &[-0.01, 100.0, 2.0, 1.0])), CellError::Num); - is_err(ev("ODDFYIELD", first(0.0575, &[0.0, 100.0, 2.0, 0.0])), CellError::Num); - is_err( - ev("ODDFPRICE", vec![d(2009, 4, 1), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(0.07), n(0.06), n(100.0), n(2.0)]), - CellError::Num, - ); - is_err(ev("ODDLPRICE", vec![d(2007, 10, 1), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0)]), CellError::Num); - is_err(ev("ODDLYIELD", vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(-1.0), n(100.0), n(2.0)]), CellError::Num); - is_err( - ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0]).into_iter().take(4).chain([t("x"), n(0.06), n(100.0), n(2.0)]).collect()), - CellError::Value, - ); - } - - #[test] - fn odd_periods_survive_hostile_input() { - // Quarterly quasi-periods across the whole date range, huge rates and yields: a value or - // an error, never a panic or a hang. - let wide = |x: f64, y: f64| vec![n(2_957_000.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; - for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, 1e-300)] { - let _ = ev("ODDFPRICE", wide(x, y)); - let _ = ev("ODDFYIELD", wide(x, y)); - } - // The long walk itself works: settled early in an odd first period of ~32,000 quarters, - // a small yield round-trips. - let long = |y: f64| vec![n(500.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(0.05), n(y), n(100.0), n(4.0), n(1.0)]; - let p = number(ev("ODDFPRICE", long(0.0001))); - close_tol(ev("ODDFYIELD", long(p)), 0.0001, 1e-9); - let last = |x: f64, y: f64| vec![n(2_958_000.0), n(2_958_465.0), n(400.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; - for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, f64::MAX)] { - let _ = ev("ODDLPRICE", last(x, y)); - let _ = ev("ODDLYIELD", last(x, y)); - } - is_err(ev("ODDFPRICE", vec![n(-5.0), n(1e20), n(0.0), n(1e10), n(0.05), n(0.05), n(100.0), n(2.0)]), CellError::Num); - } - - #[test] - fn euroconvert() { - // The examples on Microsoft's EUROCONVERT page. - let ec = |x: f64, s: &str, tg: &str, rest: Vec| { - let mut a = vec![n(x), t(s), t(tg)]; - a.extend(rest); - ev("EUROCONVERT", a) - }; - close(ec(1.2, "DEM", "EUR", vec![]), 0.61); - close(ec(1.0, "FRF", "EUR", vec![b(true), n(3.0)]), 0.152); - close(ec(1.0, "FRF", "EUR", vec![b(false), n(3.0)]), 0.15); - close(ec(1.0, "FRF", "DEM", vec![b(true), n(3.0)]), 0.29728616); - // Euro to a member currency rounds to the target's calculation precision (lira: 0). - close(ec(10.0, "eur", "ITL", vec![]), 19363.0); - close(ec(100.0, "EUR", "IEP", vec![b(true)]), 78.7564); - // Same currency returns the number untouched. - close(ec(1.23456, "DEM", "DEM", vec![]), 1.23456); - // Unknown codes and a triangulation precision below 3 are #VALUE!. - is_err(ec(1.0, "USD", "EUR", vec![]), CellError::Value); - is_err(ec(1.0, "FRF", "DEM", vec![b(true), n(2.0)]), CellError::Value); - // Hostile magnitudes give a number or #NUM!, never a panic. - is_err(ec(f64::MAX, "EUR", "ITL", vec![]), CellError::Num); - close(ec(1.0, "FRF", "DEM", vec![b(true), n(1e300)]), 1.95583 / 6.55957); - } - - #[test] - fn lifting() { - let v = ev("SLN", vec![av(row(&[100.0, 200.0])), n(0.0), n(10.0)]); - assert_eq!(rows_of(&v), vec![vec![nv(10.0), nv(20.0)]]); - is_err(ev("PMT", vec![t("abc"), n(1.0), n(1.0)]), CellError::Value); - is_err(ev("PMT", vec![e(CellError::NA), n(1.0), n(1.0)]), CellError::NA); - } - - #[test] - fn securities_read_dates_in_the_1904_system() { - let d4 = |y, m, dd| n(serial_from_ymd(DateSystem::D1904, y, m, dd).unwrap()); - let ev4 = |name: &str, args: Vec| { - let mut ctx = TestCtx { sys: DateSystem::D1904, ..TestCtx::default() }; - crate::call(crate::lookup(name).unwrap(), &args, &mut ctx) - }; - let (s, m) = (d4(2011, 3, 15), d4(2011, 11, 30)); - close(ev4("COUPNCD", vec![s.clone(), m.clone(), n(2.0)]), 39232.0); - close(ev4("COUPPCD", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 39140.0); - close(ev4("COUPDAYBS", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 15.0); - let accrint = vec![d4(2008, 3, 1), d4(2008, 8, 31), d4(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(1.0)]; - close_tol(ev4("ACCRINT", accrint), 16.576086956522, 1e-9); - // Results that already matched Excel keep matching. - let semi = |name| ev4(name, vec![s.clone(), m.clone(), n(2.0), n(0.0)]); - close(semi("COUPPCD"), 39050.0); - close(semi("COUPDAYS"), 180.0); - close(semi("COUPDAYBS"), 105.0); - close(semi("COUPDAYSNC"), 75.0); - close(semi("COUPNUM"), 2.0); - let price = vec![s.clone(), m.clone(), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]; - close_tol(ev4("PRICE", price), 99.47768286339101, 1e-9); - close_tol(ev4("DURATION", vec![s, m, n(0.08), n(0.09), n(2.0), n(0.0)]), 0.6890136809022, 1e-9); - let odd = vec![d4(2008, 11, 11), d4(2021, 3, 1), d4(2008, 10, 15), d4(2009, 3, 1), n(0.0785), n(0.0625), n(100.0), n(2.0), n(1.0)]; - close_tol(ev4("ODDFPRICE", odd), 113.597717474079, 1e-9); - } -} +mod tests; diff --git a/crates/functions/src/financial/tests.rs b/crates/functions/src/financial/tests.rs new file mode 100644 index 000000000..17f416412 --- /dev/null +++ b/crates/functions/src/financial/tests.rs @@ -0,0 +1,322 @@ +use crate::Arg; +use crate::util::testutil::*; +use gridcraft_core::{CellError, DateSystem, Value, date::serial_from_ymd}; + +fn d(y: i64, m: i64, dd: i64) -> Arg { + n(serial_from_ymd(DateSystem::D1900, y, m, dd).unwrap()) +} + +#[test] +fn tvm() { + close_tol(ev("PMT", vec![n(0.08 / 12.0), n(10.0), n(10000.0)]), -1037.032089, 1e-9); + close_tol(ev("PMT", vec![n(0.06 / 12.0), n(216.0), n(0.0), n(50000.0)]), -129.0811609, 1e-9); + close(ev("PMT", vec![n(0.0), n(10.0), n(1000.0)]), -100.0); + is_err(ev("PMT", vec![n(0.1), n(0.0), n(1000.0)]), CellError::Num); + close_tol(ev("FV", vec![n(0.06 / 12.0), n(10.0), n(-200.0), n(-500.0), n(1.0)]), 2581.403374, 1e-9); + close_tol(ev("FV", vec![n(0.01), n(12.0), n(-1000.0)]), 12682.50301, 1e-9); + close(ev("FV", vec![n(0.0), n(10.0), n(-100.0)]), 1000.0); + close_tol(ev("PV", vec![n(0.08 / 12.0), n(240.0), n(500.0)]), -59777.14585, 1e-9); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0), n(1.0)]), 59.6738657, 1e-8); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0)]), 60.0821229, 1e-8); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0)]), -9.57859404, 1e-8); + close_tol(ev("IPMT", vec![n(0.1 / 12.0), n(1.0), n(36.0), n(8000.0)]), -66.66666667, 1e-9); + close_tol(ev("IPMT", vec![n(0.1), n(3.0), n(3.0), n(8000.0)]), -292.4471299, 1e-9); + is_err(ev("IPMT", vec![n(0.1), n(4.0), n(3.0), n(8000.0)]), CellError::Num); + close_tol(ev("PPMT", vec![n(0.1 / 12.0), n(1.0), n(24.0), n(2000.0)]), -75.62318601, 1e-9); + close_tol(ev("PPMT", vec![n(0.08), n(10.0), n(10.0), n(200000.0)]), -27598.05346, 1e-9); + close_tol(ev("RATE", vec![n(48.0), n(-200.0), n(8000.0)]), 0.0077014725, 1e-7); + close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -11135.23213, 1e-9); + close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -937.5, 1e-9); + close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -934.1071234, 1e-9); + close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -68.27827118, 1e-9); + is_err(ev("CUMIPMT", vec![n(0.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), CellError::Num); + 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(1e-300), n(10.0), n(1e12)]), -1e11, 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); + close_tol(ev("NPV", vec![n(0.08), rf(col(&[8000.0, 9200.0, 10000.0, 12000.0, 14500.0]))]), 41922.06155, 1e-9); + is_err(ev("NPV", vec![n(-1.0), n(1.0)]), CellError::Div0); + let flows = col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0]); + close_tol(ev("IRR", vec![rf(flows)]), -0.021244848, 1e-7); + close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0, 26000.0]))]), 0.086630948, 1e-7); + close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0])), n(-0.1)]), -0.44350694, 1e-7); + is_err(ev("IRR", vec![rf(col(&[1.0, 2.0]))]), CellError::Num); + let vals = col(&[-10000.0, 2750.0, 4250.0, 3250.0, 2750.0]); + let dates = col(&[39448.0, 39508.0, 39751.0, 39859.0, 39904.0]); + close_tol(ev("XNPV", vec![n(0.09), rf(vals.clone()), rf(dates.clone())]), 2086.647602, 1e-9); + close_tol(ev("XIRR", vec![rf(vals.clone()), rf(dates)]), 0.373362535, 1e-7); + is_err(ev("XNPV", vec![n(0.09), rf(vals), rf(col(&[1.0, 2.0]))]), CellError::Num); + let m = col(&[-120000.0, 39000.0, 30000.0, 21000.0, 37000.0, 46000.0]); + close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6); + close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7); + is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0); + // Excel 365 gives -1 when every flow is negative: the positive flows are worth nothing. + close_tol(ev("MIRR", vec![rf(col(&[-10000.0, -3400.0, -6500.0, -1000.0])), n(0.07), n(0.01)]), -1.0, 1e-12); + close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089); + close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584); + is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num); + close_tol(ev("NOMINAL", vec![n(0.053543), n(4.0)]), 0.052500319, 1e-8); + close_tol(ev("ISPMT", vec![n(0.1 / 12.0), n(0.0), n(36.0), n(8000000.0)]), -66666.66667, 1e-9); + is_err(ev("ISPMT", vec![n(0.1), n(1.0), n(0.0), n(1.0)]), CellError::Div0); + close_tol(ev("PDURATION", vec![n(0.025), n(2000.0), n(2200.0)]), 3.859866163, 1e-9); + is_err(ev("PDURATION", vec![n(0.0), n(2000.0), n(2200.0)]), CellError::Num); + close_tol(ev("RRI", vec![n(96.0), n(10000.0), n(11000.0)]), 0.0009933, 1e-4); + close(ev("DOLLARDE", vec![n(1.02), n(16.0)]), 1.125); + close(ev("DOLLARDE", vec![n(1.1), n(32.0)]), 1.3125); + close(ev("DOLLARFR", vec![n(1.125), n(16.0)]), 1.02); + close(ev("DOLLARFR", vec![n(1.125), n(32.0)]), 1.04); + is_err(ev("DOLLARDE", vec![n(1.02), n(0.0)]), CellError::Div0); + is_err(ev("DOLLARDE", vec![n(1.02), n(-1.0)]), CellError::Num); +} + +#[test] +fn depreciation() { + close(ev("SLN", vec![n(30000.0), n(7500.0), n(10.0)]), 2250.0); + is_err(ev("SLN", vec![n(30000.0), n(7500.0), n(0.0)]), CellError::Div0); + close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(1.0)]), 4090.909091, 1e-9); + close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(10.0)]), 409.0909091, 1e-9); + let dbv = |p: f64| ev("DB", vec![n(1000000.0), n(100000.0), n(6.0), n(p), n(7.0)]); + close_tol(dbv(1.0), 186083.3333, 1e-9); + close_tol(dbv(2.0), 259639.4167, 1e-9); + close_tol(dbv(3.0), 176814.4428, 1e-9); + close_tol(dbv(7.0), 15845.09847, 1e-9); + is_err(dbv(8.0), CellError::Num); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(3650.0), n(1.0)]), 1.315068493, 1e-8); + close(ev("DDB", vec![n(2400.0), n(300.0), n(120.0), n(1.0), n(2.0)]), 40.0); + close(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(1.0), n(2.0)]), 480.0); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(2.0), n(1.5)]), 306.0, 1e-12); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(10.0)]), 22.1225472, 1e-8); + is_err(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(11.0)]), CellError::Num); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(3650.0), n(0.0), n(1.0)]), 1.315068493, 1e-8); + close(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(0.0), n(1.0)]), 40.0); + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(1.0)]), 480.0); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0)]), 396.3060533, 1e-8); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0), n(1.5)]), 311.8089367, 1e-8); + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(0.875), n(1.5)]), 315.0); + // Over the whole life the total depreciation is cost - salvage. + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(10.0)]), 2100.0); + is_err(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(5.0), n(4.0)]), CellError::Num); + close_tol(ev("AMORLINC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 360.0, 1e-9); + close(ev("AMORDEGRC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 776.0); +} + +#[test] +fn coupons() { + let args = |f: &str| ev(f, vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(1.0)]); + close(args("COUPDAYBS"), 71.0); + close(args("COUPDAYS"), 181.0); + close(args("COUPDAYSNC"), 110.0); + close(args("COUPNCD"), 40678.0); + close(args("COUPPCD"), 40497.0); + close(args("COUPNUM"), 2.0); + close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(0.0)]), 180.0); + close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(4.0), n(3.0)]), 91.25); + close(ev("COUPNUM", vec![d(2007, 1, 25), d(2008, 11, 15), n(2.0), n(1.0)]), 4.0); + is_err(ev("COUPNUM", vec![d(2012, 1, 25), d(2011, 11, 15), n(2.0)]), CellError::Num); + is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(3.0)]), CellError::Num); + is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(5.0)]), CellError::Num); + // End-of-month maturity keeps month-end coupon dates. + close(ev("COUPPCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2010, 11, 30).unwrap()); + close(ev("COUPNCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2011, 5, 31).unwrap()); +} + +#[test] +fn securities() { + close_tol(ev("DISC", vec![d(2007, 1, 25), d(2007, 6, 15), n(97.975), n(100.0), n(1.0)]), 0.052420213, 1e-8); + close_tol(ev("PRICEDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(0.0525), n(100.0), n(2.0)]), 99.79583333, 1e-9); + close_tol(ev("YIELDDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(99.795), n(100.0), n(2.0)]), 0.052823, 1e-5); + close_tol(ev("INTRATE", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(1014420.0), n(2.0)]), 0.05768, 1e-9); + close_tol(ev("RECEIVED", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(0.0575), n(2.0)]), 1014584.654, 1e-9); + is_err(ev("DISC", vec![d(2008, 2, 15), d(2008, 1, 15), n(97.0), n(100.0)]), CellError::Num); + close_tol(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.09)]), 98.45, 1e-12); + close_tol(ev("TBILLYIELD", vec![d(2008, 3, 31), d(2008, 6, 1), n(98.45)]), 0.091417, 1e-6); + close_tol(ev("TBILLEQ", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.0914)]), 0.094151, 1e-6); + is_err(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2009, 6, 1), n(0.09)]), CellError::Num); + close_tol(ev("ACCRINTM", vec![d(2008, 4, 1), d(2008, 6, 15), n(0.1), n(1000.0), n(3.0)]), 20.54794521, 1e-9); + is_err(ev("ACCRINTM", vec![d(2008, 6, 15), d(2008, 4, 1), n(0.1)]), CellError::Num); + close_tol(ev("ACCRINT", vec![d(2008, 3, 1), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0)]), 16.66666667, 1e-9); + close_tol(ev("ACCRINT", vec![d(2008, 3, 5), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0), b(false)]), 15.55555556, 1e-9); + close_tol(ev("PRICEMAT", vec![d(2008, 2, 15), d(2008, 4, 13), d(2007, 11, 11), n(0.061), n(0.061), n(0.0)]), 99.98449888, 1e-9); + close_tol(ev("YIELDMAT", vec![d(2008, 3, 15), d(2008, 11, 3), d(2007, 11, 8), n(0.0625), n(100.0123), n(0.0)]), 0.060954, 1e-6); + close_tol(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]), 94.63436, 1e-7); + close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2016, 11, 15), n(0.0575), n(95.04287), n(100.0), n(2.0), n(0.0)]), 0.065, 1e-6); + is_err(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(-0.01), n(0.065), n(100.0), n(2.0)]), CellError::Num); + close_tol(ev("DURATION", vec![d(2018, 7, 1), d(2048, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 10.9191453, 1e-8); + close_tol(ev("MDURATION", vec![d(2008, 1, 1), d(2016, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 5.73567, 1e-6); + // A one-coupon bond uses the closed form for yield; PRICE and YIELD invert each other. + let p = ev("PRICE", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(0.04), n(100.0), n(2.0)]); + let pv = match p { + Value::Number(x) => x, + _ => panic!(), + }; + close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(pv), n(100.0), n(2.0)]), 0.04, 1e-9); +} + +fn number(v: Value) -> f64 { + match v { + Value::Number(x) => x, + other => panic!("expected a number, got {other:?}"), + } +} + +#[test] +fn odd_periods() { + // The worked examples on Microsoft's function pages (results as printed there). + let first = |x: f64, rest: &[f64]| { + let mut a = vec![d(2008, 11, 11), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(x)]; + a.extend(rest.iter().map(|v| n(*v))); + a + }; + close_tol(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0])), 113.60, 0.005 / 113.6); + close_tol(ev("ODDFYIELD", first(0.0575, &[84.50, 100.0, 2.0, 0.0])), 0.0772, 0.00005); + let lp = vec![d(2008, 2, 7), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0), n(0.0)]; + close_tol(ev("ODDLPRICE", lp), 99.88, 0.005 / 99.88); + let ly = vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(99.875), n(100.0), n(2.0), n(0.0)]; + close_tol(ev("ODDLYIELD", ly), 0.04519, 0.000005); + + // Yield → price → yield, for a short and a long odd first period and a long odd last period. + for (issue, basis) in [(d(2008, 10, 15), 0.0), (d(2007, 1, 20), 1.0), (d(2008, 1, 31), 3.0)] { + let args = |x: f64| vec![d(2008, 11, 11), d(2021, 3, 1), issue.clone(), d(2009, 3, 1), n(0.06), n(x), n(100.0), n(2.0), n(basis)]; + let p = number(ev("ODDFPRICE", args(0.05))); + close_tol(ev("ODDFYIELD", args(p)), 0.05, 1e-9); + } + let last = |x: f64| vec![d(2009, 2, 1), d(2009, 12, 15), d(2008, 3, 15), n(0.04), n(x), n(100.0), n(4.0), n(1.0)]; + let p = number(ev("ODDLPRICE", last(0.07))); + close_tol(ev("ODDLYIELD", last(p)), 0.07, 1e-9); + + // Argument checks: frequency, basis, date order, negative yield, non-positive price. + is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 3.0, 1.0])), CellError::Num); + is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 5.0])), CellError::Num); + is_err(ev("ODDFPRICE", first(0.0785, &[-0.01, 100.0, 2.0, 1.0])), CellError::Num); + is_err(ev("ODDFYIELD", first(0.0575, &[0.0, 100.0, 2.0, 0.0])), CellError::Num); + is_err( + ev("ODDFPRICE", vec![d(2009, 4, 1), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(0.07), n(0.06), n(100.0), n(2.0)]), + CellError::Num, + ); + is_err(ev("ODDLPRICE", vec![d(2007, 10, 1), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0)]), CellError::Num); + is_err(ev("ODDLYIELD", vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(-1.0), n(100.0), n(2.0)]), CellError::Num); + is_err( + ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0]).into_iter().take(4).chain([t("x"), n(0.06), n(100.0), n(2.0)]).collect()), + CellError::Value, + ); +} + +#[test] +fn odd_periods_survive_hostile_input() { + // Quarterly quasi-periods across the whole date range, huge rates and yields: a value or + // an error, never a panic or a hang. + let wide = |x: f64, y: f64| vec![n(2_957_000.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; + for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, 1e-300)] { + let _ = ev("ODDFPRICE", wide(x, y)); + let _ = ev("ODDFYIELD", wide(x, y)); + } + // The long walk itself works: settled early in an odd first period of ~32,000 quarters, + // a small yield round-trips. + let long = |y: f64| vec![n(500.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(0.05), n(y), n(100.0), n(4.0), n(1.0)]; + let p = number(ev("ODDFPRICE", long(0.0001))); + close_tol(ev("ODDFYIELD", long(p)), 0.0001, 1e-9); + let last = |x: f64, y: f64| vec![n(2_958_000.0), n(2_958_465.0), n(400.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; + for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, f64::MAX)] { + let _ = ev("ODDLPRICE", last(x, y)); + let _ = ev("ODDLYIELD", last(x, y)); + } + is_err(ev("ODDFPRICE", vec![n(-5.0), n(1e20), n(0.0), n(1e10), n(0.05), n(0.05), n(100.0), n(2.0)]), CellError::Num); +} + +#[test] +fn euroconvert() { + // The examples on Microsoft's EUROCONVERT page. + let ec = |x: f64, s: &str, tg: &str, rest: Vec| { + let mut a = vec![n(x), t(s), t(tg)]; + a.extend(rest); + ev("EUROCONVERT", a) + }; + close(ec(1.2, "DEM", "EUR", vec![]), 0.61); + close(ec(1.0, "FRF", "EUR", vec![b(true), n(3.0)]), 0.152); + close(ec(1.0, "FRF", "EUR", vec![b(false), n(3.0)]), 0.15); + close(ec(1.0, "FRF", "DEM", vec![b(true), n(3.0)]), 0.29728616); + // Euro to a member currency rounds to the target's calculation precision (lira: 0). + close(ec(10.0, "eur", "ITL", vec![]), 19363.0); + close(ec(100.0, "EUR", "IEP", vec![b(true)]), 78.7564); + // Same currency returns the number untouched. + close(ec(1.23456, "DEM", "DEM", vec![]), 1.23456); + // Unknown codes and a triangulation precision below 3 are #VALUE!. + is_err(ec(1.0, "USD", "EUR", vec![]), CellError::Value); + is_err(ec(1.0, "FRF", "DEM", vec![b(true), n(2.0)]), CellError::Value); + // Hostile magnitudes give a number or #NUM!, never a panic. + is_err(ec(f64::MAX, "EUR", "ITL", vec![]), CellError::Num); + close(ec(1.0, "FRF", "DEM", vec![b(true), n(1e300)]), 1.95583 / 6.55957); +} + +#[test] +fn lifting() { + let v = ev("SLN", vec![av(row(&[100.0, 200.0])), n(0.0), n(10.0)]); + assert_eq!(rows_of(&v), vec![vec![nv(10.0), nv(20.0)]]); + is_err(ev("PMT", vec![t("abc"), n(1.0), n(1.0)]), CellError::Value); + is_err(ev("PMT", vec![e(CellError::NA), n(1.0), n(1.0)]), CellError::NA); +} + +#[test] +fn securities_read_dates_in_the_1904_system() { + let d4 = |y, m, dd| n(serial_from_ymd(DateSystem::D1904, y, m, dd).unwrap()); + let ev4 = |name: &str, args: Vec| { + let mut ctx = TestCtx { sys: DateSystem::D1904, ..TestCtx::default() }; + crate::call(crate::lookup(name).unwrap(), &args, &mut ctx) + }; + let (s, m) = (d4(2011, 3, 15), d4(2011, 11, 30)); + close(ev4("COUPNCD", vec![s.clone(), m.clone(), n(2.0)]), 39232.0); + close(ev4("COUPPCD", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 39140.0); + close(ev4("COUPDAYBS", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 15.0); + let accrint = vec![d4(2008, 3, 1), d4(2008, 8, 31), d4(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(1.0)]; + close_tol(ev4("ACCRINT", accrint), 16.576086956522, 1e-9); + // Results that already matched Excel keep matching. + let semi = |name| ev4(name, vec![s.clone(), m.clone(), n(2.0), n(0.0)]); + close(semi("COUPPCD"), 39050.0); + close(semi("COUPDAYS"), 180.0); + close(semi("COUPDAYBS"), 105.0); + close(semi("COUPDAYSNC"), 75.0); + close(semi("COUPNUM"), 2.0); + let price = vec![s.clone(), m.clone(), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]; + close_tol(ev4("PRICE", price), 99.47768286339101, 1e-9); + close_tol(ev4("DURATION", vec![s, m, n(0.08), n(0.09), n(2.0), n(0.0)]), 0.6890136809022, 1e-9); + let odd = vec![d4(2008, 11, 11), d4(2021, 3, 1), d4(2008, 10, 15), d4(2009, 3, 1), n(0.0785), n(0.0625), n(100.0), n(2.0), n(1.0)]; + close_tol(ev4("ODDFPRICE", odd), 113.597717474079, 1e-9); +}