Finance

CIHex.pmHex DocsCoverage Status

An Elixir library for cash-flow analysis. It covers internal rate of return (xirr/irr), net present value (xnpv/npv), and modified IRR (mirr), along with the usual time-value-of-money and depreciation helpers. Options are validated with nimble_options, and amounts may be Decimal values when you have that optional dependency installed.

The functions are organised into domain modules:

The Finance.CashFlow rate and value functions come in two forms. The dated ones (xirr, xnpv) work with flows that land on arbitrary dates, discounting on an Actual/365 basis to match spreadsheet XIRR/XNPV. The periodic ones (irr, npv, mirr) take a plain list of amounts spread over equally spaced periods, for when the exact dates don't matter.

The flat Finance.foo functions (e.g. Finance.xirr/1) still work but are deprecated — call the domain module instead. They will be removed in 2.0.

Installation

Add finance to your dependencies in mix.exs:

def deps do
[{:finance, "~> 1.6"}]
end

If you also want to pass Decimal amounts, add {:decimal, "~> 3.0"} alongside it.

Usage

Pass a list of {date, amount} cash flows. Money coming in is positive and money going out is negative, and the series needs at least one of each — without flows in both directions there is no rate to solve for.

Finance.CashFlow.xirr([
{~D[2015-06-01], 1_000_000},
{~D[2015-10-01], -2_200_000},
{~D[2015-11-01], -800_000}
])
#=> {:ok, 21.118359}

Dates can also be {year, month, day} tuples, and if it reads better you can supply two parallel lists instead of pairs:

Finance.CashFlow.xirr([{2019, 1, 1}, {2020, 1, 1}], [-1000, 1100])
#=> {:ok, 0.1}

If you would rather work with the rate directly than unwrap an :ok tuple, xirr!/1 and xirr!/2 return it on its own and raise on error.

Periodic functions

For flows at equally spaced periods 0, 1, 2, …, pass a plain list of amounts:

Finance.CashFlow.irr([-1000, 500, 500, 300]) #=> {:ok, 0.156579}
Finance.CashFlow.npv(0.1, [-1000, 600, 600]) #=> {:ok, 41.322314}
Finance.CashFlow.mirr([-120_000, 39_000, 30_000, 21_000, 37_000, 46_000], 0.10, 0.12)
#=> {:ok, 0.126094}

One thing to watch: npv/2 places the first amount at period 0, which is what makes npv(irr(a), a) ≈ 0 hold. A spreadsheet NPV instead places the first amount at period 1, so the two won't agree unless you account for that.

Amounts: numbers, Decimal, and Money

Amounts may be any number — integer minor units such as cents, or floats. If your app already depends on Decimal, you can pass Decimal values straight through, with no conversion on your side:

Finance.CashFlow.xirr([{~D[2019-01-01], Decimal.new("-1000")}, {~D[2020-01-01], Decimal.new("1100")}])
#=> {:ok, 0.1}

ex_money%Money{} values work too — common when amounts come from an Ecto money column — and here the currency matters:

Finance.CashFlow.xirr([{~D[2019-01-01], Money.new(:USD, "-1000")}, {~D[2020-01-01], Money.new(:USD, "1100")}])
#=> {:ok, 0.1}
# A series may not mix currencies:
Finance.CashFlow.irr([Money.new(:USD, "-1000"), Money.new(:EUR, "1100")])
#=> {:error, :mixed_currencies}

Both Decimal and ex_money are optional — apps that don't use them pull in nothing extra (finance reads a %Money{}'s amount without depending on it). Plain numbers and Decimal are currency-neutral, so they never trip the currency check. Either way the result comes back as a float: rate-of-return math is inherently irrational, so accepting these types is convenience at the call site, not added precision in the answer.

Errors

When the data can't produce a result, xirr/1 and xirr/2 return {:error, reason}, where reason is one of:

ReasonMeaning
:mismatched_lengthsdate and amount lists differ in length
:insufficient_datafewer than two distinct-date flows
:single_signed_flowall amounts have the same sign
:invalid_datea date could not be parsed
:did_not_convergeno rate found within the iteration limit
:mixed_currenciesa series mixes two or more %Money{} currencies

Solver

The rate functions (irr, xirr, rate, ytm) find their rate with a safeguarded Newton-Raphson — the classic rtsafe. It brackets the root, then each step is a Newton step when that step lands inside the bracket and is converging fast enough, and a bisection step otherwise. This keeps Newton's speed on ordinary flows and bisection's guaranteed convergence on awkward ones, in a single pass. Because the maintained bracket always encloses a sign change, the result is a genuine root rather than a stalled non-root. The solver is swappable via the :solver option or config :finance, solver: MySolver.

Finance.Solver.Brent ships as an alternative: Brent's method, which is derivative-free and so spends one NPV evaluation per step instead of two. On short series the default is quicker, but Brent is faster on long-horizon flows — long amortization schedules or bond ladders — where each evaluation is expensive. Pass solver: Finance.Solver.Brent where it pays.

bench/solver_strategies.exs compares the two shipped solvers against reference strategies across flow sets of growing length (NPV/derivative evaluations per solve, and median time):

flow setsafeguarded Newton (default)Brentplain Newton, then bisectpure bisection
4 flows13 evals · 7.5 µs14 evals · 7.4 µs8 evals · 3.7 µs65 evals · 22 µs
60-period loan13 evals · 94 µs16 evals · 72 µs44 evals · 256 µs65 evals · 335 µs
480-period loan31 evals · 1.5 ms24 evals · 0.86 ms265 evals · 11.6 ms65 evals · 2.3 ms

Safeguarded Newton is the default all-rounder — fastest or near-fastest across the board, and its bracket always encloses a sign change so the result is a genuine root. Finance.Solver.Brent ties it on the shortest flows and pulls ahead as they lengthen (~1.3× faster on the medium loan, ~1.7× on the long one), because it spends one evaluation per step instead of two. Plain Newton edges both out on the tiny set but burns its whole iteration budget on long flows before a separate bisection pass rescues it (~8× slower than the default). Run it with mix run bench/solver_strategies.exs.

Batch and the native backend

Finance.CashFlow.irr_many/2 and xirr_many/2 solve a whole portfolio in one call, returning a list of {:ok, rate} / {:error, reason} in order. They dispatch through the solver's solve_many/2, which the default solver runs in parallel across schedulers (chunked Task.async_stream).

Because the solver is swappable, a batch can run on a native backend with no API change. finance_rustler is a Rust (Rustler) backend whose solve_many/2 runs the whole batch in one call over a rayon thread pool — add it and point :solver at it:

# mix.exs
{:finance, "~> 1.6"},
{:finance_rustler, "~> 0.2"}
# config/config.exs
config :finance, solver: FinanceRustler.Solver

Its bench/solve_many.exs compares the batch strategies — median time to solve a whole batch:

batchnative (rayon)pure (chunked)sequential
1,000 × 4-flow2.4 ms7.6 ms8.1 ms
1,000 × 60-period loan14.8 ms23.7 ms185 ms
5,000 × 60-period loan114 ms98 ms1,004 ms

Both parallel strategies beat a sequential map by 10–13×. The native backend is fastest on batches of small series (~3× on the 4-flow set); the chunked pure solver pulls even on large, heavier batches and uses far less memory. So the native backend is an opt-in for throughput and for keeping heavy work off the BEAM schedulers — not a requirement.

Development

mix deps.get
mix test
mix format
mix credo --strict
mix dialyzer

See CHANGELOG.md for the release history.