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/bisection.rb
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Xirr
|
|
4
|
-
#
|
|
4
|
+
# Bisection solver: repeatedly halves a bracket that straddles the root. It
|
|
5
|
+
# always converges on a bracketed flow but only linearly, so it is slower than
|
|
6
|
+
# {RtSafe}, which is the default. Kept for `xirr(method: :bisection)`.
|
|
5
7
|
class Bisection
|
|
6
8
|
include Base
|
|
7
9
|
|
|
8
10
|
# Calculates yearly Internal Rate of Return
|
|
9
|
-
# @return [
|
|
11
|
+
# @return [Float]
|
|
10
12
|
# @param midpoint [Float]
|
|
11
13
|
# An initial guess rate will override the {Cashflow#irr_guess}
|
|
12
14
|
def xirr(midpoint, options)
|
|
13
15
|
# Initial values
|
|
14
|
-
left = [
|
|
15
|
-
right = [
|
|
16
|
+
left = [-0.99999999, cf.irr_guess].min
|
|
17
|
+
right = [9.99999999, cf.irr_guess + 1].max
|
|
16
18
|
@original_right = right
|
|
17
19
|
midpoint ||= cf.irr_guess
|
|
18
20
|
|
|
@@ -23,16 +25,16 @@ module Xirr
|
|
|
23
25
|
|
|
24
26
|
private
|
|
25
27
|
|
|
26
|
-
# @param midpoint [
|
|
28
|
+
# @param midpoint [Float]
|
|
27
29
|
# @return [Boolean]
|
|
28
30
|
# Checks if result is the right limit.
|
|
29
31
|
def right_limit_reached?(midpoint)
|
|
30
32
|
(@original_right - midpoint).abs < Xirr.config.eps
|
|
31
33
|
end
|
|
32
34
|
|
|
33
|
-
# @param left [
|
|
34
|
-
# @param midpoint [
|
|
35
|
-
# @param right [
|
|
35
|
+
# @param left [Float]
|
|
36
|
+
# @param midpoint [Float]
|
|
37
|
+
# @param right [Float]
|
|
36
38
|
# @return [Array]
|
|
37
39
|
# Calculates the Bisections
|
|
38
40
|
def bisection(left, midpoint, right)
|
|
@@ -57,11 +59,13 @@ module Xirr
|
|
|
57
59
|
|
|
58
60
|
def get_answer(midpoint, options, runs)
|
|
59
61
|
if runs >= options[:iteration_limit]
|
|
60
|
-
if options[:raise_exception]
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
raise ArgumentError, "Did not converge after #{runs} tries." if options[:raise_exception]
|
|
63
|
+
|
|
64
|
+
nil
|
|
63
65
|
else
|
|
64
|
-
midpoint.round
|
|
66
|
+
answer = midpoint.round(Xirr.config.precision)
|
|
67
|
+
# A midpoint parked at the -100% floor means no root was bracketed.
|
|
68
|
+
answer <= -1 ? nil : answer
|
|
65
69
|
end
|
|
66
70
|
end
|
|
67
71
|
|
data/lib/xirr/bonds.rb
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Xirr
|
|
4
|
+
# Fixed income: pricing a bond, solving for its yield, and the standard risk
|
|
5
|
+
# metrics (Macaulay and modified duration, convexity).
|
|
6
|
+
#
|
|
7
|
+
# A bond pays a coupon of +coupon_rate+ a year, split across +freq+ payments
|
|
8
|
+
# (semiannual by default), and returns its +face+ value at maturity, +years+
|
|
9
|
+
# from now. Rates are quoted per year; +ytm+ is the yield to maturity.
|
|
10
|
+
# Settlement is assumed to fall on a coupon date, so prices are clean and the
|
|
11
|
+
# number of coupon periods is whole.
|
|
12
|
+
#
|
|
13
|
+
# The risk metrics don't depend on the face value (it cancels out), so they
|
|
14
|
+
# omit it and lead with +coupon_rate+, unlike {price} and {ytm}.
|
|
15
|
+
module Bonds
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Price of a bond: the present value of its coupons and face value discounted
|
|
19
|
+
# at the yield +ytm+. Prices at par when the coupon rate equals the yield.
|
|
20
|
+
# @return [Float]
|
|
21
|
+
def price(face, coupon_rate, ytm, years, freq = 2, precision: Xirr.config.precision)
|
|
22
|
+
n = periods(years, freq)
|
|
23
|
+
value = RtSafe.present_value(bond_flows(face, coupon_rate, n, freq), ytm.to_f / freq)
|
|
24
|
+
round_value(value, precision)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Yield to maturity: the annual yield that discounts a bond's coupons and
|
|
28
|
+
# face value back to +price+. The inverse of {price}; reuses the {RtSafe}
|
|
29
|
+
# solver.
|
|
30
|
+
# @return [Float]
|
|
31
|
+
def ytm(face, coupon_rate, price, years, freq = 2, guess: 0.05, precision: Xirr.config.precision)
|
|
32
|
+
n = periods(years, freq)
|
|
33
|
+
flows = [[0.0, -price * 1.0]] + bond_flows(face, coupon_rate, n, freq)
|
|
34
|
+
periodic = RtSafe.find(flows, guess: guess)
|
|
35
|
+
raise ArgumentError, 'ytm did not converge' if periodic.nil?
|
|
36
|
+
|
|
37
|
+
round_value(periodic * freq, precision)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Macaulay duration — the present-value-weighted average time, in years,
|
|
41
|
+
# until a bond's cash flows are received.
|
|
42
|
+
# @return [Float]
|
|
43
|
+
def duration(coupon_rate, ytm, years, freq = 2, precision: Xirr.config.precision)
|
|
44
|
+
with_metric(coupon_rate, years, freq, precision) do |flows|
|
|
45
|
+
macaulay(flows, ytm.to_f / freq, freq)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Modified duration — Macaulay duration divided by +1 + ytm/freq+. Estimates
|
|
50
|
+
# the percentage price change for a small change in yield.
|
|
51
|
+
# @return [Float]
|
|
52
|
+
def modified_duration(coupon_rate, ytm, years, freq = 2, precision: Xirr.config.precision)
|
|
53
|
+
with_metric(coupon_rate, years, freq, precision) do |flows|
|
|
54
|
+
macaulay(flows, ytm.to_f / freq, freq) / (1 + ytm.to_f / freq)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Convexity, in years² — the second-order sensitivity of a bond's price to
|
|
59
|
+
# yield. Pairs with modified duration to refine a price-change estimate.
|
|
60
|
+
# @return [Float]
|
|
61
|
+
def convexity(coupon_rate, ytm, years, freq = 2, precision: Xirr.config.precision)
|
|
62
|
+
with_metric(coupon_rate, years, freq, precision) do |flows|
|
|
63
|
+
convexity_value(flows, ytm.to_f / freq, freq)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# --- helpers ------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def round_value(value, precision)
|
|
70
|
+
value.round(precision) + 0.0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Validate periods, build unit-face flows, apply +block+, and round.
|
|
74
|
+
def with_metric(coupon_rate, years, freq, precision)
|
|
75
|
+
flows = bond_flows(1, coupon_rate, periods(years, freq), freq)
|
|
76
|
+
round_value(yield(flows), precision)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Whole, positive coupon-period count.
|
|
80
|
+
def periods(years, freq)
|
|
81
|
+
n = years * freq
|
|
82
|
+
t = n.to_i
|
|
83
|
+
raise ArgumentError, 'years * freq must be a positive whole number' unless freq > 0 && n == t && t.positive?
|
|
84
|
+
|
|
85
|
+
t
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Coupon + redemption flows on +face+ as [[period, amount]], k = 1..n; the
|
|
89
|
+
# face value is added onto the final coupon.
|
|
90
|
+
def bond_flows(face, coupon_rate, n, freq)
|
|
91
|
+
coupon = face * coupon_rate / freq
|
|
92
|
+
(1..n).map do |k|
|
|
93
|
+
amount = k == n ? coupon + face : coupon
|
|
94
|
+
[k * 1.0, amount * 1.0]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def weighted_pv(flows, rate)
|
|
99
|
+
flows.map { |k, cf| [k, cf / (1 + rate)**k] }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Macaulay duration in years: PV-weighted average period, divided by freq.
|
|
103
|
+
def macaulay(flows, rate, freq)
|
|
104
|
+
weighted = weighted_pv(flows, rate)
|
|
105
|
+
price = weighted.sum { |_k, pv| pv }
|
|
106
|
+
weighted.sum { |k, pv| k * pv } / price / freq
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Convexity in years²: the k·(k+1)-weighted PV sum, annualized by freq².
|
|
110
|
+
def convexity_value(flows, rate, freq)
|
|
111
|
+
weighted = weighted_pv(flows, rate)
|
|
112
|
+
price = weighted.sum { |_k, pv| pv }
|
|
113
|
+
num = weighted.sum { |k, pv| k * (k + 1) * pv }
|
|
114
|
+
num / price / (1 + rate)**2 / freq**2
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private_class_method :round_value, :with_metric, :periods, :bond_flows,
|
|
118
|
+
:weighted_pv, :macaulay, :convexity_value
|
|
119
|
+
end
|
|
120
|
+
end
|
data/lib/xirr/brent.rb
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Xirr
|
|
4
|
+
# Brent's method: a derivative-free root finder combining inverse quadratic
|
|
5
|
+
# interpolation, the secant method, and bisection. It reuses {RtSafe}'s
|
|
6
|
+
# bracketing, so it is as robust as the default solver but never evaluates the
|
|
7
|
+
# NPV derivative — each iteration is cheaper, though it needs more of them.
|
|
8
|
+
#
|
|
9
|
+
# In practice it roughly ties {RtSafe}; it is offered for very large cashflows,
|
|
10
|
+
# where the cheaper per-iteration cost can win. Select it with
|
|
11
|
+
# `xirr(method: :brent)`. Unlike the Newton-based solvers it ignores the initial
|
|
12
|
+
# guess — it works from the bracket.
|
|
13
|
+
class Brent
|
|
14
|
+
include Base
|
|
15
|
+
|
|
16
|
+
# @param guess [Float, nil] ignored; Brent brackets the root itself
|
|
17
|
+
# @param options [Hash] reads +:iteration_limit+
|
|
18
|
+
# @return [Float, nil]
|
|
19
|
+
def xirr(_guess, options)
|
|
20
|
+
limit = (options && options[:iteration_limit]) || Xirr.config.iteration_limit
|
|
21
|
+
Brent.find(flows, iteration_limit: limit)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Pure solver over normalized +[time, amount]+ flows.
|
|
25
|
+
# @param flows [Array<Array(Float, Float)>]
|
|
26
|
+
# @return [Float, nil]
|
|
27
|
+
def self.find(flows, tolerance: Xirr.config.eps, iteration_limit: Xirr.config.iteration_limit, precision: Xirr.config.precision)
|
|
28
|
+
rate = zbrent(flows, tolerance.to_f, iteration_limit)
|
|
29
|
+
return nil if rate.nil? || rate.nan? || rate.infinite?
|
|
30
|
+
|
|
31
|
+
rounded = rate.round(precision)
|
|
32
|
+
rounded <= -1.0 ? nil : rounded
|
|
33
|
+
rescue FloatDomainError, Math::DomainError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Bracket a sign change (reusing RtSafe), then iterate Brent's method within
|
|
38
|
+
# it. Returns the rate, or nil if it can't bracket or converge.
|
|
39
|
+
def self.zbrent(flows, tol, iteration_limit)
|
|
40
|
+
low = RtSafe.safe_low(flows)
|
|
41
|
+
f_low = RtSafe.present_value(flows, low)
|
|
42
|
+
bounds = RtSafe.bracket(flows, low, f_low, 1.0)
|
|
43
|
+
return nil if bounds.nil?
|
|
44
|
+
|
|
45
|
+
a, b = bounds
|
|
46
|
+
fa = f_low # a == low
|
|
47
|
+
fb = RtSafe.present_value(flows, b)
|
|
48
|
+
c = a
|
|
49
|
+
fc = fa
|
|
50
|
+
d = e = b - a
|
|
51
|
+
|
|
52
|
+
iteration_limit.times do
|
|
53
|
+
# Keep c as the contrapoint — opposite sign to b, so [b, c] brackets.
|
|
54
|
+
if (fb.positive? && fc.positive?) || (fb.negative? && fc.negative?)
|
|
55
|
+
c = a
|
|
56
|
+
fc = fa
|
|
57
|
+
d = e = b - a
|
|
58
|
+
end
|
|
59
|
+
# Ensure b is the better estimate.
|
|
60
|
+
if fc.abs < fb.abs
|
|
61
|
+
a = b
|
|
62
|
+
b = c
|
|
63
|
+
c = a
|
|
64
|
+
fa = fb
|
|
65
|
+
fb = fc
|
|
66
|
+
fc = fa
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
tol1 = 2.0 * Float::EPSILON * b.abs + 0.5 * tol
|
|
70
|
+
xm = 0.5 * (c - b)
|
|
71
|
+
return b if xm.abs <= tol1 || fb.zero?
|
|
72
|
+
|
|
73
|
+
if e.abs >= tol1 && fa.abs > fb.abs
|
|
74
|
+
s = fb / fa
|
|
75
|
+
if a == c
|
|
76
|
+
# Secant step.
|
|
77
|
+
p = 2.0 * xm * s
|
|
78
|
+
q = 1.0 - s
|
|
79
|
+
else
|
|
80
|
+
# Inverse quadratic interpolation.
|
|
81
|
+
q = fa / fc
|
|
82
|
+
r = fb / fc
|
|
83
|
+
p = s * (2.0 * xm * q * (q - r) - (b - a) * (r - 1.0))
|
|
84
|
+
q = (q - 1.0) * (r - 1.0) * (s - 1.0)
|
|
85
|
+
end
|
|
86
|
+
q = -q if p.positive?
|
|
87
|
+
p = p.abs
|
|
88
|
+
min1 = 3.0 * xm * q - (tol1 * q).abs
|
|
89
|
+
min2 = (e * q).abs
|
|
90
|
+
if 2.0 * p < (min1 < min2 ? min1 : min2)
|
|
91
|
+
e = d
|
|
92
|
+
d = p / q # accept interpolation
|
|
93
|
+
else
|
|
94
|
+
d = e = xm # fall back to bisection
|
|
95
|
+
end
|
|
96
|
+
else
|
|
97
|
+
d = e = xm # bounds decreasing too slowly; bisect
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
a = b
|
|
101
|
+
fa = fb
|
|
102
|
+
b += d.abs > tol1 ? d : (xm.positive? ? tol1 : -tol1)
|
|
103
|
+
fb = RtSafe.present_value(flows, b)
|
|
104
|
+
end
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
data/lib/xirr/cashflow.rb
CHANGED
|
@@ -45,40 +45,109 @@ module Xirr
|
|
|
45
45
|
@max_date ||= map(&:date).max
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
#
|
|
48
|
+
# A rough starting rate for the solver: the cash-on-cash multiple annualized
|
|
49
|
+
# over the investment horizon, +multiple^(1 / years) - 1+.
|
|
50
|
+
#
|
|
51
|
+
# It falls back to 0.0 whenever that estimate is undefined or unusable — an
|
|
52
|
+
# invalid or empty cashflow, a horizon of zero, a non-positive multiple, or a
|
|
53
|
+
# rate at or below -100% (which the solver can't start from). The default
|
|
54
|
+
# +rtsafe+ solver does not depend on this being accurate; it only needs a
|
|
55
|
+
# finite rate above -1.
|
|
49
56
|
# @return [Float]
|
|
50
57
|
def irr_guess
|
|
51
|
-
return @irr_guess = 0.0
|
|
52
|
-
return @irr_guess = 0.0 if multiple <= 0
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@irr_guess.infinite? ? 0.0 :
|
|
58
|
+
return @irr_guess = 0.0 unless valid?
|
|
59
|
+
return @irr_guess = 0.0 if periods_of_investment.zero? || multiple <= 0
|
|
60
|
+
|
|
61
|
+
guess = ((multiple**(1.0 / periods_of_investment)) - 1).round(3)
|
|
62
|
+
@irr_guess = (guess.nan? || guess.infinite? || guess <= -1) ? 0.0 : guess
|
|
56
63
|
end
|
|
57
64
|
|
|
58
65
|
# @param guess [Float]
|
|
59
66
|
# @param method [Symbol]
|
|
60
|
-
# @return [Float]
|
|
67
|
+
# @return [Float] the rate, or +Xirr.config.replace_for_nil+ when it can't converge
|
|
61
68
|
def xirr(guess: nil, method: nil, ** options)
|
|
62
69
|
method, options = process_options(method, options)
|
|
63
70
|
if invalid?
|
|
64
71
|
raise ArgumentError, invalid_message if options[:raise_exception]
|
|
65
|
-
|
|
66
|
-
else
|
|
67
|
-
xirr = choose_(method).send :xirr, guess, options
|
|
68
|
-
xirr = choose_(other_calculation_method(method)).send(:xirr, guess, options) if (xirr.nil? || xirr.nan?) && fallback
|
|
69
|
-
xirr || Xirr.config.replace_for_nil
|
|
72
|
+
return Xirr.config.replace_for_nil
|
|
70
73
|
end
|
|
74
|
+
|
|
75
|
+
result = choose_(method).send(:xirr, guess, options)
|
|
76
|
+
# rtsafe already combines a Newton step with a bisection safeguard, so it is
|
|
77
|
+
# the fallback for the fragile solvers; when it is itself the primary there
|
|
78
|
+
# is nothing better to try, and a failure means the flow has no rate.
|
|
79
|
+
if unconverged?(result) && fallback && method != :rtsafe && method != :rtsafe_c
|
|
80
|
+
result = choose_(:rtsafe).send(:xirr, guess, options)
|
|
81
|
+
end
|
|
82
|
+
if unconverged?(result)
|
|
83
|
+
raise ArgumentError, 'XIRR did not converge' if options[:raise_exception]
|
|
84
|
+
return Xirr.config.replace_for_nil
|
|
85
|
+
end
|
|
86
|
+
result
|
|
87
|
+
ensure
|
|
88
|
+
# A per-call +period:+ must not leak into later #xnpv / #mirr calls.
|
|
89
|
+
@temporary_period = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Same as {#xirr}, but raises +ArgumentError+ when the cashflow is invalid or
|
|
93
|
+
# the rate can't be found, instead of returning +Xirr.config.replace_for_nil+.
|
|
94
|
+
# @return [Float]
|
|
95
|
+
def xirr!(guess: nil, method: nil, ** options)
|
|
96
|
+
xirr(guess: guess, method: method, **options.merge(raise_exception: true))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Net present value of the dated flows discounted at +rate+ on the same
|
|
100
|
+
# Actual/period basis {#xirr} uses. Roughly zero when +rate+ is the XIRR.
|
|
101
|
+
# @param rate [Float]
|
|
102
|
+
# @return [Float]
|
|
103
|
+
def xnpv(rate)
|
|
104
|
+
inject(0.0) { |sum, t| sum + t.amount / (1.0 + rate) ** ((t.date - min_date) / period) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Modified IRR of the dated flows. Positive flows are assumed reinvested at
|
|
108
|
+
# +reinvest_rate+, negative flows financed at +finance_rate+. Unlike XIRR it
|
|
109
|
+
# has a closed form and a single answer.
|
|
110
|
+
# @param finance_rate [Float]
|
|
111
|
+
# @param reinvest_rate [Float]
|
|
112
|
+
# @return [Float]
|
|
113
|
+
def mirr(finance_rate, reinvest_rate)
|
|
114
|
+
raise ArgumentError, invalid_message if invalid?
|
|
115
|
+
years = periods_of_investment
|
|
116
|
+
raise ArgumentError, 'Flows span no time' if years.zero?
|
|
117
|
+
|
|
118
|
+
future_of_inflows = 0.0
|
|
119
|
+
present_of_outflows = 0.0
|
|
120
|
+
each do |t|
|
|
121
|
+
if t.amount > 0
|
|
122
|
+
future_of_inflows += t.amount * (1.0 + reinvest_rate) ** ((max_date - t.date) / period)
|
|
123
|
+
elsif t.amount < 0
|
|
124
|
+
present_of_outflows += t.amount / (1.0 + finance_rate) ** ((t.date - min_date) / period)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
((future_of_inflows / -present_of_outflows) ** (1.0 / years) - 1).round(Xirr.config.precision)
|
|
71
129
|
end
|
|
72
130
|
|
|
73
131
|
def process_options(method, options)
|
|
74
132
|
@temporary_period = options[:period]
|
|
75
|
-
options[:raise_exception]
|
|
76
|
-
options[:iteration_limit]
|
|
133
|
+
options[:raise_exception] = resolve_option(options, :raise_exception)
|
|
134
|
+
options[:iteration_limit] = resolve_option(options, :iteration_limit)
|
|
77
135
|
return switch_fallback(method), options
|
|
78
136
|
end
|
|
79
137
|
|
|
80
|
-
#
|
|
81
|
-
#
|
|
138
|
+
# Resolve an option in precedence order: this call, then the cashflow's own
|
|
139
|
+
# options, then the global config. Reads +key?+ so an explicit +false+ or +0+
|
|
140
|
+
# is honored rather than overwritten.
|
|
141
|
+
# @return [Object]
|
|
142
|
+
def resolve_option(options, key)
|
|
143
|
+
return options[key] if options.key?(key)
|
|
144
|
+
return @options[key] if @options.key?(key)
|
|
145
|
+
|
|
146
|
+
Xirr.config.public_send(key)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Providing a method turns off fallback; otherwise use the configured default
|
|
150
|
+
# with fallback on. Returns the method to run.
|
|
82
151
|
# @param method [Symbol]
|
|
83
152
|
# @return [Symbol]
|
|
84
153
|
def switch_fallback(method)
|
|
@@ -91,15 +160,14 @@ module Xirr
|
|
|
91
160
|
end
|
|
92
161
|
end
|
|
93
162
|
|
|
94
|
-
|
|
95
|
-
method == :newton_method ? :bisection : :newton_method
|
|
96
|
-
end
|
|
163
|
+
private :process_options, :resolve_option, :switch_fallback
|
|
97
164
|
|
|
165
|
+
# A copy of the cashflow with same-date transactions merged into one.
|
|
166
|
+
# @return [Cashflow]
|
|
98
167
|
def compact_cf
|
|
99
|
-
# self
|
|
100
168
|
compact = Hash.new 0
|
|
101
169
|
each { |flow| compact[flow.date] += flow.amount }
|
|
102
|
-
Cashflow.new
|
|
170
|
+
Cashflow.new(flow: compact.map { |date, amount| Transaction.new(amount, date: date) }, period: period, **options)
|
|
103
171
|
end
|
|
104
172
|
|
|
105
173
|
# First investment date
|
|
@@ -122,6 +190,9 @@ module Xirr
|
|
|
122
190
|
def <<(arg)
|
|
123
191
|
super arg
|
|
124
192
|
sort! { |x, y| x.date <=> y.date }
|
|
193
|
+
# Adding a transaction can change the date range and leading sign, so drop
|
|
194
|
+
# the values memoized from the old contents.
|
|
195
|
+
@min_date = @max_date = @first_transaction_direction = nil
|
|
125
196
|
self
|
|
126
197
|
end
|
|
127
198
|
|
|
@@ -132,22 +203,37 @@ module Xirr
|
|
|
132
203
|
# @return [Class]
|
|
133
204
|
def choose_(method)
|
|
134
205
|
case method
|
|
206
|
+
when :rtsafe
|
|
207
|
+
RtSafe.new compact_cf
|
|
208
|
+
when :brent
|
|
209
|
+
Brent.new compact_cf
|
|
210
|
+
when :rtsafe_c
|
|
211
|
+
unless Xirr::NATIVE
|
|
212
|
+
raise ArgumentError, 'the native :rtsafe_c extension is not compiled; ' \
|
|
213
|
+
'reinstall the gem with a C toolchain or run `rake compile`'
|
|
214
|
+
end
|
|
215
|
+
RtSafeC.new compact_cf
|
|
135
216
|
when :bisection
|
|
136
217
|
Bisection.new compact_cf
|
|
137
218
|
when :newton_method
|
|
138
219
|
NewtonMethod.new compact_cf
|
|
139
220
|
else
|
|
140
|
-
raise ArgumentError, "
|
|
221
|
+
raise ArgumentError, "unknown method #{method.inspect}; use :rtsafe, :rtsafe_c, :brent, :bisection, or :newton_method"
|
|
141
222
|
end
|
|
142
223
|
end
|
|
143
224
|
|
|
225
|
+
# A solver result that means "no rate found": nil, or a NaN.
|
|
226
|
+
# @return [Boolean]
|
|
227
|
+
def unconverged?(value)
|
|
228
|
+
value.nil? || (value.respond_to?(:nan?) && value.nan?)
|
|
229
|
+
end
|
|
230
|
+
|
|
144
231
|
# @api private
|
|
145
232
|
# Sorts the {Cashflow} by date ascending
|
|
146
233
|
# and finds the signal of the first transaction.
|
|
147
234
|
# This implies the first transaction is a disbursement
|
|
148
235
|
# @return [Integer]
|
|
149
236
|
def first_transaction_direction
|
|
150
|
-
# self.sort! { |x, y| x.date <=> y.date }
|
|
151
237
|
@first_transaction_direction ||= first.amount / first.amount.abs
|
|
152
238
|
end
|
|
153
239
|
|
data/lib/xirr/config.rb
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xirr
|
|
2
4
|
include ActiveSupport::Configurable
|
|
3
5
|
|
|
4
|
-
#
|
|
6
|
+
# Default configuration. Each entry becomes both a config setting
|
|
7
|
+
# (+Xirr.config.eps+) and a frozen constant of the same name upcased
|
|
8
|
+
# (+Xirr::EPS+); the constant keeps the original default even after the setting
|
|
9
|
+
# is reconfigured.
|
|
5
10
|
default_values = {
|
|
6
11
|
eps: '1.0e-6'.to_f,
|
|
7
12
|
period: 365.0,
|
|
8
13
|
iteration_limit: 50,
|
|
9
14
|
precision: 6,
|
|
10
|
-
default_method: :
|
|
15
|
+
default_method: :rtsafe,
|
|
11
16
|
fallback: true,
|
|
12
17
|
replace_for_nil: 0.0,
|
|
13
|
-
compact: true,
|
|
14
18
|
raise_exception: false
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
# Iterates though default values and sets in config
|
|
18
21
|
default_values.each do |key, value|
|
|
19
|
-
|
|
22
|
+
config.public_send("#{key}=", value)
|
|
20
23
|
const_set key.to_s.upcase.to_sym, value
|
|
21
24
|
end
|
|
22
25
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Xirr
|
|
4
|
+
# Writing an asset down from its +cost+ to its +salvage+ value over its +life+.
|
|
5
|
+
# {sln} spreads the loss evenly; {syd}, {ddb}, and {db} are accelerated methods
|
|
6
|
+
# that depreciate more early on and return the amount for a single +period+
|
|
7
|
+
# (counting from 1).
|
|
8
|
+
module Depreciation
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Straight-line depreciation: the equal amount the asset is written down by
|
|
12
|
+
# each period.
|
|
13
|
+
# @return [Float]
|
|
14
|
+
def sln(cost, salvage, life)
|
|
15
|
+
raise ArgumentError, 'life must be non-zero' if life.zero?
|
|
16
|
+
|
|
17
|
+
(cost - salvage) / life * 1.0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Sum-of-years'-digits depreciation for a single +period+ — an accelerated
|
|
21
|
+
# method charging more in the early periods.
|
|
22
|
+
# @return [Float]
|
|
23
|
+
def syd(cost, salvage, life, period)
|
|
24
|
+
raise ArgumentError, 'undefined' if life <= 0 || period < 1 || period > life
|
|
25
|
+
|
|
26
|
+
(cost - salvage) * (life - period + 1) * 2.0 / (life * (life + 1))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Double-declining-balance depreciation for a single +period+: each period
|
|
30
|
+
# takes +factor/life+ of the remaining book value (never below +salvage+).
|
|
31
|
+
# +factor+ defaults to 2 (the usual double-declining rate).
|
|
32
|
+
# @return [Float]
|
|
33
|
+
def ddb(cost, salvage, life, period, factor = 2)
|
|
34
|
+
n = period.to_i
|
|
35
|
+
unless life.positive? && factor.positive? && period == n && n >= 1 && n <= life
|
|
36
|
+
raise ArgumentError, 'undefined'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
declining_balance(cost, salvage, factor.to_f / life, n)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Fixed-declining-balance depreciation for a single +period+. Like {ddb}, but
|
|
43
|
+
# the rate is derived from +cost+, +salvage+, and +life+ (rounded to three
|
|
44
|
+
# places, as spreadsheets do). +month+ is how many months the asset was in
|
|
45
|
+
# service during its first year (default 12); a shorter first year spills the
|
|
46
|
+
# remainder into an extra final period.
|
|
47
|
+
# @return [Float]
|
|
48
|
+
def db(cost, salvage, life, period, month = 12)
|
|
49
|
+
n = period.to_i
|
|
50
|
+
unless cost.positive? && salvage >= 0 && life.positive? && month >= 1 && month <= 12 &&
|
|
51
|
+
period == n && n >= 1 && n <= life + 1
|
|
52
|
+
raise ArgumentError, 'undefined'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
fixed_declining(cost, salvage, life, n, month)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# --- helpers ------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
# Walk periods 1..period carrying accumulated depreciation; return the amount
|
|
61
|
+
# for the final period. Depreciation stops at the salvage floor.
|
|
62
|
+
def declining_balance(cost, salvage, rate, period)
|
|
63
|
+
accumulated = 0.0
|
|
64
|
+
dep = 0.0
|
|
65
|
+
(1..period).each do
|
|
66
|
+
book = cost - accumulated
|
|
67
|
+
dep = [[book * rate, book - salvage].min, 0.0].max
|
|
68
|
+
accumulated += dep
|
|
69
|
+
end
|
|
70
|
+
dep
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def fixed_declining(cost, salvage, life, period, month)
|
|
74
|
+
rate = (1 - (salvage.to_f / cost)**(1.0 / life)).round(3)
|
|
75
|
+
accumulated = 0.0
|
|
76
|
+
dep = 0.0
|
|
77
|
+
(1..period).each do |p|
|
|
78
|
+
dep = db_period(cost, accumulated, rate, life, month, p)
|
|
79
|
+
accumulated += dep
|
|
80
|
+
end
|
|
81
|
+
dep
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def db_period(cost, accumulated, rate, life, month, period)
|
|
85
|
+
return cost * rate * month / 12.0 if period == 1
|
|
86
|
+
|
|
87
|
+
if period <= life
|
|
88
|
+
(cost - accumulated) * rate
|
|
89
|
+
else
|
|
90
|
+
# The partial last period when the first year was shorter than 12 months.
|
|
91
|
+
(cost - accumulated) * rate * (12 - month) / 12.0
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private_class_method :declining_balance, :fixed_declining, :db_period
|
|
96
|
+
end
|
|
97
|
+
end
|