Current section
Files
Jump to
Current section
Files
lib/finance.ex
defmodule Finance do
@options_schema NimbleOptions.new!(
guess: [
type: :float,
default: 0.1,
doc: "initial rate for the Newton-Raphson solver"
],
tolerance: [
type: :float,
default: 1.0e-9,
doc: "convergence threshold on the net present value"
],
max_iterations: [
type: :pos_integer,
default: 100,
doc: "cap on solver iterations before giving up"
],
precision: [
type: :non_neg_integer,
default: 6,
doc: "decimal places the result is rounded to"
]
)
@moduledoc """
Financial calculations for cash-flow analysis: internal rate of return, net
present value, modified IRR, and the usual time-value-of-money and
depreciation helpers.
The functions fall into four families, grouped by the question they answer:
* **Rate of return** — when you have a stream of cash flows and want to know
the rate they imply. `xirr/2` and `irr/1` find that rate; `mirr/3`
answers a variation of the same question. `xirr/2` works with
`{date, amount}` flows at arbitrary dates and discounts on an Actual/365
basis, matching the spreadsheet `XIRR`; `irr/1` is its counterpart for
amounts spread over equally spaced periods `0, 1, 2, …`.
* **Present and future value** — when you know a rate and want to value a
stream of flows against it. `xnpv/2` handles dated flows, `npv/2` handles
periodic ones.
* **Time value of money** — `fv/5`, `pv/5`, `pmt/5`, `nper/5`, and `rate/6`
each take a standard annuity and solve it for one unknown, the way a
financial calculator does.
* **Depreciation** — `sln/3`, `syd/4`, `ddb/5`, and `db/5` write an asset
down from its cost to its salvage value over its useful life, either
evenly or on an accelerated schedule.
* **Volatility** — `volatility/2` annualises the standard deviation of a
price series' returns.
The centerpiece is XIRR, which finds the rate `r` that brings the net present
value of a set of dated flows to zero:
Σ cf_i / (1 + r)^t_i = 0
where `t_i` is the number of years between a flow and the earliest one in the
series. There is no closed form for this, so the solver looks for the root
numerically: it runs Newton-Raphson, using the analytic derivative of the NPV,
and falls back to a bracketing bisection when a Newton step wanders outside the
valid domain or fails to converge. Throughout, it follows the conventions a
spreadsheet `XIRR` uses — Actual/365 day counting, a `0.1` starting guess, and
a cap of 100 iterations.
## Example
iex> Finance.xirr([{~D[2019-01-01], -1000}, {~D[2020-01-01], 1100}])
{:ok, 0.1}
You can pass dated flows as `{date, amount}` pairs or as two parallel lists of
dates and amounts, whichever reads better at the call site. Dates may be `Date`
structs or Erlang-style `{year, month, day}` tuples. Amounts may be any number
— integer minor units such as cents, or floats — or a `Decimal` when you have
that optional dependency installed. Whatever you feed in, the result comes back
as a float.
## Options
The rate-finding and value functions take an optional keyword list to tune the
solver and the rounding. Options are validated with `nimble_options`: passing
an unknown key or a bad value raises, since that is a mistake in your code,
whereas problems with the data itself come back as `{:error, reason}` for you
to handle.
#{NimbleOptions.docs(@options_schema)}
"""
@typedoc "A date, given either as a `Date` struct or as an Erlang-style `{year, month, day}` tuple."
@type date :: Date.t() | {integer, integer, integer}
@typedoc "A cash-flow amount. This can be any number, or a `Decimal` when you have that optional dependency installed."
@type amount :: number | Decimal.t()
@typedoc "A cash flow on a given date. Money coming in is positive, money going out is negative."
@type cash_flow :: {date, amount}
@typedoc "An annual rate of return expressed as a fraction, so `0.1` means 10%."
@type rate :: float
@type option ::
{:guess, number}
| {:tolerance, number}
| {:max_iterations, pos_integer}
| {:precision, non_neg_integer}
@type error ::
:mismatched_lengths
| :insufficient_data
| :single_signed_flow
| :invalid_date
| :did_not_converge
| :undefined
@days_in_year 365.0
# === XIRR — internal rate of return for dated flows ======================
@doc """
Finds the XIRR of a list of `{date, amount}` cash flows — the annual rate of
return that the flows imply, given when each one lands.
Reach for this when your cash flows happen on irregular dates rather than at
neat intervals. See `xirr/2` if you want to pass options or use the two-list
form.
iex> Finance.xirr([{~D[2015-06-01], 1_000_000}, {~D[2015-10-01], -2_200_000}, {~D[2015-11-01], -800_000}])
{:ok, 21.118359}
"""
@spec xirr([cash_flow]) :: {:ok, rate} | {:error, error}
def xirr(cash_flows) when is_list(cash_flows), do: xirr(cash_flows, [])
@doc """
Finds the XIRR, accepting either `{date, amount}` pairs together with options,
or two parallel lists — one of dates, one of amounts.
The result is `{:ok, rate}` on success or `{:error, reason}` when the data
can't yield a rate. Flows that fall on the same date are added together first.
The series has to include at least one positive amount and one negative one,
because without money flowing both in and out there is no return to solve for.
iex> Finance.xirr([{~D[2019-01-01], -1000}, {~D[2020-01-01], 1100}], guess: 0.5)
{:ok, 0.1}
iex> Finance.xirr([~D[2019-01-01], ~D[2020-01-01]], [-1000, 1100])
{:ok, 0.1}
"""
@spec xirr([cash_flow], [option]) :: {:ok, rate} | {:error, error}
@spec xirr([date], [amount]) :: {:ok, rate} | {:error, error}
def xirr(first, second) when is_list(first) and is_list(second) do
if options?(second) do
compute(first, second)
else
zip(first, second, [])
end
end
@doc """
Finds the XIRR from two parallel lists — dates and amounts — while also taking
options for the solver.
iex> Finance.xirr([~D[2019-01-01], ~D[2020-01-01]], [-1000, 1100], precision: 2)
{:ok, 0.1}
"""
@spec xirr([date], [amount], [option]) :: {:ok, rate} | {:error, error}
def xirr(dates, values, opts)
when is_list(dates) and is_list(values) and is_list(opts) do
zip(dates, values, opts)
end
@doc "Same as `xirr/1`, but hands back the rate on its own and raises `ArgumentError` if the calculation fails."
@spec xirr!([cash_flow]) :: rate
def xirr!(cash_flows), do: cash_flows |> xirr() |> unwrap!()
@doc "Same as `xirr/2`, but hands back the rate on its own and raises `ArgumentError` if the calculation fails."
@spec xirr!([cash_flow] | [date], [option] | [amount]) :: rate
def xirr!(first, second), do: first |> xirr(second) |> unwrap!()
# === XNPV — net present value of dated flows =============================
@doc """
Computes the **XNPV** — the net present value of a set of dated cash flows,
discounted back at `rate`.
Σ cf_i / (1 + rate)^t_i
Each time `t_i` is measured in years from the earliest flow on an Actual/365
basis, the same day-count convention `xirr/2` uses. That shared convention is
what ties the two together: `xnpv(r, flows)` comes out to roughly zero when
`r` is `xirr(flows)`, so this is a handy way to check an XIRR result. And
because a net present value is defined for any series, the flows here don't
have to change sign the way `xirr/2` requires.
The result is `{:ok, value}` or `{:error, reason}`. Flows on the same date are
added together first, and you can pass the same `:precision` option as
`xirr/2` (it defaults to `6`).
iex> Finance.xnpv(0.1, [{~D[2019-01-01], -1000}, {~D[2020-01-01], 1000}])
{:ok, -90.909091}
iex> Finance.xnpv(0.1, [{~D[2019-01-01], -1000}, {~D[2020-01-01], 1100}])
{:ok, 0.0}
"""
@spec xnpv(rate, [cash_flow]) :: {:ok, number} | {:error, error}
def xnpv(rate, cash_flows) when is_number(rate) and is_list(cash_flows) do
xnpv(rate, cash_flows, [])
end
@doc "Same as `xnpv/2`, and additionally takes a `:precision` option. See `xnpv/2`."
@spec xnpv(rate, [cash_flow], [option]) :: {:ok, number} | {:error, error}
def xnpv(rate, cash_flows, opts)
when is_number(rate) and is_list(cash_flows) and is_list(opts) do
opts = options(opts)
with {:ok, flows} <- normalize(cash_flows) do
{:ok, round_value(present_value(flows, rate), opts)}
end
end
@doc "Same as `xnpv/2`, but hands back the value on its own and raises `ArgumentError` if the calculation fails."
@spec xnpv!(rate, [cash_flow]) :: number
def xnpv!(rate, cash_flows), do: rate |> xnpv(cash_flows) |> unwrap!()
# === IRR — internal rate of return for periodic flows ====================
@doc """
Computes the **IRR** — the internal rate of return for a list of amounts that
occur at equally spaced periods `0, 1, 2, …`. Think of it as `xirr/2` for the
common case where your flows land at regular intervals and you don't need to
track exact dates.
What comes back is the rate per period. As with `xirr/2`, the series has to
contain at least one positive amount and one negative one.
iex> Finance.irr([-1000, 1100])
{:ok, 0.1}
iex> Finance.irr([-1000, 500, 500, 300])
{:ok, 0.156579}
"""
@spec irr([amount]) :: {:ok, rate} | {:error, error}
def irr(amounts) when is_list(amounts), do: irr(amounts, [])
@doc "Same as `irr/1`, and additionally takes the same options as `xirr/2`."
@spec irr([amount], [option]) :: {:ok, rate} | {:error, error}
def irr(amounts, opts) when is_list(amounts) and is_list(opts) do
opts = options(opts)
flows = periodic_flows(amounts)
with :ok <- validate(flows) do
solve(flows, opts)
end
end
@doc "Same as `irr/1`, but hands back the rate on its own and raises `ArgumentError` if the calculation fails."
@spec irr!([amount], [option]) :: rate
def irr!(amounts, opts \\ []), do: amounts |> irr(opts) |> unwrap!()
# === NPV — net present value of periodic flows ===========================
@doc """
Computes the periodic **NPV** — the net present value of `amounts` occurring at
equally spaced periods `0, 1, 2, …`, discounted at `rate`.
Σ amount_i / (1 + rate)^i (i starting at 0)
> #### Convention {: .info}
> The first amount sits at period 0 and so is left undiscounted. That is what
> lets `npv/2` and `irr/1` line up: `npv(irr(a), a)` comes out to roughly
> zero. It also means the result differs from a spreadsheet `NPV`, which
> places the first amount at period 1. If you want to match a spreadsheet,
> discount the first amount yourself or prepend a leading `0` to the list.
iex> Finance.npv(0.1, [-1000, 1100])
{:ok, 0.0}
iex> Finance.npv(0.1, [-1000, 600, 600])
{:ok, 41.322314}
"""
@spec npv(rate, [amount]) :: {:ok, number} | {:error, error}
def npv(rate, amounts) when is_number(rate) and is_list(amounts) do
npv(rate, amounts, [])
end
@doc "Same as `npv/2`, and additionally takes a `:precision` option. See `npv/2`."
@spec npv(rate, [amount], [option]) :: {:ok, number} | {:error, error}
def npv(_rate, [], _opts), do: {:error, :insufficient_data}
def npv(rate, amounts, opts)
when is_number(rate) and is_list(amounts) and is_list(opts) do
opts = options(opts)
{:ok, round_value(present_value(periodic_flows(amounts), rate), opts)}
end
@doc "Same as `npv/2`, but hands back the value on its own and raises `ArgumentError` if the calculation fails."
@spec npv!(rate, [amount]) :: number
def npv!(rate, amounts), do: rate |> npv(amounts) |> unwrap!()
# === MIRR — modified internal rate of return =============================
@doc """
Computes the **MIRR** — the modified internal rate of return for periodic
`amounts`. It refines the idea behind IRR by letting you set two separate
rates: positive flows are assumed to be reinvested at `reinvest_rate`, and
negative flows are assumed to be financed at `finance_rate`.
Because those assumptions are spelled out, MIRR has a closed form and a single
answer, which sidesteps the multiple-root and convergence trouble that IRR can
run into. As with `irr/1`, the series has to contain at least one positive
amount and one negative one.
iex> Finance.mirr([-120_000, 39_000, 30_000, 21_000, 37_000, 46_000], 0.10, 0.12)
{:ok, 0.126094}
"""
@spec mirr([amount], number, number, [option]) :: {:ok, rate} | {:error, error}
def mirr(amounts, finance_rate, reinvest_rate, opts \\ [])
when is_list(amounts) and is_number(finance_rate) and is_number(reinvest_rate) and
is_list(opts) do
opts = options(opts)
values = Enum.map(amounts, &to_amount/1)
n = length(values)
cond do
n < 2 -> {:error, :insufficient_data}
not signed_both_ways?(values) -> {:error, :single_signed_flow}
true -> {:ok, round_value(modified_irr(values, finance_rate, reinvest_rate, n), opts)}
end
end
@doc "Same as `mirr/3`, but hands back the rate on its own and raises `ArgumentError` if the calculation fails."
@spec mirr!([amount], number, number, [option]) :: rate
def mirr!(amounts, finance_rate, reinvest_rate, opts \\ []) do
amounts |> mirr(finance_rate, reinvest_rate, opts) |> unwrap!()
end
defp modified_irr(values, finance_rate, reinvest_rate, n) do
periods = n - 1
future_of_inflows =
values
|> Enum.with_index()
|> Enum.reduce(0.0, fn {value, i}, acc ->
if value > 0, do: acc + value * :math.pow(1 + reinvest_rate, periods - i), else: acc
end)
present_of_outflows =
values
|> Enum.with_index()
|> Enum.reduce(0.0, fn {value, i}, acc ->
if value < 0, do: acc + value / :math.pow(1 + finance_rate, i), else: acc
end)
:math.pow(future_of_inflows / -present_of_outflows, 1 / periods) - 1
end
# === TVM — time-value-of-money scalars ===================================
#
# These solve the standard annuity equation for one unknown:
#
# pv·(1+r)^n + pmt·(1 + r·type)·((1+r)^n − 1)/r + fv = 0
#
# `type` is 0 for payments at the end of each period (ordinary annuity) or 1
# for the beginning (annuity due). Sign convention follows spreadsheets: money
# you receive is positive, money you pay out is negative.
@doc """
Works out the future value of an investment: what it grows to after `nper`
periods, starting from a present value of `pv`, with a fixed payment of `pmt`
each period, all compounding at `rate`.
Use this to answer "if I put this much in now and add this much every period,
what will I have at the end?". As with the rest of the time-value-of-money
functions, `type` chooses when each payment happens — `0` for the end of the
period (an ordinary annuity) or `1` for the beginning (an annuity due) — and
the sign convention follows spreadsheets, so money you receive is positive and
money you pay out is negative.
iex> {:ok, value} = Finance.fv(0.05, 10, -100, -1000)
iex> Float.round(value, 2)
2886.68
"""
@spec fv(number, number, number, number, 0 | 1) :: {:ok, float} | {:error, error}
def fv(rate, nper, pmt, pv \\ 0.0, type \\ 0)
when is_number(rate) and is_number(nper) and is_number(pmt) and is_number(pv) and
type in [0, 1] do
value =
if rate == 0,
do: -(pv + pmt * nper),
else: -(pv * :math.pow(1 + rate, nper) + pmt * annuity(rate, nper, type))
{:ok, value * 1.0}
end
@doc "Same as `fv/5`, but hands back the value on its own and raises `ArgumentError` if the calculation fails."
@spec fv!(number, number, number, number, 0 | 1) :: float
def fv!(rate, nper, pmt, pv \\ 0.0, type \\ 0), do: rate |> fv(nper, pmt, pv, type) |> unwrap!()
@doc """
Works out the present value of an investment: what a future stream is worth
today. The stream is `pmt` paid each period for `nper` periods plus a lump sum
`fv` at the end, all discounted back at `rate`.
It answers the mirror image of `fv/5`'s question — "how much would I need to
put in now to fund these future payments?". The same `type` and sign
conventions apply.
iex> {:ok, value} = Finance.pv(0.05, 10, -100, -1000)
iex> Float.round(value, 2)
1386.09
"""
@spec pv(number, number, number, number, 0 | 1) :: {:ok, float} | {:error, error}
def pv(rate, nper, pmt, fv \\ 0.0, type \\ 0)
when is_number(rate) and is_number(nper) and is_number(pmt) and is_number(fv) and
type in [0, 1] do
value =
if rate == 0,
do: -(fv + pmt * nper),
else: -(fv + pmt * annuity(rate, nper, type)) / :math.pow(1 + rate, nper)
{:ok, value * 1.0}
end
@doc "Same as `pv/5`, but hands back the value on its own and raises `ArgumentError` if the calculation fails."
@spec pv!(number, number, number, number, 0 | 1) :: float
def pv!(rate, nper, pmt, fv \\ 0.0, type \\ 0), do: rate |> pv(nper, pmt, fv, type) |> unwrap!()
@doc """
Works out the level payment per period needed to pay off a present value `pv`
(and arrive at a future value `fv`) over `nper` periods at `rate`.
This is the loan-payment question: given an amount borrowed today, what fixed
installment clears it over the term? The same `type` and sign conventions
apply, so a loan you take out is a positive `pv` and the payment comes back
negative.
iex> {:ok, payment} = Finance.pmt(0.10, 10, 1000)
iex> Float.round(payment, 2)
-162.75
"""
@spec pmt(number, number, number, number, 0 | 1) :: {:ok, float} | {:error, error}
def pmt(rate, nper, pv, fv \\ 0.0, type \\ 0)
when is_number(rate) and is_number(nper) and is_number(pv) and is_number(fv) and
type in [0, 1] do
cond do
nper == 0 -> {:error, :undefined}
rate == 0 -> {:ok, -(pv + fv) / nper * 1.0}
true -> {:ok, -(pv * :math.pow(1 + rate, nper) + fv) / annuity(rate, nper, type) * 1.0}
end
end
@doc "Same as `pmt/5`, but hands back the value on its own and raises `ArgumentError` if the calculation fails."
@spec pmt!(number, number, number, number, 0 | 1) :: float
def pmt!(rate, nper, pv, fv \\ 0.0, type \\ 0), do: rate |> pmt(nper, pv, fv, type) |> unwrap!()
@doc """
Works out how many periods it takes for payments of `pmt` to pay off a present
value `pv` (reaching future value `fv`) at `rate`.
This is the "how long until it's paid off?" question. When the numbers don't
describe a situation that ever resolves, it returns `{:error, :undefined}`.
iex> {:ok, periods} = Finance.nper(0.05, -100, 1000)
iex> Float.round(periods, 2)
14.21
"""
@spec nper(number, number, number, number, 0 | 1) :: {:ok, float} | {:error, error}
def nper(rate, pmt, pv, fv \\ 0.0, type \\ 0)
when is_number(rate) and is_number(pmt) and is_number(pv) and is_number(fv) and
type in [0, 1] do
nper_periods(rate, pmt, pv, fv, type)
end
defp nper_periods(rate, pmt, pv, fv, type) do
cond do
rate == 0 and pmt == 0 -> {:error, :undefined}
rate == 0 -> {:ok, -(pv + fv) / pmt * 1.0}
1 + rate <= 0 -> {:error, :undefined}
true -> nper_with_rate(rate, pmt, pv, fv, type)
end
end
@doc "Same as `nper/5`, but hands back the value on its own and raises `ArgumentError` if the calculation fails."
@spec nper!(number, number, number, number, 0 | 1) :: float
def nper!(rate, pmt, pv, fv \\ 0.0, type \\ 0), do: rate |> nper(pmt, pv, fv, type) |> unwrap!()
@doc """
Works out the interest rate per period of an annuity described by `nper`
payments of `pmt`, a present value `pv`, and a future value `fv`. `nper` has to
be a whole number of periods.
There is no closed form for the rate, so this reuses the same numerical solver
as `irr/1` and takes the same options as `xirr/2`. If it can't pin down a rate,
it returns `{:error, :did_not_converge}`.
iex> Finance.rate(10, -100, 1000)
{:ok, 0.0}
"""
@spec rate(number, number, number, number, 0 | 1, [option]) :: {:ok, rate} | {:error, error}
def rate(nper, pmt, pv, fv \\ 0.0, type \\ 0, opts \\ [])
when is_number(nper) and is_number(pmt) and is_number(pv) and is_number(fv) and
type in [0, 1] and is_list(opts) do
n = trunc(nper)
if nper == n and n > 0 do
solve(tvm_flows(n, pmt, pv, fv, type), options(opts))
else
{:error, :undefined}
end
end
@doc "Same as `rate/6`, but hands back the rate on its own and raises `ArgumentError` if the calculation fails."
@spec rate!(number, number, number, number, 0 | 1, [option]) :: rate
def rate!(nper, pmt, pv, fv \\ 0.0, type \\ 0, opts \\ []) do
nper |> rate(pmt, pv, fv, type, opts) |> unwrap!()
end
# (1 + r·type) · ((1+r)^n − 1) / r — the annuity factor that multiplies pmt.
defp annuity(rate, nper, type) do
(1 + rate * type) * (:math.pow(1 + rate, nper) - 1) / rate
end
defp nper_with_rate(rate, pmt, pv, fv, type) do
k = pmt * (1 + rate * type) / rate
denom = pv + k
cond do
denom == 0 -> {:error, :undefined}
(k - fv) / denom <= 0 -> {:error, :undefined}
true -> {:ok, :math.log((k - fv) / denom) / :math.log(1 + rate)}
end
end
# Represent a TVM problem as a cash-flow series so `rate` can reuse the solver:
# `pv` at period 0, `pmt` each period, `fv` at the last period.
defp tvm_flows(nper, pmt, pv, fv, type) do
payment_periods = if type == 1, do: 0..(nper - 1), else: 1..nper
payment_periods
|> Enum.reduce(%{}, fn i, acc -> Map.update(acc, i * 1.0, pmt * 1.0, &(&1 + pmt)) end)
|> Map.update(0.0, pv * 1.0, &(&1 + pv))
|> Map.update(nper * 1.0, fv * 1.0, &(&1 + fv))
|> Map.to_list()
end
# === Depreciation ========================================================
#
# An asset of `cost` is written down to `salvage` over `life` periods. `sln`
# spreads the loss evenly; `syd`, `ddb`, and `db` are accelerated methods that
# depreciate more early on and return the amount for a single `period` (1-based).
@doc """
Computes straight-line depreciation: the equal amount an asset is written down
by each period as it declines from its `cost` to its `salvage` value over
`life` periods.
This is the plainest of the depreciation methods — it spreads the loss in
value evenly, so every period sees the same write-down.
iex> Finance.sln(10_000, 1_000, 5)
{:ok, 1800.0}
"""
@spec sln(number, number, number) :: {:ok, float} | {:error, error}
def sln(cost, salvage, life)
when is_number(cost) and is_number(salvage) and is_number(life) do
if life == 0 do
{:error, :undefined}
else
{:ok, (cost - salvage) / life * 1.0}
end
end
@doc "Same as `sln/3`, but hands back the value on its own and raises `ArgumentError` if the calculation fails."
@spec sln!(number, number, number) :: float
def sln!(cost, salvage, life), do: cost |> sln(salvage, life) |> unwrap!()
@doc """
Computes sum-of-years'-digits depreciation for a single `period` (counting from
1).
This is an accelerated method: it charges more depreciation in the early
periods and less later on, which suits assets that lose most of their value up
front. It returns the write-down for the one `period` you ask about rather than
the whole schedule.
iex> Finance.syd(10_000, 1_000, 5, 1)
{:ok, 3000.0}
iex> Finance.syd(10_000, 1_000, 5, 5)
{:ok, 600.0}
"""
@spec syd(number, number, number, number) :: {:ok, float} | {:error, error}
def syd(cost, salvage, life, period)
when is_number(cost) and is_number(salvage) and is_number(life) and is_number(period) do
if life <= 0 or period < 1 or period > life do
{:error, :undefined}
else
{:ok, (cost - salvage) * (life - period + 1) * 2 / (life * (life + 1)) * 1.0}
end
end
@doc "Same as `syd/4`, but hands back the value on its own and raises `ArgumentError` if the calculation fails."
@spec syd!(number, number, number, number) :: float
def syd!(cost, salvage, life, period), do: cost |> syd(salvage, life, period) |> unwrap!()
@doc """
Computes double-declining-balance depreciation for a single `period`.
This is another accelerated method: each period it takes a fixed fraction of
the remaining book value, so the write-down shrinks as the asset ages. The
`factor` sets how aggressive that fraction is, defaulting to `2` for the usual
double-declining rate. The depreciation is capped so it never drives the book
value below `salvage`.
iex> Finance.ddb(10_000, 1_000, 5, 1)
{:ok, 4000.0}
iex> Finance.ddb(10_000, 1_000, 5, 2)
{:ok, 2400.0}
"""
@spec ddb(number, number, number, number, number) :: {:ok, float} | {:error, error}
def ddb(cost, salvage, life, period, factor \\ 2)
when is_number(cost) and is_number(salvage) and is_number(life) and is_number(period) and
is_number(factor) do
n = trunc(period)
if ddb_valid?(life, factor, period, n) do
{:ok, declining_balance(cost, salvage, factor / life, n)}
else
{:error, :undefined}
end
end
defp ddb_valid?(life, factor, period, n) do
life > 0 and factor > 0 and period == n and n >= 1 and n <= life
end
@doc "Same as `ddb/5`, but hands back the value on its own and raises `ArgumentError` if the calculation fails."
@spec ddb!(number, number, number, number, number) :: float
def ddb!(cost, salvage, life, period, factor \\ 2) do
cost |> ddb(salvage, life, period, factor) |> unwrap!()
end
@doc """
Computes fixed-declining-balance depreciation for a single `period`.
Like `ddb/5`, this applies a constant rate to the declining book value, but the
rate is derived directly from `cost`, `salvage`, and `life` (and rounded to
three decimal places, matching what spreadsheets do). Use `month` to say how
many months the asset was in service during its first year; it defaults to a
full `12`, and a shorter first year spills the remaining depreciation into an
extra final period.
iex> Finance.db(10_000, 1_000, 5, 1)
{:ok, 3690.0}
iex> Finance.db(10_000, 1_000, 5, 2)
{:ok, 2328.39}
"""
@spec db(number, number, number, number, number) :: {:ok, float} | {:error, error}
def db(cost, salvage, life, period, month \\ 12)
when is_number(cost) and is_number(salvage) and is_number(life) and is_number(period) and
is_number(month) do
n = trunc(period)
if db_valid?(cost, salvage, life, month, period, n) do
{:ok, fixed_declining(cost, salvage, life, n, month)}
else
{:error, :undefined}
end
end
@doc "Same as `db/5`, but hands back the value on its own and raises `ArgumentError` if the calculation fails."
@spec db!(number, number, number, number, number) :: float
def db!(cost, salvage, life, period, month \\ 12) do
cost |> db(salvage, life, period, month) |> unwrap!()
end
defp db_valid?(cost, salvage, life, month, period, n) do
cost > 0 and salvage >= 0 and life > 0 and month >= 1 and month <= 12 and
period == n and n >= 1 and n <= life + 1
end
# Walk periods 1..period, carrying accumulated depreciation, and return the
# amount for the final period. Depreciation stops at the salvage floor.
defp declining_balance(cost, salvage, rate, period) do
Enum.reduce(1..period, {0.0, 0.0}, fn _p, {accumulated, _dep} ->
book = cost - accumulated
dep = max(min(book * rate, book - salvage), 0.0)
{accumulated + dep, dep}
end)
|> elem(1)
end
defp fixed_declining(cost, salvage, life, period, month) do
rate = Float.round(1 - :math.pow(salvage / cost, 1 / life), 3)
Enum.reduce(1..period, {0.0, 0.0}, fn p, {accumulated, _dep} ->
dep = db_period(cost, accumulated, rate, life, month, p)
{accumulated + dep, dep}
end)
|> elem(1)
end
defp db_period(cost, _accumulated, rate, _life, month, 1), do: cost * rate * month / 12
defp db_period(cost, accumulated, rate, life, month, period) do
if period <= life do
(cost - accumulated) * rate
else
# The partial last period when the first year was shorter than 12 months.
(cost - accumulated) * rate * (12 - month) / 12
end
end
# === Volatility ==========================================================
@volatility_options_schema NimbleOptions.new!(
periods_per_year: [
type: :pos_integer,
default: 252,
doc:
"number of periods in a year, used to annualise (252 trading days by default)"
],
returns: [
type: {:in, [:simple, :log]},
default: :simple,
doc:
"how to measure each period's return: `:simple` `(b - a) / a` or `:log` `ln(b / a)`"
],
precision: [
type: :non_neg_integer,
default: 6,
doc: "decimal places the result is rounded to"
]
)
@doc """
Annualised volatility of a price series — the standard deviation of its
period-over-period returns, scaled up to a yearly figure.
Give it a list of prices in time order (daily closes, say). It measures the
return between each consecutive pair, takes their sample standard deviation,
and annualises by `√periods_per_year`. At least three prices are needed, and
every price must be positive.
iex> Finance.volatility([100, 102, 101, 103, 105])
{:ok, 0.234528}
## Options
#{NimbleOptions.docs(@volatility_options_schema)}
"""
@spec volatility([number], keyword) :: {:ok, float} | {:error, error}
def volatility(prices, opts \\ []) when is_list(prices) do
opts = NimbleOptions.validate!(opts, @volatility_options_schema)
returns = period_returns(prices, Keyword.fetch!(opts, :returns))
cond do
returns == :error ->
{:error, :undefined}
length(returns) < 2 ->
{:error, :insufficient_data}
true ->
{:ok, round_value(annualise(returns, Keyword.fetch!(opts, :periods_per_year)), opts)}
end
end
@doc "Same as `volatility/2`, but hands back the value on its own and raises `ArgumentError` if it can't be computed."
@spec volatility!([number], keyword) :: float
def volatility!(prices, opts \\ []), do: prices |> volatility(opts) |> unwrap!()
# Consecutive-pair returns, or :error if a price is non-positive. Order does
# not matter for the standard deviation, so the reversed list is fine.
defp period_returns(prices, kind) do
prices
|> Enum.zip(Enum.drop(prices, 1))
|> Enum.reduce_while([], fn {a, b}, acc ->
if is_number(a) and is_number(b) and a > 0 and b > 0 do
{:cont, [period_return(kind, a, b) | acc]}
else
{:halt, :error}
end
end)
end
defp period_return(:simple, a, b), do: (b - a) / a
defp period_return(:log, a, b), do: :math.log(b / a)
defp annualise(returns, periods_per_year) do
n = length(returns)
mean = Enum.sum(returns) / n
sum_of_squares = Enum.reduce(returns, 0.0, fn r, acc -> acc + (r - mean) * (r - mean) end)
:math.sqrt(sum_of_squares / (n - 1)) * :math.sqrt(periods_per_year)
end
# === Dispatch & shared helpers ===========================================
# An empty list or a proper keyword list is treated as options. A list of
# `{date, amount}` pairs is not a keyword list (its keys are dates, not
# atoms), and a list of numeric amounts is not either — so the two input
# shapes are never confused.
defp options?([]), do: true
defp options?(list), do: Keyword.keyword?(list)
defp zip(dates, values, opts) when length(dates) == length(values) do
dates |> Enum.zip(values) |> compute(opts)
end
defp zip(_dates, _values, _opts), do: {:error, :mismatched_lengths}
defp compute(cash_flows, opts) do
opts = options(opts)
with {:ok, flows} <- normalize(cash_flows),
:ok <- validate(flows) do
solve(flows, opts)
end
end
# Validate options against the schema, applying defaults. Raises
# `NimbleOptions.ValidationError` on an unknown key or bad value.
defp options(opts), do: NimbleOptions.validate!(opts, @options_schema)
# `+ 0.0` collapses a floating-point negative zero to `0.0`.
defp round_value(value, opts) do
Float.round(value, Keyword.fetch!(opts, :precision)) + 0.0
end
defp unwrap!({:ok, value}), do: value
defp unwrap!({:error, reason}), do: raise(ArgumentError, "could not compute: #{reason}")
# --- Normalization -------------------------------------------------------
# Parse dates, re-express each flow's time as years since the earliest date,
# and merge flows that fall on the same date.
defp normalize([]), do: {:error, :insufficient_data}
defp normalize(cash_flows) do
parsed = Enum.map(cash_flows, fn {date, amount} -> {to_date(date), to_amount(amount)} end)
min_date = parsed |> Enum.map(&elem(&1, 0)) |> Enum.min(Date)
flows =
parsed
|> Enum.reduce(%{}, fn {date, amount}, acc ->
period = Date.diff(date, min_date) / @days_in_year
Map.update(acc, period, amount, &(&1 + amount))
end)
|> Map.to_list()
{:ok, flows}
rescue
_ in [ArgumentError, FunctionClauseError] -> {:error, :invalid_date}
end
# Build position-indexed flows (period 0, 1, 2, …) for the periodic functions.
defp periodic_flows(amounts) do
amounts
|> Enum.with_index()
|> Enum.map(fn {amount, index} -> {index / 1, to_amount(amount)} end)
end
defp to_date(%Date{} = date), do: date
defp to_date({y, m, d}), do: Date.from_erl!({y, m, d})
# Coerce a cash-flow amount to a float. Accepts plain numbers and, when the
# optional Decimal dependency is present, `%Decimal{}` values. Shared by every
# normalizer so the whole function family accepts the same inputs.
defp to_amount(%Decimal{} = amount), do: Decimal.to_float(amount)
defp to_amount(amount) when is_number(amount), do: amount / 1
defp validate(flows) do
amounts = Enum.map(flows, &elem(&1, 1))
cond do
length(flows) < 2 -> {:error, :insufficient_data}
not signed_both_ways?(amounts) -> {:error, :single_signed_flow}
true -> :ok
end
end
defp signed_both_ways?(amounts) do
Enum.any?(amounts, &(&1 > 0)) and Enum.any?(amounts, &(&1 < 0))
end
# --- Solver --------------------------------------------------------------
defp solve(flows, opts) do
guess = Keyword.fetch!(opts, :guess)
tolerance = Keyword.fetch!(opts, :tolerance)
max_iterations = Keyword.fetch!(opts, :max_iterations)
result =
case newton(flows, guess, max_iterations, tolerance) do
{:ok, rate} -> {:ok, rate}
:diverged -> bisect(flows, max_iterations, tolerance)
end
case result do
{:ok, rate} -> {:ok, Float.round(rate, Keyword.fetch!(opts, :precision))}
:diverged -> {:error, :did_not_converge}
end
rescue
ArithmeticError -> {:error, :did_not_converge}
end
defp newton(_flows, _rate, 0, _tol), do: :diverged
defp newton(flows, rate, iterations, tol) do
f = present_value(flows, rate)
derivative = present_value_derivative(flows, rate)
cond do
abs(f) < tol -> {:ok, rate}
derivative == 0.0 -> :diverged
true -> newton_step(flows, rate, rate - f / derivative, iterations, tol)
end
end
defp newton_step(flows, rate, next, iterations, tol) do
cond do
# A Newton step outside the (-1, ∞) domain: halve the distance to -1.
next <= -1.0 -> newton(flows, (rate - 1.0) / 2.0, iterations - 1, tol)
abs(next - rate) < tol -> {:ok, next}
true -> newton(flows, next, iterations - 1, tol)
end
end
defp bisect(flows, max_iterations, tol) do
low = -0.999999
case bracket(flows, low, present_value(flows, low), 1.0) do
{:ok, low, high} -> {:ok, bisection(flows, low, high, max_iterations, tol)}
:diverged -> :diverged
end
end
# Expand the upper bound until the NPV changes sign, giving us a bracket.
defp bracket(_flows, _low, _f_low, high) when high > 1.0e7, do: :diverged
defp bracket(flows, low, f_low, high) do
if f_low * present_value(flows, high) <= 0 do
{:ok, low, high}
else
bracket(flows, low, f_low, high * 2 + 1)
end
end
defp bisection(_flows, low, high, 0, _tol), do: (low + high) / 2
defp bisection(flows, low, high, iterations, tol) do
mid = (low + high) / 2
f_mid = present_value(flows, mid)
cond do
abs(f_mid) < tol or high - low < tol -> mid
present_value(flows, low) * f_mid < 0 -> bisection(flows, low, mid, iterations - 1, tol)
true -> bisection(flows, mid, high, iterations - 1, tol)
end
end
# Net present value: Σ amount / (1 + rate)^t
defp present_value(flows, rate) do
Enum.reduce(flows, 0.0, fn {t, amount}, acc ->
acc + amount / :math.pow(1 + rate, t)
end)
end
# Derivative of the NPV with respect to rate: Σ -t · amount / (1 + rate)^(t+1)
defp present_value_derivative(flows, rate) do
Enum.reduce(flows, 0.0, fn {t, amount}, acc ->
acc + -t * amount / :math.pow(1 + rate, t + 1)
end)
end
end