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/newton_method.rb
CHANGED
|
@@ -1,61 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
require 'bigdecimal/newton'
|
|
3
2
|
|
|
4
3
|
module Xirr
|
|
5
|
-
#
|
|
4
|
+
# Plain Newton-Raphson: step from the guess by -xnpv/xnpv' until the step is
|
|
5
|
+
# smaller than the tolerance. Fast when it converges, but with no bracketing it
|
|
6
|
+
# can walk off to a non-root or below -100%; {RtSafe} is the safeguarded default
|
|
7
|
+
# that avoids that. Kept for `xirr(method: :newton_method)`.
|
|
6
8
|
class NewtonMethod
|
|
7
9
|
include Base
|
|
8
|
-
include Newton
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
#
|
|
12
|
-
# @
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
11
|
+
# @param guess [Float, nil] initial rate
|
|
12
|
+
# @param options [Hash] reads +:iteration_limit+
|
|
13
|
+
# @return [Float, nil] the rate rounded to +Xirr.config.precision+, or nil
|
|
14
|
+
def xirr(guess, options)
|
|
15
|
+
limit = (options && options[:iteration_limit]) || Xirr.config.iteration_limit
|
|
16
|
+
rate = (guess || cf.irr_guess).to_f
|
|
17
|
+
|
|
18
|
+
limit.times do
|
|
19
|
+
derivative = xnpv_derivative(rate)
|
|
20
|
+
return nil if derivative.zero?
|
|
21
|
+
|
|
22
|
+
step = xnpv(rate).to_f / derivative
|
|
23
|
+
rate -= step
|
|
24
|
+
# Below -100% the discount base (1 + rate) turns negative and the next
|
|
25
|
+
# xnpv raises it to a fractional power, producing a Complex. Bail first.
|
|
26
|
+
return nil if rate.nan? || rate.infinite? || rate <= -1
|
|
27
|
+
break if step.abs < Xirr.config.eps
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def initialize(transactions, function)
|
|
33
|
-
@transactions = transactions
|
|
34
|
-
@function = function
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Necessary for #nlsolve
|
|
38
|
-
# @param x [BigDecimal]
|
|
39
|
-
def values(x)
|
|
40
|
-
value = @transactions.send(@function, BigDecimal(x[0].to_s, Xirr.config.precision))
|
|
41
|
-
[BigDecimal(value.to_s, Xirr.config.precision)]
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Calculates XIRR using Newton method
|
|
46
|
-
# @return [BigDecimal]
|
|
47
|
-
# @param guess [Float]
|
|
48
|
-
def xirr(guess, _options)
|
|
49
|
-
func = Function.new(self, :xnpv)
|
|
50
|
-
rate = [guess || cf.irr_guess]
|
|
51
|
-
begin
|
|
52
|
-
nlsolve(func, rate)
|
|
53
|
-
(rate[0] <= -1 || rate[0].nan?) ? nil : rate[0].round(Xirr.config.precision)
|
|
54
|
-
|
|
55
|
-
# rate[0].round(Xirr.config.precision)
|
|
56
|
-
rescue
|
|
57
|
-
nil
|
|
58
|
-
end
|
|
30
|
+
rate.nan? ? nil : rate.round(Xirr.config.precision)
|
|
31
|
+
rescue FloatDomainError, Math::DomainError, RangeError
|
|
32
|
+
nil
|
|
59
33
|
end
|
|
60
34
|
end
|
|
61
35
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Xirr
|
|
4
|
+
# Periodic (dateless) cash-flow functions. Where {Cashflow} works with dated
|
|
5
|
+
# flows, these take a plain list of amounts landing at equally spaced periods
|
|
6
|
+
# 0, 1, 2, …, for when the exact dates don't matter. The rate they return is
|
|
7
|
+
# per period.
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Internal rate of return of +amounts+ at periods 0, 1, 2, …
|
|
11
|
+
#
|
|
12
|
+
# Xirr.irr([-1000, 1100]) # => 0.1
|
|
13
|
+
# Xirr.irr([-1000, 500, 500, 300]) # => 0.156579
|
|
14
|
+
#
|
|
15
|
+
# @param amounts [Array<Numeric>]
|
|
16
|
+
# @param guess [Float] initial rate for the solver
|
|
17
|
+
# @return [Float] the rate per period
|
|
18
|
+
# @raise [ArgumentError] when there aren't at least one inflow and one outflow
|
|
19
|
+
def irr(amounts, guess: 0.1)
|
|
20
|
+
flows = validated_flows(amounts)
|
|
21
|
+
rate = RtSafe.find(flows, guess: guess)
|
|
22
|
+
raise ArgumentError, 'IRR did not converge' if rate.nil?
|
|
23
|
+
rate
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Net present value of +amounts+ at periods 0, 1, 2, … discounted at +rate+.
|
|
27
|
+
# The first amount sits at period 0 and is left undiscounted, so
|
|
28
|
+
# +Xirr.npv(Xirr.irr(a), a)+ comes out to roughly zero. This differs from a
|
|
29
|
+
# spreadsheet +NPV+, which places the first amount at period 1.
|
|
30
|
+
#
|
|
31
|
+
# Xirr.npv(0.1, [-1000, 1100]) # => 0.0
|
|
32
|
+
# Xirr.npv(0.1, [-1000, 600, 600]) # => 41.322314
|
|
33
|
+
#
|
|
34
|
+
# @param rate [Numeric]
|
|
35
|
+
# @param amounts [Array<Numeric>]
|
|
36
|
+
# @return [Float]
|
|
37
|
+
def npv(rate, amounts)
|
|
38
|
+
amounts.each_with_index.inject(0.0) do |sum, (amount, i)|
|
|
39
|
+
sum + amount.to_f / (1.0 + rate) ** i
|
|
40
|
+
end.round(Xirr.config.precision)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Modified internal rate of return of periodic +amounts+. Positive flows are
|
|
44
|
+
# assumed reinvested at +reinvest_rate+, negative flows financed at
|
|
45
|
+
# +finance_rate+.
|
|
46
|
+
#
|
|
47
|
+
# Xirr.mirr([-120_000, 39_000, 30_000, 21_000, 37_000, 46_000], 0.10, 0.12)
|
|
48
|
+
# # => 0.126094
|
|
49
|
+
#
|
|
50
|
+
# @param amounts [Array<Numeric>]
|
|
51
|
+
# @param finance_rate [Numeric]
|
|
52
|
+
# @param reinvest_rate [Numeric]
|
|
53
|
+
# @return [Float]
|
|
54
|
+
def mirr(amounts, finance_rate, reinvest_rate)
|
|
55
|
+
values = validated_amounts(amounts)
|
|
56
|
+
periods = values.length - 1
|
|
57
|
+
|
|
58
|
+
future_of_inflows = values.each_with_index.inject(0.0) do |acc, (value, i)|
|
|
59
|
+
value > 0 ? acc + value * (1.0 + reinvest_rate) ** (periods - i) : acc
|
|
60
|
+
end
|
|
61
|
+
present_of_outflows = values.each_with_index.inject(0.0) do |acc, (value, i)|
|
|
62
|
+
value < 0 ? acc + value / (1.0 + finance_rate) ** i : acc
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
((future_of_inflows / -present_of_outflows) ** (1.0 / periods) - 1).round(Xirr.config.precision)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @api private
|
|
69
|
+
def validated_amounts(amounts)
|
|
70
|
+
values = amounts.map(&:to_f)
|
|
71
|
+
raise ArgumentError, 'Need at least two amounts' if values.length < 2
|
|
72
|
+
unless values.any?(&:positive?) && values.any?(&:negative?)
|
|
73
|
+
raise ArgumentError, 'Need at least one positive and one negative amount'
|
|
74
|
+
end
|
|
75
|
+
values
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @api private
|
|
79
|
+
def validated_flows(amounts)
|
|
80
|
+
validated_amounts(amounts).each_with_index.map { |amount, i| [i.to_f, amount] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private_class_method :validated_amounts, :validated_flows
|
|
84
|
+
end
|
data/lib/xirr/rates.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Xirr
|
|
4
|
+
# Converting between the ways an interest rate can be quoted. A nominal rate
|
|
5
|
+
# paired with a compounding frequency, the effective annual rate it actually
|
|
6
|
+
# earns, and a continuously-compounded rate all describe the same return in
|
|
7
|
+
# different terms; these move between them.
|
|
8
|
+
module Rates
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Effective annual rate earned by a +nominal+ rate compounded +m+ times a
|
|
12
|
+
# year: +(1 + nominal/m)^m − 1+.
|
|
13
|
+
# @return [Float]
|
|
14
|
+
def effective_annual_rate(nominal, m)
|
|
15
|
+
raise ArgumentError, 'm must be positive' if m <= 0
|
|
16
|
+
|
|
17
|
+
(1 + nominal / m)**m - 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Nominal rate that, compounded +m+ times a year, produces the +effective+
|
|
21
|
+
# annual rate. The inverse of {effective_annual_rate}.
|
|
22
|
+
# @return [Float]
|
|
23
|
+
def nominal_rate(effective, m)
|
|
24
|
+
raise ArgumentError, 'm must be positive' if m <= 0
|
|
25
|
+
raise ArgumentError, 'effective must be above -100%' if 1 + effective <= 0
|
|
26
|
+
|
|
27
|
+
m * ((1 + effective)**(1.0 / m) - 1)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Per-period rate equivalent to a continuously-compounded annual +rate+, for
|
|
31
|
+
# +periods+ periods a year: +e^(rate/periods) − 1+.
|
|
32
|
+
# @return [Float]
|
|
33
|
+
def continuous_to_periodic(rate, periods)
|
|
34
|
+
raise ArgumentError, 'periods must be positive' if periods <= 0
|
|
35
|
+
|
|
36
|
+
Math.exp(rate / periods) - 1
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/xirr/returns.rb
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Xirr
|
|
4
|
+
# Performance and risk metrics: {volatility}, {cagr}, {payback_period},
|
|
5
|
+
# {discounted_payback_period}, {profitability_index}, and {twr}.
|
|
6
|
+
#
|
|
7
|
+
# The cash-flow functions follow the same convention as {Xirr.npv}: the initial
|
|
8
|
+
# outlay sits at index 0 (undiscounted) and later flows fall at periods 1, 2, ….
|
|
9
|
+
module Returns
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Annualised volatility of a price series — the sample standard deviation of
|
|
13
|
+
# its period-over-period returns, scaled up by +√periods_per_year+. Needs at
|
|
14
|
+
# least three positive prices, in time order.
|
|
15
|
+
# @param returns [:simple, :log] how to measure each return: +(b-a)/a+ or +ln(b/a)+
|
|
16
|
+
# @return [Float]
|
|
17
|
+
def volatility(prices, periods_per_year: 252, returns: :simple, precision: Xirr.config.precision)
|
|
18
|
+
rets = period_returns(prices, returns)
|
|
19
|
+
raise ArgumentError, 'need at least three prices' if rets.length < 2
|
|
20
|
+
|
|
21
|
+
round_value(annualise(rets, periods_per_year), precision)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Compound annual growth rate — the constant yearly rate that grows
|
|
25
|
+
# +begin_value+ into +end_value+ over +years+.
|
|
26
|
+
# @return [Float]
|
|
27
|
+
def cagr(begin_value, end_value, years, precision: Xirr.config.precision)
|
|
28
|
+
if begin_value <= 0 || years <= 0 || end_value.to_f / begin_value < 0
|
|
29
|
+
raise ArgumentError, 'undefined'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
round_value((end_value.to_f / begin_value)**(1.0 / years) - 1, precision)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Payback period — how many periods of cash flow it takes to recover the
|
|
36
|
+
# initial outlay, interpolating within the recovering period. The first
|
|
37
|
+
# amount is the outlay (negative), the rest are inflows.
|
|
38
|
+
# @return [Float]
|
|
39
|
+
def payback_period(cash_flows, precision: Xirr.config.precision)
|
|
40
|
+
recovery(cash_flows, precision)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Like {payback_period}, but recovers the outlay from cash flows discounted
|
|
44
|
+
# at +rate+, so it accounts for the time value of money.
|
|
45
|
+
# @return [Float]
|
|
46
|
+
def discounted_payback_period(cash_flows, rate, precision: Xirr.config.precision)
|
|
47
|
+
recovery(discount_flows(cash_flows, rate), precision)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Profitability index — the present value of future inflows per unit of
|
|
51
|
+
# initial investment, discounted at +rate+. Above 1 means the project adds
|
|
52
|
+
# value. Equivalent to +1 + NPV / initial investment+.
|
|
53
|
+
# @return [Float]
|
|
54
|
+
def profitability_index(cash_flows, rate, precision: Xirr.config.precision)
|
|
55
|
+
raise ArgumentError, 'need at least one flow' if cash_flows.empty?
|
|
56
|
+
raise ArgumentError, 'the first flow must be an outlay (negative)' if cash_flows.first >= 0
|
|
57
|
+
|
|
58
|
+
npv = Xirr.npv(rate, cash_flows)
|
|
59
|
+
round_value(1 + npv / -cash_flows.first, precision)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Time-weighted return — period returns linked geometrically,
|
|
63
|
+
# +∏(1 + rᵢ) − 1+. Immune to the timing of cash flows. Pass
|
|
64
|
+
# +periods_per_year+ to annualise.
|
|
65
|
+
# @return [Float]
|
|
66
|
+
def twr(period_returns, periods_per_year: nil, precision: Xirr.config.precision)
|
|
67
|
+
raise ArgumentError, 'need at least one return' if period_returns.empty?
|
|
68
|
+
raise ArgumentError, 'returns must be numbers' unless period_returns.all? { |r| r.is_a?(Numeric) }
|
|
69
|
+
|
|
70
|
+
round_value(time_weighted(period_returns, periods_per_year), precision)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# --- helpers ------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def round_value(value, precision)
|
|
76
|
+
value.round(precision) + 0.0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Consecutive-pair returns. Raises if a price is non-numeric or non-positive.
|
|
80
|
+
def period_returns(prices, kind)
|
|
81
|
+
prices.each_cons(2).map do |a, b|
|
|
82
|
+
raise ArgumentError, 'prices must be numbers' unless a.is_a?(Numeric) && b.is_a?(Numeric)
|
|
83
|
+
raise ArgumentError, 'every price must be positive' unless a.positive? && b.positive?
|
|
84
|
+
|
|
85
|
+
kind == :log ? Math.log(b.to_f / a) : (b - a).to_f / a
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def annualise(returns, periods_per_year)
|
|
90
|
+
n = returns.length
|
|
91
|
+
mean = returns.sum / n.to_f
|
|
92
|
+
sum_of_squares = returns.sum { |r| (r - mean)**2 }
|
|
93
|
+
Math.sqrt(sum_of_squares / (n - 1)) * Math.sqrt(periods_per_year)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def recovery(flows, precision)
|
|
97
|
+
whole, shortfall, recovering_flow = cumulative_recovery(flows)
|
|
98
|
+
round_value(whole + shortfall / recovering_flow, precision)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Find the first period where the running cumulative crosses from negative to
|
|
102
|
+
# non-negative. Raises when it never crosses or there are no flows.
|
|
103
|
+
def cumulative_recovery(flows)
|
|
104
|
+
raise ArgumentError, 'need at least one flow' if flows.empty?
|
|
105
|
+
|
|
106
|
+
cumulative = 0.0
|
|
107
|
+
flows.each_with_index do |flow, i|
|
|
108
|
+
nxt = cumulative + flow
|
|
109
|
+
return [i - 1, -cumulative, flow] if i.positive? && cumulative.negative? && nxt >= 0
|
|
110
|
+
|
|
111
|
+
cumulative = nxt
|
|
112
|
+
end
|
|
113
|
+
raise ArgumentError, 'the outlay is never recovered'
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def discount_flows(flows, rate)
|
|
117
|
+
flows.each_with_index.map { |flow, i| flow / (1 + rate)**i }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def time_weighted(returns, periods_per_year)
|
|
121
|
+
cumulative = returns.reduce(1.0) { |acc, r| acc * (1 + r) } - 1
|
|
122
|
+
return cumulative if periods_per_year.nil?
|
|
123
|
+
|
|
124
|
+
(1 + cumulative)**(periods_per_year.to_f / returns.length) - 1
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private_class_method :round_value, :period_returns, :annualise, :recovery,
|
|
128
|
+
:cumulative_recovery, :discount_flows, :time_weighted
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/xirr/rtsafe.rb
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Xirr
|
|
4
|
+
# Safeguarded Newton-Raphson root finder — the classic +rtsafe+.
|
|
5
|
+
#
|
|
6
|
+
# It brackets a sign change of the net present value first, then each iteration
|
|
7
|
+
# takes a Newton step when that step lands inside the bracket and is shrinking
|
|
8
|
+
# the interval fast enough, and a bisection step otherwise. This keeps Newton's
|
|
9
|
+
# speed on well-behaved flows while retaining bisection's guaranteed
|
|
10
|
+
# convergence, in a single pass rather than running Newton to exhaustion and
|
|
11
|
+
# then bisecting separately.
|
|
12
|
+
#
|
|
13
|
+
# The maintained bracket always encloses a sign change, so the result is a
|
|
14
|
+
# genuine root rather than a stalled non-root, and a long-dated flow whose raw
|
|
15
|
+
# Newton step would overflow takes a bisection step instead.
|
|
16
|
+
class RtSafe
|
|
17
|
+
include Base
|
|
18
|
+
|
|
19
|
+
# Stop expanding the upper bound once it passes this — the flow has no root
|
|
20
|
+
# in a sane rate range.
|
|
21
|
+
BRACKET_CEILING = 1.0e7
|
|
22
|
+
|
|
23
|
+
# Solves the compacted {Cashflow} the instance was built with.
|
|
24
|
+
# @param guess [Float, nil] initial rate; used when it lands inside the bracket
|
|
25
|
+
# @param options [Hash] reads +:iteration_limit+
|
|
26
|
+
# @return [Float, nil] the rate rounded to +Xirr.config.precision+, or nil when it can't converge
|
|
27
|
+
def xirr(guess, options)
|
|
28
|
+
limit = (options && options[:iteration_limit]) || Xirr.config.iteration_limit
|
|
29
|
+
start = guess || cf.irr_guess
|
|
30
|
+
RtSafe.find(flows, guess: start.to_f, iteration_limit: limit)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Pure solver over normalized +[time, amount]+ flows (time in years/periods).
|
|
34
|
+
# Shared by the dated {Cashflow} path and the periodic {Xirr} module helpers.
|
|
35
|
+
# @param flows [Array<Array(Float, Float)>]
|
|
36
|
+
# @return [Float, nil]
|
|
37
|
+
def self.find(flows, guess: 0.1, tolerance: Xirr.config.eps, iteration_limit: Xirr.config.iteration_limit, precision: Xirr.config.precision)
|
|
38
|
+
rate = rtsafe(flows, guess.to_f, tolerance.to_f, iteration_limit)
|
|
39
|
+
return nil if rate.nil? || rate.nan? || rate.infinite?
|
|
40
|
+
|
|
41
|
+
# Round before the floor check: a rate just above -1 can round down to it.
|
|
42
|
+
rounded = rate.round(precision)
|
|
43
|
+
rounded <= -1.0 ? nil : rounded
|
|
44
|
+
rescue FloatDomainError, Math::DomainError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Net present value of +flows+ at +rate+: Σ amount / (1 + rate)^t
|
|
49
|
+
def self.present_value(flows, rate)
|
|
50
|
+
flows.inject(0.0) { |sum, (t, amount)| sum + amount / (1.0 + rate) ** t }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Derivative of the NPV with respect to rate: Σ -t · amount / (1 + rate)^(t+1)
|
|
54
|
+
def self.present_value_derivative(flows, rate)
|
|
55
|
+
flows.inject(0.0) { |sum, (t, amount)| sum + (-t * amount / (1.0 + rate) ** (t + 1)) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Bracket a sign change, then run the safeguarded iteration from +guess+
|
|
59
|
+
# (when it falls inside the bracket) or the midpoint.
|
|
60
|
+
def self.rtsafe(flows, guess, tol, iteration_limit)
|
|
61
|
+
low = safe_low(flows)
|
|
62
|
+
f_low = present_value(flows, low)
|
|
63
|
+
bounds = bracket(flows, low, f_low, 1.0)
|
|
64
|
+
return nil if bounds.nil?
|
|
65
|
+
|
|
66
|
+
a, b = bounds
|
|
67
|
+
# a == low, so the NPV at a is f_low. Orient so it is negative at xlo and
|
|
68
|
+
# positive at xhi — the invariant the step selection relies on.
|
|
69
|
+
xlo, xhi = f_low < 0.0 ? [a, b] : [b, a]
|
|
70
|
+
x = (guess > a && guess < b) ? guess : (a + b) / 2.0
|
|
71
|
+
f = present_value(flows, x)
|
|
72
|
+
df = present_value_derivative(flows, x)
|
|
73
|
+
search(flows, x, xlo, xhi, f, df, (b - a).abs, tol, iteration_limit)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Iterate to the root, taking a Newton or bisection step each time. Looped
|
|
77
|
+
# (not recursed) so a large +iteration_limit+ can't overflow the stack.
|
|
78
|
+
# @return [Float, nil] the root, or nil if it doesn't converge within +iters+
|
|
79
|
+
def self.search(flows, x, xlo, xhi, f, df, dxold, tol, iters)
|
|
80
|
+
iters.times do
|
|
81
|
+
nxt, dx = move(x, xlo, xhi, f, df, dxold)
|
|
82
|
+
return nxt if dx.abs < tol
|
|
83
|
+
|
|
84
|
+
f = present_value(flows, nxt)
|
|
85
|
+
df = present_value_derivative(flows, nxt)
|
|
86
|
+
f < 0.0 ? xlo = nxt : xhi = nxt
|
|
87
|
+
x = nxt
|
|
88
|
+
dxold = dx
|
|
89
|
+
end
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A Newton step when it's usable, a bisection step otherwise.
|
|
94
|
+
# @return [Array(Float, Float)] +[next_x, step]+
|
|
95
|
+
def self.move(x, xlo, xhi, f, df, dxold)
|
|
96
|
+
if newton_usable?(x, xlo, xhi, f, df, dxold)
|
|
97
|
+
dx = f / df
|
|
98
|
+
[x - dx, dx]
|
|
99
|
+
else
|
|
100
|
+
dx = (xhi - xlo) / 2.0
|
|
101
|
+
[xlo + dx, dx]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Prefer Newton when the derivative isn't flat, the step lands inside the
|
|
106
|
+
# bracket, and it shrinks the interval by at least half. Comparing the Newton
|
|
107
|
+
# point against the bracket — rather than the classic product form — avoids an
|
|
108
|
+
# overflow in the steep zone near the bracket's floor. +df != 0+ short-circuits
|
|
109
|
+
# before +x - f / df+.
|
|
110
|
+
def self.newton_usable?(x, xlo, xhi, f, df, dxold)
|
|
111
|
+
df != 0.0 && inside?(x - f / df, xlo, xhi) && (2.0 * f).abs <= (dxold * df).abs
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.inside?(point, xlo, xhi)
|
|
115
|
+
point >= [xlo, xhi].min && point <= [xlo, xhi].max
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The bracket's floor. As +rate+ nears -1, +(1 + rate)^t+ underflows to zero
|
|
119
|
+
# (then divides by zero) for large +t+, so raise the floor just enough that the
|
|
120
|
+
# longest-dated flow's discount factor stays finite. For short-dated flows this
|
|
121
|
+
# is the familiar -0.999999; for a 30-year monthly schedule it sits higher.
|
|
122
|
+
def self.safe_low(flows)
|
|
123
|
+
max_t = flows.map { |t, _amount| t }.max
|
|
124
|
+
max_t = 1.0 if max_t.nil? || max_t < 1.0
|
|
125
|
+
[1.0e-290 ** (1.0 / max_t), 1.0e-6].max - 1.0
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Expand the upper bound until the NPV changes sign, giving a bracket.
|
|
129
|
+
def self.bracket(flows, low, f_low, high)
|
|
130
|
+
return nil if high > BRACKET_CEILING
|
|
131
|
+
|
|
132
|
+
if straddles_zero?(f_low, present_value(flows, high))
|
|
133
|
+
[low, high]
|
|
134
|
+
else
|
|
135
|
+
bracket(flows, low, f_low, high * 2 + 1)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Whether +a+ and +b+ sit on opposite sides of zero. Comparing signs rather
|
|
140
|
+
# than the product +a * b+ avoids overflow when the NPV is astronomically large
|
|
141
|
+
# near the bracket's floor for long-dated flows.
|
|
142
|
+
def self.straddles_zero?(a, b)
|
|
143
|
+
(a <= 0 && b >= 0) || (a >= 0 && b <= 0)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Xirr
|
|
4
|
+
# True when the native rtsafe extension compiled and loaded.
|
|
5
|
+
begin
|
|
6
|
+
require 'xirr/xirr_native'
|
|
7
|
+
NATIVE = true
|
|
8
|
+
rescue LoadError
|
|
9
|
+
NATIVE = false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# C-backed rtsafe. Same algorithm and results as {RtSafe}, run in a native
|
|
13
|
+
# extension. Only usable when {NATIVE} is true (the extension was compiled);
|
|
14
|
+
# otherwise the gem runs the pure-Ruby {RtSafe} instead.
|
|
15
|
+
class RtSafeC
|
|
16
|
+
include Base
|
|
17
|
+
|
|
18
|
+
# @param guess [Float, nil]
|
|
19
|
+
# @param options [Hash] reads +:iteration_limit+
|
|
20
|
+
# @return [Float, nil]
|
|
21
|
+
def xirr(guess, options)
|
|
22
|
+
limit = (options && options[:iteration_limit]) || Xirr.config.iteration_limit
|
|
23
|
+
start = (guess || cf.irr_guess).to_f
|
|
24
|
+
rate = Xirr::Native.rtsafe(flows, start, Xirr.config.eps.to_f, limit)
|
|
25
|
+
return nil if rate.nil?
|
|
26
|
+
|
|
27
|
+
# Round before the floor check: a rate just above -1 can round down to it.
|
|
28
|
+
rounded = rate.round(Xirr.config.precision)
|
|
29
|
+
rounded <= -1.0 ? nil : rounded
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/xirr/transaction.rb
CHANGED
|
@@ -6,17 +6,12 @@ module Xirr
|
|
|
6
6
|
attr_reader :amount, :date
|
|
7
7
|
|
|
8
8
|
# @example
|
|
9
|
-
# Transaction.new
|
|
9
|
+
# Transaction.new(-1000, date: Date.new(2013, 1, 1))
|
|
10
10
|
# @param amount [Numeric]
|
|
11
|
-
# @param opts [Hash]
|
|
12
|
-
# @note Don't forget to add date: [Date] in the opts hash.
|
|
11
|
+
# @param opts [Hash] must include +:date+, the date the amount falls on
|
|
13
12
|
def initialize(amount, opts = {})
|
|
14
13
|
self.amount = amount
|
|
15
|
-
|
|
16
|
-
# Set optional attributes..
|
|
17
|
-
opts.each do |key, value|
|
|
18
|
-
send("#{key}=", value)
|
|
19
|
-
end
|
|
14
|
+
opts.each { |key, value| send("#{key}=", value) }
|
|
20
15
|
end
|
|
21
16
|
|
|
22
17
|
# Sets the date
|
|
@@ -30,7 +25,7 @@ module Xirr
|
|
|
30
25
|
# @param value [Numeric]
|
|
31
26
|
# @return [Float]
|
|
32
27
|
def amount=(value)
|
|
33
|
-
@amount = value.to_f
|
|
28
|
+
@amount = value.to_f
|
|
34
29
|
end
|
|
35
30
|
|
|
36
31
|
# @return [String]
|