xirr 0.7.1 → 1.0.1

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: 6d1bfa1f5f5d2e73b6825148f58fa796eafb126a10cf23d3ca3cb2870148929b
4
+ data.tar.gz: fb3209c75bf2e28e76053a7c9b27406523fb38efea638a5090ddaf3309854517
5
5
  SHA512:
6
- metadata.gz: f6e38c8051ab43168cfafb1442fc897a96e9e25c62b55eb95320cc8f22fca57421ac360626ade4764115c17a4c7aa0fca84568f8d03238d78bc5bb3a123039cf
7
- data.tar.gz: 4b450e70ef5d58150e0b9cf416f9246e67b8fa015449f6697042dafe49b49e9114065bc6f711dafa41c5e2bcbff7882ee87d2a77ae55046c89c55aa7d81f6b90
6
+ metadata.gz: d924152e766469d3199f0ea9d0bb53d1ebf76198023f77e1528c58d9c6a31e1edbd83285d683946abbe4a752cbd47dc54d471ffe01b1e6801e03813f39c38da6
7
+ data.tar.gz: 33b9837ed6778eb50588a28db97625f56479aa319f7cc1f435b314f1b986d4e7d131a55a1886ddc5a261761c51d5cc6044b9dca3951453c821d01cf529293021
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ name: Ruby ${{ matrix.ruby }} / ActiveSupport ${{ matrix.activesupport }}
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ ruby: ['3.1', '3.2', '3.3']
16
+ activesupport: ['6.1', '7.2', '8.0', '8.1']
17
+ exclude:
18
+ # ActiveSupport 8 requires Ruby >= 3.2.
19
+ - ruby: '3.1'
20
+ activesupport: '8.0'
21
+ - ruby: '3.1'
22
+ activesupport: '8.1'
23
+ env:
24
+ # Read by the Gemfile to pin activesupport to this line.
25
+ ACTIVESUPPORT: ${{ matrix.activesupport }}
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: ruby/setup-ruby@v1
29
+ with:
30
+ ruby-version: ${{ matrix.ruby }}
31
+ bundler-cache: true
32
+ - run: bundle exec rake
data/.gitignore CHANGED
Binary file
data/CHANGE_LOG.md CHANGED
@@ -1,8 +1,45 @@
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.1
2
+ * Support Rails 8.0 and 8.1: allow `activesupport` `< 9` (was `< 8`).
3
+ * `Xirr.config` no longer uses `ActiveSupport::Configurable`, which Rails 8.1
4
+ deprecates (it printed a deprecation warning on load) and Rails 8.2 removes.
5
+ `Xirr.config` is still an `ActiveSupport::OrderedOptions` and
6
+ `Xirr.configure { |c| ... }` works as before.
7
+ * CI now tests every supported ActiveSupport line (6.1, 7.2, 8.0, 8.1).
8
+
9
+ ## Version 1.0.0
10
+ * New default solver: a safeguarded Newton (`rtsafe`) that brackets the root and
11
+ falls back to a bisection step per iteration. It converges on flows the old
12
+ Newton/bisection pair failed on — long maturities, low rates, and returns near
13
+ -100% — without overflowing.
14
+ * `Cashflow#xirr` no longer silently returns `0.0` for a flow that does have a
15
+ rate; the more robust solver now finds it. `Cashflow#xirr!` raises on an
16
+ invalid flow or genuine non-convergence instead of returning
17
+ `config.replace_for_nil`.
18
+ * Added `Cashflow#xnpv(rate)` and `Cashflow#mirr(finance_rate, reinvest_rate)`.
19
+ * Added periodic (dateless) helpers `Xirr.irr`, `Xirr.npv`, `Xirr.mirr` for
20
+ amounts at equally spaced periods.
21
+ * Added the wider finance toolkit ported from the finance-elixir library:
22
+ `Xirr::TVM` (`fv`, `pv`, `pmt`, `ipmt`, `ppmt`, `nper`, `rate`,
23
+ `amortization_schedule`), `Xirr::Rates` (`effective_annual_rate`,
24
+ `nominal_rate`, `continuous_to_periodic`), `Xirr::Bonds` (`price`, `ytm`,
25
+ `duration`, `modified_duration`, `convexity`), `Xirr::Depreciation` (`sln`,
26
+ `syd`, `ddb`, `db`), and `Xirr::Returns` (`volatility`, `cagr`,
27
+ `payback_period`, `discounted_payback_period`, `profitability_index`, `twr`).
28
+ * Added an optional native (C) build of the rtsafe solver, selectable with
29
+ `xirr(method: :rtsafe_c)`. It is best-effort: without a compiler the gem falls
30
+ back to pure Ruby, and `Xirr::NATIVE` reports whether it loaded. Added a
31
+ `benchmark/solvers.rb` comparing the solvers.
32
+ * Added a `:brent` solver (`xirr(method: :brent)`): derivative-free Brent's
33
+ method over rtsafe's bracketing — as robust as rtsafe, and cheaper per
34
+ iteration, which can help on very large cashflows.
35
+ * The `:bisection` and `:newton_method` methods are still available via
36
+ `xirr(method:)`. `:newton_method` was reimplemented in plain Ruby, dropping the
37
+ deprecated `bigdecimal/newton` standard library.
38
+ * Dropped the `bigdecimal` dependency entirely — results are `Float`, rounded to
39
+ `config.precision`. (The solvers already computed in `Float`.)
40
+ * Removed the defunct Travis and Coveralls setup; added GitHub Actions CI.
41
+ * Allow activesupport 7 (Fixes #30). Breaking: require activesupport >= 6.1 and
42
+ ruby >= 3.1.
6
43
 
7
44
  ## Version 0.7.0
8
45
  * Removed `RubyInLine`
data/CLAUDE.md ADDED
@@ -0,0 +1,86 @@
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, < 9`. To test one
14
+ ActiveSupport line, `ACTIVESUPPORT=8.1 bundle update activesupport` (the Gemfile
15
+ pins it; CI runs a Ruby × ActiveSupport matrix this way). There is no system Ruby on
16
+ the dev box used here; see the project memory for the asdf build recipe and the
17
+ `PATH` / `LD_LIBRARY_PATH` needed to run the suite.
18
+
19
+ ## Architecture
20
+
21
+ The gem computes XIRR (the IRR of an irregularly-dated cashflow, like Excel's
22
+ XIRR) and carries a wider finance toolkit. Everything works in `Float` — there is
23
+ no `BigDecimal` dependency. Functions return plain numbers and raise
24
+ `ArgumentError` on inputs with no answer (no Elixir-style ok/error tuples).
25
+
26
+ ### Cashflow and the solvers
27
+
28
+ - **`Xirr::Transaction`** (`transaction.rb`) — one dated amount (`Float`, `Date`).
29
+ - **`Xirr::Cashflow`** (`cashflow.rb`) — an `Array` subclass; the orchestrator and
30
+ public entry point (`#xirr`, `#xirr!`, `#xnpv`, `#mirr`). It validates the flow,
31
+ resolves options, compacts same-date transactions (`#compact_cf`), picks a
32
+ solver, and delegates. It does no root-finding itself.
33
+ - **Solvers** all `include Xirr::Base` (`base.rb`), which supplies `xnpv`,
34
+ `xnpv_derivative`, and the memoized `[years_from_start, amount]` `flows`:
35
+ - **`RtSafe`** (`rtsafe.rb`) — safeguarded Newton (the classic *rtsafe*): bracket
36
+ a sign change, then take a Newton step when it stays inside the bracket and a
37
+ bisection step otherwise. **This is the default** (`config.default_method`).
38
+ - **`RtSafeC`** (`rtsafe_c.rb` + `ext/xirr/xirr_native.c`) — the same algorithm
39
+ in C. Optional: loaded only if compiled (`Xirr::NATIVE`); `method: :rtsafe_c`.
40
+ **`rtsafe.rb` and `xirr_native.c` are meant to stay algorithmically identical —
41
+ change them together and re-check parity.**
42
+ - **`Brent`** (`brent.rb`) — derivative-free Brent's method over rtsafe's
43
+ bracketing (`method: :brent`); as robust as rtsafe, cheaper per iteration but
44
+ needs more of them, so it roughly ties rtsafe except on very large flows.
45
+ - **`Bisection`, `NewtonMethod`** — legacy solvers kept for `method:` and the
46
+ benchmark. rtsafe dominates both (faster and more robust).
47
+
48
+ ### Control flow of `Cashflow#xirr`
49
+
50
+ 1. `process_options` resolves `raise_exception` / `iteration_limit` / `period` in
51
+ precedence order (call → cashflow → config) via `resolve_option`, and
52
+ `switch_fallback` picks the method (an explicit `method:` turns fallback off).
53
+ 2. Invalid flow (no sign change) → `config.replace_for_nil`, or raises if asked.
54
+ 3. `compact_cf` merges same-date transactions; the chosen solver runs on it.
55
+ 4. On non-convergence, if fallback is on and the method wasn't already rtsafe, it
56
+ retries with `:rtsafe` (rtsafe is the safety net; nothing beats it, so it does
57
+ not fall back to the weaker solvers).
58
+
59
+ ### Key conventions
60
+
61
+ - **Sign is relative to the first transaction** (`first_transaction_direction`):
62
+ `inflow`/`outflows` and validity are defined by each amount's sign times that
63
+ direction, not by absolute positive/negative.
64
+ - **rtsafe does not depend on `irr_guess`** — it brackets independently and only
65
+ uses a guess when it falls inside the bracket. `irr_guess` is a public
66
+ convenience and the starting point for the fragile legacy Newton.
67
+ - **Precision & convergence** come from config: `eps` (tolerance on the *rate
68
+ step/interval*, not on NPV), `precision` (rounding), `iteration_limit`, `period`
69
+ (days/year).
70
+
71
+ ### The other finance modules
72
+
73
+ Module functions (not on `Cashflow`), each in its own file: `Xirr::TVM`
74
+ (`fv`/`pv`/`pmt`/`ipmt`/`ppmt`/`nper`/`rate`/`amortization_schedule`),
75
+ `Xirr::Rates`, `Xirr::Bonds`, `Xirr::Depreciation`, `Xirr::Returns`, plus periodic
76
+ `Xirr.irr`/`npv`/`mirr` (`periodic.rb`). `TVM.rate` and `Bonds.ytm` reuse
77
+ `RtSafe.find`. These were ported from the `finance-elixir` library; its tests are
78
+ the source of the expected values in `test/`.
79
+
80
+ ### Configuration
81
+
82
+ `config.rb` defines `Xirr.config` (an `ActiveSupport::OrderedOptions`) and
83
+ `Xirr.configure`. It no longer uses `ActiveSupport::Configurable`, which Rails 8.1
84
+ deprecates and 8.2 removes, so don't reintroduce it. Each default is both
85
+ `Xirr.config.<key>` and a frozen constant `Xirr::<KEY>` (the constant keeps the
86
+ original default after reconfiguration). Configure with `Xirr.configure { |c| ... }`.
data/Gemfile CHANGED
@@ -2,3 +2,9 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in xirr.gemspec
4
4
  gemspec
5
+
6
+ # Pin one ActiveSupport line to test against it, as CI does:
7
+ # ACTIVESUPPORT=8.1 bundle update activesupport && bundle exec rake
8
+ if (activesupport = ENV['ACTIVESUPPORT'])
9
+ gem 'activesupport', "~> #{activesupport}.0"
10
+ end
data/README.md CHANGED
@@ -1,17 +1,18 @@
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
+ [![Gem Version](https://badge.fury.io/rb/xirr.svg)](https://badge.fury.io/rb/xirr)
4
+ [![CI](https://github.com/tubedude/xirr/actions/workflows/ci.yml/badge.svg)](https://github.com/tubedude/xirr/actions/workflows/ci.yml)
11
5
 
12
- # Xirr
6
+ Calculates the XIRR of a cashflow — the internal rate of return for
7
+ transactions that land on arbitrary dates, the way a spreadsheet's `XIRR` does.
13
8
 
14
- This is a gem to calculate XIRR on Bisection Method or Newton Method.
9
+ The default solver is a safeguarded Newton method (`rtsafe`): it brackets the
10
+ root first, then takes a Newton step when that step stays inside the bracket and
11
+ a bisection step otherwise. That converges on flows the plain Newton or bisection
12
+ methods struggle with — long maturities, low rates, and returns near -100% — and
13
+ reports non-convergence instead of returning a wrong number. The `:bisection` and
14
+ `:newton_method` solvers remain available, and there is an optional native (C)
15
+ build of `rtsafe`. See [Choosing a solver](#choosing-a-solver).
15
16
 
16
17
  ## Installation
17
18
 
@@ -37,7 +38,7 @@ cf << Xirr::Transaction.new(-1000, date: '2014-01-01'.to_date)
37
38
  cf << Xirr::Transaction.new(-2000, date: '2014-03-01'.to_date)
38
39
  cf << Xirr::Transaction.new( 4500, date: '2015-12-01'.to_date)
39
40
  cf.xirr
40
- # 0.25159694345042327 # [BigDecimal]
41
+ # 0.251405 # Float, rounded to config.precision
41
42
 
42
43
  flow = []
43
44
  flow << Xirr::Transaction.new(-1000, date: '2014-01-01'.to_date)
@@ -46,17 +47,132 @@ flow << Xirr::Transaction.new( 4500, date: '2015-12-01'.to_date)
46
47
 
47
48
  cf = Xirr::Cashflow.new(flow: flow)
48
49
  cf.xirr
49
- ```
50
+ ```
51
+
52
+ `xirr` returns `config.replace_for_nil` (0.0 by default) when it can't find a
53
+ rate. Use `xirr!` when you would rather have an exception:
54
+
55
+ ```rb
56
+ cf.xirr! # raises ArgumentError on an invalid or unsolvable flow
57
+ cf.xirr(method: :bisection)
58
+ ```
59
+
60
+ ### Related figures
61
+
62
+ ```rb
63
+ cf.xnpv(0.1) # net present value of the dated flows at a given rate
64
+ cf.mirr(0.10, 0.12) # modified IRR: finance rate, then reinvestment rate
65
+ ```
66
+
67
+ ### Periodic (dateless) flows
68
+
69
+ When the flows fall at equally spaced periods and the exact dates don't matter,
70
+ work with a plain list of amounts. The rate is per period.
71
+
72
+ ```rb
73
+ Xirr.irr([-1000, 1100]) # => 0.1
74
+ Xirr.npv(0.1, [-1000, 600, 600]) # => 41.322314
75
+ Xirr.mirr([-120_000, 39_000, 30_000, 21_000, 37_000, 46_000], 0.10, 0.12)
76
+ # => 0.126094
77
+ ```
78
+
79
+ ### More finance functions
80
+
81
+ Beyond cash-flow analysis, the gem carries the usual finance toolkit, grouped
82
+ into modules. Every function returns a plain number and raises `ArgumentError`
83
+ on inputs that have no answer.
84
+
85
+ ```rb
86
+ # Time value of money — Xirr::TVM
87
+ Xirr::TVM.fv(0.05, 10, -100, -1000) # => 2886.68 (future value)
88
+ Xirr::TVM.pmt(0.10, 10, 1000) # => -162.75 (level payment)
89
+ Xirr::TVM.nper(0.05, -100, 1000) # => 14.21 (number of periods)
90
+ Xirr::TVM.amortization_schedule(0.10 / 12, 12, 1000)
91
+ # => [{period: 1, payment: -87.92, interest: -8.33, principal: -79.59, balance: 920.41}, ...]
92
+
93
+ # Rate conversions — Xirr::Rates
94
+ Xirr::Rates.effective_annual_rate(0.10, 12) # => 0.104713
95
+
96
+ # Fixed income — Xirr::Bonds
97
+ Xirr::Bonds.price(1000, 0.08, 0.10, 10) # => 875.377897
98
+ Xirr::Bonds.ytm(1000, 0.08, 875.377897, 10) # => 0.1
99
+ Xirr::Bonds.duration(0.06, 0.06, 3, 1) # => 2.833393
100
+
101
+ # Depreciation — Xirr::Depreciation
102
+ Xirr::Depreciation.sln(10_000, 1_000, 5) # => 1800.0
103
+ Xirr::Depreciation.ddb(10_000, 1_000, 5, 1) # => 4000.0
104
+
105
+ # Performance & risk — Xirr::Returns
106
+ Xirr::Returns.cagr(1000, 2000, 10) # => 0.071773
107
+ Xirr::Returns.twr([0.10, -0.05, 0.08]) # => 0.1286
108
+ Xirr::Returns.volatility([100, 102, 101, 103, 105]) # => 0.234528
109
+ ```
110
+
111
+ ## Choosing a solver
112
+
113
+ Four solvers are available; pick one per call with `method:`, or set a default
114
+ globally with `config.default_method`. They all return the same rate — they
115
+ differ in speed and robustness.
116
+
117
+ ```rb
118
+ cf.xirr(method: :rtsafe) # default: safeguarded Newton, pure Ruby
119
+ cf.xirr(method: :rtsafe_c) # same algorithm, native C extension (optional)
120
+ cf.xirr(method: :brent) # derivative-free; can win on very large flows
121
+ cf.xirr(method: :newton_method) # plain Newton — fast, but guess-sensitive
122
+ cf.xirr(method: :bisection) # robust on a bracketed flow, but slowest
123
+
124
+ Xirr.configure { |c| c.default_method = :rtsafe_c }
125
+ ```
126
+
127
+ `:rtsafe` is the default because among the pure-Ruby solvers it is both the
128
+ fastest and the one that converges reliably — it combines a Newton step with a
129
+ bisection safeguard, so `:newton_method` and `:bisection` exist mainly for
130
+ comparison. `:rtsafe_c` is faster still but needs the native extension.
131
+
132
+ `:brent` shares rtsafe's bracketing, so it is just as robust, but is
133
+ derivative-free: cheaper per iteration at the cost of more of them. It roughly
134
+ ties rtsafe on ordinary flows and can pull ahead on very large cashflows, where
135
+ skipping the derivative pass matters.
136
+
137
+ ### The optional native extension
138
+
139
+ The gem ships a C build of `rtsafe`. It is optional: if a compiler isn't
140
+ available at install time the gem falls back to the pure-Ruby solver, and nothing
141
+ about the public API changes. Check whether it loaded with `Xirr::NATIVE`;
142
+ selecting `:rtsafe_c` without it raises `ArgumentError`. Build it during
143
+ development with `rake compile`.
144
+
145
+ ## Benchmark
146
+
147
+ Time per `xirr` call, averaged over many runs (Ruby 3.3.1). All four solvers
148
+ return the same rate; lower is faster. Reproduce with
149
+ `ruby -Ilib benchmark/solvers.rb`.
150
+
151
+ | Cashflow | `rtsafe_c` | `rtsafe` | `newton_method` | `bisection` |
152
+ | --- | ---: | ---: | ---: | ---: |
153
+ | moderate (3 flows) | 0.04 ms | 0.06 ms | 0.10 ms | 0.40 ms |
154
+ | high rate (~22) | 0.05 ms | 0.08 ms | 0.10 ms | 0.66 ms |
155
+ | long horizon (56 yr) | 0.03 ms | 0.06 ms | 0.07 ms | 0.29 ms |
156
+ | near -100% | 0.06 ms | 0.11 ms | 0.12 ms | 0.29 ms |
157
+ | large (361 monthly flows) | 2.59 ms | 4.05 ms | 8.10 ms | 40.4 ms |
158
+
159
+ `rtsafe` beats plain Newton and is several times faster than bisection, which
160
+ converges only linearly. The native `rtsafe_c` is roughly 1.5–2× faster again.
50
161
 
51
162
  ## Configuration
52
163
 
53
- # intializer/xirr.rb
164
+ # initializer/xirr.rb
54
165
 
55
166
  Xirr.configure do |config|
56
- config.eps = '1.0e-12'
57
- config.days_in_year = 365.25
167
+ config.eps = 1.0e-12 # convergence tolerance on the rate (the step/interval size)
168
+ config.period = 365.25 # days per year used to discount
58
169
  end
59
170
 
171
+ Other settings: `precision` (decimal places, default 6), `iteration_limit`
172
+ (default 50), `default_method` (default `:rtsafe`), `fallback` (default true),
173
+ `replace_for_nil` (returned when no rate is found, default 0.0), and
174
+ `raise_exception` (default false).
175
+
60
176
 
61
177
  ## Documentation
62
178
 
@@ -64,17 +180,8 @@ http://rubydoc.info/github/tubedude/xirr/master/frames
64
180
 
65
181
  ## Supported versions
66
182
 
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
183
+ Ruby: >= 3.1
184
+ ActiveSupport: >= 6.1, < 9 (Rails 6.1 through 8.1)
78
185
 
79
186
  ## Thanks
80
187
 
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
+ }