diff --git a/lib/irr.mjs b/lib/irr.mjs index 1cff08f..37ff3a9 100644 --- a/lib/irr.mjs +++ b/lib/irr.mjs @@ -156,8 +156,10 @@ export function computeXIRR(cashFlows, guess = 0.10, maxIter = 1000, tol = 1e-8) const sorted = [...cashFlows].sort((a, b) => a.date - b.date); const d0 = sorted[0].date; - // Convert dates to year fractions from first date - const yearFracs = sorted.map(cf => (cf.date - d0) / (365.25 * 24 * 60 * 60 * 1000)); + // Convert dates to year fractions from first date. + // Excel XIRR uses a 365-day basis (matching the engine-side computeXIRR) — + // a 365.25 basis drifts every solved rate by ~1e-5 per leap span. + const yearFracs = sorted.map(cf => (cf.date - d0) / (365 * 24 * 60 * 60 * 1000)); const amounts = sorted.map(cf => cf.amount); function xirrNPV(rate) { diff --git a/tests/lib/test-lib.mjs b/tests/lib/test-lib.mjs index 72f2e0a..27e10bb 100644 --- a/tests/lib/test-lib.mjs +++ b/tests/lib/test-lib.mjs @@ -46,6 +46,19 @@ console.log('Testing: lib/irr.mjs'); { date: new Date('2021-01-01'), amount: 1100 }, ]); near(xirr, 0.10, 2e-3, 'XIRR of -1000 → +1100 one year later ≈ 10%'); + + // Excel XIRR uses a 365-day basis (not 365.25) — discriminate on a leap span: + // -100 on 2020-01-01 → +110 on 2021-01-01 spans 366 days (leap 2020), so + // Excel solves (1+r)^(366/365) = 1.1 → r = 1.1^(365/366) − 1. The old + // 365.25 basis lands ~3.6e-5 away, well outside the 1e-6 gate. + const leapXirr = computeXIRR([ + { date: new Date('2020-01-01'), amount: -100 }, + { date: new Date('2021-01-01'), amount: 110 }, + ]); + const xirr365 = Math.pow(1.1, 365 / 366) - 1; + const xirr36525 = Math.pow(1.1, 365.25 / 366) - 1; + assert(Math.abs(xirr365 - xirr36525) > 1e-5, 'negative control: 365 vs 365.25 basis differ measurably'); + near(leapXirr, xirr365, 1e-6, "XIRR on a 366-day leap span uses Excel's 365-day basis"); } // ── Waterfall ────────────────────────────────────────────────────────────────