Finance
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:
Finance.CashFlow— net present value and internal rate of return (npv,xnpv,irr,xirr,mirr), plus batchedirr_many/xirr_many.Finance.TVM— time-value-of-money scalars (pv,fv,pmt,ipmt,ppmt,nper,rate) plusamortization_schedule.Finance.Rates— rate conversions (effective_annual_rate,nominal_rate,continuous_to_periodic).Finance.Bonds— fixed income (price,ytm,duration,modified_duration,convexity).Finance.Depreciation—sln,syd,ddb,db.Finance.Returns— performance and risk metrics (volatility,cagr,payback_period,discounted_payback_period,profitability_index,twr).Finance.Solver— the root-finding strategy behind the rate functions, swappable via the:solveroption orconfig :finance, solver: MySolver.
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.foofunctions (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:
| Reason | Meaning |
|---|---|
:mismatched_lengths | date and amount lists differ in length |
:insufficient_data | fewer than two distinct-date flows |
:single_signed_flow | all amounts have the same sign |
:invalid_date | a date could not be parsed |
:did_not_converge | no rate found within the iteration limit |
:mixed_currencies | a 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 set | safeguarded Newton (default) | Brent | plain Newton, then bisect | pure bisection |
|---|---|---|---|---|
| 4 flows | 13 evals · 7.5 µs | 14 evals · 7.4 µs | 8 evals · 3.7 µs | 65 evals · 22 µs |
| 60-period loan | 13 evals · 94 µs | 16 evals · 72 µs | 44 evals · 256 µs | 65 evals · 335 µs |
| 480-period loan | 31 evals · 1.5 ms | 24 evals · 0.86 ms | 265 evals · 11.6 ms | 65 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:
| batch | native (rayon) | pure (chunked) | sequential |
|---|---|---|---|
| 1,000 × 4-flow | 2.4 ms | 7.6 ms | 8.1 ms |
| 1,000 × 60-period loan | 14.8 ms | 23.7 ms | 185 ms |
| 5,000 × 60-period loan | 114 ms | 98 ms | 1,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.