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.
@@ -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
- private_constant :ATTRIBUTES, :MONETARY_ATTRIBUTES
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
- attributes = ATTRIBUTES.map { |name| public_send(name) }
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 monthly payment. For loans with more than one rate, returns nil
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.decimal(principal, name: 'principal')
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.decimal(rate, name: 'rate')
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: 'periods')
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
- def initialize(principal, *rates, balloon: 0, interest_only_periods: 0, origination_fee: 0, finance_origination_fee: false, &block)
120
- @principal = Validation.decimal(principal, name: 'principal')
121
- raise(ArgumentError, 'principal must be positive.') unless @principal.positive?
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
- @origination_fee = Validation.decimal(origination_fee, name: 'origination_fee')
124
- raise(ArgumentError, 'origination_fee must be non-negative.') if @origination_fee.negative?
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
- # compute the total duration from all of the rates.
142
- @periods = rates.sum(&:duration)
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
- rate.duration.to_i.times do
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
- int = Precision.money(@balance * rate.monthly)
186
- interest = Interest.new(int, period: @period)
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.each do |rate|
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] the time required to pay off the loan, in months
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 = Amortization.payment(@balance, rate.monthly, periods, balloon: @balloon)
288
- Payment.new(amount, period: @period).tap do |payment|
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