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.
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'finrb/calendars/base'
4
+ require 'finrb/calendars/hebrew_calendar'
5
+ require 'finrb/calendars/israel_tase'
6
+ require 'finrb/calendars/us_federal_reserve'
@@ -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