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.
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
@@ -1,4 +1,4 @@
1
1
  module Xirr
2
2
  # Version of the Gem
3
- VERSION = '0.7.1'
3
+ VERSION = '1.0.0'
4
4
  end
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
@@ -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
@@ -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
@@ -130,11 +130,11 @@ describe 'Cashflows' do
130
130
  end
131
131
 
132
132
  it 'is invalid' do
133
- assert true, !@cf.valid?
133
+ refute @cf.valid?
134
134
  end
135
135
 
136
136
  it 'returns 0 instead of exception ' do
137
- assert_equal BigDecimal(0, 6), @cf.xirr
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 'raises error when xirr is called' do
149
- assert true, !@cf.irr_guess
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
- assert true, !@cf.valid?
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 'is invalid when #irr_guess is called' do
169
- assert true, !@cf.irr_guess
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
- assert true, @cf.xirr(method: :newton_method).nan?
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 'reapeated cashflow' do
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
- # it 'has a compact cashflow' do
223
- # assert_equal 2, @cf.compact_cf.count
224
- # end
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 'is a long and bad investment and newton generates an error' do
271
- skip 'Test is weirdly taking too long'
272
- assert_equal '-1.0'.to_f, @cf.xirr #(method: :newton_method)
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 'gives nil value for xirr' do
328
- assert_equal 0.0, @cf.xirr
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
- assert_equal '0.2'.to_f, @cf.irr_guess
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
- assert_equal '0.072'.to_f, @cf.irr_guess
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
- assert_equal '0.195'.to_f, @cf.irr_guess
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
- assert_equal '0.033'.to_f, @cf.irr_guess
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
- end
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
@@ -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