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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1281b23c8069cbd5bf16cecf50ae129ea2d4bab31c4c5e89906d970f6e15b7d
4
- data.tar.gz: 2431eed14a1de0deb4694621eb8136b10b53c6ce44d888682101e9ca04e46aca
3
+ metadata.gz: 3aca532866fb5a06872ab2ea0e49a812d4ad4e2a638df03e456e7ea2c742efd7
4
+ data.tar.gz: 7cf7a6cd4334bc92a5949cdad6d291a0001fa4a2986c86b413106612dca08dd6
5
5
  SHA512:
6
- metadata.gz: f6e38c8051ab43168cfafb1442fc897a96e9e25c62b55eb95320cc8f22fca57421ac360626ade4764115c17a4c7aa0fca84568f8d03238d78bc5bb3a123039cf
7
- data.tar.gz: 4b450e70ef5d58150e0b9cf416f9246e67b8fa015449f6697042dafe49b49e9114065bc6f711dafa41c5e2bcbff7882ee87d2a77ae55046c89c55aa7d81f6b90
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
- ## Unreleased
2
- * Fix removal of `RubyInLine`
3
- * Allow activesupport 7. Fixes #30
4
- * Breaking: Require activesupport >= 6.1
5
- * Breaking: Require ruby >= 3.1
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
- [![Build Status](https://travis-ci.org/tubedude/xirr.svg)](https://travis-ci.org/tubedude/xirr)[![Coverage Status](https://coveralls.io/repos/tubedude/xirr/badge.svg?branch=master)](https://coveralls.io/r/tubedude/xirr?branch=master)[![Code Climate](https://codeclimate.com/github/tubedude/xirr/badges/gpa.svg)](https://codeclimate.com/github/tubedude/xirr)
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
- I haven't tried it yet, but I liked two things:
9
- - All calculations are underlying C ( = Fast )
10
- - Bad results return NaN ( = you can decide how to deal with it )
3
+ [![CI](https://github.com/tubedude/xirr/actions/workflows/ci.yml/badge.svg)](https://github.com/tubedude/xirr/actions/workflows/ci.yml)
11
4
 
12
- # Xirr
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
- This is a gem to calculate XIRR on Bisection Method or Newton Method.
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.25159694345042327 # [BigDecimal]
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
- # intializer/xirr.rb
163
+ # initializer/xirr.rb
54
164
 
55
165
  Xirr.configure do |config|
56
- config.eps = '1.0e-12'
57
- config.days_in_year = 365.25
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
- - 2.2.1
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
@@ -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
- # Base module for XIRR calculation Methods
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
- # Calculates days until last transaction
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 Present Value function that will be used to reduce the cashflow
23
- # @param rate [BigDecimal]
24
- # @return [BigDecimal]
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
- cf.inject(0) do |sum, t|
27
- # sum + (xnpv_c rate, t.amount, periods_from_start(t.date))
28
- sum + t.amount / (1+rate.to_f) ** periods_from_start(t.date)
29
- end
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