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
data/lib/xirr/tvm.rb
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Xirr
|
|
4
|
+
# Time-value-of-money scalars: {fv}, {pv}, {pmt}, {ipmt}, {ppmt}, {nper}, and
|
|
5
|
+
# {rate}, plus an {amortization_schedule}. Each solves the standard annuity
|
|
6
|
+
# equation for one unknown:
|
|
7
|
+
#
|
|
8
|
+
# pv·(1+r)^n + pmt·(1 + r·type)·((1+r)^n − 1)/r + fv = 0
|
|
9
|
+
#
|
|
10
|
+
# +type+ is 0 for payments at the end of each period (an ordinary annuity) or 1
|
|
11
|
+
# for the beginning (an annuity due). The sign convention follows spreadsheets:
|
|
12
|
+
# money you receive is positive, money you pay out is negative.
|
|
13
|
+
module TVM
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Future value: what an investment grows to after +nper+ periods, starting
|
|
17
|
+
# from present value +pv+ with a fixed +pmt+ each period, compounding at
|
|
18
|
+
# +rate+.
|
|
19
|
+
# @return [Float]
|
|
20
|
+
def fv(rate, nper, pmt, pv = 0.0, type = 0)
|
|
21
|
+
validate_type!(type)
|
|
22
|
+
if rate.zero?
|
|
23
|
+
-(pv + pmt * nper) * 1.0
|
|
24
|
+
else
|
|
25
|
+
-(pv * (1 + rate)**nper + pmt * annuity(rate, nper, type)) * 1.0
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Present value: what a stream of +pmt+ per period for +nper+ periods plus a
|
|
30
|
+
# lump sum +fv+ at the end is worth today, discounted at +rate+.
|
|
31
|
+
# @return [Float]
|
|
32
|
+
def pv(rate, nper, pmt, fv = 0.0, type = 0)
|
|
33
|
+
validate_type!(type)
|
|
34
|
+
if rate.zero?
|
|
35
|
+
-(fv + pmt * nper) * 1.0
|
|
36
|
+
else
|
|
37
|
+
-(fv + pmt * annuity(rate, nper, type)) / (1 + rate)**nper * 1.0
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Level payment per period that pays off +pv+ (and reaches +fv+) over +nper+
|
|
42
|
+
# periods at +rate+.
|
|
43
|
+
# @return [Float]
|
|
44
|
+
def pmt(rate, nper, pv, fv = 0.0, type = 0)
|
|
45
|
+
validate_type!(type)
|
|
46
|
+
raise ArgumentError, 'nper must be non-zero' if nper.zero?
|
|
47
|
+
return -(pv + fv) / nper * 1.0 if rate.zero?
|
|
48
|
+
|
|
49
|
+
-(pv * (1 + rate)**nper + fv) / annuity(rate, nper, type) * 1.0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Interest portion of the payment in period +per+ (counting from 1). Pairs
|
|
53
|
+
# with {ppmt}: the two add up to {pmt} for every period.
|
|
54
|
+
# @return [Float]
|
|
55
|
+
def ipmt(rate, per, nper, pv, fv = 0.0, type = 0)
|
|
56
|
+
validate_type!(type)
|
|
57
|
+
split_payment(rate, per, nper, pv, fv, type).first
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Principal portion of the payment in period +per+ (counting from 1). The
|
|
61
|
+
# companion of {ipmt}.
|
|
62
|
+
# @return [Float]
|
|
63
|
+
def ppmt(rate, per, nper, pv, fv = 0.0, type = 0)
|
|
64
|
+
validate_type!(type)
|
|
65
|
+
split_payment(rate, per, nper, pv, fv, type).last
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Number of periods it takes for payments of +pmt+ to pay off +pv+ (reaching
|
|
69
|
+
# +fv+) at +rate+.
|
|
70
|
+
# @return [Float]
|
|
71
|
+
def nper(rate, pmt, pv, fv = 0.0, type = 0)
|
|
72
|
+
validate_type!(type)
|
|
73
|
+
raise ArgumentError, 'undefined' if rate.zero? && pmt.zero?
|
|
74
|
+
return -(pv + fv) / pmt * 1.0 if rate.zero?
|
|
75
|
+
raise ArgumentError, 'undefined' if 1 + rate <= 0
|
|
76
|
+
|
|
77
|
+
k = pmt * (1 + rate * type) / rate
|
|
78
|
+
denom = pv + k
|
|
79
|
+
raise ArgumentError, 'undefined' if denom.zero? || (k - fv) / denom <= 0
|
|
80
|
+
|
|
81
|
+
Math.log((k - fv) / denom) / Math.log(1 + rate)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Interest rate per period of an annuity of +nper+ payments of +pmt+, present
|
|
85
|
+
# value +pv+, and future value +fv+. There is no closed form, so this reuses
|
|
86
|
+
# the {RtSafe} solver.
|
|
87
|
+
# @return [Float]
|
|
88
|
+
def rate(nper, pmt, pv, fv = 0.0, type = 0, guess: 0.1)
|
|
89
|
+
validate_type!(type)
|
|
90
|
+
n = nper.to_i
|
|
91
|
+
raise ArgumentError, 'nper must be a positive whole number' unless nper == n && n.positive?
|
|
92
|
+
|
|
93
|
+
result = RtSafe.find(tvm_flows(n, pmt, pv, fv, type), guess: guess)
|
|
94
|
+
raise ArgumentError, 'rate did not converge' if result.nil?
|
|
95
|
+
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Full amortization schedule for a loan of +pv+ repaid with a level payment
|
|
100
|
+
# over +nper+ periods at +rate+. Returns an array of row hashes with
|
|
101
|
+
# +:period+, +:payment+, +:interest+, +:principal+, and +:balance+; the
|
|
102
|
+
# balance runs from +pv+ down to exactly 0. Monetary columns are rounded to
|
|
103
|
+
# +precision+ (default 2, i.e. cents).
|
|
104
|
+
# @return [Array<Hash>]
|
|
105
|
+
def amortization_schedule(rate, nper, pv, precision: 2)
|
|
106
|
+
n = nper.to_i
|
|
107
|
+
raise ArgumentError, 'nper must be a positive whole number' unless nper == n && n >= 1
|
|
108
|
+
|
|
109
|
+
build_schedule(rate.to_f, n, pv.to_f, precision)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# --- helpers ------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def validate_type!(type)
|
|
115
|
+
raise ArgumentError, 'type must be 0 (ordinary) or 1 (due)' unless type == 0 || type == 1
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# (1 + r·type) · ((1+r)^n − 1) / r — the factor that multiplies pmt.
|
|
119
|
+
def annuity(rate, nper, type)
|
|
120
|
+
(1 + rate * type) * ((1 + rate)**nper - 1) / rate
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Split period +per+'s level payment into [interest, principal].
|
|
124
|
+
def split_payment(rate, per, nper, pv, fv, type)
|
|
125
|
+
n = per.to_i
|
|
126
|
+
unless per == n && n >= 1 && n <= nper
|
|
127
|
+
raise ArgumentError, 'per must be a whole number within 1..nper'
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
payment = pmt(rate, nper, pv, fv, type)
|
|
131
|
+
balance = fv(rate, n - 1, payment, pv, type)
|
|
132
|
+
interest = annuity_due_adjust(balance * rate, rate, n, type)
|
|
133
|
+
# + 0.0 collapses a floating-point negative zero to 0.0.
|
|
134
|
+
[interest + 0.0, payment - interest + 0.0]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# For an annuity due (type 1) the first period carries no interest and later
|
|
138
|
+
# periods discount one step; an ordinary annuity (type 0) is left as-is.
|
|
139
|
+
def annuity_due_adjust(interest, rate, per, type)
|
|
140
|
+
return interest if type == 0
|
|
141
|
+
return 0.0 if per == 1
|
|
142
|
+
|
|
143
|
+
interest / (1 + rate)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A TVM problem as a cash-flow series so {rate} can reuse the solver: +pv+ at
|
|
147
|
+
# period 0, +pmt+ each period, +fv+ at the last period.
|
|
148
|
+
def tvm_flows(nper, pmt, pv, fv, type)
|
|
149
|
+
periods = type == 1 ? (0...nper) : (1..nper)
|
|
150
|
+
acc = Hash.new(0.0)
|
|
151
|
+
periods.each { |i| acc[i * 1.0] += pmt * 1.0 }
|
|
152
|
+
acc[0.0] += pv * 1.0
|
|
153
|
+
acc[nper * 1.0] += fv * 1.0
|
|
154
|
+
acc.to_a
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Build the schedule in integer minor units (10^precision), so money is exact
|
|
158
|
+
# and the balance ends at exactly zero, then convert back to floats.
|
|
159
|
+
def build_schedule(rate, nper, pv, precision)
|
|
160
|
+
scale = 10**precision
|
|
161
|
+
payment = pmt(rate, nper, pv)
|
|
162
|
+
rows = integer_schedule(rate, nper, (pv * scale).round, (payment * scale).round)
|
|
163
|
+
rows.map do |period, pay, interest, principal, balance|
|
|
164
|
+
{
|
|
165
|
+
period: period,
|
|
166
|
+
payment: pay / scale.to_f,
|
|
167
|
+
interest: interest / scale.to_f,
|
|
168
|
+
principal: principal / scale.to_f,
|
|
169
|
+
balance: balance / scale.to_f
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Running balance in integer minor units; the final row pays off whatever
|
|
175
|
+
# remains, so the balance ends at exactly 0.
|
|
176
|
+
def integer_schedule(rate, nper, opening, payment)
|
|
177
|
+
balance = opening
|
|
178
|
+
(1..nper).map do |period|
|
|
179
|
+
interest = (-balance * rate).round
|
|
180
|
+
scheduled = period == nper ? -balance : payment - interest
|
|
181
|
+
# Keep the balance retiring toward zero: never grow it and never overpay.
|
|
182
|
+
principal = [[scheduled, -balance].max, 0].min
|
|
183
|
+
balance += principal
|
|
184
|
+
[period, interest + principal, interest, principal, balance]
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private_class_method :validate_type!, :annuity, :split_payment, :annuity_due_adjust,
|
|
189
|
+
:tvm_flows, :build_schedule, :integer_schedule
|
|
190
|
+
end
|
|
191
|
+
end
|
data/lib/xirr/version.rb
CHANGED
data/lib/xirr.rb
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
require 'xirr/version'
|
|
2
|
-
require 'bigdecimal'
|
|
3
2
|
require 'active_support/configurable'
|
|
4
|
-
require 'active_support/concern'
|
|
5
3
|
require 'xirr/config'
|
|
6
4
|
require 'xirr/base'
|
|
5
|
+
require 'xirr/rtsafe'
|
|
6
|
+
require 'xirr/rtsafe_c'
|
|
7
|
+
require 'xirr/brent'
|
|
7
8
|
require 'xirr/bisection'
|
|
8
9
|
require 'xirr/newton_method'
|
|
10
|
+
require 'xirr/periodic'
|
|
11
|
+
require 'xirr/tvm'
|
|
12
|
+
require 'xirr/rates'
|
|
13
|
+
require 'xirr/bonds'
|
|
14
|
+
require 'xirr/depreciation'
|
|
15
|
+
require 'xirr/returns'
|
|
9
16
|
|
|
10
17
|
# @abstract adds a {Xirr::Cashflow} and {Xirr::Transaction} classes to calculate IRR of irregular transactions.
|
|
11
18
|
# Calculates Xirr
|
data/test/test_bonds.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require_relative 'test_helper'
|
|
2
|
+
|
|
3
|
+
describe 'Xirr::Bonds' do
|
|
4
|
+
it 'price — at par when coupon equals yield' do
|
|
5
|
+
assert_equal 100.0, Xirr::Bonds.price(100, 0.05, 0.05, 10)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
it 'price — at a discount when yield exceeds coupon' do
|
|
9
|
+
assert_in_delta 875.377897, Xirr::Bonds.price(1000, 0.08, 0.10, 10), 1e-6
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'ytm is the inverse of price' do
|
|
13
|
+
assert_in_delta 0.1, Xirr::Bonds.ytm(1000, 0.08, 875.377897, 10), 1e-6
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'duration — Macaulay' do
|
|
17
|
+
assert_equal 10.0, Xirr::Bonds.duration(0.0, 0.05, 10, 1)
|
|
18
|
+
assert_in_delta 2.833393, Xirr::Bonds.duration(0.06, 0.06, 3, 1), 1e-6
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'modified_duration' do
|
|
22
|
+
assert_in_delta 9.52381, Xirr::Bonds.modified_duration(0.0, 0.05, 10, 1), 1e-5
|
|
23
|
+
assert_in_delta 2.673012, Xirr::Bonds.modified_duration(0.06, 0.06, 3, 1), 1e-6
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'convexity' do
|
|
27
|
+
assert_in_delta 99.773243, Xirr::Bonds.convexity(0.0, 0.05, 10, 1), 1e-6
|
|
28
|
+
assert_in_delta 9.891032, Xirr::Bonds.convexity(0.06, 0.06, 3, 1), 1e-6
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'rejects a degenerate period count' do
|
|
32
|
+
assert_raises(ArgumentError) { Xirr::Bonds.price(100, 0.05, 0.05, 0) }
|
|
33
|
+
end
|
|
34
|
+
end
|
data/test/test_brent.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require_relative 'test_helper'
|
|
2
|
+
|
|
3
|
+
describe 'Xirr::Brent solver' do
|
|
4
|
+
def cf(*pairs)
|
|
5
|
+
c = Cashflow.new
|
|
6
|
+
pairs.each { |amount, date| c << Transaction.new(amount, date: date.to_date) }
|
|
7
|
+
c
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'matches known XIRR values' do
|
|
11
|
+
ok = cf([1000, '1985-01-01'], [-600, '1990-01-01'], [-6000, '1995-01-01'])
|
|
12
|
+
assert_equal '0.225683'.to_f, ok.xirr(method: :brent)
|
|
13
|
+
|
|
14
|
+
long = Cashflow.new(flow: [
|
|
15
|
+
Transaction.new(-1000, date: Date.new(1957, 1, 1)),
|
|
16
|
+
Transaction.new(390_000, date: Date.new(2013, 1, 1))
|
|
17
|
+
])
|
|
18
|
+
assert_equal '0.112339'.to_f, long.xirr(method: :brent)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'handles high rates and near -100% returns' do
|
|
22
|
+
high = cf([1_000_000, '2020-01-01'], [-2_200_000, '2020-05-01'], [-800_000, '2020-06-01'])
|
|
23
|
+
assert_in_delta 21.66094, high.xirr(method: :brent).to_f, 1e-4
|
|
24
|
+
|
|
25
|
+
near1 = cf([-10_000, '2014-04-15'], [305.6, '2014-05-15'], [500, '2014-10-19'])
|
|
26
|
+
assert_in_delta(-0.996815, near1.xirr(method: :brent).to_f, 1e-5)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'agrees with rtsafe across random cashflows' do
|
|
30
|
+
rng = Random.new(0xB6E17)
|
|
31
|
+
base = Date.new(2000, 1, 1)
|
|
32
|
+
checked = 0
|
|
33
|
+
100.times do
|
|
34
|
+
c = Cashflow.new
|
|
35
|
+
c << Transaction.new(-(1_000 + rng.rand(9_000)), date: base)
|
|
36
|
+
n = 1 + rng.rand(8)
|
|
37
|
+
(1..n).each { |i| c << Transaction.new(rng.rand(3_000) - 700, date: base + i * (30 + rng.rand(300))) }
|
|
38
|
+
c << Transaction.new(500 + rng.rand(6_000), date: base + (n + 1) * 400)
|
|
39
|
+
next unless c.valid?
|
|
40
|
+
|
|
41
|
+
# Different algorithms can land on opposite sides of the last rounded digit,
|
|
42
|
+
# so agree to within a rounding step rather than bit-for-bit.
|
|
43
|
+
assert_in_delta c.xirr(method: :rtsafe).to_f, c.xirr(method: :brent).to_f, 1e-5
|
|
44
|
+
checked += 1
|
|
45
|
+
end
|
|
46
|
+
assert checked > 50, "only #{checked} cases checked"
|
|
47
|
+
end
|
|
48
|
+
end
|
data/test/test_cashflow.rb
CHANGED
|
@@ -130,11 +130,11 @@ describe 'Cashflows' do
|
|
|
130
130
|
end
|
|
131
131
|
|
|
132
132
|
it 'is invalid' do
|
|
133
|
-
|
|
133
|
+
refute @cf.valid?
|
|
134
134
|
end
|
|
135
135
|
|
|
136
136
|
it 'returns 0 instead of exception ' do
|
|
137
|
-
assert_equal
|
|
137
|
+
assert_equal 0.0, @cf.xirr
|
|
138
138
|
end
|
|
139
139
|
|
|
140
140
|
it 'with a wrong method is invalid' do
|
|
@@ -145,8 +145,8 @@ describe 'Cashflows' do
|
|
|
145
145
|
assert_raises(ArgumentError) { @cf.xirr raise_exception: true }
|
|
146
146
|
end
|
|
147
147
|
|
|
148
|
-
it '
|
|
149
|
-
|
|
148
|
+
it 'has a zero irr_guess' do
|
|
149
|
+
assert_equal 0.0, @cf.irr_guess
|
|
150
150
|
end
|
|
151
151
|
end
|
|
152
152
|
|
|
@@ -158,15 +158,15 @@ describe 'Cashflows' do
|
|
|
158
158
|
end
|
|
159
159
|
|
|
160
160
|
it 'is invalid' do
|
|
161
|
-
|
|
161
|
+
refute @cf.valid?
|
|
162
162
|
end
|
|
163
163
|
|
|
164
164
|
it 'raises error when #xirr is called' do
|
|
165
165
|
assert_raises(ArgumentError) { @cf.xirr raise_exception: true }
|
|
166
166
|
end
|
|
167
167
|
|
|
168
|
-
it '
|
|
169
|
-
|
|
168
|
+
it 'has a zero irr_guess' do
|
|
169
|
+
assert_equal 0.0, @cf.irr_guess
|
|
170
170
|
end
|
|
171
171
|
end
|
|
172
172
|
|
|
@@ -184,7 +184,8 @@ describe 'Cashflows' do
|
|
|
184
184
|
end
|
|
185
185
|
|
|
186
186
|
it 'has an Internal Rate of Return on Newton Method' do
|
|
187
|
-
|
|
187
|
+
# The pure-Ruby Newton now finds this root instead of failing on it.
|
|
188
|
+
assert_equal '-0.034592'.to_f, @cf.xirr(method: :newton_method)
|
|
188
189
|
end
|
|
189
190
|
|
|
190
191
|
it 'has an educated guess' do
|
|
@@ -210,7 +211,7 @@ describe 'Cashflows' do
|
|
|
210
211
|
end
|
|
211
212
|
end
|
|
212
213
|
|
|
213
|
-
describe '
|
|
214
|
+
describe 'repeated cashflow' do
|
|
214
215
|
before(:all) do
|
|
215
216
|
@cf = Cashflow.new
|
|
216
217
|
@cf << Transaction.new(1000.0, date: '2011-12-07'.to_date)
|
|
@@ -218,10 +219,10 @@ describe 'Cashflows' do
|
|
|
218
219
|
@cf << Transaction.new(-2000.0, date: '2013-05-21'.to_date)
|
|
219
220
|
@cf << Transaction.new(-4000.0, date: '2013-05-21'.to_date)
|
|
220
221
|
end
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
|
|
223
|
+
it 'has a compact cashflow' do
|
|
224
|
+
assert_equal 2, @cf.compact_cf.count
|
|
225
|
+
end
|
|
225
226
|
|
|
226
227
|
it 'sums all transactions' do
|
|
227
228
|
assert_equal -3000.0, @cf.compact_cf.map(&:amount).inject(&:+)
|
|
@@ -267,9 +268,10 @@ describe 'Cashflows' do
|
|
|
267
268
|
@cf << Transaction.new(-144413.24, date: '2013-05-21'.to_date)
|
|
268
269
|
end
|
|
269
270
|
|
|
270
|
-
it '
|
|
271
|
-
|
|
272
|
-
|
|
271
|
+
it 'returns replace_for_nil for a net-positive flow with no rate' do
|
|
272
|
+
# The old solver hung or raised here; rtsafe finds no sign change and
|
|
273
|
+
# returns cleanly.
|
|
274
|
+
assert_equal Xirr::REPLACE_FOR_NIL, @cf.xirr
|
|
273
275
|
end
|
|
274
276
|
end
|
|
275
277
|
|
|
@@ -324,8 +326,10 @@ describe 'Cashflows' do
|
|
|
324
326
|
@cf << Transaction.new(8509.93, date: '2021-06-21'.to_date)
|
|
325
327
|
end
|
|
326
328
|
|
|
327
|
-
it '
|
|
328
|
-
|
|
329
|
+
it 'finds the rate the old solver missed' do
|
|
330
|
+
# The previous default solver returned config.replace_for_nil (0.0) here.
|
|
331
|
+
# rtsafe converges on this flow at the default precision.
|
|
332
|
+
assert_in_delta(-0.831717, @cf.xirr.to_f, 1e-6)
|
|
329
333
|
end
|
|
330
334
|
|
|
331
335
|
it 'gives correct value with configuration' do
|
|
@@ -350,7 +354,8 @@ describe 'Cashflows' do
|
|
|
350
354
|
@cf = Cashflow.new
|
|
351
355
|
@cf << Transaction.new(1000, date: '2000-01-01'.to_date)
|
|
352
356
|
@cf << Transaction.new(-1200, date: '2001-01-01'.to_date)
|
|
353
|
-
|
|
357
|
+
# 2000 is a leap year, so the horizon is 366/365 years, not exactly 1.
|
|
358
|
+
assert_equal 0.199, @cf.irr_guess
|
|
354
359
|
end
|
|
355
360
|
|
|
356
361
|
it 'Zero Periods of Investment' do
|
|
@@ -377,7 +382,8 @@ describe 'Cashflows' do
|
|
|
377
382
|
@cf << Transaction.new(1000, date: '2000-01-01'.to_date)
|
|
378
383
|
@cf << Transaction.new(-100, date: '2010-01-01'.to_date)
|
|
379
384
|
@cf << Transaction.new(-100, date: '2020-01-01'.to_date)
|
|
380
|
-
|
|
385
|
+
# 200 returned on 1000 over 20 years annualizes to a negative rate.
|
|
386
|
+
assert_equal(-0.077, @cf.irr_guess)
|
|
381
387
|
end
|
|
382
388
|
|
|
383
389
|
it 'Very Small Periods of Investment' do
|
|
@@ -393,7 +399,8 @@ describe 'Cashflows' do
|
|
|
393
399
|
@cf << Transaction.new(-500, date: '2001-01-01'.to_date)
|
|
394
400
|
@cf << Transaction.new(-300, date: '2002-01-01'.to_date)
|
|
395
401
|
@cf << Transaction.new(-200, date: '2003-01-01'.to_date)
|
|
396
|
-
|
|
402
|
+
# Inflows and outflows net to zero, so the cash-on-cash guess is 0.
|
|
403
|
+
assert_equal 0.0, @cf.irr_guess
|
|
397
404
|
end
|
|
398
405
|
|
|
399
406
|
it 'Only Negative Cash Flows' do
|
|
@@ -408,7 +415,38 @@ describe 'Cashflows' do
|
|
|
408
415
|
@cf << Transaction.new(500, date: '2000-01-01'.to_date)
|
|
409
416
|
@cf << Transaction.new(-100, date: '2005-01-01'.to_date)
|
|
410
417
|
@cf << Transaction.new(-400, date: '2010-01-01'.to_date)
|
|
411
|
-
|
|
418
|
+
# Inflows and outflows net to zero, so the cash-on-cash guess is 0.
|
|
419
|
+
assert_equal 0.0, @cf.irr_guess
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
describe 'option and state handling' do
|
|
424
|
+
it 'lets a per-call raise_exception: false override a cashflow-level true' do
|
|
425
|
+
cf = Cashflow.new(flow: [
|
|
426
|
+
Transaction.new(-600, date: '1990-01-01'.to_date),
|
|
427
|
+
Transaction.new(-600, date: '1995-01-01'.to_date)
|
|
428
|
+
], raise_exception: true)
|
|
429
|
+
assert_raises(ArgumentError) { cf.xirr }
|
|
430
|
+
assert_equal Xirr::REPLACE_FOR_NIL, cf.xirr(raise_exception: false)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
it 'does not let a per-call period leak into later xnpv/mirr' do
|
|
434
|
+
cf = Cashflow.new(flow: [
|
|
435
|
+
Transaction.new(-1000, date: '2000-01-01'.to_date),
|
|
436
|
+
Transaction.new(1200, date: '2001-01-01'.to_date)
|
|
437
|
+
])
|
|
438
|
+
before = cf.xnpv(0.1)
|
|
439
|
+
cf.xirr(period: 100)
|
|
440
|
+
assert_equal before, cf.xnpv(0.1)
|
|
412
441
|
end
|
|
413
|
-
|
|
442
|
+
|
|
443
|
+
it 'refreshes memoized dates when a transaction is pushed' do
|
|
444
|
+
cf = Cashflow.new
|
|
445
|
+
cf << Transaction.new(1000, date: '2000-01-01'.to_date)
|
|
446
|
+
cf << Transaction.new(-500, date: '2005-01-01'.to_date)
|
|
447
|
+
cf.min_date # memoize
|
|
448
|
+
cf << Transaction.new(-500, date: '1990-01-01'.to_date)
|
|
449
|
+
assert_equal Date.new(1990, 1, 1), cf.min_date
|
|
450
|
+
end
|
|
451
|
+
end
|
|
414
452
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require_relative 'test_helper'
|
|
2
|
+
|
|
3
|
+
describe 'Xirr::Depreciation' do
|
|
4
|
+
it 'sln — straight line' do
|
|
5
|
+
assert_equal 1800.0, Xirr::Depreciation.sln(10_000, 1_000, 5)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
it 'syd — sum of years digits' do
|
|
9
|
+
assert_equal 3000.0, Xirr::Depreciation.syd(10_000, 1_000, 5, 1)
|
|
10
|
+
assert_equal 600.0, Xirr::Depreciation.syd(10_000, 1_000, 5, 5)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'ddb — double declining balance' do
|
|
14
|
+
assert_equal 4000.0, Xirr::Depreciation.ddb(10_000, 1_000, 5, 1)
|
|
15
|
+
assert_equal 2400.0, Xirr::Depreciation.ddb(10_000, 1_000, 5, 2)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'db — fixed declining balance' do
|
|
19
|
+
assert_in_delta 3690.0, Xirr::Depreciation.db(10_000, 1_000, 5, 1), 1e-2
|
|
20
|
+
assert_in_delta 2328.39, Xirr::Depreciation.db(10_000, 1_000, 5, 2), 1e-2
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'rejects a period outside the asset life' do
|
|
24
|
+
assert_raises(ArgumentError) { Xirr::Depreciation.syd(10_000, 1_000, 5, 6) }
|
|
25
|
+
end
|
|
26
|
+
end
|
data/test/test_helper.rb
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
2
2
|
|
|
3
|
-
require 'coveralls'
|
|
4
|
-
# Coveralls.wear!
|
|
5
|
-
|
|
6
3
|
require 'minitest/autorun'
|
|
7
4
|
require 'minitest/spec'
|
|
8
5
|
|
|
@@ -10,9 +7,18 @@ require 'active_support/all'
|
|
|
10
7
|
|
|
11
8
|
require 'xirr/config.rb'
|
|
12
9
|
require 'xirr/base.rb'
|
|
10
|
+
require 'xirr/rtsafe.rb'
|
|
11
|
+
require 'xirr/rtsafe_c.rb'
|
|
12
|
+
require 'xirr/brent.rb'
|
|
13
13
|
require 'xirr/bisection.rb'
|
|
14
14
|
require 'xirr/newton_method.rb'
|
|
15
15
|
require 'xirr/cashflow.rb'
|
|
16
16
|
require 'xirr/transaction.rb'
|
|
17
|
+
require 'xirr/periodic.rb'
|
|
18
|
+
require 'xirr/tvm.rb'
|
|
19
|
+
require 'xirr/rates.rb'
|
|
20
|
+
require 'xirr/bonds.rb'
|
|
21
|
+
require 'xirr/depreciation.rb'
|
|
22
|
+
require 'xirr/returns.rb'
|
|
17
23
|
include Xirr
|
|
18
24
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require_relative 'test_helper'
|
|
2
|
+
|
|
3
|
+
describe 'Periodic (dateless) functions' do
|
|
4
|
+
describe 'Xirr.irr' do
|
|
5
|
+
it 'solves a simple two-period flow' do
|
|
6
|
+
assert_in_delta 0.1, Xirr.irr([-1000, 1100]), 1e-6
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
it 'solves a multi-period flow' do
|
|
10
|
+
assert_in_delta 0.156579, Xirr.irr([-1000, 500, 500, 300]), 1e-6
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'agrees with npv at the root' do
|
|
14
|
+
rate = Xirr.irr([-1000, 500, 500, 300])
|
|
15
|
+
# The rate is rounded to config.precision, so npv lands near — not exactly — zero.
|
|
16
|
+
assert_in_delta 0.0, Xirr.npv(rate, [-1000, 500, 500, 300]), 1e-3
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'raises without both a positive and a negative amount' do
|
|
20
|
+
assert_raises(ArgumentError) { Xirr.irr([-1000, -500]) }
|
|
21
|
+
assert_raises(ArgumentError) { Xirr.irr([1000, 500]) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe 'Xirr.npv' do
|
|
26
|
+
it 'leaves the first amount at period 0' do
|
|
27
|
+
assert_equal 0.0, Xirr.npv(0.1, [-1000, 1100])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'discounts later amounts' do
|
|
31
|
+
assert_in_delta 41.322314, Xirr.npv(0.1, [-1000, 600, 600]), 1e-6
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe 'Xirr.mirr' do
|
|
36
|
+
it 'matches the closed form' do
|
|
37
|
+
amounts = [-120_000, 39_000, 30_000, 21_000, 37_000, 46_000]
|
|
38
|
+
assert_in_delta 0.126094, Xirr.mirr(amounts, 0.10, 0.12), 1e-6
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'raises without both a positive and a negative amount' do
|
|
42
|
+
assert_raises(ArgumentError) { Xirr.mirr([-1, -2], 0.1, 0.1) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/test/test_rates.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require_relative 'test_helper'
|
|
2
|
+
|
|
3
|
+
describe 'Xirr::Rates' do
|
|
4
|
+
it 'effective_annual_rate' do
|
|
5
|
+
assert_in_delta 0.104713, Xirr::Rates.effective_annual_rate(0.10, 12), 1e-6
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
it 'nominal_rate is the inverse of effective_annual_rate' do
|
|
9
|
+
ear = Xirr::Rates.effective_annual_rate(0.10, 12)
|
|
10
|
+
assert_in_delta 0.1, Xirr::Rates.nominal_rate(ear, 12), 1e-10
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'continuous_to_periodic' do
|
|
14
|
+
assert_in_delta 0.105171, Xirr::Rates.continuous_to_periodic(0.10, 1), 1e-6
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'rejects a non-positive compounding frequency' do
|
|
18
|
+
assert_raises(ArgumentError) { Xirr::Rates.effective_annual_rate(0.10, 0) }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require_relative 'test_helper'
|
|
2
|
+
|
|
3
|
+
describe 'Xirr::Returns' do
|
|
4
|
+
it 'volatility — annualised' do
|
|
5
|
+
assert_in_delta 0.234528, Xirr::Returns.volatility([100, 102, 101, 103, 105]), 1e-6
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
it 'cagr — compound annual growth rate' do
|
|
9
|
+
assert_in_delta 0.071773, Xirr::Returns.cagr(1000, 2000, 10), 1e-6
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'payback_period' do
|
|
13
|
+
assert_equal 2.5, Xirr::Returns.payback_period([-1000, 400, 400, 400])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'discounted_payback_period' do
|
|
17
|
+
assert_in_delta 1.916667, Xirr::Returns.discounted_payback_period([-1000, 600, 600, 600], 0.1), 1e-6
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'profitability_index' do
|
|
21
|
+
assert_in_delta 1.041322, Xirr::Returns.profitability_index([-1000, 600, 600], 0.1), 1e-6
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'twr — cumulative' do
|
|
25
|
+
assert_in_delta 0.1286, Xirr::Returns.twr([0.10, -0.05, 0.08]), 1e-6
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'twr — annualised' do
|
|
29
|
+
assert_in_delta 0.082432, Xirr::Returns.twr([0.02, 0.02], periods_per_year: 4), 1e-6
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'raises when the outlay is never recovered' do
|
|
33
|
+
assert_raises(ArgumentError) { Xirr::Returns.payback_period([-1000, 100, 100]) }
|
|
34
|
+
end
|
|
35
|
+
end
|