xirr 0.7.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +21 -0
- data/.gitignore +0 -0
- data/CHANGE_LOG.md +34 -5
- data/CLAUDE.md +82 -0
- data/README.md +133 -27
- data/Rakefile +10 -0
- data/benchmark/solvers.rb +59 -0
- data/ext/xirr/extconf.rb +23 -0
- data/ext/xirr/xirr_native.c +125 -0
- data/lib/xirr/base.rb +28 -13
- data/lib/xirr/bisection.rb +16 -12
- data/lib/xirr/bonds.rb +120 -0
- data/lib/xirr/brent.rb +108 -0
- data/lib/xirr/cashflow.rb +109 -23
- data/lib/xirr/config.rb +8 -5
- data/lib/xirr/depreciation.rb +97 -0
- data/lib/xirr/newton_method.rb +24 -50
- data/lib/xirr/periodic.rb +84 -0
- data/lib/xirr/rates.rb +39 -0
- data/lib/xirr/returns.rb +130 -0
- data/lib/xirr/rtsafe.rb +146 -0
- data/lib/xirr/rtsafe_c.rb +32 -0
- data/lib/xirr/transaction.rb +4 -9
- data/lib/xirr/tvm.rb +191 -0
- data/lib/xirr/version.rb +1 -1
- data/lib/xirr.rb +9 -2
- data/test/test_bonds.rb +34 -0
- data/test/test_brent.rb +48 -0
- data/test/test_cashflow.rb +61 -23
- data/test/test_depreciation.rb +26 -0
- data/test/test_helper.rb +9 -3
- data/test/test_periodic.rb +45 -0
- data/test/test_rates.rb +20 -0
- data/test/test_returns.rb +35 -0
- data/test/test_solver_properties.rb +135 -0
- data/test/test_transactions.rb +2 -2
- data/test/test_tvm.rb +69 -0
- data/xirr.gemspec +6 -4
- metadata +39 -42
- data/.coveralls.yml +0 -2
- data/.travis.yml +0 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3aca532866fb5a06872ab2ea0e49a812d4ad4e2a638df03e456e7ea2c742efd7
|
|
4
|
+
data.tar.gz: 7cf7a6cd4334bc92a5949cdad6d291a0001fa4a2986c86b413106612dca08dd6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1f9c988579766fc11c056506f760c12e88996cedcfee582c0497822155a50a943e6674de0e3bca7a90c2afbb7a9e5e61569d75e5cad4b2b809e7a03b527c00c9
|
|
7
|
+
data.tar.gz: 16a1104a0f0961beabcd2fcb2ae0f4043f4ef2f6682e044e2d8cc2077d64c257b6d0c9ba7cf2903c61ac08d62243b950ca47990131699839cb9ec634b8c14fc9
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
ruby: ['3.1', '3.2', '3.3']
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: ruby/setup-ruby@v1
|
|
18
|
+
with:
|
|
19
|
+
ruby-version: ${{ matrix.ruby }}
|
|
20
|
+
bundler-cache: true
|
|
21
|
+
- run: bundle exec rake
|
data/.gitignore
CHANGED
|
Binary file
|
data/CHANGE_LOG.md
CHANGED
|
@@ -1,8 +1,37 @@
|
|
|
1
|
-
##
|
|
2
|
-
*
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
## Version 1.0.0
|
|
2
|
+
* New default solver: a safeguarded Newton (`rtsafe`) that brackets the root and
|
|
3
|
+
falls back to a bisection step per iteration. It converges on flows the old
|
|
4
|
+
Newton/bisection pair failed on — long maturities, low rates, and returns near
|
|
5
|
+
-100% — without overflowing.
|
|
6
|
+
* `Cashflow#xirr` no longer silently returns `0.0` for a flow that does have a
|
|
7
|
+
rate; the more robust solver now finds it. `Cashflow#xirr!` raises on an
|
|
8
|
+
invalid flow or genuine non-convergence instead of returning
|
|
9
|
+
`config.replace_for_nil`.
|
|
10
|
+
* Added `Cashflow#xnpv(rate)` and `Cashflow#mirr(finance_rate, reinvest_rate)`.
|
|
11
|
+
* Added periodic (dateless) helpers `Xirr.irr`, `Xirr.npv`, `Xirr.mirr` for
|
|
12
|
+
amounts at equally spaced periods.
|
|
13
|
+
* Added the wider finance toolkit ported from the finance-elixir library:
|
|
14
|
+
`Xirr::TVM` (`fv`, `pv`, `pmt`, `ipmt`, `ppmt`, `nper`, `rate`,
|
|
15
|
+
`amortization_schedule`), `Xirr::Rates` (`effective_annual_rate`,
|
|
16
|
+
`nominal_rate`, `continuous_to_periodic`), `Xirr::Bonds` (`price`, `ytm`,
|
|
17
|
+
`duration`, `modified_duration`, `convexity`), `Xirr::Depreciation` (`sln`,
|
|
18
|
+
`syd`, `ddb`, `db`), and `Xirr::Returns` (`volatility`, `cagr`,
|
|
19
|
+
`payback_period`, `discounted_payback_period`, `profitability_index`, `twr`).
|
|
20
|
+
* Added an optional native (C) build of the rtsafe solver, selectable with
|
|
21
|
+
`xirr(method: :rtsafe_c)`. It is best-effort: without a compiler the gem falls
|
|
22
|
+
back to pure Ruby, and `Xirr::NATIVE` reports whether it loaded. Added a
|
|
23
|
+
`benchmark/solvers.rb` comparing the solvers.
|
|
24
|
+
* Added a `:brent` solver (`xirr(method: :brent)`): derivative-free Brent's
|
|
25
|
+
method over rtsafe's bracketing — as robust as rtsafe, and cheaper per
|
|
26
|
+
iteration, which can help on very large cashflows.
|
|
27
|
+
* The `:bisection` and `:newton_method` methods are still available via
|
|
28
|
+
`xirr(method:)`. `:newton_method` was reimplemented in plain Ruby, dropping the
|
|
29
|
+
deprecated `bigdecimal/newton` standard library.
|
|
30
|
+
* Dropped the `bigdecimal` dependency entirely — results are `Float`, rounded to
|
|
31
|
+
`config.precision`. (The solvers already computed in `Float`.)
|
|
32
|
+
* Removed the defunct Travis and Coveralls setup; added GitHub Actions CI.
|
|
33
|
+
* Allow activesupport 7 (Fixes #30). Breaking: require activesupport >= 6.1 and
|
|
34
|
+
ruby >= 3.1.
|
|
6
35
|
|
|
7
36
|
## Version 0.7.0
|
|
8
37
|
* Removed `RubyInLine`
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
- `rake` / `rake test_units` — run the full test suite (Minitest, `test/*.rb`).
|
|
8
|
+
- `ruby -Ilib -Itest test/test_cashflow.rb` — run one test file.
|
|
9
|
+
- `ruby -Ilib -Itest test/test_cashflow.rb -n "/pattern/"` — filter by test name.
|
|
10
|
+
- `rake compile` — build the optional native extension into `lib/xirr/` (needs a C compiler).
|
|
11
|
+
- `ruby -Ilib benchmark/solvers.rb` — benchmark the solvers.
|
|
12
|
+
|
|
13
|
+
Requires Ruby `>= 3.1` and `activesupport >= 6.1, < 8`. There is no system Ruby on
|
|
14
|
+
the dev box used here; see the project memory for the asdf build recipe and the
|
|
15
|
+
`PATH` / `LD_LIBRARY_PATH` needed to run the suite.
|
|
16
|
+
|
|
17
|
+
## Architecture
|
|
18
|
+
|
|
19
|
+
The gem computes XIRR (the IRR of an irregularly-dated cashflow, like Excel's
|
|
20
|
+
XIRR) and carries a wider finance toolkit. Everything works in `Float` — there is
|
|
21
|
+
no `BigDecimal` dependency. Functions return plain numbers and raise
|
|
22
|
+
`ArgumentError` on inputs with no answer (no Elixir-style ok/error tuples).
|
|
23
|
+
|
|
24
|
+
### Cashflow and the solvers
|
|
25
|
+
|
|
26
|
+
- **`Xirr::Transaction`** (`transaction.rb`) — one dated amount (`Float`, `Date`).
|
|
27
|
+
- **`Xirr::Cashflow`** (`cashflow.rb`) — an `Array` subclass; the orchestrator and
|
|
28
|
+
public entry point (`#xirr`, `#xirr!`, `#xnpv`, `#mirr`). It validates the flow,
|
|
29
|
+
resolves options, compacts same-date transactions (`#compact_cf`), picks a
|
|
30
|
+
solver, and delegates. It does no root-finding itself.
|
|
31
|
+
- **Solvers** all `include Xirr::Base` (`base.rb`), which supplies `xnpv`,
|
|
32
|
+
`xnpv_derivative`, and the memoized `[years_from_start, amount]` `flows`:
|
|
33
|
+
- **`RtSafe`** (`rtsafe.rb`) — safeguarded Newton (the classic *rtsafe*): bracket
|
|
34
|
+
a sign change, then take a Newton step when it stays inside the bracket and a
|
|
35
|
+
bisection step otherwise. **This is the default** (`config.default_method`).
|
|
36
|
+
- **`RtSafeC`** (`rtsafe_c.rb` + `ext/xirr/xirr_native.c`) — the same algorithm
|
|
37
|
+
in C. Optional: loaded only if compiled (`Xirr::NATIVE`); `method: :rtsafe_c`.
|
|
38
|
+
**`rtsafe.rb` and `xirr_native.c` are meant to stay algorithmically identical —
|
|
39
|
+
change them together and re-check parity.**
|
|
40
|
+
- **`Brent`** (`brent.rb`) — derivative-free Brent's method over rtsafe's
|
|
41
|
+
bracketing (`method: :brent`); as robust as rtsafe, cheaper per iteration but
|
|
42
|
+
needs more of them, so it roughly ties rtsafe except on very large flows.
|
|
43
|
+
- **`Bisection`, `NewtonMethod`** — legacy solvers kept for `method:` and the
|
|
44
|
+
benchmark. rtsafe dominates both (faster and more robust).
|
|
45
|
+
|
|
46
|
+
### Control flow of `Cashflow#xirr`
|
|
47
|
+
|
|
48
|
+
1. `process_options` resolves `raise_exception` / `iteration_limit` / `period` in
|
|
49
|
+
precedence order (call → cashflow → config) via `resolve_option`, and
|
|
50
|
+
`switch_fallback` picks the method (an explicit `method:` turns fallback off).
|
|
51
|
+
2. Invalid flow (no sign change) → `config.replace_for_nil`, or raises if asked.
|
|
52
|
+
3. `compact_cf` merges same-date transactions; the chosen solver runs on it.
|
|
53
|
+
4. On non-convergence, if fallback is on and the method wasn't already rtsafe, it
|
|
54
|
+
retries with `:rtsafe` (rtsafe is the safety net; nothing beats it, so it does
|
|
55
|
+
not fall back to the weaker solvers).
|
|
56
|
+
|
|
57
|
+
### Key conventions
|
|
58
|
+
|
|
59
|
+
- **Sign is relative to the first transaction** (`first_transaction_direction`):
|
|
60
|
+
`inflow`/`outflows` and validity are defined by each amount's sign times that
|
|
61
|
+
direction, not by absolute positive/negative.
|
|
62
|
+
- **rtsafe does not depend on `irr_guess`** — it brackets independently and only
|
|
63
|
+
uses a guess when it falls inside the bracket. `irr_guess` is a public
|
|
64
|
+
convenience and the starting point for the fragile legacy Newton.
|
|
65
|
+
- **Precision & convergence** come from config: `eps` (tolerance on the *rate
|
|
66
|
+
step/interval*, not on NPV), `precision` (rounding), `iteration_limit`, `period`
|
|
67
|
+
(days/year).
|
|
68
|
+
|
|
69
|
+
### The other finance modules
|
|
70
|
+
|
|
71
|
+
Module functions (not on `Cashflow`), each in its own file: `Xirr::TVM`
|
|
72
|
+
(`fv`/`pv`/`pmt`/`ipmt`/`ppmt`/`nper`/`rate`/`amortization_schedule`),
|
|
73
|
+
`Xirr::Rates`, `Xirr::Bonds`, `Xirr::Depreciation`, `Xirr::Returns`, plus periodic
|
|
74
|
+
`Xirr.irr`/`npv`/`mirr` (`periodic.rb`). `TVM.rate` and `Bonds.ytm` reuse
|
|
75
|
+
`RtSafe.find`. These were ported from the `finance-elixir` library; its tests are
|
|
76
|
+
the source of the expected values in `test/`.
|
|
77
|
+
|
|
78
|
+
### Configuration
|
|
79
|
+
|
|
80
|
+
`config.rb` uses `ActiveSupport::Configurable`. Each default is both
|
|
81
|
+
`Xirr.config.<key>` and a frozen constant `Xirr::<KEY>` (the constant keeps the
|
|
82
|
+
original default after reconfiguration). Configure with `Xirr.configure { |c| ... }`.
|
data/README.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
## NOTE
|
|
4
|
-
|
|
5
|
-
This gem is not very well maintained. You can check out a new and properlly maintained library for fast XIRR calculation:
|
|
6
|
-
https://github.com/fintual-oss/fast-xirr
|
|
1
|
+
# Xirr
|
|
7
2
|
|
|
8
|
-
|
|
9
|
-
- All calculations are underlying C ( = Fast )
|
|
10
|
-
- Bad results return NaN ( = you can decide how to deal with it )
|
|
3
|
+
[](https://github.com/tubedude/xirr/actions/workflows/ci.yml)
|
|
11
4
|
|
|
12
|
-
|
|
5
|
+
Calculates the XIRR of a cashflow — the internal rate of return for
|
|
6
|
+
transactions that land on arbitrary dates, the way a spreadsheet's `XIRR` does.
|
|
13
7
|
|
|
14
|
-
|
|
8
|
+
The default solver is a safeguarded Newton method (`rtsafe`): it brackets the
|
|
9
|
+
root first, then takes a Newton step when that step stays inside the bracket and
|
|
10
|
+
a bisection step otherwise. That converges on flows the plain Newton or bisection
|
|
11
|
+
methods struggle with — long maturities, low rates, and returns near -100% — and
|
|
12
|
+
reports non-convergence instead of returning a wrong number. The `:bisection` and
|
|
13
|
+
`:newton_method` solvers remain available, and there is an optional native (C)
|
|
14
|
+
build of `rtsafe`. See [Choosing a solver](#choosing-a-solver).
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
|
@@ -37,7 +37,7 @@ cf << Xirr::Transaction.new(-1000, date: '2014-01-01'.to_date)
|
|
|
37
37
|
cf << Xirr::Transaction.new(-2000, date: '2014-03-01'.to_date)
|
|
38
38
|
cf << Xirr::Transaction.new( 4500, date: '2015-12-01'.to_date)
|
|
39
39
|
cf.xirr
|
|
40
|
-
# 0.
|
|
40
|
+
# 0.251405 # Float, rounded to config.precision
|
|
41
41
|
|
|
42
42
|
flow = []
|
|
43
43
|
flow << Xirr::Transaction.new(-1000, date: '2014-01-01'.to_date)
|
|
@@ -46,17 +46,132 @@ flow << Xirr::Transaction.new( 4500, date: '2015-12-01'.to_date)
|
|
|
46
46
|
|
|
47
47
|
cf = Xirr::Cashflow.new(flow: flow)
|
|
48
48
|
cf.xirr
|
|
49
|
-
```
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`xirr` returns `config.replace_for_nil` (0.0 by default) when it can't find a
|
|
52
|
+
rate. Use `xirr!` when you would rather have an exception:
|
|
53
|
+
|
|
54
|
+
```rb
|
|
55
|
+
cf.xirr! # raises ArgumentError on an invalid or unsolvable flow
|
|
56
|
+
cf.xirr(method: :bisection)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Related figures
|
|
60
|
+
|
|
61
|
+
```rb
|
|
62
|
+
cf.xnpv(0.1) # net present value of the dated flows at a given rate
|
|
63
|
+
cf.mirr(0.10, 0.12) # modified IRR: finance rate, then reinvestment rate
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Periodic (dateless) flows
|
|
67
|
+
|
|
68
|
+
When the flows fall at equally spaced periods and the exact dates don't matter,
|
|
69
|
+
work with a plain list of amounts. The rate is per period.
|
|
70
|
+
|
|
71
|
+
```rb
|
|
72
|
+
Xirr.irr([-1000, 1100]) # => 0.1
|
|
73
|
+
Xirr.npv(0.1, [-1000, 600, 600]) # => 41.322314
|
|
74
|
+
Xirr.mirr([-120_000, 39_000, 30_000, 21_000, 37_000, 46_000], 0.10, 0.12)
|
|
75
|
+
# => 0.126094
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### More finance functions
|
|
79
|
+
|
|
80
|
+
Beyond cash-flow analysis, the gem carries the usual finance toolkit, grouped
|
|
81
|
+
into modules. Every function returns a plain number and raises `ArgumentError`
|
|
82
|
+
on inputs that have no answer.
|
|
83
|
+
|
|
84
|
+
```rb
|
|
85
|
+
# Time value of money — Xirr::TVM
|
|
86
|
+
Xirr::TVM.fv(0.05, 10, -100, -1000) # => 2886.68 (future value)
|
|
87
|
+
Xirr::TVM.pmt(0.10, 10, 1000) # => -162.75 (level payment)
|
|
88
|
+
Xirr::TVM.nper(0.05, -100, 1000) # => 14.21 (number of periods)
|
|
89
|
+
Xirr::TVM.amortization_schedule(0.10 / 12, 12, 1000)
|
|
90
|
+
# => [{period: 1, payment: -87.92, interest: -8.33, principal: -79.59, balance: 920.41}, ...]
|
|
91
|
+
|
|
92
|
+
# Rate conversions — Xirr::Rates
|
|
93
|
+
Xirr::Rates.effective_annual_rate(0.10, 12) # => 0.104713
|
|
94
|
+
|
|
95
|
+
# Fixed income — Xirr::Bonds
|
|
96
|
+
Xirr::Bonds.price(1000, 0.08, 0.10, 10) # => 875.377897
|
|
97
|
+
Xirr::Bonds.ytm(1000, 0.08, 875.377897, 10) # => 0.1
|
|
98
|
+
Xirr::Bonds.duration(0.06, 0.06, 3, 1) # => 2.833393
|
|
99
|
+
|
|
100
|
+
# Depreciation — Xirr::Depreciation
|
|
101
|
+
Xirr::Depreciation.sln(10_000, 1_000, 5) # => 1800.0
|
|
102
|
+
Xirr::Depreciation.ddb(10_000, 1_000, 5, 1) # => 4000.0
|
|
103
|
+
|
|
104
|
+
# Performance & risk — Xirr::Returns
|
|
105
|
+
Xirr::Returns.cagr(1000, 2000, 10) # => 0.071773
|
|
106
|
+
Xirr::Returns.twr([0.10, -0.05, 0.08]) # => 0.1286
|
|
107
|
+
Xirr::Returns.volatility([100, 102, 101, 103, 105]) # => 0.234528
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Choosing a solver
|
|
111
|
+
|
|
112
|
+
Four solvers are available; pick one per call with `method:`, or set a default
|
|
113
|
+
globally with `config.default_method`. They all return the same rate — they
|
|
114
|
+
differ in speed and robustness.
|
|
115
|
+
|
|
116
|
+
```rb
|
|
117
|
+
cf.xirr(method: :rtsafe) # default: safeguarded Newton, pure Ruby
|
|
118
|
+
cf.xirr(method: :rtsafe_c) # same algorithm, native C extension (optional)
|
|
119
|
+
cf.xirr(method: :brent) # derivative-free; can win on very large flows
|
|
120
|
+
cf.xirr(method: :newton_method) # plain Newton — fast, but guess-sensitive
|
|
121
|
+
cf.xirr(method: :bisection) # robust on a bracketed flow, but slowest
|
|
122
|
+
|
|
123
|
+
Xirr.configure { |c| c.default_method = :rtsafe_c }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`:rtsafe` is the default because among the pure-Ruby solvers it is both the
|
|
127
|
+
fastest and the one that converges reliably — it combines a Newton step with a
|
|
128
|
+
bisection safeguard, so `:newton_method` and `:bisection` exist mainly for
|
|
129
|
+
comparison. `:rtsafe_c` is faster still but needs the native extension.
|
|
130
|
+
|
|
131
|
+
`:brent` shares rtsafe's bracketing, so it is just as robust, but is
|
|
132
|
+
derivative-free: cheaper per iteration at the cost of more of them. It roughly
|
|
133
|
+
ties rtsafe on ordinary flows and can pull ahead on very large cashflows, where
|
|
134
|
+
skipping the derivative pass matters.
|
|
135
|
+
|
|
136
|
+
### The optional native extension
|
|
137
|
+
|
|
138
|
+
The gem ships a C build of `rtsafe`. It is optional: if a compiler isn't
|
|
139
|
+
available at install time the gem falls back to the pure-Ruby solver, and nothing
|
|
140
|
+
about the public API changes. Check whether it loaded with `Xirr::NATIVE`;
|
|
141
|
+
selecting `:rtsafe_c` without it raises `ArgumentError`. Build it during
|
|
142
|
+
development with `rake compile`.
|
|
143
|
+
|
|
144
|
+
## Benchmark
|
|
145
|
+
|
|
146
|
+
Time per `xirr` call, averaged over many runs (Ruby 3.3.1). All four solvers
|
|
147
|
+
return the same rate; lower is faster. Reproduce with
|
|
148
|
+
`ruby -Ilib benchmark/solvers.rb`.
|
|
149
|
+
|
|
150
|
+
| Cashflow | `rtsafe_c` | `rtsafe` | `newton_method` | `bisection` |
|
|
151
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
152
|
+
| moderate (3 flows) | 0.04 ms | 0.06 ms | 0.10 ms | 0.40 ms |
|
|
153
|
+
| high rate (~22) | 0.05 ms | 0.08 ms | 0.10 ms | 0.66 ms |
|
|
154
|
+
| long horizon (56 yr) | 0.03 ms | 0.06 ms | 0.07 ms | 0.29 ms |
|
|
155
|
+
| near -100% | 0.06 ms | 0.11 ms | 0.12 ms | 0.29 ms |
|
|
156
|
+
| large (361 monthly flows) | 2.59 ms | 4.05 ms | 8.10 ms | 40.4 ms |
|
|
157
|
+
|
|
158
|
+
`rtsafe` beats plain Newton and is several times faster than bisection, which
|
|
159
|
+
converges only linearly. The native `rtsafe_c` is roughly 1.5–2× faster again.
|
|
50
160
|
|
|
51
161
|
## Configuration
|
|
52
162
|
|
|
53
|
-
#
|
|
163
|
+
# initializer/xirr.rb
|
|
54
164
|
|
|
55
165
|
Xirr.configure do |config|
|
|
56
|
-
config.eps
|
|
57
|
-
config.
|
|
166
|
+
config.eps = 1.0e-12 # convergence tolerance on the rate (the step/interval size)
|
|
167
|
+
config.period = 365.25 # days per year used to discount
|
|
58
168
|
end
|
|
59
169
|
|
|
170
|
+
Other settings: `precision` (decimal places, default 6), `iteration_limit`
|
|
171
|
+
(default 50), `default_method` (default `:rtsafe`), `fallback` (default true),
|
|
172
|
+
`replace_for_nil` (returned when no rate is found, default 0.0), and
|
|
173
|
+
`raise_exception` (default false).
|
|
174
|
+
|
|
60
175
|
|
|
61
176
|
## Documentation
|
|
62
177
|
|
|
@@ -64,17 +179,8 @@ http://rubydoc.info/github/tubedude/xirr/master/frames
|
|
|
64
179
|
|
|
65
180
|
## Supported versions
|
|
66
181
|
|
|
67
|
-
Ruby:
|
|
68
|
-
|
|
69
|
-
- 2.3
|
|
70
|
-
- 2.4
|
|
71
|
-
- 2.7
|
|
72
|
-
|
|
73
|
-
ActiveSupport:
|
|
74
|
-
- 4.2
|
|
75
|
-
- 5
|
|
76
|
-
- 6
|
|
77
|
-
- 7
|
|
182
|
+
Ruby: >= 3.1
|
|
183
|
+
ActiveSupport: >= 6.1, < 8
|
|
78
184
|
|
|
79
185
|
## Thanks
|
|
80
186
|
|
data/Rakefile
CHANGED
|
@@ -2,6 +2,7 @@ require "bundler/gem_tasks"
|
|
|
2
2
|
|
|
3
3
|
require 'rake'
|
|
4
4
|
require 'rake/testtask'
|
|
5
|
+
require 'fileutils'
|
|
5
6
|
|
|
6
7
|
task :default => [:test_units]
|
|
7
8
|
|
|
@@ -10,3 +11,12 @@ Rake::TestTask.new("test_units") do |t|
|
|
|
10
11
|
t.verbose = false
|
|
11
12
|
t.warning = false
|
|
12
13
|
end
|
|
14
|
+
|
|
15
|
+
desc 'Build the optional native rtsafe extension into lib/xirr'
|
|
16
|
+
task :compile do
|
|
17
|
+
Dir.chdir('ext/xirr') { ruby 'extconf.rb'; sh 'make' }
|
|
18
|
+
built = Dir['ext/xirr/xirr_native.{so,bundle}'].first
|
|
19
|
+
raise 'native build produced no library (compiler missing?)' unless built
|
|
20
|
+
FileUtils.cp built, 'lib/xirr/'
|
|
21
|
+
puts "compiled #{File.basename(built)} -> lib/xirr/"
|
|
22
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Benchmarks the solvers (rtsafe_c when the native extension is built, rtsafe,
|
|
2
|
+
# newton_method, bisection) across a range of cashflows: moderate, high-rate,
|
|
3
|
+
# long-horizon, near -100%, and a large flow.
|
|
4
|
+
#
|
|
5
|
+
# ruby -Ilib benchmark/solvers.rb
|
|
6
|
+
#
|
|
7
|
+
# Reports average time per full `xirr` call and the value each solver returns
|
|
8
|
+
# (so speed can be read alongside whether the solver actually converged).
|
|
9
|
+
|
|
10
|
+
require 'active_support/all'
|
|
11
|
+
require 'xirr'
|
|
12
|
+
include Xirr
|
|
13
|
+
|
|
14
|
+
def cf(pairs)
|
|
15
|
+
c = Cashflow.new
|
|
16
|
+
pairs.each { |amount, date| c << Transaction.new(amount, date: date.to_date) }
|
|
17
|
+
c
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# --- test cashflows -------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
monthly = [[-100_000, Date.new(1990, 1, 1)]]
|
|
23
|
+
360.times { |m| monthly << [650, Date.new(1990, 1, 1) >> (m + 1)] }
|
|
24
|
+
|
|
25
|
+
FLOWS = {
|
|
26
|
+
'moderate (3 flows, ~0.23)' => cf([[1000, '1985-01-01'], [-600, '1990-01-01'], [-6000, '1995-01-01']]),
|
|
27
|
+
'high rate (~22)' => cf([[1_000_000, '2020-01-01'], [-2_200_000, '2020-05-01'], [-800_000, '2020-06-01']]),
|
|
28
|
+
'long horizon (56 yr)' => cf([[-1000, '1957-01-01'], [390_000, '2013-01-01']]),
|
|
29
|
+
'near -100%' => cf([[-10_000, '2014-04-15'], [305.6, '2014-05-15'], [500, '2014-10-19']]),
|
|
30
|
+
'large (361 monthly flows)' => cf(monthly),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
METHODS = %i[rtsafe brent newton_method bisection]
|
|
34
|
+
METHODS.unshift(:rtsafe_c) if Xirr::NATIVE
|
|
35
|
+
|
|
36
|
+
# --- timing ---------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
def time_call(cashflow, method, iterations)
|
|
39
|
+
# Warm up memoized dates / guess.
|
|
40
|
+
cashflow.xirr(method: method)
|
|
41
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
42
|
+
iterations.times { cashflow.xirr(method: method) }
|
|
43
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
44
|
+
(elapsed / iterations) * 1_000.0 # ms per call
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
puts format('%-28s %-15s %12s %s', 'cashflow', 'solver', 'ms/call', 'result')
|
|
48
|
+
puts '-' * 78
|
|
49
|
+
|
|
50
|
+
FLOWS.each do |name, cashflow|
|
|
51
|
+
iterations = cashflow.size > 50 ? 200 : 5_000
|
|
52
|
+
METHODS.each_with_index do |method, i|
|
|
53
|
+
ms = time_call(cashflow, method, iterations)
|
|
54
|
+
result = cashflow.xirr(method: method)
|
|
55
|
+
label = i.zero? ? name : ''
|
|
56
|
+
puts format('%-28s %-15s %12.4f %s', label, method, ms, result)
|
|
57
|
+
end
|
|
58
|
+
puts
|
|
59
|
+
end
|
data/ext/xirr/extconf.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require 'mkmf'
|
|
2
|
+
|
|
3
|
+
# The native rtsafe solver is optional. If it can't be built — no working C
|
|
4
|
+
# compiler, or create_makefile fails — write a do-nothing Makefile so
|
|
5
|
+
# `gem install` still succeeds; at runtime the require is rescued and the gem
|
|
6
|
+
# falls back to the pure-Ruby solver.
|
|
7
|
+
def skip_native(reason)
|
|
8
|
+
warn "xirr: skipping native extension (#{reason}); using pure Ruby."
|
|
9
|
+
File.write('Makefile', "all:\n\t@true\ninstall:\n\t@true\nclean:\n\t@true\n")
|
|
10
|
+
true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# try_compile actually exercises the toolchain, which create_makefile does not.
|
|
14
|
+
if !try_compile('int main(void){return 0;}')
|
|
15
|
+
skip_native('no working C compiler')
|
|
16
|
+
else
|
|
17
|
+
begin
|
|
18
|
+
$CFLAGS << ' -O3'
|
|
19
|
+
create_makefile('xirr/xirr_native')
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
skip_native(e.message)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#include <ruby.h>
|
|
2
|
+
#include <math.h>
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Native implementation of the safeguarded-Newton (rtsafe) solver. This mirrors
|
|
6
|
+
* lib/xirr/rtsafe.rb line for line so the two produce the same result; keep them
|
|
7
|
+
* in sync. Flows arrive as an array of [time, amount] pairs (time in years).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
#define BRACKET_CEILING 1.0e7
|
|
11
|
+
|
|
12
|
+
static double present_value(long n, const double *t, const double *amt, double rate) {
|
|
13
|
+
double sum = 0.0;
|
|
14
|
+
long i;
|
|
15
|
+
for (i = 0; i < n; i++) sum += amt[i] / pow(1.0 + rate, t[i]);
|
|
16
|
+
return sum;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static double present_value_derivative(long n, const double *t, const double *amt, double rate) {
|
|
20
|
+
double sum = 0.0;
|
|
21
|
+
long i;
|
|
22
|
+
for (i = 0; i < n; i++) sum += -t[i] * amt[i] / pow(1.0 + rate, t[i] + 1.0);
|
|
23
|
+
return sum;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Raise the floor just enough that the longest-dated discount factor stays finite. */
|
|
27
|
+
static double safe_low(long n, const double *t) {
|
|
28
|
+
double max_t = 1.0, v;
|
|
29
|
+
long i;
|
|
30
|
+
for (i = 0; i < n; i++) if (t[i] > max_t) max_t = t[i];
|
|
31
|
+
v = pow(1.0e-290, 1.0 / max_t);
|
|
32
|
+
if (v < 1.0e-6) v = 1.0e-6;
|
|
33
|
+
return v - 1.0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static int straddles_zero(double a, double b) {
|
|
37
|
+
return (a <= 0 && b >= 0) || (a >= 0 && b <= 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static int inside(double point, double xlo, double xhi) {
|
|
41
|
+
double lo = xlo < xhi ? xlo : xhi;
|
|
42
|
+
double hi = xlo < xhi ? xhi : xlo;
|
|
43
|
+
return point >= lo && point <= hi;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Returns the rate, or NAN when it can't converge. */
|
|
47
|
+
static double rtsafe(long n, const double *t, const double *amt,
|
|
48
|
+
double guess, double tol, long max_iter) {
|
|
49
|
+
double low = safe_low(n, t);
|
|
50
|
+
double f_low = present_value(n, t, amt, low);
|
|
51
|
+
double high = 1.0, a, b, xlo, xhi, x, f, df, dxold;
|
|
52
|
+
long i;
|
|
53
|
+
|
|
54
|
+
/* Expand the upper bound until the NPV changes sign. */
|
|
55
|
+
while (!straddles_zero(f_low, present_value(n, t, amt, high))) {
|
|
56
|
+
high = high * 2 + 1;
|
|
57
|
+
if (high > BRACKET_CEILING) return NAN;
|
|
58
|
+
}
|
|
59
|
+
a = low;
|
|
60
|
+
b = high;
|
|
61
|
+
|
|
62
|
+
/* a == low, so the NPV at a is f_low. Orient so it is negative at xlo and
|
|
63
|
+
positive at xhi. */
|
|
64
|
+
if (f_low < 0.0) { xlo = a; xhi = b; }
|
|
65
|
+
else { xlo = b; xhi = a; }
|
|
66
|
+
|
|
67
|
+
x = (guess > a && guess < b) ? guess : (a + b) / 2.0;
|
|
68
|
+
f = present_value(n, t, amt, x);
|
|
69
|
+
df = present_value_derivative(n, t, amt, x);
|
|
70
|
+
dxold = fabs(b - a);
|
|
71
|
+
|
|
72
|
+
for (i = 0; i < max_iter; i++) {
|
|
73
|
+
double next, dx, f_next, df_next;
|
|
74
|
+
int newton_usable = 0;
|
|
75
|
+
|
|
76
|
+
if (df != 0.0 && inside(x - f / df, xlo, xhi) && fabs(2.0 * f) <= fabs(dxold * df))
|
|
77
|
+
newton_usable = 1;
|
|
78
|
+
|
|
79
|
+
if (newton_usable) { dx = f / df; next = x - dx; }
|
|
80
|
+
else { dx = (xhi - xlo) / 2.0; next = xlo + dx; }
|
|
81
|
+
|
|
82
|
+
if (fabs(dx) < tol) return next;
|
|
83
|
+
|
|
84
|
+
f_next = present_value(n, t, amt, next);
|
|
85
|
+
df_next = present_value_derivative(n, t, amt, next);
|
|
86
|
+
if (f_next < 0.0) xlo = next; else xhi = next;
|
|
87
|
+
|
|
88
|
+
x = next; f = f_next; df = df_next; dxold = dx;
|
|
89
|
+
}
|
|
90
|
+
return NAN; /* did not converge within the iteration limit */
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* Xirr::Native.rtsafe(flows, guess, tolerance, max_iterations) -> Float | nil */
|
|
94
|
+
static VALUE rb_rtsafe(VALUE self, VALUE flows, VALUE guess, VALUE tol, VALUE max_iter) {
|
|
95
|
+
long n, i;
|
|
96
|
+
double *t, *amt, rate;
|
|
97
|
+
/* ALLOCV buffers are freed by the GC even if a conversion below raises, so a
|
|
98
|
+
bad element can't leak them. */
|
|
99
|
+
VALUE t_buf, amt_buf;
|
|
100
|
+
|
|
101
|
+
Check_Type(flows, T_ARRAY);
|
|
102
|
+
n = RARRAY_LEN(flows);
|
|
103
|
+
t = ALLOCV_N(double, t_buf, n);
|
|
104
|
+
amt = ALLOCV_N(double, amt_buf, n);
|
|
105
|
+
|
|
106
|
+
for (i = 0; i < n; i++) {
|
|
107
|
+
VALUE pair = rb_ary_entry(flows, i);
|
|
108
|
+
Check_Type(pair, T_ARRAY);
|
|
109
|
+
t[i] = NUM2DBL(rb_ary_entry(pair, 0));
|
|
110
|
+
amt[i] = NUM2DBL(rb_ary_entry(pair, 1));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
rate = rtsafe(n, t, amt, NUM2DBL(guess), NUM2DBL(tol), NUM2LONG(max_iter));
|
|
114
|
+
ALLOCV_END(t_buf);
|
|
115
|
+
ALLOCV_END(amt_buf);
|
|
116
|
+
|
|
117
|
+
if (isnan(rate) || isinf(rate) || rate <= -1.0) return Qnil;
|
|
118
|
+
return DBL2NUM(rate);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
void Init_xirr_native(void) {
|
|
122
|
+
VALUE mXirr = rb_define_module("Xirr");
|
|
123
|
+
VALUE mNative = rb_define_module_under(mXirr, "Native");
|
|
124
|
+
rb_define_singleton_method(mNative, "rtsafe", rb_rtsafe, 4);
|
|
125
|
+
}
|
data/lib/xirr/base.rb
CHANGED
|
@@ -1,32 +1,47 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Xirr
|
|
4
|
-
#
|
|
4
|
+
# Shared numerics for the solver classes: the net present value of a cashflow,
|
|
5
|
+
# its derivative, and the discounting timeline. Each solver includes this and
|
|
6
|
+
# is constructed with the {Cashflow} it works on.
|
|
5
7
|
module Base
|
|
6
|
-
extend ActiveSupport::Concern
|
|
7
8
|
attr_reader :cf
|
|
8
9
|
|
|
9
|
-
# @param cf [Cashflow]
|
|
10
|
-
# Must provide the calling Cashflow in order to calculate
|
|
10
|
+
# @param cf [Cashflow] the cashflow to solve
|
|
11
11
|
def initialize(cf)
|
|
12
12
|
@cf = cf
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
#
|
|
16
|
-
# @return [Rational]
|
|
15
|
+
# Periods (years, with the default period) from the first transaction to +date+.
|
|
17
16
|
# @param date [Date]
|
|
17
|
+
# @return [Float]
|
|
18
18
|
def periods_from_start(date)
|
|
19
19
|
(date - cf.min_date) / cf.period
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
# Net
|
|
23
|
-
# @param rate [
|
|
24
|
-
# @return [
|
|
22
|
+
# Net present value of the cashflow at +rate+: Σ amount / (1 + rate)^t.
|
|
23
|
+
# @param rate [Float]
|
|
24
|
+
# @return [Float]
|
|
25
25
|
def xnpv(rate)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
r = rate.to_f
|
|
27
|
+
flows.inject(0.0) { |sum, (t, amount)| sum + amount / (1 + r)**t }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Derivative of {#xnpv} with respect to rate: Σ -t · amount / (1 + rate)^(t+1).
|
|
31
|
+
# @param rate [Float]
|
|
32
|
+
# @return [Float]
|
|
33
|
+
def xnpv_derivative(rate)
|
|
34
|
+
r = rate.to_f
|
|
35
|
+
flows.inject(0.0) { |sum, (t, amount)| sum + (-t * amount / (1 + r)**(t + 1)) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# The cashflow as [years_from_start, amount] pairs, built once per solve so
|
|
41
|
+
# the date arithmetic isn't repeated on every NPV evaluation.
|
|
42
|
+
# @return [Array<Array(Float, Float)>]
|
|
43
|
+
def flows
|
|
44
|
+
@flows ||= cf.map { |t| [periods_from_start(t.date).to_f, t.amount.to_f] }
|
|
30
45
|
end
|
|
31
46
|
end
|
|
32
47
|
end
|