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.
@@ -0,0 +1,135 @@
1
+ require_relative 'test_helper'
2
+
3
+ # Property and stress tests for the rtsafe solver. These are the tests that would
4
+ # catch a bracketing or overflow regression: they assert the invariant a solver
5
+ # must satisfy — the returned rate sits within one precision-step of a genuine
6
+ # sign change of the net present value — rather than a hand-computed number.
7
+ describe 'rtsafe solver properties' do
8
+ # The rounding granularity of a converged result.
9
+ H = 10.0**(-Xirr.config.precision)
10
+
11
+ # The returned rate is a real root to the configured precision when the NPV
12
+ # changes sign across [rate - H, rate + H] (or is already ~0 there). This holds
13
+ # regardless of the flow's magnitude or maturity, so it doubles as the property
14
+ # assertion and the stress-case assertion.
15
+ def assert_root(cf, rate)
16
+ here = cf.xnpv(rate)
17
+ lo = cf.xnpv(rate - H)
18
+ hi = cf.xnpv(rate + H)
19
+ straddles = (lo <= 0 && hi >= 0) || (lo >= 0 && hi <= 0)
20
+ assert straddles || here.abs < 1e-6,
21
+ "rate #{rate} is not a root to precision #{Xirr.config.precision}: " \
22
+ "xnpv(r-H)=#{lo}, xnpv(r)=#{here}, xnpv(r+H)=#{hi}"
23
+ end
24
+
25
+ # A valid, single-sign-change cashflow: an initial outlay followed by returns.
26
+ def random_cashflow(rng)
27
+ cf = Cashflow.new
28
+ base = Date.new(2000, 1, 1)
29
+ cf << Transaction.new(-(1_000 + rng.rand(9_000)), date: base)
30
+ n = 1 + rng.rand(10)
31
+ (1..n).each do |i|
32
+ days = i * (30 + rng.rand(400))
33
+ cf << Transaction.new(rng.rand(3_500) - 800, date: base + days)
34
+ end
35
+ # Guarantee at least one inflow so the series changes sign.
36
+ cf << Transaction.new(500 + rng.rand(6_000), date: base + (n + 1) * 400)
37
+ cf
38
+ end
39
+
40
+ it 'returns a genuine root for random cashflows' do
41
+ rng = Random.new(0xC0FFEE)
42
+ checked = 0
43
+ 300.times do
44
+ cf = random_cashflow(rng)
45
+ next unless cf.valid?
46
+
47
+ rate =
48
+ begin
49
+ cf.xirr!(method: :rtsafe)
50
+ rescue ArgumentError
51
+ next # genuine non-convergence, nothing to assert about
52
+ end
53
+
54
+ assert_root(cf, rate)
55
+ checked += 1
56
+ end
57
+ assert checked > 100, "property test was nearly vacuous: only #{checked} cases checked"
58
+ end
59
+
60
+ it 'agrees with bisection and newton on a well-behaved flow' do
61
+ cf = Cashflow.new
62
+ cf << Transaction.new(1000, date: '1985-01-01'.to_date)
63
+ cf << Transaction.new(-600, date: '1990-01-01'.to_date)
64
+ cf << Transaction.new(-6000, date: '1995-01-01'.to_date)
65
+ assert_in_delta cf.xirr(method: :bisection).to_f, cf.xirr(method: :rtsafe).to_f, 1e-5
66
+ assert_in_delta cf.xirr(method: :newton_method).to_f, cf.xirr(method: :rtsafe).to_f, 1e-5
67
+ end
68
+
69
+ describe 'stress cases' do
70
+ it 'handles a 30-year monthly schedule (long maturity, low per-period rate)' do
71
+ cf = Cashflow.new
72
+ base = Date.new(1990, 1, 1)
73
+ cf << Transaction.new(-100_000, date: base)
74
+ 360.times { |m| cf << Transaction.new(650, date: base >> (m + 1)) }
75
+ rate = cf.xirr!(method: :rtsafe)
76
+ assert rate > 0, "expected a positive rate, got #{rate}"
77
+ assert_root(cf, rate)
78
+ end
79
+
80
+ it 'handles a near -100% return without overflowing' do
81
+ cf = Cashflow.new
82
+ cf << Transaction.new(-10_000, date: '2014-04-15'.to_date)
83
+ cf << Transaction.new(305.6, date: '2014-05-15'.to_date)
84
+ cf << Transaction.new(500, date: '2014-10-19'.to_date)
85
+ rate = cf.xirr!(method: :rtsafe)
86
+ assert rate < -0.9, "expected a rate near -1, got #{rate}"
87
+ assert rate > -1.0
88
+ end
89
+
90
+ it 'handles a very long, low-rate horizon' do
91
+ cf = Cashflow.new flow: [
92
+ Transaction.new(-1000, date: Date.new(1957, 1, 1)),
93
+ Transaction.new(390_000, date: Date.new(2013, 1, 1))
94
+ ]
95
+ assert_in_delta 0.112339, cf.xirr!(method: :rtsafe).to_f, 1e-5
96
+ end
97
+
98
+ it 'reports non-convergence rather than crashing on an impossible bracket' do
99
+ # One day apart with an enormous return: the root is astronomically high and
100
+ # lies outside any sane bracket, so rtsafe gives up cleanly.
101
+ cf = Cashflow.new
102
+ cf << Transaction.new(1000, date: '1985-01-01'.to_date)
103
+ cf << Transaction.new(-6000, date: '1985-01-02'.to_date)
104
+ assert_raises(ArgumentError) { cf.xirr!(method: :rtsafe) }
105
+ end
106
+
107
+ it 'the native rtsafe_c matches the pure-Ruby rtsafe' do
108
+ skip 'native extension not compiled' unless Xirr::NATIVE
109
+ rng = Random.new(0xBEEF)
110
+ 50.times do
111
+ cf = random_cashflow(rng)
112
+ next unless cf.valid?
113
+ assert_equal cf.xirr(method: :rtsafe), cf.xirr(method: :rtsafe_c)
114
+ end
115
+ end
116
+
117
+ it 'raises a clear error when rtsafe_c is chosen without the extension' do
118
+ skip 'extension is compiled here' if Xirr::NATIVE
119
+ cf = Cashflow.new
120
+ cf << Transaction.new(-1000, date: '2014-01-01'.to_date)
121
+ cf << Transaction.new(1100, date: '2015-01-01'.to_date)
122
+ assert_raises(ArgumentError) { cf.xirr(method: :rtsafe_c) }
123
+ end
124
+
125
+ it 'newton does not crash when a guess overshoots below -100%' do
126
+ # A Newton step from this guess drives the rate below -1, where the
127
+ # discount base goes negative; the solver must bail, not raise.
128
+ cf = Cashflow.new
129
+ cf << Transaction.new(-10_000, date: '2014-04-15'.to_date)
130
+ cf << Transaction.new(305.6, date: '2014-05-15'.to_date)
131
+ cf << Transaction.new(500, date: '2014-10-19'.to_date)
132
+ assert_equal Xirr::REPLACE_FOR_NIL, cf.xirr(method: :newton_method, guess: 0.1)
133
+ end
134
+ end
135
+ end
@@ -6,10 +6,10 @@ describe 'Transaction' do
6
6
  end
7
7
 
8
8
  it 'converts amount to float' do
9
- assert true, @t.amount.kind_of?(Float)
9
+ assert_kind_of Float, @t.amount
10
10
  end
11
11
 
12
- it 'retreives the date' do
12
+ it 'retrieves the date' do
13
13
  assert_equal Date.today, @t.date
14
14
  end
15
15
 
data/test/test_tvm.rb ADDED
@@ -0,0 +1,69 @@
1
+ require_relative 'test_helper'
2
+
3
+ describe 'Xirr::TVM' do
4
+ it 'fv — future value' do
5
+ assert_in_delta 2886.68, Xirr::TVM.fv(0.05, 10, -100, -1000).round(2), 1e-2
6
+ end
7
+
8
+ it 'pv — present value' do
9
+ assert_in_delta 1386.09, Xirr::TVM.pv(0.05, 10, -100, -1000).round(2), 1e-2
10
+ end
11
+
12
+ it 'pmt — level payment' do
13
+ assert_in_delta(-162.75, Xirr::TVM.pmt(0.10, 10, 1000).round(2), 1e-2)
14
+ end
15
+
16
+ it 'ipmt — interest portion' do
17
+ assert_in_delta(-8.333333, Xirr::TVM.ipmt(0.10 / 12, 1, 12, 1000), 1e-6)
18
+ end
19
+
20
+ it 'ppmt — principal portion' do
21
+ assert_in_delta(-79.582554, Xirr::TVM.ppmt(0.10 / 12, 1, 12, 1000), 1e-6)
22
+ end
23
+
24
+ it 'ipmt + ppmt equals pmt for every period' do
25
+ payment = Xirr::TVM.pmt(0.10 / 12, 12, 1000)
26
+ (1..12).each do |per|
27
+ split = Xirr::TVM.ipmt(0.10 / 12, per, 12, 1000) + Xirr::TVM.ppmt(0.10 / 12, per, 12, 1000)
28
+ assert_in_delta payment, split, 1e-9
29
+ end
30
+ end
31
+
32
+ it 'nper — number of periods' do
33
+ assert_in_delta 14.21, Xirr::TVM.nper(0.05, -100, 1000).round(2), 1e-2
34
+ end
35
+
36
+ it 'rate — solves for the periodic rate' do
37
+ assert_equal 0.0, Xirr::TVM.rate(10, -100, 1000)
38
+ end
39
+
40
+ it 'validates the type argument' do
41
+ assert_raises(ArgumentError) { Xirr::TVM.fv(0.05, 10, -100, -1000, 2) }
42
+ end
43
+
44
+ it 'handles annuity due (type 1)' do
45
+ # A due payment is the ordinary payment discounted one period earlier.
46
+ ordinary = Xirr::TVM.pmt(0.10, 10, 1000, 0, 0)
47
+ due = Xirr::TVM.pmt(0.10, 10, 1000, 0, 1)
48
+ assert_in_delta ordinary / 1.10, due, 1e-9
49
+ end
50
+
51
+ describe 'amortization_schedule' do
52
+ before do
53
+ @schedule = Xirr::TVM.amortization_schedule(0.10 / 12, 12, 1000)
54
+ end
55
+
56
+ it 'has a correct first row' do
57
+ first = @schedule.first
58
+ assert_equal(-87.92, first[:payment])
59
+ assert_equal(-8.33, first[:interest])
60
+ assert_equal(-79.59, first[:principal])
61
+ assert_equal 920.41, first[:balance]
62
+ end
63
+
64
+ it 'pays the balance down to exactly zero' do
65
+ assert_equal 0.0, @schedule.last[:balance]
66
+ assert_equal 12, @schedule.length
67
+ end
68
+ end
69
+ end
data/xirr.gemspec CHANGED
@@ -8,8 +8,8 @@ Gem::Specification.new do |spec|
8
8
  spec.version = Xirr::VERSION
9
9
  spec.authors = ['tubedude']
10
10
  spec.email = ['beto@trevisan.me']
11
- spec.summary = %q{Calculates XIRR (Bisection and Newton method) of a cashflow}
12
- spec.description = %q{Calculates IRR of a Cashflow, similar to Excel's, XIRR formula. It defaults to Newton Method, but will calculate Bisection as well.}
11
+ spec.summary = %q{XIRR and a finance toolkit for Ruby, built on a safeguarded-Newton solver}
12
+ spec.description = %q{Calculates the XIRR of a cashflow (the internal rate of return for transactions on arbitrary dates, like Excel's XIRR) using a safeguarded-Newton solver, with an optional native C build. Also includes time-value-of-money, bond, depreciation, rate-conversion, and performance-metric functions.}
13
13
  spec.homepage = 'https://github.com/tubedude/xirr'
14
14
  spec.license = 'MIT'
15
15
 
@@ -18,13 +18,15 @@ Gem::Specification.new do |spec|
18
18
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
19
  spec.require_paths = ['lib']
20
20
 
21
+ # Optional native rtsafe solver. The build is best-effort (see extconf.rb); the
22
+ # gem falls back to the pure-Ruby solver when it isn't compiled.
23
+ spec.extensions = ['ext/xirr/extconf.rb']
24
+
21
25
  spec.required_ruby_version = '>=3.1'
22
26
 
23
27
  spec.add_dependency 'activesupport', '>= 6.1', '< 8'
24
28
 
25
- spec.add_development_dependency 'activesupport', '>= 6.1', '< 8'
26
29
  spec.add_development_dependency 'minitest', '~> 5.14'
27
- spec.add_development_dependency 'coveralls', '~> 0.8'
28
30
  spec.add_development_dependency 'bundler', '>= 2.2'
29
31
  spec.add_development_dependency 'rake', '~> 13.0'
30
32
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: xirr
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tubedude
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-08-19 00:00:00.000000000 Z
11
+ date: 2026-07-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -30,26 +30,6 @@ dependencies:
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '8'
33
- - !ruby/object:Gem::Dependency
34
- name: activesupport
35
- requirement: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - ">="
38
- - !ruby/object:Gem::Version
39
- version: '6.1'
40
- - - "<"
41
- - !ruby/object:Gem::Version
42
- version: '8'
43
- type: :development
44
- prerelease: false
45
- version_requirements: !ruby/object:Gem::Requirement
46
- requirements:
47
- - - ">="
48
- - !ruby/object:Gem::Version
49
- version: '6.1'
50
- - - "<"
51
- - !ruby/object:Gem::Version
52
- version: '8'
53
33
  - !ruby/object:Gem::Dependency
54
34
  name: minitest
55
35
  requirement: !ruby/object:Gem::Requirement
@@ -64,20 +44,6 @@ dependencies:
64
44
  - - "~>"
65
45
  - !ruby/object:Gem::Version
66
46
  version: '5.14'
67
- - !ruby/object:Gem::Dependency
68
- name: coveralls
69
- requirement: !ruby/object:Gem::Requirement
70
- requirements:
71
- - - "~>"
72
- - !ruby/object:Gem::Version
73
- version: '0.8'
74
- type: :development
75
- prerelease: false
76
- version_requirements: !ruby/object:Gem::Requirement
77
- requirements:
78
- - - "~>"
79
- - !ruby/object:Gem::Version
80
- version: '0.8'
81
47
  - !ruby/object:Gem::Dependency
82
48
  name: bundler
83
49
  requirement: !ruby/object:Gem::Requirement
@@ -106,35 +72,58 @@ dependencies:
106
72
  - - "~>"
107
73
  - !ruby/object:Gem::Version
108
74
  version: '13.0'
109
- description: Calculates IRR of a Cashflow, similar to Excel's, XIRR formula. It defaults
110
- to Newton Method, but will calculate Bisection as well.
75
+ description: Calculates the XIRR of a cashflow (the internal rate of return for transactions
76
+ on arbitrary dates, like Excel's XIRR) using a safeguarded-Newton solver, with an
77
+ optional native C build. Also includes time-value-of-money, bond, depreciation,
78
+ rate-conversion, and performance-metric functions.
111
79
  email:
112
80
  - beto@trevisan.me
113
81
  executables: []
114
- extensions: []
82
+ extensions:
83
+ - ext/xirr/extconf.rb
115
84
  extra_rdoc_files: []
116
85
  files:
117
- - ".coveralls.yml"
86
+ - ".github/workflows/ci.yml"
118
87
  - ".gitignore"
119
88
  - ".ruby-gemset"
120
89
  - ".ruby-version"
121
- - ".travis.yml"
122
90
  - CHANGE_LOG.md
91
+ - CLAUDE.md
123
92
  - Gemfile
124
93
  - LICENSE.txt
125
94
  - README.md
126
95
  - Rakefile
96
+ - benchmark/solvers.rb
97
+ - ext/xirr/extconf.rb
98
+ - ext/xirr/xirr_native.c
127
99
  - lib/xirr.rb
128
100
  - lib/xirr/base.rb
129
101
  - lib/xirr/bisection.rb
102
+ - lib/xirr/bonds.rb
103
+ - lib/xirr/brent.rb
130
104
  - lib/xirr/cashflow.rb
131
105
  - lib/xirr/config.rb
106
+ - lib/xirr/depreciation.rb
132
107
  - lib/xirr/newton_method.rb
108
+ - lib/xirr/periodic.rb
109
+ - lib/xirr/rates.rb
110
+ - lib/xirr/returns.rb
111
+ - lib/xirr/rtsafe.rb
112
+ - lib/xirr/rtsafe_c.rb
133
113
  - lib/xirr/transaction.rb
114
+ - lib/xirr/tvm.rb
134
115
  - lib/xirr/version.rb
116
+ - test/test_bonds.rb
117
+ - test/test_brent.rb
135
118
  - test/test_cashflow.rb
119
+ - test/test_depreciation.rb
136
120
  - test/test_helper.rb
121
+ - test/test_periodic.rb
122
+ - test/test_rates.rb
123
+ - test/test_returns.rb
124
+ - test/test_solver_properties.rb
137
125
  - test/test_transactions.rb
126
+ - test/test_tvm.rb
138
127
  - xirr.gemspec
139
128
  homepage: https://github.com/tubedude/xirr
140
129
  licenses:
@@ -158,8 +147,16 @@ requirements: []
158
147
  rubygems_version: 3.5.23
159
148
  signing_key:
160
149
  specification_version: 4
161
- summary: Calculates XIRR (Bisection and Newton method) of a cashflow
150
+ summary: XIRR and a finance toolkit for Ruby, built on a safeguarded-Newton solver
162
151
  test_files:
152
+ - test/test_bonds.rb
153
+ - test/test_brent.rb
163
154
  - test/test_cashflow.rb
155
+ - test/test_depreciation.rb
164
156
  - test/test_helper.rb
157
+ - test/test_periodic.rb
158
+ - test/test_rates.rb
159
+ - test/test_returns.rb
160
+ - test/test_solver_properties.rb
165
161
  - test/test_transactions.rb
162
+ - test/test_tvm.rb
data/.coveralls.yml DELETED
@@ -1,2 +0,0 @@
1
- service_name: travis-ci
2
- repo_token: WWGlMyxTY9oj85CzGICcyYqlpXPG29UsN
data/.travis.yml DELETED
@@ -1,8 +0,0 @@
1
- language: ruby
2
- rvm:
3
- - '2.6.9'
4
- - '2.7.6'
5
- - '3.0.4'
6
- - '3.3.0'
7
- script:
8
- - bundle exec rake test_units