finrb 1.1.0 → 1.3.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 +56 -0
- data/CONTRIBUTING.md +89 -0
- data/README.md +83 -10
- data/SECURITY.md +41 -0
- data/lib/finrb/accounting.rb +93 -74
- data/lib/finrb/amortization.rb +167 -35
- data/lib/finrb/calendars/base.rb +161 -0
- data/lib/finrb/calendars/hebrew_calendar.rb +77 -0
- data/lib/finrb/calendars/israel_tase.rb +96 -0
- data/lib/finrb/calendars/us_federal_reserve.rb +82 -0
- data/lib/finrb/calendars.rb +6 -0
- data/lib/finrb/cashflows.rb +4 -7
- data/lib/finrb/config.rb +5 -5
- data/lib/finrb/day_count.rb +62 -0
- data/lib/finrb/fixed_rate_bond.rb +140 -0
- data/lib/finrb/rates.rb +3 -7
- data/lib/finrb/ratios.rb +41 -40
- data/lib/finrb/returns.rb +49 -52
- data/lib/finrb/schedule.rb +126 -0
- 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/lib/finrb.rb +4 -0
- data/sig/finrb.rbs +114 -5
- metadata +26 -2
data/lib/finrb/amortization.rb
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'calendars'
|
|
3
4
|
require_relative 'cashflows'
|
|
5
|
+
require_relative 'day_count'
|
|
4
6
|
require_relative 'decimal'
|
|
5
7
|
require_relative 'precision'
|
|
8
|
+
require_relative 'schedule'
|
|
6
9
|
require_relative 'transaction'
|
|
7
10
|
require_relative 'validation'
|
|
11
|
+
require 'date'
|
|
8
12
|
|
|
9
13
|
module Finrb
|
|
10
14
|
# the Amortization class provides an interface for working with loan amortizations.
|
|
@@ -19,41 +23,49 @@ module Finrb
|
|
|
19
23
|
# rate = Rate.new(0.0425, :apr, :duration => (5 * 12))
|
|
20
24
|
# extra_payments = Finrb::Amortization.new(250000, rate){ |period| period.payment - 150 }
|
|
21
25
|
class Amortization
|
|
26
|
+
FREQUENCY_MONTHS = Schedule::FREQUENCY_MONTHS
|
|
27
|
+
STUB_CONVENTIONS = Schedule::STUB_CONVENTIONS
|
|
28
|
+
public_constant :FREQUENCY_MONTHS, :STUB_CONVENTIONS
|
|
29
|
+
|
|
22
30
|
# Immutable breakdown of one amortization period. Payments retain finrb's
|
|
23
31
|
# cashflow sign convention and are negative; the other monetary fields are
|
|
24
32
|
# non-negative.
|
|
25
33
|
class Entry
|
|
26
34
|
ATTRIBUTES = %i[period opening_balance payment interest principal additional_payment balloon_payment interest_only closing_balance].freeze
|
|
27
35
|
MONETARY_ATTRIBUTES = ATTRIBUTES - %i[period interest_only]
|
|
28
|
-
|
|
36
|
+
DATE_ATTRIBUTE = :date
|
|
37
|
+
private_constant :ATTRIBUTES, :DATE_ATTRIBUTE, :MONETARY_ATTRIBUTES
|
|
29
38
|
|
|
30
|
-
attr_reader(*ATTRIBUTES)
|
|
39
|
+
attr_reader(*ATTRIBUTES, DATE_ATTRIBUTE)
|
|
31
40
|
|
|
32
|
-
def initialize(period:, opening_balance:, payment:, interest:, principal:, additional_payment:, balloon_payment:, interest_only:, closing_balance:)
|
|
41
|
+
def initialize(period:, opening_balance:, payment:, interest:, principal:, additional_payment:, balloon_payment:, interest_only:, closing_balance:, date: nil)
|
|
33
42
|
raise(ArgumentError, 'period must be a non-negative integer.') unless period.is_a?(Integer) && !period.negative?
|
|
34
43
|
raise(ArgumentError, 'interest_only must be true or false.') unless [true, false].include?(interest_only)
|
|
44
|
+
raise(ArgumentError, 'date must be a Date or nil.') unless date.nil? || date.instance_of?(Date)
|
|
35
45
|
|
|
36
46
|
@period = period
|
|
37
47
|
@interest_only = interest_only
|
|
48
|
+
@date = date
|
|
38
49
|
MONETARY_ATTRIBUTES.each do |name|
|
|
39
50
|
value = binding.local_variable_get(name)
|
|
40
|
-
instance_variable_set("@#{name}", Validation.decimal(value, name: name.to_s))
|
|
51
|
+
instance_variable_set("@#{name}", Validation.decimal(value, name: name.to_s.tr('_', ' ')))
|
|
41
52
|
end
|
|
42
53
|
freeze
|
|
43
54
|
end
|
|
44
55
|
|
|
45
56
|
def ==(other)
|
|
46
|
-
other.instance_of?(self.class) && ATTRIBUTES.all? { |name| public_send(name) == other.public_send(name) }
|
|
57
|
+
other.instance_of?(self.class) && ([DATE_ATTRIBUTE] + ATTRIBUTES).all? { |name| public_send(name) == other.public_send(name) }
|
|
47
58
|
end
|
|
48
59
|
alias eql? ==
|
|
49
60
|
|
|
50
61
|
def hash
|
|
51
|
-
|
|
52
|
-
attributes.hash
|
|
62
|
+
[date, *ATTRIBUTES.map { |name| public_send(name) }].hash
|
|
53
63
|
end
|
|
54
64
|
|
|
55
65
|
def to_h
|
|
56
|
-
ATTRIBUTES.to_h { |name| [name, public_send(name)] }
|
|
66
|
+
attributes = ATTRIBUTES.to_h { |name| [name, public_send(name)] }
|
|
67
|
+
attributes[DATE_ATTRIBUTE] = date unless date.nil?
|
|
68
|
+
attributes
|
|
57
69
|
end
|
|
58
70
|
|
|
59
71
|
alias interest_only? interest_only
|
|
@@ -71,7 +83,7 @@ module Finrb
|
|
|
71
83
|
attr_reader :origination_fee
|
|
72
84
|
# @return [Integer] number of leading periods that pay interest but no scheduled principal
|
|
73
85
|
attr_reader :interest_only_periods
|
|
74
|
-
# @return [Flt::DecNum] the required
|
|
86
|
+
# @return [Flt::DecNum] the required regular payment. For loans with more than one rate, returns nil
|
|
75
87
|
attr_reader :payment
|
|
76
88
|
# @return [Flt::DecNum] the principal amount of the loan
|
|
77
89
|
attr_reader :principal
|
|
@@ -79,6 +91,18 @@ module Finrb
|
|
|
79
91
|
attr_reader :rates
|
|
80
92
|
# @return [Array<Entry>] immutable period-by-period loan breakdown
|
|
81
93
|
attr_reader :schedule
|
|
94
|
+
# @return [Date, nil] the date from which payment dates are generated
|
|
95
|
+
attr_reader :start_date
|
|
96
|
+
# @return [Finrb::Calendars::Base, nil] the calendar used to adjust dated payments
|
|
97
|
+
attr_reader :calendar
|
|
98
|
+
# @return [Symbol, nil] business-day convention used when adjusting dated payments
|
|
99
|
+
attr_reader :business_day_convention
|
|
100
|
+
# @return [Symbol] day-count convention used for dated interest accrual
|
|
101
|
+
attr_reader :day_count
|
|
102
|
+
# @return [Symbol] interval between dated payments
|
|
103
|
+
attr_reader :frequency
|
|
104
|
+
# @return [Symbol] final partial-period handling
|
|
105
|
+
attr_reader :stub
|
|
82
106
|
|
|
83
107
|
# @return [Flt::DecNum] the periodic payment due on a loan
|
|
84
108
|
# @param [Flt::DecNum] principal the initial amount of the loan or investment
|
|
@@ -91,16 +115,14 @@ module Finrb
|
|
|
91
115
|
# Amortization.payment(200000, rate.monthly, rate.duration) #=> Flt::DecNum('-926.23')
|
|
92
116
|
# @see https://en.wikipedia.org/wiki/Amortization_calculator
|
|
93
117
|
def self.payment(principal, rate, periods, balloon: 0)
|
|
94
|
-
principal = Validation.
|
|
95
|
-
raise(ArgumentError, 'principal must be positive.') unless principal.positive?
|
|
118
|
+
principal = Validation.positive_decimal(principal, name: 'principal', message: 'principal must be positive.')
|
|
96
119
|
|
|
97
120
|
balloon = Validation.decimal(balloon, name: 'balloon')
|
|
98
121
|
raise(ArgumentError, 'balloon must be non-negative and no greater than principal.') unless balloon.between?(0, principal)
|
|
99
122
|
|
|
100
|
-
rate = Validation.
|
|
101
|
-
raise(ArgumentError, 'periodic rate must be greater than -1.') if rate <= -1
|
|
123
|
+
rate = Validation.decimal_greater_than(rate, minimum: -1, name: 'periodic rate')
|
|
102
124
|
|
|
103
|
-
periods = Validation.positive_integer(periods, name: '
|
|
125
|
+
periods = Validation.positive_integer(periods, name: 'period count')
|
|
104
126
|
|
|
105
127
|
if rate.zero?
|
|
106
128
|
# simplified formula to avoid division-by-zero when interest rate is zero
|
|
@@ -116,12 +138,19 @@ module Finrb
|
|
|
116
138
|
# @param [Flt::DecNum] principal the initial amount of the loan or investment
|
|
117
139
|
# @param [Rate] rates the applicable interest rates
|
|
118
140
|
# @param [Proc] block
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
141
|
+
# @param [Finrb::Calendars::Base, nil] calendar optional market calendar for dated payment adjustment
|
|
142
|
+
# @param [Symbol, nil] business_day_convention required when calendar is supplied
|
|
143
|
+
# @param [Symbol] frequency dated payment interval; rate durations remain in months
|
|
144
|
+
# @param [Symbol] stub explicit handling for a final partial dated period
|
|
145
|
+
def initialize(principal, *rates, balloon: 0, interest_only_periods: 0, origination_fee: 0, finance_origination_fee: false, start_date: nil, calendar: nil, business_day_convention: nil, day_count: DayCount::DEFAULT, frequency: :monthly, stub: :none, &block)
|
|
146
|
+
@principal = Validation.positive_decimal(principal, name: 'principal', message: 'principal must be positive.')
|
|
147
|
+
raise(ArgumentError, 'start_date must be a Date or nil.') unless start_date.nil? || start_date.instance_of?(Date)
|
|
148
|
+
|
|
149
|
+
validate_day_count!(start_date, day_count)
|
|
122
150
|
|
|
123
|
-
|
|
124
|
-
|
|
151
|
+
validate_calendar_options!(start_date, calendar, business_day_convention)
|
|
152
|
+
|
|
153
|
+
@origination_fee = Validation.non_negative_decimal(origination_fee, name: 'origination fee')
|
|
125
154
|
raise(ArgumentError, 'finance_origination_fee must be true or false.') unless [true, false].include?(finance_origination_fee)
|
|
126
155
|
raise(ArgumentError, 'an unfinanced origination_fee must be less than principal.') if !finance_origination_fee && @origination_fee >= @principal
|
|
127
156
|
|
|
@@ -138,12 +167,16 @@ module Finrb
|
|
|
138
167
|
@rates = rates
|
|
139
168
|
@block = block
|
|
140
169
|
|
|
141
|
-
|
|
142
|
-
|
|
170
|
+
initialize_schedule(start_date, frequency, stub, rates, calendar, business_day_convention)
|
|
171
|
+
|
|
143
172
|
valid_interest_only = interest_only_periods.is_a?(Integer) && interest_only_periods.between?(0, @periods - 1)
|
|
144
173
|
raise(ArgumentError, 'interest_only_periods must be a non-negative integer shorter than the loan term.') unless valid_interest_only
|
|
145
174
|
|
|
146
175
|
@interest_only_periods = interest_only_periods
|
|
176
|
+
@start_date = start_date
|
|
177
|
+
@calendar = calendar
|
|
178
|
+
@business_day_convention = business_day_convention
|
|
179
|
+
@day_count = day_count
|
|
147
180
|
@period = 0
|
|
148
181
|
|
|
149
182
|
compute
|
|
@@ -153,7 +186,7 @@ module Finrb
|
|
|
153
186
|
# @return [Numeric] -1, 0, or +1
|
|
154
187
|
# @param [Amortization] other
|
|
155
188
|
def ==(other)
|
|
156
|
-
(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)
|
|
189
|
+
(principal == other.principal) && (start_date == other.start_date) && (calendar == other.calendar) && (business_day_convention == other.business_day_convention) && (day_count == other.day_count) && (frequency == other.frequency) && (stub == other.stub) && (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)
|
|
157
190
|
end
|
|
158
191
|
|
|
159
192
|
attr_reader :finance_origination_fee
|
|
@@ -168,13 +201,31 @@ module Finrb
|
|
|
168
201
|
@transactions.filter_map { |trans| trans.difference if trans.payment? }
|
|
169
202
|
end
|
|
170
203
|
|
|
204
|
+
# Calculate the effective annual yield implied by borrower proceeds and
|
|
205
|
+
# scheduled loan payments. This is a cashflow-equivalent borrowing yield,
|
|
206
|
+
# not a jurisdiction-specific legal APR.
|
|
207
|
+
# @param [Numeric, nil] guess initial rate used by XIRR; defaults to Finrb.config.guess
|
|
208
|
+
# @return [Rate] the effective annual cashflow-equivalent yield
|
|
209
|
+
# @raise [ArgumentError] if the amortization has no start date
|
|
210
|
+
# @example
|
|
211
|
+
# rate = Rate.new(0.05, :apr, duration: 12)
|
|
212
|
+
# loan = Amortization.new(10_000, rate, start_date: Date.new(2025, 1, 15), origination_fee: 250)
|
|
213
|
+
# loan.cashflow_yield.apy #=> effective annual borrower cost
|
|
214
|
+
def cashflow_yield(guess = nil)
|
|
215
|
+
raise(ArgumentError, 'cashflow_yield requires a start_date.') unless start_date
|
|
216
|
+
|
|
217
|
+
transactions = [Transaction.new(net_proceeds, date: start_date)]
|
|
218
|
+
transactions.concat(schedule.map { |entry| Transaction.new(entry.payment, date: entry.date) })
|
|
219
|
+
Cashflow.xirr(transactions, guess)
|
|
220
|
+
end
|
|
221
|
+
|
|
171
222
|
# amortize the balance of loan with the given interest rate
|
|
172
223
|
# @return none
|
|
173
224
|
# @param [Rate] rate the interest rate to use in the amortization
|
|
174
|
-
def amortize(rate)
|
|
225
|
+
def amortize(rate, periods)
|
|
175
226
|
regular_payment = nil
|
|
176
227
|
|
|
177
|
-
|
|
228
|
+
periods.times do
|
|
178
229
|
# Do this first in case the balance is zero already.
|
|
179
230
|
break if @balance.zero?
|
|
180
231
|
|
|
@@ -182,13 +233,16 @@ module Finrb
|
|
|
182
233
|
regular_payment ||= build_regular_payment(rate) unless interest_only
|
|
183
234
|
|
|
184
235
|
# Compute and record interest on the outstanding balance.
|
|
185
|
-
|
|
186
|
-
|
|
236
|
+
due_date = @payment_dates&.fetch(@period)
|
|
237
|
+
periodic_rate = due_date ? dated_period_rate(rate, @period) : rate.monthly
|
|
238
|
+
int = Precision.money(@balance * periodic_rate)
|
|
239
|
+
interest = Interest.new(int, period: @period, date: due_date)
|
|
187
240
|
@balance += interest.amount
|
|
188
241
|
@transactions << interest.dup
|
|
189
242
|
|
|
190
|
-
payment = interest_only ? build_interest_only_payment(int) : regular_payment
|
|
243
|
+
payment = interest_only ? build_interest_only_payment(int, due_date) : regular_payment
|
|
191
244
|
payment.period = @period
|
|
245
|
+
payment.date = due_date if due_date
|
|
192
246
|
payment.amount = -@balance if payment.amount.abs > @balance
|
|
193
247
|
@additional_by_period << [-payment.difference, Flt::DecNum(0)].max
|
|
194
248
|
@interest_only_by_period << interest_only
|
|
@@ -207,8 +261,8 @@ module Finrb
|
|
|
207
261
|
@additional_by_period = []
|
|
208
262
|
@interest_only_by_period = []
|
|
209
263
|
|
|
210
|
-
@rates.
|
|
211
|
-
amortize(rate)
|
|
264
|
+
@rates.each_with_index do |rate, index|
|
|
265
|
+
amortize(rate, @rate_period_counts.fetch(index))
|
|
212
266
|
end
|
|
213
267
|
|
|
214
268
|
# Add the residual balloon and any rounding remainder to the last payment.
|
|
@@ -230,7 +284,7 @@ module Finrb
|
|
|
230
284
|
|
|
231
285
|
private :amortize, :compute
|
|
232
286
|
|
|
233
|
-
# @return [Integer]
|
|
287
|
+
# @return [Integer] number of payments in the amortization schedule
|
|
234
288
|
# @example In most cases, the duration is equal to the total duration of all rates
|
|
235
289
|
# rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
|
|
236
290
|
# amt = Finrb::Amortization.new(300000, rate)
|
|
@@ -271,12 +325,66 @@ module Finrb
|
|
|
271
325
|
|
|
272
326
|
private
|
|
273
327
|
|
|
328
|
+
def validate_calendar_options!(start_date, calendar, convention)
|
|
329
|
+
if calendar.nil?
|
|
330
|
+
raise(ArgumentError, 'business_day_convention requires a calendar.') unless convention.nil?
|
|
331
|
+
|
|
332
|
+
return
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
raise(ArgumentError, 'calendar must be a Finrb::Calendars::Base instance.') unless calendar.is_a?(Calendars::Base)
|
|
336
|
+
raise(ArgumentError, 'calendar adjustment requires a start_date.') unless start_date
|
|
337
|
+
raise(ArgumentError, "business_day_convention must be one of #{Calendars::Base::CONVENTIONS.join(', ')}.") unless Calendars::Base::CONVENTIONS.include?(convention)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def validate_day_count!(start_date, day_count)
|
|
341
|
+
raise(ArgumentError, "day_count must be one of #{DayCount::CONVENTIONS.join(', ')}.") unless DayCount::CONVENTIONS.include?(day_count)
|
|
342
|
+
raise(ArgumentError, 'a non-default day_count requires a start_date.') if start_date.nil? && day_count != DayCount::DEFAULT
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def validate_schedule_options!(start_date, frequency, stub)
|
|
346
|
+
raise(ArgumentError, "frequency must be one of #{FREQUENCY_MONTHS.keys.join(', ')}.") unless FREQUENCY_MONTHS.key?(frequency)
|
|
347
|
+
raise(ArgumentError, "stub must be one of #{STUB_CONVENTIONS.join(', ')}.") unless STUB_CONVENTIONS.include?(stub)
|
|
348
|
+
raise(ArgumentError, 'a non-monthly frequency requires a start_date.') if start_date.nil? && frequency != :monthly
|
|
349
|
+
raise(ArgumentError, 'a non-default stub requires a start_date.') if start_date.nil? && stub != :none
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def initialize_schedule(start_date, frequency, stub, rates, calendar, business_day_convention)
|
|
353
|
+
@term_months = rates.sum(&:duration)
|
|
354
|
+
validate_schedule_options!(start_date, frequency, stub)
|
|
355
|
+
@frequency = frequency
|
|
356
|
+
@stub = stub
|
|
357
|
+
@dated_schedule = start_date && Schedule.from_months(start_date:, term_months: @term_months, frequency:, stub:, calendar:, business_day_convention:)
|
|
358
|
+
@payment_dates = @dated_schedule&.payment_dates
|
|
359
|
+
@periods = @payment_dates ? @payment_dates.length : @term_months
|
|
360
|
+
@rate_period_counts = start_date ? periods_per_rate(rates, frequency, @periods) : rates.map(&:duration)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def periods_per_rate(rates, frequency, total_periods)
|
|
364
|
+
interval = FREQUENCY_MONTHS.fetch(frequency)
|
|
365
|
+
cumulative_months = 0
|
|
366
|
+
previous_period_count = 0
|
|
367
|
+
rates.each_with_index.map do |rate, index|
|
|
368
|
+
cumulative_months += rate.duration
|
|
369
|
+
if index == rates.length - 1
|
|
370
|
+
total_periods - previous_period_count
|
|
371
|
+
else
|
|
372
|
+
raise(ArgumentError, 'rate changes must align with a payment date for dated non-monthly schedules.') if (cumulative_months % interval).nonzero?
|
|
373
|
+
|
|
374
|
+
period_count = cumulative_months / interval
|
|
375
|
+
periods_in_segment = period_count - previous_period_count
|
|
376
|
+
previous_period_count = period_count
|
|
377
|
+
periods_in_segment
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
274
382
|
def build_schedule
|
|
275
383
|
opening_balance = @amount_financed
|
|
276
384
|
@transactions.each_slice(2).with_index.map do |(interest, payment), index|
|
|
277
385
|
principal = -(payment.amount + interest.amount)
|
|
278
386
|
closing_balance = opening_balance - principal
|
|
279
|
-
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:)
|
|
387
|
+
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:, date: payment.date)
|
|
280
388
|
opening_balance = closing_balance
|
|
281
389
|
entry
|
|
282
390
|
end
|
|
@@ -284,15 +392,20 @@ module Finrb
|
|
|
284
392
|
|
|
285
393
|
def build_regular_payment(rate)
|
|
286
394
|
periods = @periods - @period
|
|
287
|
-
amount =
|
|
288
|
-
|
|
395
|
+
amount =
|
|
396
|
+
if @start_date
|
|
397
|
+
dated_payment(@balance, rate, @period, @balloon)
|
|
398
|
+
else
|
|
399
|
+
Amortization.payment(@balance, rate.monthly, periods, balloon: @balloon)
|
|
400
|
+
end
|
|
401
|
+
Payment.new(amount, period: @period, date: @payment_dates && @payment_dates.fetch(@period)).tap do |payment|
|
|
289
402
|
payment.modify(&@block) if @block
|
|
290
403
|
validate_payment!(payment)
|
|
291
404
|
end
|
|
292
405
|
end
|
|
293
406
|
|
|
294
|
-
def build_interest_only_payment(interest)
|
|
295
|
-
Payment.new(-interest, period: @period).tap do |payment|
|
|
407
|
+
def build_interest_only_payment(interest, date)
|
|
408
|
+
Payment.new(-interest, period: @period, date:).tap do |payment|
|
|
296
409
|
payment.modify(&@block) if @block
|
|
297
410
|
validate_payment!(payment, allow_zero: true)
|
|
298
411
|
end
|
|
@@ -305,5 +418,24 @@ module Finrb
|
|
|
305
418
|
requirement = allow_zero ? 'must not produce a positive amount' : 'must produce a negative amount'
|
|
306
419
|
raise(ArgumentError, "payment modification #{requirement}.")
|
|
307
420
|
end
|
|
421
|
+
|
|
422
|
+
def dated_period_rate(rate, period_index)
|
|
423
|
+
period = @dated_schedule.periods.fetch(period_index)
|
|
424
|
+
year_fraction = DayCount.year_fraction(period.accrual_start_date, period.payment_date, convention: @day_count)
|
|
425
|
+
periodic_rate = Precision.rate(rate.apr * year_fraction)
|
|
426
|
+
Validation.decimal_greater_than(periodic_rate, minimum: -1, name: 'dated periodic rate')
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def dated_payment(balance, rate, period_index, balloon)
|
|
430
|
+
periods = (period_index...@periods).map { |index| dated_period_rate(rate, index) }
|
|
431
|
+
discount_factors = []
|
|
432
|
+
growth = Flt::DecNum('1')
|
|
433
|
+
periods.each do |periodic_rate|
|
|
434
|
+
growth *= periodic_rate + 1
|
|
435
|
+
discount_factors << (Flt::DecNum('1') / growth)
|
|
436
|
+
end
|
|
437
|
+
balloon_discounted = balloon * discount_factors.last
|
|
438
|
+
-Precision.money((balance - balloon_discounted) / discount_factors.sum)
|
|
439
|
+
end
|
|
308
440
|
end
|
|
309
441
|
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module Finrb
|
|
6
|
+
module Calendars
|
|
7
|
+
# A dated market calendar with explicit business-day adjustment rules.
|
|
8
|
+
class Base
|
|
9
|
+
CONVENTIONS = %i[following modified_following preceding modified_preceding half_month_modified_following nearest unadjusted].freeze
|
|
10
|
+
public_constant :CONVENTIONS
|
|
11
|
+
|
|
12
|
+
attr_reader :additional_holidays, :removed_holidays
|
|
13
|
+
|
|
14
|
+
def initialize(additional_holidays: [], removed_holidays: [])
|
|
15
|
+
@additional_holidays = validate_dates(additional_holidays, 'additional_holidays')
|
|
16
|
+
@removed_holidays = validate_dates(removed_holidays, 'removed_holidays')
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def business_day?(date)
|
|
21
|
+
date = normalize_date(date)
|
|
22
|
+
!weekend?(date) && holiday_names(date).empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def holiday?(date)
|
|
26
|
+
!business_day?(date)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def holiday_names(date)
|
|
30
|
+
date = normalize_date(date)
|
|
31
|
+
names = @removed_holidays.include?(date) ? [] : holidays_for(date)
|
|
32
|
+
names += ['Additional market holiday'] if @additional_holidays.include?(date)
|
|
33
|
+
names.uniq.freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def holidays_between(start_date, end_date, include_weekends: false)
|
|
37
|
+
start_date = normalize_date(start_date)
|
|
38
|
+
end_date = normalize_date(end_date)
|
|
39
|
+
raise(ArgumentError, 'start_date must not be after end_date.') if start_date > end_date
|
|
40
|
+
raise(ArgumentError, 'include_weekends must be true or false.') unless [true, false].include?(include_weekends)
|
|
41
|
+
|
|
42
|
+
holidays =
|
|
43
|
+
(start_date..end_date).each_with_object({}) do |date, result|
|
|
44
|
+
names = holiday_names(date)
|
|
45
|
+
names = ['Weekend'].freeze if include_weekends && weekend?(date) && names.empty?
|
|
46
|
+
result[date] = names unless names.empty?
|
|
47
|
+
end
|
|
48
|
+
holidays.freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ==(other)
|
|
52
|
+
other.instance_of?(self.class) && additional_holidays == other.additional_holidays && removed_holidays == other.removed_holidays
|
|
53
|
+
end
|
|
54
|
+
alias eql? ==
|
|
55
|
+
|
|
56
|
+
def hash
|
|
57
|
+
[self.class, additional_holidays, removed_holidays].hash
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def adjust(date, convention:)
|
|
61
|
+
date = normalize_date(date)
|
|
62
|
+
raise(ArgumentError, "business-day convention must be one of #{CONVENTIONS.join(', ')}.") unless CONVENTIONS.include?(convention)
|
|
63
|
+
|
|
64
|
+
return date if convention == :unadjusted || business_day?(date)
|
|
65
|
+
|
|
66
|
+
case convention
|
|
67
|
+
when :following
|
|
68
|
+
seek_business_day(date, 1)
|
|
69
|
+
when :modified_following
|
|
70
|
+
following = seek_business_day(date, 1)
|
|
71
|
+
following.month == date.month ? following : seek_business_day(date, -1)
|
|
72
|
+
when :preceding
|
|
73
|
+
seek_business_day(date, -1)
|
|
74
|
+
when :modified_preceding
|
|
75
|
+
preceding = seek_business_day(date, -1)
|
|
76
|
+
preceding.month == date.month ? preceding : seek_business_day(date, 1)
|
|
77
|
+
when :half_month_modified_following
|
|
78
|
+
following = seek_business_day(date, 1)
|
|
79
|
+
crosses_month = following.month != date.month
|
|
80
|
+
crosses_midmonth = date.day <= 15 && following.day > 15
|
|
81
|
+
crosses_month || crosses_midmonth ? seek_business_day(date, -1) : following
|
|
82
|
+
when :nearest
|
|
83
|
+
nearest_business_day(date)
|
|
84
|
+
else
|
|
85
|
+
raise(ArgumentError, "unsupported business-day convention: #{convention}.")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def advance(date, business_days:, convention: :following)
|
|
90
|
+
date = normalize_date(date)
|
|
91
|
+
raise(ArgumentError, 'business_days must be an integer.') unless business_days.is_a?(Integer)
|
|
92
|
+
raise(ArgumentError, "business-day convention must be one of #{CONVENTIONS.join(', ')}.") unless CONVENTIONS.include?(convention)
|
|
93
|
+
return adjust(date, convention:) if business_days.zero?
|
|
94
|
+
|
|
95
|
+
direction = business_days.positive? ? 1 : -1
|
|
96
|
+
remaining = business_days.abs
|
|
97
|
+
advanced = date
|
|
98
|
+
while remaining.positive?
|
|
99
|
+
advanced += direction
|
|
100
|
+
remaining -= 1 if business_day?(advanced)
|
|
101
|
+
end
|
|
102
|
+
advanced
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
protected
|
|
106
|
+
|
|
107
|
+
def weekend?(_date)
|
|
108
|
+
raise(NotImplementedError, 'calendar subclasses must define their weekend days.')
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def holidays_for(_date)
|
|
112
|
+
raise(NotImplementedError, 'calendar subclasses must define their market holidays.')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def normalize_date(date)
|
|
118
|
+
raise(ArgumentError, 'date must be a Date.') unless date.is_a?(Date)
|
|
119
|
+
|
|
120
|
+
normalized_date = Date.new(date.year, date.month, date.day, Date::GREGORIAN)
|
|
121
|
+
supported_dates = self.class::SUPPORTED_DATE_RANGE
|
|
122
|
+
return normalized_date if supported_dates.cover?(normalized_date)
|
|
123
|
+
|
|
124
|
+
raise(RangeError, "#{name} supports dates from #{supported_dates.begin} through #{supported_dates.end}.")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_dates(dates, name)
|
|
128
|
+
raise(ArgumentError, "#{name} must be an array of Dates.") unless dates.is_a?(Array) && dates.all?(Date)
|
|
129
|
+
|
|
130
|
+
normalized_dates = []
|
|
131
|
+
dates.each do |date|
|
|
132
|
+
normalized_date = normalize_date(date)
|
|
133
|
+
normalized_dates << normalized_date unless normalized_dates.include?(normalized_date)
|
|
134
|
+
end
|
|
135
|
+
normalized_dates.freeze
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def seek_business_day(date, direction)
|
|
139
|
+
candidate = date
|
|
140
|
+
370.times do
|
|
141
|
+
candidate += direction
|
|
142
|
+
return candidate if business_day?(candidate)
|
|
143
|
+
end
|
|
144
|
+
raise(ArgumentError, 'no business day found within one year of the requested date.')
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def nearest_business_day(date)
|
|
148
|
+
distance = 1
|
|
149
|
+
loop do
|
|
150
|
+
preceding = date - distance
|
|
151
|
+
following = date + distance
|
|
152
|
+
return following if business_day?(following)
|
|
153
|
+
return preceding if business_day?(preceding)
|
|
154
|
+
|
|
155
|
+
distance += 1
|
|
156
|
+
raise(ArgumentError, 'no business day found within one year of the requested date.') if distance > 370
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module Finrb
|
|
6
|
+
module Calendars
|
|
7
|
+
# Arithmetic Hebrew calendar dates used for the TASE holiday rules.
|
|
8
|
+
module HebrewCalendar
|
|
9
|
+
ANCHOR_HEBREW_YEAR = 5785
|
|
10
|
+
ANCHOR_ROSH_HASHANAH = Date.new(2024, 10, 3, Date::GREGORIAN)
|
|
11
|
+
private_constant :ANCHOR_HEBREW_YEAR, :ANCHOR_ROSH_HASHANAH
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def leap_year?(year)
|
|
16
|
+
((year * 7) + 1) % 19 < 7
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def date(year, month, day)
|
|
20
|
+
months = months_in_year(year)
|
|
21
|
+
raise(ArgumentError, 'invalid Hebrew month.') unless months.include?(month)
|
|
22
|
+
|
|
23
|
+
month_length = days_in_month(year, month)
|
|
24
|
+
raise(ArgumentError, 'invalid Hebrew day.') unless day.between?(1, month_length)
|
|
25
|
+
|
|
26
|
+
offset = 0
|
|
27
|
+
months.each do |candidate_month|
|
|
28
|
+
break if candidate_month == month
|
|
29
|
+
|
|
30
|
+
offset += days_in_month(year, candidate_month)
|
|
31
|
+
end
|
|
32
|
+
Date.jd(rosh_hashanah_jd(year) + offset + day - 1, Date::GREGORIAN)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def rosh_hashanah_jd(year)
|
|
36
|
+
ANCHOR_ROSH_HASHANAH.jd + elapsed_days(year) - elapsed_days(ANCHOR_HEBREW_YEAR)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def months_in_year(year)
|
|
40
|
+
after_tishri = (7..(leap_year?(year) ? 13 : 12)).to_a
|
|
41
|
+
after_tishri + (1..6).to_a
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def days_in_month(year, month)
|
|
45
|
+
year_length = elapsed_days(year + 1) - elapsed_days(year)
|
|
46
|
+
case month
|
|
47
|
+
when 1, 3, 5, 7, 11
|
|
48
|
+
30
|
|
49
|
+
when 2, 4, 6, 10, 13
|
|
50
|
+
29
|
|
51
|
+
when 8
|
|
52
|
+
year_length % 10 == 5 ? 30 : 29
|
|
53
|
+
when 9
|
|
54
|
+
year_length % 10 == 3 ? 29 : 30
|
|
55
|
+
when 12
|
|
56
|
+
leap_year?(year) ? 30 : 29
|
|
57
|
+
else
|
|
58
|
+
raise(ArgumentError, 'invalid Hebrew month.')
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def elapsed_days(year)
|
|
63
|
+
elapsed_months = ((year * 235) - 234) / 19
|
|
64
|
+
parts_elapsed = ((elapsed_months % 1080) * 793) + 204
|
|
65
|
+
hours_elapsed = (elapsed_months * 12) + 5 + ((elapsed_months / 1080) * 793) + (parts_elapsed / 1080)
|
|
66
|
+
day = (elapsed_months * 29) + 1 + (hours_elapsed / 24)
|
|
67
|
+
parts = ((hours_elapsed % 24) * 1080) + (parts_elapsed % 1080)
|
|
68
|
+
|
|
69
|
+
molad_postponement = parts >= 19_440 || ((day % 7) == 2 && parts >= 9_924 && !leap_year?(year)) || ((day % 7) == 1 && parts >= 16_789 && leap_year?(year - 1))
|
|
70
|
+
day += 1 if molad_postponement
|
|
71
|
+
day += 1 if [0, 3, 5].include?(day % 7)
|
|
72
|
+
day
|
|
73
|
+
end
|
|
74
|
+
private_class_method :elapsed_days, :days_in_month, :months_in_year, :rosh_hashanah_jd
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|