finrb 1.0.1 → 1.2.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/CHANGELOG.md +50 -0
- data/CONTRIBUTING.md +89 -0
- data/README.md +58 -2
- data/SECURITY.md +41 -0
- data/lib/finrb/accounting.rb +93 -74
- data/lib/finrb/amortization.rb +145 -44
- data/lib/finrb/cashflows.rb +32 -9
- data/lib/finrb/config.rb +5 -5
- data/lib/finrb/rates.rb +3 -21
- data/lib/finrb/ratios.rb +41 -40
- data/lib/finrb/returns.rb +138 -39
- data/lib/finrb/transaction.rb +0 -11
- data/lib/finrb/tvm.rb +154 -52
- data/lib/finrb/validation.rb +42 -0
- data/lib/finrb/version.rb +1 -1
- data/lib/finrb/yields.rb +42 -31
- data/sig/finrb.rbs +39 -4
- metadata +17 -1
data/lib/finrb/amortization.rb
CHANGED
|
@@ -18,20 +18,67 @@ module Finrb
|
|
|
18
18
|
# @example Borrow $250,000 under a 30 year, fixed-rate loan with a 4.25% APR, but pay $150 extra each month
|
|
19
19
|
# rate = Rate.new(0.0425, :apr, :duration => (5 * 12))
|
|
20
20
|
# extra_payments = Finrb::Amortization.new(250000, rate){ |period| period.payment - 150 }
|
|
21
|
-
# @api public
|
|
22
21
|
class Amortization
|
|
22
|
+
# Immutable breakdown of one amortization period. Payments retain finrb's
|
|
23
|
+
# cashflow sign convention and are negative; the other monetary fields are
|
|
24
|
+
# non-negative.
|
|
25
|
+
class Entry
|
|
26
|
+
ATTRIBUTES = %i[period opening_balance payment interest principal additional_payment balloon_payment interest_only closing_balance].freeze
|
|
27
|
+
MONETARY_ATTRIBUTES = ATTRIBUTES - %i[period interest_only]
|
|
28
|
+
private_constant :ATTRIBUTES, :MONETARY_ATTRIBUTES
|
|
29
|
+
|
|
30
|
+
attr_reader(*ATTRIBUTES)
|
|
31
|
+
|
|
32
|
+
def initialize(period:, opening_balance:, payment:, interest:, principal:, additional_payment:, balloon_payment:, interest_only:, closing_balance:)
|
|
33
|
+
raise(ArgumentError, 'period must be a non-negative integer.') unless period.is_a?(Integer) && !period.negative?
|
|
34
|
+
raise(ArgumentError, 'interest_only must be true or false.') unless [true, false].include?(interest_only)
|
|
35
|
+
|
|
36
|
+
@period = period
|
|
37
|
+
@interest_only = interest_only
|
|
38
|
+
MONETARY_ATTRIBUTES.each do |name|
|
|
39
|
+
value = binding.local_variable_get(name)
|
|
40
|
+
instance_variable_set("@#{name}", Validation.decimal(value, name: name.to_s.tr('_', ' ')))
|
|
41
|
+
end
|
|
42
|
+
freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def ==(other)
|
|
46
|
+
other.instance_of?(self.class) && ATTRIBUTES.all? { |name| public_send(name) == other.public_send(name) }
|
|
47
|
+
end
|
|
48
|
+
alias eql? ==
|
|
49
|
+
|
|
50
|
+
def hash
|
|
51
|
+
attributes = ATTRIBUTES.map { |name| public_send(name) }
|
|
52
|
+
attributes.hash
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_h
|
|
56
|
+
ATTRIBUTES.to_h { |name| [name, public_send(name)] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
alias interest_only? interest_only
|
|
60
|
+
end
|
|
61
|
+
|
|
23
62
|
# @return [Flt::DecNum] the balance of the loan at the end of the amortization period (usually zero)
|
|
24
|
-
# @api public
|
|
25
63
|
attr_reader :balance
|
|
64
|
+
# @return [Flt::DecNum] contractual principal settled as a balloon in the final period
|
|
65
|
+
attr_reader :balloon
|
|
66
|
+
# @return [Flt::DecNum] principal balance including any financed origination fee
|
|
67
|
+
attr_reader :amount_financed
|
|
68
|
+
# @return [Flt::DecNum] cash made available to the borrower after an unfinanced fee
|
|
69
|
+
attr_reader :net_proceeds
|
|
70
|
+
# @return [Flt::DecNum] fee charged when the loan is originated
|
|
71
|
+
attr_reader :origination_fee
|
|
72
|
+
# @return [Integer] number of leading periods that pay interest but no scheduled principal
|
|
73
|
+
attr_reader :interest_only_periods
|
|
26
74
|
# @return [Flt::DecNum] the required monthly payment. For loans with more than one rate, returns nil
|
|
27
|
-
# @api public
|
|
28
75
|
attr_reader :payment
|
|
29
76
|
# @return [Flt::DecNum] the principal amount of the loan
|
|
30
|
-
# @api public
|
|
31
77
|
attr_reader :principal
|
|
32
78
|
# @return [Array] the interest rates used for calculating the amortization
|
|
33
|
-
# @api public
|
|
34
79
|
attr_reader :rates
|
|
80
|
+
# @return [Array<Entry>] immutable period-by-period loan breakdown
|
|
81
|
+
attr_reader :schedule
|
|
35
82
|
|
|
36
83
|
# @return [Flt::DecNum] the periodic payment due on a loan
|
|
37
84
|
# @param [Flt::DecNum] principal the initial amount of the loan or investment
|
|
@@ -43,21 +90,22 @@ module Finrb
|
|
|
43
90
|
# rate.duration #=> 360
|
|
44
91
|
# Amortization.payment(200000, rate.monthly, rate.duration) #=> Flt::DecNum('-926.23')
|
|
45
92
|
# @see https://en.wikipedia.org/wiki/Amortization_calculator
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
principal = Validation.decimal(principal, name: 'principal')
|
|
49
|
-
raise(ArgumentError, 'principal must be positive.') unless principal.positive?
|
|
93
|
+
def self.payment(principal, rate, periods, balloon: 0)
|
|
94
|
+
principal = Validation.positive_decimal(principal, name: 'principal', message: 'principal must be positive.')
|
|
50
95
|
|
|
51
|
-
|
|
52
|
-
raise(ArgumentError, '
|
|
96
|
+
balloon = Validation.decimal(balloon, name: 'balloon')
|
|
97
|
+
raise(ArgumentError, 'balloon must be non-negative and no greater than principal.') unless balloon.between?(0, principal)
|
|
53
98
|
|
|
54
|
-
|
|
99
|
+
rate = Validation.decimal_greater_than(rate, minimum: -1, name: 'periodic rate')
|
|
100
|
+
|
|
101
|
+
periods = Validation.positive_integer(periods, name: 'period count')
|
|
55
102
|
|
|
56
103
|
if rate.zero?
|
|
57
104
|
# simplified formula to avoid division-by-zero when interest rate is zero
|
|
58
|
-
-Precision.money(principal / periods)
|
|
105
|
+
-Precision.money((principal - balloon) / periods)
|
|
59
106
|
else
|
|
60
|
-
|
|
107
|
+
growth = (rate + 1)**periods
|
|
108
|
+
-Precision.money(((principal * growth) - balloon) * rate / (growth - 1))
|
|
61
109
|
end
|
|
62
110
|
end
|
|
63
111
|
|
|
@@ -66,10 +114,19 @@ module Finrb
|
|
|
66
114
|
# @param [Flt::DecNum] principal the initial amount of the loan or investment
|
|
67
115
|
# @param [Rate] rates the applicable interest rates
|
|
68
116
|
# @param [Proc] block
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
117
|
+
def initialize(principal, *rates, balloon: 0, interest_only_periods: 0, origination_fee: 0, finance_origination_fee: false, &block)
|
|
118
|
+
@principal = Validation.positive_decimal(principal, name: 'principal', message: 'principal must be positive.')
|
|
119
|
+
|
|
120
|
+
@origination_fee = Validation.non_negative_decimal(origination_fee, name: 'origination fee')
|
|
121
|
+
raise(ArgumentError, 'finance_origination_fee must be true or false.') unless [true, false].include?(finance_origination_fee)
|
|
122
|
+
raise(ArgumentError, 'an unfinanced origination_fee must be less than principal.') if !finance_origination_fee && @origination_fee >= @principal
|
|
123
|
+
|
|
124
|
+
@finance_origination_fee = finance_origination_fee
|
|
125
|
+
@amount_financed = @principal + (finance_origination_fee ? @origination_fee : 0)
|
|
126
|
+
@net_proceeds = @principal - (finance_origination_fee ? 0 : @origination_fee)
|
|
127
|
+
|
|
128
|
+
@balloon = Validation.decimal(balloon, name: 'balloon')
|
|
129
|
+
raise(ArgumentError, 'balloon must be non-negative and less than amount financed.') if @balloon.negative? || @balloon >= @amount_financed
|
|
73
130
|
raise(ArgumentError, 'at least one rate is required.') if rates.empty?
|
|
74
131
|
raise(ArgumentError, 'rates must be Finrb::Rate instances.') unless rates.all?(Rate)
|
|
75
132
|
raise(ArgumentError, 'every rate must have a duration.') if rates.any? { |rate| rate.duration.nil? }
|
|
@@ -79,7 +136,11 @@ module Finrb
|
|
|
79
136
|
|
|
80
137
|
# compute the total duration from all of the rates.
|
|
81
138
|
@periods = rates.sum(&:duration)
|
|
82
|
-
|
|
139
|
+
valid_interest_only = interest_only_periods.is_a?(Integer) && interest_only_periods.between?(0, @periods - 1)
|
|
140
|
+
raise(ArgumentError, 'interest_only_periods must be a non-negative integer shorter than the loan term.') unless valid_interest_only
|
|
141
|
+
|
|
142
|
+
@interest_only_periods = interest_only_periods
|
|
143
|
+
@period = 0
|
|
83
144
|
|
|
84
145
|
compute
|
|
85
146
|
end
|
|
@@ -87,17 +148,18 @@ module Finrb
|
|
|
87
148
|
# compare two Amortization instances
|
|
88
149
|
# @return [Numeric] -1, 0, or +1
|
|
89
150
|
# @param [Amortization] other
|
|
90
|
-
# @api public
|
|
91
151
|
def ==(other)
|
|
92
|
-
(principal == other.principal) && (rates == other.rates) && (payments == other.payments)
|
|
152
|
+
(principal == other.principal) && (origination_fee == other.origination_fee) && (finance_origination_fee? == other.finance_origination_fee?) && (balloon == other.balloon) && (interest_only_periods == other.interest_only_periods) && (rates == other.rates) && (payments == other.payments)
|
|
93
153
|
end
|
|
94
154
|
|
|
155
|
+
attr_reader :finance_origination_fee
|
|
156
|
+
alias finance_origination_fee? finance_origination_fee
|
|
157
|
+
|
|
95
158
|
# @return [Array] the amount of any additional payments in each period
|
|
96
159
|
# @example
|
|
97
160
|
# rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
|
|
98
161
|
# amt = Finrb::Amortization.new(300000, rate){ |payment| payment.amount-100}
|
|
99
162
|
# amt.additional_payments #=> [Flt::DecNum('-100.00'), Flt::DecNum('-100.00'), ... ]
|
|
100
|
-
# @api public
|
|
101
163
|
def additional_payments
|
|
102
164
|
@transactions.filter_map { |trans| trans.difference if trans.payment? }
|
|
103
165
|
end
|
|
@@ -105,32 +167,29 @@ module Finrb
|
|
|
105
167
|
# amortize the balance of loan with the given interest rate
|
|
106
168
|
# @return none
|
|
107
169
|
# @param [Rate] rate the interest rate to use in the amortization
|
|
108
|
-
# @api private
|
|
109
170
|
def amortize(rate)
|
|
110
|
-
|
|
111
|
-
# period is the remaining number of periods in the loan, not
|
|
112
|
-
# necessarily the duration of the rate itself.
|
|
113
|
-
periods = @periods - @period
|
|
114
|
-
amount = Amortization.payment(@balance, rate.monthly, periods)
|
|
115
|
-
|
|
116
|
-
pmt = Payment.new(amount, period: @period)
|
|
117
|
-
pmt.modify(&@block) if @block
|
|
118
|
-
raise(ArgumentError, 'payment modification must produce a negative amount.') unless pmt.amount.negative?
|
|
171
|
+
regular_payment = nil
|
|
119
172
|
|
|
120
173
|
rate.duration.to_i.times do
|
|
121
174
|
# Do this first in case the balance is zero already.
|
|
122
175
|
break if @balance.zero?
|
|
123
176
|
|
|
177
|
+
interest_only = @period < @interest_only_periods
|
|
178
|
+
regular_payment ||= build_regular_payment(rate) unless interest_only
|
|
179
|
+
|
|
124
180
|
# Compute and record interest on the outstanding balance.
|
|
125
181
|
int = Precision.money(@balance * rate.monthly)
|
|
126
182
|
interest = Interest.new(int, period: @period)
|
|
127
183
|
@balance += interest.amount
|
|
128
184
|
@transactions << interest.dup
|
|
129
185
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
@
|
|
186
|
+
payment = interest_only ? build_interest_only_payment(int) : regular_payment
|
|
187
|
+
payment.period = @period
|
|
188
|
+
payment.amount = -@balance if payment.amount.abs > @balance
|
|
189
|
+
@additional_by_period << [-payment.difference, Flt::DecNum(0)].max
|
|
190
|
+
@interest_only_by_period << interest_only
|
|
191
|
+
@transactions << payment.dup
|
|
192
|
+
@balance += payment.amount
|
|
134
193
|
|
|
135
194
|
@period += 1
|
|
136
195
|
end
|
|
@@ -138,26 +197,35 @@ module Finrb
|
|
|
138
197
|
|
|
139
198
|
# compute the amortization of the principal
|
|
140
199
|
# @return none
|
|
141
|
-
# @api private
|
|
142
200
|
def compute
|
|
143
|
-
@balance = @
|
|
201
|
+
@balance = @amount_financed
|
|
144
202
|
@transactions = []
|
|
203
|
+
@additional_by_period = []
|
|
204
|
+
@interest_only_by_period = []
|
|
145
205
|
|
|
146
206
|
@rates.each do |rate|
|
|
147
207
|
amortize(rate)
|
|
148
208
|
end
|
|
149
209
|
|
|
150
|
-
# Add
|
|
210
|
+
# Add the residual balloon and any rounding remainder to the last payment.
|
|
211
|
+
@balloon_by_period = Array.new(@additional_by_period.length, Flt::DecNum(0))
|
|
151
212
|
if @balance.nonzero?
|
|
213
|
+
@balloon_by_period[-1] = [@balloon, @balance].min
|
|
152
214
|
@transactions.reverse.find(&:payment?).amount -= @balance
|
|
153
215
|
@balance = 0
|
|
154
216
|
end
|
|
155
217
|
|
|
156
|
-
@payment = (payments.first if @rates.length == 1)
|
|
218
|
+
@payment = (payments.first if @rates.length == 1 && @interest_only_periods.zero?)
|
|
157
219
|
|
|
158
220
|
@transactions.freeze
|
|
221
|
+
@additional_by_period.freeze
|
|
222
|
+
@balloon_by_period.freeze
|
|
223
|
+
@interest_only_by_period.freeze
|
|
224
|
+
@schedule = build_schedule.freeze
|
|
159
225
|
end
|
|
160
226
|
|
|
227
|
+
private :amortize, :compute
|
|
228
|
+
|
|
161
229
|
# @return [Integer] the time required to pay off the loan, in months
|
|
162
230
|
# @example In most cases, the duration is equal to the total duration of all rates
|
|
163
231
|
# rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
|
|
@@ -167,12 +235,10 @@ module Finrb
|
|
|
167
235
|
# rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
|
|
168
236
|
# amt = Finrb::Amortization.new(300000, rate){ |payment| payment.amount-100}
|
|
169
237
|
# amt.duration #=> 319
|
|
170
|
-
# @api public
|
|
171
238
|
def duration
|
|
172
239
|
payments.length
|
|
173
240
|
end
|
|
174
241
|
|
|
175
|
-
# @api public
|
|
176
242
|
def inspect
|
|
177
243
|
"Amortization.new(#{@principal})"
|
|
178
244
|
end
|
|
@@ -186,7 +252,6 @@ module Finrb
|
|
|
186
252
|
# rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
|
|
187
253
|
# amt = Finrb::Amortization.new(300000, rate)
|
|
188
254
|
# amt.interest[0,6].sum #=> Flt::DecNum('5603.74')
|
|
189
|
-
# @api public
|
|
190
255
|
def interest
|
|
191
256
|
@transactions.filter_map { |trans| trans.amount if trans.interest? }
|
|
192
257
|
end
|
|
@@ -196,9 +261,45 @@ module Finrb
|
|
|
196
261
|
# rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
|
|
197
262
|
# amt = Finrb::Amortization.new(300000, rate)
|
|
198
263
|
# amt.payments.sum #=> Flt::DecNum('-500163.94')
|
|
199
|
-
# @api public
|
|
200
264
|
def payments
|
|
201
265
|
@transactions.filter_map { |trans| trans.amount if trans.payment? }
|
|
202
266
|
end
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
def build_schedule
|
|
271
|
+
opening_balance = @amount_financed
|
|
272
|
+
@transactions.each_slice(2).with_index.map do |(interest, payment), index|
|
|
273
|
+
principal = -(payment.amount + interest.amount)
|
|
274
|
+
closing_balance = opening_balance - principal
|
|
275
|
+
entry = Entry.new(period: payment.period, opening_balance:, payment: payment.amount, interest: interest.amount, principal:, additional_payment: @additional_by_period.fetch(index), balloon_payment: @balloon_by_period.fetch(index), interest_only: @interest_only_by_period.fetch(index), closing_balance:)
|
|
276
|
+
opening_balance = closing_balance
|
|
277
|
+
entry
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def build_regular_payment(rate)
|
|
282
|
+
periods = @periods - @period
|
|
283
|
+
amount = Amortization.payment(@balance, rate.monthly, periods, balloon: @balloon)
|
|
284
|
+
Payment.new(amount, period: @period).tap do |payment|
|
|
285
|
+
payment.modify(&@block) if @block
|
|
286
|
+
validate_payment!(payment)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def build_interest_only_payment(interest)
|
|
291
|
+
Payment.new(-interest, period: @period).tap do |payment|
|
|
292
|
+
payment.modify(&@block) if @block
|
|
293
|
+
validate_payment!(payment, allow_zero: true)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def validate_payment!(payment, allow_zero: false)
|
|
298
|
+
valid = payment.amount.negative? || (allow_zero && payment.amount.zero?)
|
|
299
|
+
return if valid
|
|
300
|
+
|
|
301
|
+
requirement = allow_zero ? 'must not produce a positive amount' : 'must produce a negative amount'
|
|
302
|
+
raise(ArgumentError, "payment modification #{requirement}.")
|
|
303
|
+
end
|
|
203
304
|
end
|
|
204
305
|
end
|
data/lib/finrb/cashflows.rb
CHANGED
|
@@ -12,7 +12,6 @@ require 'date'
|
|
|
12
12
|
|
|
13
13
|
module Finrb
|
|
14
14
|
# Provides methods for working with cash flows (collections of transactions)
|
|
15
|
-
# @api public
|
|
16
15
|
module Cashflow
|
|
17
16
|
class << self
|
|
18
17
|
def irr(cashflows, guess = nil)
|
|
@@ -23,6 +22,10 @@ module Finrb
|
|
|
23
22
|
sequence(cashflows).npv(rate)
|
|
24
23
|
end
|
|
25
24
|
|
|
25
|
+
def mirr(cashflows, finance_rate:, reinvestment_rate:)
|
|
26
|
+
sequence(cashflows).mirr(finance_rate:, reinvestment_rate:)
|
|
27
|
+
end
|
|
28
|
+
|
|
26
29
|
def xirr(transactions, guess = nil)
|
|
27
30
|
sequence(transactions).xirr(guess)
|
|
28
31
|
end
|
|
@@ -55,7 +58,6 @@ module Finrb
|
|
|
55
58
|
# @example
|
|
56
59
|
# Finrb::Cashflow.irr([-4000,1200,1410,1875,1050]) #=> 0.143
|
|
57
60
|
# @see https://en.wikipedia.org/wiki/Internal_rate_of_return
|
|
58
|
-
# @api public
|
|
59
61
|
def irr(guess = nil)
|
|
60
62
|
validate_numeric_cashflows!
|
|
61
63
|
|
|
@@ -71,13 +73,11 @@ module Finrb
|
|
|
71
73
|
# @example
|
|
72
74
|
# Finrb::Cashflow.npv([-100.0, 60, 60, 60], 0.1) #=> 49.211
|
|
73
75
|
# @see https://en.wikipedia.org/wiki/Net_present_value
|
|
74
|
-
# @api public
|
|
75
76
|
def npv(rate)
|
|
76
77
|
validate_numeric_cashflows!
|
|
77
78
|
cashflows = map { |entry| Validation.decimal(entry, name: 'cashflow amount') }
|
|
78
79
|
|
|
79
|
-
rate = Validation.
|
|
80
|
-
raise(DomainError, 'Rate must be greater than -1.') if rate <= -1
|
|
80
|
+
rate = Validation.decimal_greater_than(rate, minimum: -1, name: 'rate', error: DomainError)
|
|
81
81
|
|
|
82
82
|
total = Flt::DecNum.new(0.to_s)
|
|
83
83
|
cashflows.each_with_index do |cashflow, index|
|
|
@@ -87,6 +87,32 @@ module Finrb
|
|
|
87
87
|
total
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# Calculate the modified internal rate of return for equally spaced
|
|
91
|
+
# cashflows using separate financing and reinvestment assumptions.
|
|
92
|
+
# @return [Flt::DecNum] modified per-period internal rate of return
|
|
93
|
+
def mirr(finance_rate:, reinvestment_rate:)
|
|
94
|
+
validate_numeric_cashflows!
|
|
95
|
+
raise(InvalidCashflowError, 'MIRR requires at least two cashflows.') if size < 2
|
|
96
|
+
|
|
97
|
+
cashflows = map { |entry| Validation.decimal(entry, name: 'cashflow amount') }
|
|
98
|
+
raise(InvalidCashflowError, 'Cashflow needs at least one positive and one negative value.') if cashflows.none?(&:positive?) || cashflows.none?(&:negative?)
|
|
99
|
+
|
|
100
|
+
finance_rate = Validation.decimal_greater_than(finance_rate, minimum: -1, name: 'finance rate', error: DomainError)
|
|
101
|
+
reinvestment_rate = Validation.decimal_greater_than(reinvestment_rate, minimum: -1, name: 'reinvestment rate', error: DomainError)
|
|
102
|
+
|
|
103
|
+
last_period = cashflows.size - 1
|
|
104
|
+
future_positive =
|
|
105
|
+
cashflows.each_with_index.sum do |amount, index|
|
|
106
|
+
amount.positive? ? amount * ((reinvestment_rate + 1)**(last_period - index)) : Flt::DecNum(0)
|
|
107
|
+
end
|
|
108
|
+
present_negative =
|
|
109
|
+
cashflows.each_with_index.sum do |amount, index|
|
|
110
|
+
amount.negative? ? amount / ((finance_rate + 1)**index) : Flt::DecNum(0)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
((future_positive / -present_negative)**(Flt::DecNum(1) / last_period)) - 1
|
|
114
|
+
end
|
|
115
|
+
|
|
90
116
|
# Calculate the effective annual internal rate of return for an ordered
|
|
91
117
|
# sequence of dated transactions.
|
|
92
118
|
#
|
|
@@ -108,7 +134,6 @@ module Finrb
|
|
|
108
134
|
# @transactions << Transaction.new( 600, :date => Time.new(1990,01,01))
|
|
109
135
|
# @transactions << Transaction.new( 600, :date => Time.new(1995,01,01))
|
|
110
136
|
# Finrb::Cashflow.xirr(@transactions, 0.6) #=> Rate("0.024851", :effective, :compounds => :annually)
|
|
111
|
-
# @api public
|
|
112
137
|
def xirr(guess = nil)
|
|
113
138
|
validate_dated_cashflows!
|
|
114
139
|
|
|
@@ -127,11 +152,9 @@ module Finrb
|
|
|
127
152
|
# @transactions << Transaction.new( 600, :date => Time.new(1990,01,01))
|
|
128
153
|
# @transactions << Transaction.new( 600, :date => Time.new(1995,01,01))
|
|
129
154
|
# Finrb::Cashflow.xnpv(@transactions, 0.6).round(2) #=> -937.41
|
|
130
|
-
# @api public
|
|
131
155
|
def xnpv(rate)
|
|
132
156
|
validate_dated_cashflows!
|
|
133
|
-
rate = Validation.
|
|
134
|
-
raise(DomainError, 'Rate must be greater than -1.') if rate <= -1
|
|
157
|
+
rate = Validation.decimal_greater_than(rate, minimum: -1, name: 'rate', error: DomainError)
|
|
135
158
|
|
|
136
159
|
sum do |t|
|
|
137
160
|
t.amount / ((rate + 1)**(date_diff(start, t.date) / days_in_period))
|
data/lib/finrb/config.rb
CHANGED
|
@@ -41,16 +41,16 @@ module Finrb
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def self.build_configuration(values)
|
|
44
|
-
eps = configuration_decimal(values.fetch(:eps), name: '
|
|
45
|
-
raise(ArgumentError, '
|
|
44
|
+
eps = configuration_decimal(values.fetch(:eps), name: 'solver tolerance')
|
|
45
|
+
raise(ArgumentError, 'solver tolerance must be positive.') unless eps.positive?
|
|
46
46
|
|
|
47
|
-
guess = configuration_decimal(values.fetch(:guess), name: 'guess')
|
|
48
|
-
raise(ArgumentError, 'guess must be greater than -1.') if guess <= -1
|
|
47
|
+
guess = configuration_decimal(values.fetch(:guess), name: 'rate guess')
|
|
48
|
+
raise(ArgumentError, 'rate guess must be greater than -1.') if guess <= -1
|
|
49
49
|
|
|
50
50
|
business_days = values.fetch(:business_days)
|
|
51
51
|
periodic_compound = values.fetch(:periodic_compound)
|
|
52
52
|
booleans = [business_days, periodic_compound].all? { |value| value.equal?(true) || value.equal?(false) }
|
|
53
|
-
raise(ArgumentError, '
|
|
53
|
+
raise(ArgumentError, 'business days and periodic compounding settings must be boolean.') unless booleans
|
|
54
54
|
|
|
55
55
|
Configuration.new(eps:, guess:, business_days:, periodic_compound:)
|
|
56
56
|
end
|
data/lib/finrb/rates.rb
CHANGED
|
@@ -6,7 +6,6 @@ require_relative 'validation'
|
|
|
6
6
|
module Finrb
|
|
7
7
|
# the Rate class provides an interface for working with interest rates.
|
|
8
8
|
# {render:Rate#new}
|
|
9
|
-
# @api public
|
|
10
9
|
class Rate
|
|
11
10
|
include Comparable
|
|
12
11
|
|
|
@@ -18,10 +17,7 @@ module Finrb
|
|
|
18
17
|
infinite = value.infinite? if value.respond_to?(:infinite?)
|
|
19
18
|
return Flt::DecNum.infinity if [true, 1].include?(infinite)
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
raise(ArgumentError, 'compounding periods must be positive.') unless periods.positive?
|
|
23
|
-
|
|
24
|
-
periods
|
|
20
|
+
Validation.positive_decimal(value, name: 'compounding periods', message: 'compounding periods must be positive.')
|
|
25
21
|
end
|
|
26
22
|
private_class_method :compounding_periods
|
|
27
23
|
|
|
@@ -31,7 +27,6 @@ module Finrb
|
|
|
31
27
|
# @param [Numeric] periods the number of compounding periods per year
|
|
32
28
|
# @example
|
|
33
29
|
# Rate.to_effective(0.05, 4) #=> Flt::DecNum('0.05095')
|
|
34
|
-
# @api public
|
|
35
30
|
def self.to_effective(rate, periods)
|
|
36
31
|
rate = Validation.decimal(rate, name: 'rate')
|
|
37
32
|
periods = compounding_periods(periods)
|
|
@@ -50,10 +45,8 @@ module Finrb
|
|
|
50
45
|
# @example
|
|
51
46
|
# Rate.to_nominal(0.06, 365) #=> Flt::DecNum('0.05827')
|
|
52
47
|
# @see https://www.miniwebtool.com/nominal-interest-rate-calculator/
|
|
53
|
-
# @api public
|
|
54
48
|
def self.to_nominal(rate, periods)
|
|
55
|
-
rate = Validation.
|
|
56
|
-
raise(ArgumentError, 'effective rate must be greater than -1.') if rate <= -1
|
|
49
|
+
rate = Validation.decimal_greater_than(rate, minimum: -1, name: 'effective rate')
|
|
57
50
|
|
|
58
51
|
periods = compounding_periods(periods)
|
|
59
52
|
|
|
@@ -75,7 +68,6 @@ module Finrb
|
|
|
75
68
|
# Rate.new(0.035, :apr) #=> Rate(0.035, :apr)
|
|
76
69
|
# @see https://en.wikipedia.org/wiki/Effective_interest_rate
|
|
77
70
|
# @see https://en.wikipedia.org/wiki/Nominal_interest_rate
|
|
78
|
-
# @api public
|
|
79
71
|
def initialize(rate, type, opts = {})
|
|
80
72
|
raise(ArgumentError, 'options must be a Hash.') unless opts.is_a?(Hash)
|
|
81
73
|
raise(ArgumentError, 'options may only contain compounds and duration.') unless (opts.keys - %i[compounds duration]).empty?
|
|
@@ -97,13 +89,10 @@ module Finrb
|
|
|
97
89
|
end
|
|
98
90
|
|
|
99
91
|
# @return [Integer] the duration for which the rate is valid, in months
|
|
100
|
-
# @api public
|
|
101
92
|
attr_reader :duration
|
|
102
93
|
# @return [Flt::DecNum] the effective interest rate
|
|
103
|
-
# @api public
|
|
104
94
|
attr_reader :effective
|
|
105
95
|
# @return [Flt::DecNum] the nominal interest rate
|
|
106
|
-
# @api public
|
|
107
96
|
attr_reader :nominal
|
|
108
97
|
|
|
109
98
|
# compare two Rates, using the effective rate
|
|
@@ -113,21 +102,18 @@ module Finrb
|
|
|
113
102
|
# r1 = Rate.new(0.15, :nominal) #=> Rate.new(0.160755, :apr)
|
|
114
103
|
# r2 = Rate.new(0.155, :nominal, :compounds => :semiannually) #=> Rate.new(0.161006, :apr)
|
|
115
104
|
# r1 <=> r2 #=> -1
|
|
116
|
-
# @api public
|
|
117
105
|
def <=>(other)
|
|
118
106
|
@effective <=> other.effective
|
|
119
107
|
end
|
|
120
108
|
|
|
121
109
|
# Return the nominal annual percentage rate for the configured compounding frequency.
|
|
122
110
|
# @return [Flt::DecNum] the nominal annual percentage rate
|
|
123
|
-
# @api public
|
|
124
111
|
def apr
|
|
125
112
|
nominal
|
|
126
113
|
end
|
|
127
114
|
|
|
128
115
|
# Return the effective annual percentage yield.
|
|
129
116
|
# @return [Flt::DecNum] the effective annual percentage yield
|
|
130
|
-
# @api public
|
|
131
117
|
def apy
|
|
132
118
|
effective
|
|
133
119
|
end
|
|
@@ -136,7 +122,6 @@ module Finrb
|
|
|
136
122
|
# @return none
|
|
137
123
|
# @param [Symbol, Numeric] input the compounding frequency
|
|
138
124
|
# @raise [ArgumentError] if input is not an accepted keyword or Numeric
|
|
139
|
-
# @api private
|
|
140
125
|
def compounds=(input)
|
|
141
126
|
@periods =
|
|
142
127
|
case input
|
|
@@ -152,13 +137,12 @@ module Finrb
|
|
|
152
137
|
end
|
|
153
138
|
|
|
154
139
|
def duration=(value)
|
|
155
|
-
@duration = Validation.positive_integer(value, name: 'duration')
|
|
140
|
+
@duration = Validation.positive_integer(value, name: 'duration in months')
|
|
156
141
|
end
|
|
157
142
|
|
|
158
143
|
# set the effective interest rate
|
|
159
144
|
# @return none
|
|
160
145
|
# @param [Flt::DecNum] rate the effective interest rate
|
|
161
|
-
# @api private
|
|
162
146
|
def effective=(rate)
|
|
163
147
|
raise(ArgumentError, 'effective rate must be greater than -1.') if rate <= -1
|
|
164
148
|
|
|
@@ -176,7 +160,6 @@ module Finrb
|
|
|
176
160
|
# rate.apr.round(6) #=> Flt::DecNum('0.15')
|
|
177
161
|
# rate.apy.round(6) #=> Flt::DecNum('0.160755')
|
|
178
162
|
# rate.monthly.round(6) #=> Flt::DecNum('0.0125')
|
|
179
|
-
# @api public
|
|
180
163
|
def monthly
|
|
181
164
|
@monthly ||= Precision.rate(Rate.to_nominal(effective, 12) / 12)
|
|
182
165
|
end
|
|
@@ -184,7 +167,6 @@ module Finrb
|
|
|
184
167
|
# set the nominal interest rate
|
|
185
168
|
# @return none
|
|
186
169
|
# @param [Flt::DecNum] rate the nominal interest rate
|
|
187
|
-
# @api private
|
|
188
170
|
def nominal=(rate)
|
|
189
171
|
raise(ArgumentError, 'nominal rate must keep every compounded period greater than -100%.') if !@periods.infinite? && rate <= -@periods
|
|
190
172
|
|