npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

xirr-rs

v0.3.1

Published

rust based xirr for nodejs

Readme

xirr-rs

XIRR for Node.js that returns the same rate your spreadsheet does.

A native (Rust) implementation of XIRR — the internal rate of return for an irregular schedule of cash flows — built to match Excel, Google Sheets and LibreOffice Calc, including which root they pick when a cash flow has more than one valid IRR, and to tell you when the rate they picked is not actually a root.

npm install xirr-rs

Prebuilt binaries for macOS (x64, arm64), Linux (glibc and musl, x64 and arm64) and Windows x64. No build step, no Python, no node-gyp.


Quick start

const { xirr } = require('xirr-rs')

const dates = Float64Array.from([Date.UTC(2020, 0, 1), Date.UTC(2021, 0, 1), Date.UTC(2022, 0, 1)])
const amounts = Float64Array.from([-1000, 750, 500])

xirr(dates, amounts) // { status: 'root', rate: 0.175009264615451, roots: [0.175009264615451] }
xirr(dates, amounts).rate // 0.175009264615451  (17.50%)

The same cash flow in a spreadsheet:

=XIRR({-1000;750;500}, {DATE(2020,1,1);DATE(2021,1,1);DATE(2022,1,1)})
→ 0.175009264615451

Why this package exists

Most JavaScript XIRR implementations run Newton's method once from a guessed starting rate and throw if it does not converge. That fails on long horizons, on sign reversals, and — worse — it can silently return a different root than your spreadsheet.

Measured against 89 cash flows whose expected values come from LibreOffice Calc:

| | agreed with the spreadsheet | threw | silently wrong root | | ------------------------------------ | --------------------------- | ----- | ----------------------- | | a typical Newton-only implementation | 56 / 71 | 12 | 3 | | xirr-rs | 71 / 71 | 0 | 0 |

One of those three silent divergences reported -53.3% where the spreadsheet reports +970.5%. No error, no warning — just a wrong number in a report.

The multiple-root problem

XNPV(r) = 0 can have several solutions. A fund with a capital call after a distribution can have three mathematically valid IRRs. Spreadsheets do not apply a rule to choose among them — they run Newton's method from a starting guess and return wherever it lands, so the answer is path-dependent.

Over ~400 generated multiple-root cash flows:

| Selection strategy | Agreement with the spreadsheet | | ------------------------------------ | ------------------------------ | | "return the lowest root" | 37% | | "return the root nearest the guess" | 62% | | reproducing the iteration itself | 100% |

So that is what this package does. It ports the search order used by Excel-compatible spreadsheets, rather than trying to be clever.


API

xirr(dates, amounts, guess?, dayCountConvention?, policy?)

Returns a XirrResult. See Why not just a number.

| Argument | Type | Default | Notes | | -------------------- | -------------- | ------------------------- | ----------------------------------------------------- | | dates | Float64Array | — | Epoch milliseconds, truncated to the UTC calendar day | | amounts | Float64Array | — | Must contain at least one positive and one negative | | guess | number | 0.1 | Must be > -1. Part of the contract — see below | | dayCountConvention | string | 'act/365f' | Spreadsheets only implement act/365f | | policy | RootPolicy | 'spreadsheetThenRobust' | See below |

Never returns NaN. Every non-answer is a named status with rate: null.

Throws when: array lengths differ; the amounts are not both positive and negative; any date precedes dates[0]; guess <= -1 or is not finite; or the day count / policy string is unrecognised.

guess is not a performance hint. Because the answer is path-dependent when multiple roots exist, changing it can change which rate you get — exactly as in a spreadsheet.

Dates must be in chronological order from dates[0]. This matches spreadsheet behaviour, which raises #NUM! rather than reordering. Sorting silently would give you a rate that disagrees with your workbook for no visible reason.

xnpv(rate, dates, amounts, dayCountConvention?)

Net present value at a given rate. Unlike xirr it does not require both signs, so you can use it to check the residual of any rate:

You no longer need to do this to find out whether a rate is a root — the status tells you. A rate that fails the library's own check comes back as unverifiedRate, never as root:

const res = xirr(dates, amounts)
if (res.status === 'root') {
  post(res.rate) // already verified
}

If you want to run the check yourself, this is the one the library runs. Note the guard: rate is null for every failure status, and xnpv takes a number.

const res = xirr(dates, amounts)
if (res.rate !== null) {
  // Net payments that share a date first - they are one term of XNPV however
  // they were entered - then divide by the size of the terms actually being
  // summed at this rate.
  const residual = Math.abs(xnpv(res.rate, dates, amounts))
  const terms = xnpv(res.rate, dates, amounts.map(Math.abs))
  const isTrueRoot = residual <= 1e-9 * terms
}

Dividing by the discounted terms rather than the gross cash flow is what makes this work at both ends of the range. At r = -0.998 over nine years the discount factors reach 1e26, so the smallest residual an f64 can express is about 1e14 — an absolute or gross-relative test is unsatisfiable there. At the other end, a flow whose day-zero payments cancel has terms that vanish as the rate grows, and a gross-relative test passes for every large rate, on a cash flow with no root at all.

Spreadsheets use a weak convergence test and occasionally return a rate that is not actually a root. This package reproduces that rate faithfully — see Fidelity, not correction — and reports it as unverifiedRate so it is never mistaken for an answer.

xirrAllRoots(dates, amounts, dayCountConvention?)

Every rate at which XNPV crosses zero, ascending.

xirrAllRoots(dates, amounts)
// [-0.571885951525731, -0.21924296785, 0.79591178]

A length greater than one means the IRR is genuinely ambiguous and the single value from xirr is a convention, not a fact. For fund reporting, "there are three IRRs and the spreadsheet picked the leftmost" is far more useful than one silent number.

xirrRate(dates, amounts, guess?, dayCountConvention?, policy?)

The rate alone, or null if there is not one. The lossy convenience form of xirr — it cannot tell you why there is no rate. Use it where a spreadsheet-style blank cell is genuinely all you need.

signChanges(dates, amounts, dayCountConvention?)

Sign changes in the cash flow after netting payments that share a date and dropping zeros. Dates are required precisely because netting and ordering change the answer:

// Same date, so mathematically one flow of +9000. No sign change, no IRR.
signChanges(oneDate, Float64Array.from([-11000, 20000])) // 0

| Sign changes | What it proves | | ------------ | ----------------------------------------- | | 0 | No IRR exists. Proved, not suspected. | | 1 | Exactly one IRR exists. Policy cannot matter. | | odd n | At least one exists, at most n. | | even n > 0 | 0, 2, … n. Nothing is proved. |


Why not just a number

null used to be the answer for four different situations. They call for different responses, and one of them is a bug in your own data:

| status | rate | What it means | What to do | | --------------------- | -------- | ---------------------------------------------------- | ---------------------- | | root | the rate | One rate solves the cash flow. | Use it. | | multipleRoots | the pick | Several do; roots lists them all. | Report the ambiguity. | | unverifiedRate | the rate | The spreadsheet's answer is not a root. | Do not post unchecked. | | noRootExists | null | Proved: no rate can solve this flow, at all. | Reject the input. | | didNotConverge | null | A root may exist; the solver did not produce one. | Escalate. | | spreadsheetNumError | null | #NUM! reproduced under the spreadsheet policy. | Retry without parity. |

noRootExists is a statement about your data, not about this library. It is returned when the cash flow, after netting payments that share a date and dropping zeros, has every remaining amount on the same side of zero — so every term of XNPV keeps one sign and the sum never reaches zero. The commonest cause is a capital call and a distribution booked on the same day that net to an inflow. A spreadsheet shows #NUM! for these too, and cannot tell you why.

didNotConverge is a statement about this library, and should be treated as an incident rather than as "no IRR". It also covers the case where a root provably exists but lies outside the representable range below.


Reachable rates

The solver searches in u = ln(1 + r) over [ln(2⁻⁵³), ln(f64::MAX)], i.e. [-36.7368, 709.7827]. In rate terms:

| Bound | r | Why that number | | ------- | ------------------------------- | ----------------------------------------------------- | | Lowest | -0.9999999999999999 (-1 + 2⁻⁵³) | The smallest f64 strictly above -1. | | Highest | ≈ 1.7977e308 (f64::MAX) | expm1 of anything larger overflows to infinity. |

These are representability boundaries, not tuning constants, so the reachable range is the whole of what an f64 rate can express. A root outside it cannot be returned by any implementation using f64, and is reported as didNotConverge rather than as a wrong number.

This is not a theoretical concern. A cash flow whose netted day-zero position is a small outflow has a real, unique IRR far above the old 1e12 ceiling:

| Netted day-0 position | IRR | | --------------------- | --------- | | -1 | 4.38e50 | | -382 | 2.40e20 | | -1000 | 4.43e15 | | -80000 | 10.34 |

The parity guarantee is narrower than the solver: a spreadsheet's own rescan grid stops at -99%, so spreadsheet policy will still report #NUM! in places the default policy answers.


What is actually guaranteed

Two different promises, and it is worth knowing which one you are relying on.

If a spreadsheet returns a rate, so does this package — the same one. That is structural rather than empirical: the spreadsheet iteration always runs first, and under the default policy its result is returned unchanged. The robust path is only reachable once that iteration has given up, so it can add an answer but can never alter one.

If a root exists, this package finds it — proved for an odd number of sign changes, and thorough but unproved for an even number:

| Netted sign changes | Guarantee | | ------------------- | ------------------------------------------------------------------------- | | 0 | Proved: no root exists. Reported as noRootExists. | | odd (1, 3, …) | Proved: a root is found, or it is not representable as an f64. | | even (2, 4, …) | Not proved. Roots come in pairs, and a pair can hide between two points the search looks at, or touch zero without crossing it. |

The odd case is a real proof. XNPV takes opposite signs at the two ends of the searched domain, the search spans that whole domain, so a bracket must exist and bisection cannot fail. Failure then means the root is outside what an f64 can express — reported as didNotConverge, never as "no IRR".

The even case has no such argument available, so it is handled by construction instead: the search also locates the turning points of the curve and looks there. Between any two roots the curve must turn, and a root that touches zero without crossing is itself a turning point — so both ways a pair can hide are covered. Measured over 2,113 generated cash flows built to have two roots at known positions, including pairs closer together than the search grid's own spacing, every root was found. That is evidence, not a proof.

Where it still under-reports

Two constructions are known to defeat the enumeration. Both need roots packed far more tightly than any cash flow we have seen in practice, and in both xirr still returns a genuine rate — it is xirrAllRoots that comes up short, and with it the multipleRoots status:

  • Three or more roots between two adjacent search points. Two are handled, because the curve turns once between them and that turn is detectable. Three turn twice, which is not. In the dense band this needs three IRRs within about 1% of each other.
  • A tangential root lying between two ordinary roots. Needs at least four sign changes and a curve that grazes zero exactly between two crossings.

The consequence worth knowing: in those cases status reads root rather than multipleRoots, so an ambiguity is reported as a single answer. If your inputs can plausibly contain several IRRs within a percent of one another, treat signChanges() as the authority on how many roots are possible rather than the length of roots.


Root policies

xirr(dates, amounts, null, null, 'lowest')

| Policy | Behaviour | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | 'spreadsheetThenRobust' (default) | The spreadsheet's rate whenever a spreadsheet has one; bracketed root finding otherwise. Never contradicts a spreadsheet, more likely to answer. | | 'spreadsheet' | Strict parity, including spreadsheetNumError where a spreadsheet shows #NUM!. Use when output must tie out to a workbook. | | 'lowest' | Smallest root. Deterministic and conservative — for reporting where understating return is the safe direction to fail. | | 'closestToGuess' | Root nearest guess. Deterministic, and steerable if you know the expected magnitude. |

The default is deliberately conservative: it can add an answer where a spreadsheet gives up, but it can never change one.

The bottom two are the correctness-over-parity pair, and they mean it: if no root can be verified they return null rather than fall back to a spreadsheet rate that fails the check. The top two never return null where a spreadsheet returned a number — they report unverifiedRate instead, so the number is still there and still ties out to the workbook.

const flow = [
  [Date.UTC(2015, 0, 1), -1000],
  [Date.UTC(2016, 0, 1), 3000],
  [Date.UTC(2017, 0, 1), -2500],
  [Date.UTC(2018, 0, 1), 600],
]
const d = Float64Array.from(flow.map(([x]) => x))
const a = Float64Array.from(flow.map(([, y]) => y))

xirr(d, a).status // 'multipleRoots'  <- the ambiguity is not hidden
xirr(d, a).rate // -0.5718859515      <- what Calc returns
xirr(d, a).roots // three roots, ascending

xirr(d, a, null, null, 'lowest').rate // -0.5718859515
xirr(d, a, null, null, 'closestToGuess').rate // -0.2192429679

Fidelity, not correction

Spreadsheets stop iterating as soon as either the step or the residual is small. That is a weak test: it can terminate on a flat stretch at a point that is not actually a root, which is why spreadsheets occasionally report a strange IRR.

This package reproduces that behaviour rather than fixing it. Returning a "better" answer than Excel would mean your report and your workbook disagree, which is the problem this package exists to solve.

But it tells you. Every rate is independently checked before it is described: XNPV is back-calculated at the answer and compared against the size of the terms actually being summed there. A rate that fails comes back as unverifiedRate, never as root. So parity and correctness stop competing — you get the spreadsheet's number and the knowledge that it is not a root, and you decide which matters for the report you are writing.

const res = xirr(dates, amounts)

switch (res.status) {
  case 'root':
  case 'multipleRoots':
    post(res.rate) // already verified
    break
  case 'unverifiedRate':
    // Matches the workbook. Is not a root. Your call.
    flagForReview(res.rate, res.roots)
    break
  default:
    escalate(res.status)
}

roots is the useful part of that middle case. It holds what verification did find, so an empty array means no rate solves this flow at all, and a non-empty one means the spreadsheet picked a number that is not among the real answers — worth escalating, because the gap is usually enormous rather than marginal:

// A capital call and a distribution booked the same day, netting to zero:
// amounts = [-3254, +3254, +1755, -945, +2565, -559]

xirr(d, a).status // 'unverifiedRate'
xirr(d, a).rate //  46785040685798.04   <- what the spreadsheet returns
xirr(d, a).roots // [-0.7709601506]     <- the actual IRR, -77.1%

That shape is not exotic — a same-day call and distribution is among the commonest things in a fund ledger. Across a sample of such flows, 57% of spreadsheet answers failed verification and 26% of those had a genuine root the spreadsheet had missed.

If you want correctness over parity outright, use 'lowest' / 'closestToGuess', which enumerate roots properly instead of following the spreadsheet's path. On the flow above, both return -77.1%.

There is one place where this package deliberately does better: a spreadsheet's rescan grid stops at -99%, so a cash flow with an IRR below that returns #NUM!. The default policy finds it. Use 'spreadsheet' if you need the #NUM! too.


Day count conventions

Spreadsheet XIRR() only implements ACT/365F, so the parity guarantee applies only to the default. Other conventions produce a mathematically sound rate that no spreadsheet will agree with.

Supported: act/365f (default), act/365a, act/364, act/360, act/act, act/act_isda, act/act_afb, 30/360, 30e/360, 30e+/360, 30u/360, nl/365.


TypeScript

Types ship with the package and are generated from the Rust source:

import { xirr, xirrRate, xnpv, xirrAllRoots, signChanges, type XirrResult } from 'xirr-rs'

const result: XirrResult = xirr(dates, amounts)
//    result.status: 'root' | 'multipleRoots' | 'noRootExists'
//                 | 'didNotConverge' | 'spreadsheetNumError'

// Or, if a blank cell is genuinely all you need:
const rate: number | null = xirrRate(dates, amounts)

status is a string union rather than a bare string, so an unhandled case is a type error rather than a runtime surprise.


Accuracy and testing

Expected values come from a real spreadsheet engine, never from this library. The corpus is 89 cash flows (480 payments) computed by LibreOffice Calc, covering conventional flows, horizons from one day to 50 years, rates from -99.9% to +100,000%, magnitudes from 1e-2 to 1e12, multiple-root flows with up to five sign reversals, and 18 flows with no solution at all.

71 cases the spreadsheet solved    → 71 exact matches, 0 divergences
18 cases the spreadsheet could not → 17 also unsolvable, 1 rescued

__test__/golden/xirr_golden_corpus.xlsx is included so you can open it in Excel or Google Sheets and verify against your own engine.

Which engine is actually asserted. Only expected_libreoffice.csv is checked in, so the 71/71 above is LibreOffice. The test suite picks up expected_excel.csv and expected_sheets.csv automatically if you add them (see scripts/README.md), and names the missing ones in its output so their absence is not mistaken for coverage. Phase 1 is a port of the OpenOffice/LibreOffice getXirr, which is itself built for Excel compatibility, but that is a shared lineage rather than a measurement.

Correctness does not rest on any of this. Every rate the library labels a root is checked against the mathematics — |XNPV(r)| relative to the terms summed at that rate — by crates/core/tests/verification.rs, which needs no spreadsheet at all. A rate that fails is reported as unverifiedRate, which is the case a corpus comparison cannot catch anyway: the weak convergence test is common to every engine, so they all agree on it.


Attribution and licence

MIT.

The solver core derives from pyxirr (Unlicense). The spreadsheet-parity iteration is a port of AnalysisAddIn::getXirr from Apache OpenOffice (scaddins/source/analysis/financial.cxx), Apache License 2.0 — see NOTICE. brentq is a port of SciPy's brentq.c (BSD-3-Clause).

This is a clean-room reimplementation of published, Excel-compatible behaviour. It is not affiliated with or endorsed by Microsoft, Google or The Document Foundation.


Contributing

See crates/core/docs/ALGORITHM.md for how the solver works and which parts must not be changed, and AGENTS.md for the short version, aimed at AI coding assistants.

pnpm install
pnpm build          # regenerates index.js and index.d.ts - commit both
cargo test -p xirr-core
pnpm test