finrb 1.2.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 +31 -0
- data/CONTRIBUTING.md +4 -4
- data/README.md +79 -8
- data/lib/finrb/amortization.rb +161 -25
- 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/day_count.rb +62 -0
- data/lib/finrb/fixed_rate_bond.rb +140 -0
- data/lib/finrb/schedule.rb +126 -0
- data/lib/finrb/version.rb +1 -1
- data/lib/finrb.rb +4 -0
- data/sig/finrb.rbs +112 -3
- metadata +10 -2
|
@@ -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
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'hebrew_calendar'
|
|
5
|
+
|
|
6
|
+
module Finrb
|
|
7
|
+
module Calendars
|
|
8
|
+
# Tel Aviv Stock Exchange full-day trading calendar.
|
|
9
|
+
class IsraelTase < Base
|
|
10
|
+
# Contract window: QuantLib 1.43 supplies TASE holiday data through 2050.
|
|
11
|
+
# Earlier dates and later projections are not validated by this profile.
|
|
12
|
+
SUPPORTED_START_DATE = Date.new(2000, 1, 1, Date::GREGORIAN)
|
|
13
|
+
SUPPORTED_END_DATE = Date.new(2050, 12, 31, Date::GREGORIAN)
|
|
14
|
+
SUPPORTED_DATE_RANGE = (SUPPORTED_START_DATE..SUPPORTED_END_DATE)
|
|
15
|
+
public_constant :SUPPORTED_START_DATE, :SUPPORTED_END_DATE, :SUPPORTED_DATE_RANGE
|
|
16
|
+
|
|
17
|
+
TRADING_WEEK_CHANGE = Date.new(2026, 1, 5, Date::GREGORIAN)
|
|
18
|
+
WEEKEND_BEFORE_CHANGE = [5, 6].freeze
|
|
19
|
+
WEEKEND_AFTER_CHANGE = [0, 6].freeze
|
|
20
|
+
public_constant :TRADING_WEEK_CHANGE
|
|
21
|
+
private_constant :WEEKEND_BEFORE_CHANGE, :WEEKEND_AFTER_CHANGE
|
|
22
|
+
|
|
23
|
+
def name
|
|
24
|
+
'Israel TASE'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
protected
|
|
28
|
+
|
|
29
|
+
def weekend?(date)
|
|
30
|
+
weekend_days = date < TRADING_WEEK_CHANGE ? WEEKEND_BEFORE_CHANGE : WEEKEND_AFTER_CHANGE
|
|
31
|
+
weekend_days.include?(date.wday)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def holidays_for(date)
|
|
35
|
+
names = {}
|
|
36
|
+
((date.year + 3759)..(date.year + 3761)).each do |hebrew_year|
|
|
37
|
+
hebrew_holidays(hebrew_year).each do |holiday_date, name|
|
|
38
|
+
(names[holiday_date] ||= []).concat(name)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
add(names, Date.new(2026, 1, 4, Date::GREGORIAN), 'TASE trading-week transition')
|
|
42
|
+
names.fetch(date, [])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def hebrew_holidays(year)
|
|
48
|
+
holidays = {}
|
|
49
|
+
add(holidays, HebrewCalendar.date(year - 1, 6, 29), 'Rosh Hashanah Eve')
|
|
50
|
+
add(holidays, HebrewCalendar.date(year, 7, 1), 'Rosh Hashanah I')
|
|
51
|
+
add(holidays, HebrewCalendar.date(year, 7, 2), 'Rosh Hashanah II')
|
|
52
|
+
add(holidays, HebrewCalendar.date(year, 7, 9), 'Yom Kippur Eve')
|
|
53
|
+
add(holidays, HebrewCalendar.date(year, 7, 10), 'Yom Kippur')
|
|
54
|
+
add(holidays, HebrewCalendar.date(year, 7, 14), 'Sukkot Eve')
|
|
55
|
+
add(holidays, HebrewCalendar.date(year, 7, 15), 'Sukkot')
|
|
56
|
+
add(holidays, HebrewCalendar.date(year, 7, 21), 'Shemini Atzeret Eve')
|
|
57
|
+
add(holidays, HebrewCalendar.date(year, 7, 22), 'Shemini Atzeret / Simchat Torah')
|
|
58
|
+
|
|
59
|
+
adar = HebrewCalendar.leap_year?(year) ? 13 : 12
|
|
60
|
+
add(holidays, HebrewCalendar.date(year, adar, 14), 'Purim')
|
|
61
|
+
|
|
62
|
+
add(holidays, HebrewCalendar.date(year, 1, 14), 'Passover Eve I')
|
|
63
|
+
add(holidays, HebrewCalendar.date(year, 1, 15), 'Passover I')
|
|
64
|
+
add(holidays, HebrewCalendar.date(year, 1, 20), 'Passover Eve VII')
|
|
65
|
+
add(holidays, HebrewCalendar.date(year, 1, 21), 'Passover VII')
|
|
66
|
+
add(holidays, israel_independence_day(year) - 1, 'Memorial Day')
|
|
67
|
+
add(holidays, israel_independence_day(year), 'Independence Day')
|
|
68
|
+
add(holidays, HebrewCalendar.date(year, 3, 5), 'Shavuot Eve')
|
|
69
|
+
add(holidays, HebrewCalendar.date(year, 3, 6), 'Shavuot')
|
|
70
|
+
add(holidays, tisha_bav(year), 'Tisha B’Av')
|
|
71
|
+
holidays
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def israel_independence_day(year)
|
|
75
|
+
date = HebrewCalendar.date(year, 2, 5)
|
|
76
|
+
day =
|
|
77
|
+
case date.wday
|
|
78
|
+
when 6 then 3
|
|
79
|
+
when 5 then 4
|
|
80
|
+
when 1 then 6
|
|
81
|
+
else 5
|
|
82
|
+
end
|
|
83
|
+
HebrewCalendar.date(year, 2, day)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def tisha_bav(year)
|
|
87
|
+
date = HebrewCalendar.date(year, 5, 9)
|
|
88
|
+
date.saturday? ? date + 1 : date
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def add(holidays, date, name)
|
|
92
|
+
(holidays[date] ||= []) << name
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Finrb
|
|
6
|
+
module Calendars
|
|
7
|
+
# Federal Reserve Bank payment-business calendar (not NYSE or federal staff leave).
|
|
8
|
+
class USFederalReserve < Base
|
|
9
|
+
# Contract window: QuantLib 1.43 was exhaustively checked over these years;
|
|
10
|
+
# pre-1950 history is intentionally outside this profile and later dates
|
|
11
|
+
# are rejected rather than silently extrapolated.
|
|
12
|
+
SUPPORTED_START_DATE = Date.new(1950, 1, 1, Date::GREGORIAN)
|
|
13
|
+
SUPPORTED_END_DATE = Date.new(2065, 12, 31, Date::GREGORIAN)
|
|
14
|
+
SUPPORTED_DATE_RANGE = (SUPPORTED_START_DATE..SUPPORTED_END_DATE)
|
|
15
|
+
public_constant :SUPPORTED_START_DATE, :SUPPORTED_END_DATE, :SUPPORTED_DATE_RANGE
|
|
16
|
+
|
|
17
|
+
WEEKEND_DAYS = [0, 6].freeze
|
|
18
|
+
private_constant :WEEKEND_DAYS
|
|
19
|
+
|
|
20
|
+
def name
|
|
21
|
+
'US Federal Reserve'
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
protected
|
|
25
|
+
|
|
26
|
+
def weekend?(date)
|
|
27
|
+
WEEKEND_DAYS.include?(date.wday)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def holidays_for(date)
|
|
31
|
+
year = date.year
|
|
32
|
+
holidays = {}
|
|
33
|
+
add_fixed_holiday(holidays, year, 1, 1, "New Year's Day")
|
|
34
|
+
add_weekday_holiday(holidays, nth_weekday(year, 1, 1, 3), 'Birthday of Martin Luther King, Jr.') if year >= 1983
|
|
35
|
+
if year < 1971
|
|
36
|
+
add_pre_1971_fixed_holiday(holidays, Date.new(year, 2, 22, Date::GREGORIAN), "Washington's Birthday")
|
|
37
|
+
add_pre_1971_fixed_holiday(holidays, Date.new(year, 5, 30, Date::GREGORIAN), 'Memorial Day')
|
|
38
|
+
else
|
|
39
|
+
add_weekday_holiday(holidays, nth_weekday(year, 2, 1, 3), "Washington's Birthday")
|
|
40
|
+
add_weekday_holiday(holidays, last_weekday(year, 5, 1), 'Memorial Day')
|
|
41
|
+
end
|
|
42
|
+
add_fixed_holiday(holidays, year, 6, 19, 'Juneteenth National Independence Day') if year >= 2021
|
|
43
|
+
add_fixed_holiday(holidays, year, 7, 4, 'Independence Day')
|
|
44
|
+
add_weekday_holiday(holidays, nth_weekday(year, 9, 1, 1), 'Labor Day')
|
|
45
|
+
add_weekday_holiday(holidays, nth_weekday(year, 10, 1, 2), 'Columbus Day') if year >= 1971
|
|
46
|
+
add_fixed_holiday(holidays, year, 11, 11, 'Veterans Day') unless year.between?(1971, 1977)
|
|
47
|
+
add_weekday_holiday(holidays, nth_weekday(year, 10, 1, 4), 'Veterans Day') if year.between?(1971, 1977)
|
|
48
|
+
add_weekday_holiday(holidays, nth_weekday(year, 11, 4, 4), 'Thanksgiving Day')
|
|
49
|
+
add_fixed_holiday(holidays, year, 12, 25, 'Christmas Day')
|
|
50
|
+
holidays.fetch(date, [])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def add_fixed_holiday(holidays, year, month, day, name)
|
|
56
|
+
date = Date.new(year, month, day, Date::GREGORIAN)
|
|
57
|
+
add_weekday_holiday(holidays, date, name)
|
|
58
|
+
add_weekday_holiday(holidays, date + 1, name) if date.sunday?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def add_pre_1971_fixed_holiday(holidays, date, name)
|
|
62
|
+
add_weekday_holiday(holidays, date, name)
|
|
63
|
+
add_weekday_holiday(holidays, date - 1, name) if date.saturday?
|
|
64
|
+
add_weekday_holiday(holidays, date + 1, name) if date.sunday?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def add_weekday_holiday(holidays, date, name)
|
|
68
|
+
(holidays[date] ||= []) << name
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def nth_weekday(year, month, weekday, occurrence)
|
|
72
|
+
first = Date.new(year, month, 1, Date::GREGORIAN)
|
|
73
|
+
first + ((weekday - first.wday) % 7) + ((occurrence - 1) * 7)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def last_weekday(year, month, weekday)
|
|
77
|
+
last = Date.new(year, month, -1, Date::GREGORIAN)
|
|
78
|
+
last - ((last.wday - weekday) % 7)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'decimal'
|
|
4
|
+
require 'date'
|
|
5
|
+
|
|
6
|
+
module Finrb
|
|
7
|
+
# Year-fraction calculations used to express elapsed time under a named
|
|
8
|
+
# financial day-count convention.
|
|
9
|
+
module DayCount
|
|
10
|
+
CONVENTIONS = %i[actual_365_fixed actual_360 actual_actual_icma].freeze
|
|
11
|
+
DEFAULT = :actual_365_fixed
|
|
12
|
+
public_constant :CONVENTIONS, :DEFAULT
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Calculate a signed year fraction. Actual/Actual ICMA requires the regular
|
|
17
|
+
# coupon reference period and coupon frequency containing the date range.
|
|
18
|
+
def year_fraction(start_date, end_date, convention: DEFAULT, reference_period_start: nil, reference_period_end: nil, frequency: nil)
|
|
19
|
+
raise(ArgumentError, "day-count convention must be one of #{CONVENTIONS.join(', ')}.") unless CONVENTIONS.include?(convention)
|
|
20
|
+
|
|
21
|
+
start_date = normalize_date(start_date, 'start_date')
|
|
22
|
+
end_date = normalize_date(end_date, 'end_date')
|
|
23
|
+
|
|
24
|
+
if convention == :actual_actual_icma
|
|
25
|
+
icma_year_fraction(start_date, end_date, reference_period_start, reference_period_end, frequency)
|
|
26
|
+
else
|
|
27
|
+
reject_reference_period_arguments!(reference_period_start, reference_period_end, frequency)
|
|
28
|
+
denominator = convention == :actual_360 ? 360 : 365
|
|
29
|
+
|
|
30
|
+
Flt::DecNum((end_date - start_date).to_i.to_s) / Flt::DecNum(denominator.to_s)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def icma_year_fraction(start_date, end_date, reference_period_start, reference_period_end, frequency)
|
|
35
|
+
raise(ArgumentError, 'Actual/Actual ICMA requires reference_period_start, reference_period_end, and frequency.') unless reference_period_start && reference_period_end && frequency
|
|
36
|
+
|
|
37
|
+
reference_period_start = normalize_date(reference_period_start, 'reference_period_start')
|
|
38
|
+
reference_period_end = normalize_date(reference_period_end, 'reference_period_end')
|
|
39
|
+
raise(ArgumentError, 'reference_period_end must be after reference_period_start.') if reference_period_end <= reference_period_start
|
|
40
|
+
raise(ArgumentError, 'frequency must be a positive integer.') unless frequency.is_a?(Integer) && frequency.positive?
|
|
41
|
+
|
|
42
|
+
elapsed_days = Flt::DecNum((end_date - start_date).to_i.to_s)
|
|
43
|
+
reference_days = Flt::DecNum((reference_period_end - reference_period_start).to_i.to_s)
|
|
44
|
+
elapsed_days / (reference_days * frequency)
|
|
45
|
+
end
|
|
46
|
+
private_class_method :icma_year_fraction
|
|
47
|
+
|
|
48
|
+
def reject_reference_period_arguments!(reference_period_start, reference_period_end, frequency)
|
|
49
|
+
return if reference_period_start.nil? && reference_period_end.nil? && frequency.nil?
|
|
50
|
+
|
|
51
|
+
raise(ArgumentError, 'reference-period arguments are only valid for Actual/Actual ICMA.')
|
|
52
|
+
end
|
|
53
|
+
private_class_method :reject_reference_period_arguments!
|
|
54
|
+
|
|
55
|
+
def normalize_date(date, name)
|
|
56
|
+
raise(ArgumentError, "#{name} must be a Date.") unless date.instance_of?(Date)
|
|
57
|
+
|
|
58
|
+
Date.new(date.year, date.month, date.day, Date::GREGORIAN)
|
|
59
|
+
end
|
|
60
|
+
private_class_method :normalize_date
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'calendars'
|
|
4
|
+
require_relative 'config'
|
|
5
|
+
require_relative 'day_count'
|
|
6
|
+
require_relative 'errors'
|
|
7
|
+
require_relative 'numerical/brent'
|
|
8
|
+
require_relative 'numerical/rate_search'
|
|
9
|
+
require_relative 'schedule'
|
|
10
|
+
require_relative 'transaction'
|
|
11
|
+
require_relative 'validation'
|
|
12
|
+
|
|
13
|
+
module Finrb
|
|
14
|
+
# Fixed-coupon bullet bond valuation under explicit Actual/Actual ICMA rules.
|
|
15
|
+
class FixedRateBond
|
|
16
|
+
PRICE_TYPES = %i[clean dirty].freeze
|
|
17
|
+
public_constant :PRICE_TYPES
|
|
18
|
+
|
|
19
|
+
CouponPeriod = Data.define(:accrual_start_date, :accrual_end_date, :payment_date, :amount)
|
|
20
|
+
private_constant :CouponPeriod
|
|
21
|
+
|
|
22
|
+
attr_reader :face_value, :coupon_rate, :issue_date, :maturity_date, :frequency, :schedule, :calendar, :business_day_convention
|
|
23
|
+
|
|
24
|
+
def initialize(face_value:, coupon_rate:, issue_date:, maturity_date:, frequency: :semiannual, calendar: nil, business_day_convention: nil)
|
|
25
|
+
@face_value = Validation.positive_decimal(face_value, name: 'face value')
|
|
26
|
+
@coupon_rate = Validation.non_negative_decimal(coupon_rate, name: 'annual coupon rate')
|
|
27
|
+
@schedule = Schedule.new(start_date: issue_date, maturity_date:, frequency:, calendar:, business_day_convention:)
|
|
28
|
+
|
|
29
|
+
@issue_date = schedule.start_date
|
|
30
|
+
@maturity_date = schedule.maturity_date
|
|
31
|
+
@frequency = frequency
|
|
32
|
+
@coupon_frequency = 12 / Schedule::FREQUENCY_MONTHS.fetch(frequency)
|
|
33
|
+
@calendar = calendar
|
|
34
|
+
@business_day_convention = business_day_convention
|
|
35
|
+
@coupon_periods = build_coupon_periods.freeze
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def day_count
|
|
40
|
+
:actual_actual_icma
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Return future coupon and redemption cashflows as dated positive transactions.
|
|
44
|
+
def cashflows(settlement_date:)
|
|
45
|
+
settlement_date = validate_settlement_date!(settlement_date)
|
|
46
|
+
cashflows =
|
|
47
|
+
@coupon_periods.filter_map.with_index do |period, index|
|
|
48
|
+
next if period.payment_date <= settlement_date
|
|
49
|
+
|
|
50
|
+
amount = period.amount
|
|
51
|
+
amount += face_value if index == @coupon_periods.length - 1
|
|
52
|
+
Transaction.new(amount, date: period.payment_date)
|
|
53
|
+
end
|
|
54
|
+
cashflows.freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def accrued_interest(settlement_date:)
|
|
58
|
+
settlement_date = validate_settlement_date!(settlement_date)
|
|
59
|
+
period = @coupon_periods.find { |coupon_period| coupon_period.payment_date > settlement_date }
|
|
60
|
+
return Flt::DecNum(0) if period.nil?
|
|
61
|
+
|
|
62
|
+
accrual_end = [settlement_date, period.accrual_end_date].min
|
|
63
|
+
return Flt::DecNum(0) if accrual_end <= period.accrual_start_date
|
|
64
|
+
|
|
65
|
+
fraction = icma_fraction(period.accrual_start_date, accrual_end, period.accrual_start_date, period.accrual_end_date)
|
|
66
|
+
face_value * coupon_rate * fraction
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def dirty_price(settlement_date:, yield_rate:)
|
|
70
|
+
settlement_date = validate_settlement_date!(settlement_date)
|
|
71
|
+
yield_rate = Validation.decimal_greater_than(yield_rate, minimum: -1, name: 'bond yield', error: DomainError)
|
|
72
|
+
periodic_yield = yield_rate / @coupon_frequency
|
|
73
|
+
discount_base = periodic_yield + 1
|
|
74
|
+
|
|
75
|
+
@coupon_periods.each_with_index.sum(Flt::DecNum(0)) do |period, index|
|
|
76
|
+
next Flt::DecNum(0) if period.payment_date <= settlement_date
|
|
77
|
+
|
|
78
|
+
amount = period.amount
|
|
79
|
+
amount += face_value if index == @coupon_periods.length - 1
|
|
80
|
+
elapsed_coupon_periods = schedule_year_fraction(settlement_date, period.payment_date) * @coupon_frequency
|
|
81
|
+
amount / (discount_base**elapsed_coupon_periods)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def clean_price(settlement_date:, yield_rate:)
|
|
86
|
+
dirty_price(settlement_date:, yield_rate:) - accrued_interest(settlement_date:)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def yield_to_maturity(settlement_date:, price:, price_type: :clean, guess: Finrb.config.guess)
|
|
90
|
+
settlement_date = validate_settlement_date!(settlement_date)
|
|
91
|
+
raise(ArgumentError, "price_type must be one of #{PRICE_TYPES.join(', ')}.") unless PRICE_TYPES.include?(price_type)
|
|
92
|
+
|
|
93
|
+
quoted_price = Validation.positive_decimal(price, name: "#{price_type} bond price", error: DomainError)
|
|
94
|
+
dirty_price_target = price_type == :clean ? quoted_price + accrued_interest(settlement_date:) : quoted_price
|
|
95
|
+
rate_function = ->(rate) { dirty_price(settlement_date:, yield_rate: rate) - dirty_price_target }
|
|
96
|
+
bounds = Numerical::RateSearch.new.bracket(rate_function, guess:)
|
|
97
|
+
return bounds.first if bounds.first == bounds.last
|
|
98
|
+
|
|
99
|
+
Numerical::Brent.new(tolerance: Finrb.config.eps).solve(rate_function, lower: bounds.first, upper: bounds.last)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def build_coupon_periods
|
|
105
|
+
previous_unadjusted_date = issue_date
|
|
106
|
+
schedule.periods.map do |period|
|
|
107
|
+
accrual_start_date = previous_unadjusted_date
|
|
108
|
+
accrual_end_date = period.unadjusted_payment_date
|
|
109
|
+
fraction = icma_fraction(accrual_start_date, accrual_end_date, accrual_start_date, accrual_end_date)
|
|
110
|
+
coupon_amount = face_value * coupon_rate * fraction
|
|
111
|
+
previous_unadjusted_date = accrual_end_date
|
|
112
|
+
CouponPeriod.new(accrual_start_date:, accrual_end_date:, payment_date: period.payment_date, amount: coupon_amount)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def icma_fraction(start_date, end_date, reference_period_start, reference_period_end)
|
|
117
|
+
DayCount.year_fraction(start_date, end_date, convention: :actual_actual_icma, reference_period_start:, reference_period_end:, frequency: @coupon_frequency)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def schedule_year_fraction(start_date, end_date)
|
|
121
|
+
@coupon_periods.sum(Flt::DecNum(0)) do |period|
|
|
122
|
+
overlap_start = [start_date, period.accrual_start_date].max
|
|
123
|
+
overlap_end = [end_date, period.accrual_end_date].min
|
|
124
|
+
next Flt::DecNum(0) if overlap_end <= overlap_start
|
|
125
|
+
|
|
126
|
+
icma_fraction(overlap_start, overlap_end, period.accrual_start_date, period.accrual_end_date)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def validate_settlement_date!(date)
|
|
131
|
+
raise(ArgumentError, 'settlement_date must be a Date.') unless date.instance_of?(Date)
|
|
132
|
+
|
|
133
|
+
date = Date.new(date.year, date.month, date.day, Date::GREGORIAN)
|
|
134
|
+
raise(ArgumentError, 'settlement_date must be on or after issue_date.') if date < issue_date
|
|
135
|
+
raise(ArgumentError, 'settlement_date must be before maturity_date.') if date >= maturity_date
|
|
136
|
+
|
|
137
|
+
date
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|