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
|
@@ -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
|
data/lib/finrb/cashflows.rb
CHANGED
|
@@ -77,8 +77,7 @@ module Finrb
|
|
|
77
77
|
validate_numeric_cashflows!
|
|
78
78
|
cashflows = map { |entry| Validation.decimal(entry, name: 'cashflow amount') }
|
|
79
79
|
|
|
80
|
-
rate = Validation.
|
|
81
|
-
raise(DomainError, 'Rate must be greater than -1.') if rate <= -1
|
|
80
|
+
rate = Validation.decimal_greater_than(rate, minimum: -1, name: 'rate', error: DomainError)
|
|
82
81
|
|
|
83
82
|
total = Flt::DecNum.new(0.to_s)
|
|
84
83
|
cashflows.each_with_index do |cashflow, index|
|
|
@@ -98,9 +97,8 @@ module Finrb
|
|
|
98
97
|
cashflows = map { |entry| Validation.decimal(entry, name: 'cashflow amount') }
|
|
99
98
|
raise(InvalidCashflowError, 'Cashflow needs at least one positive and one negative value.') if cashflows.none?(&:positive?) || cashflows.none?(&:negative?)
|
|
100
99
|
|
|
101
|
-
finance_rate = Validation.
|
|
102
|
-
reinvestment_rate = Validation.
|
|
103
|
-
raise(DomainError, 'Finance and reinvestment rates must be greater than -1.') if finance_rate <= -1 || reinvestment_rate <= -1
|
|
100
|
+
finance_rate = Validation.decimal_greater_than(finance_rate, minimum: -1, name: 'finance rate', error: DomainError)
|
|
101
|
+
reinvestment_rate = Validation.decimal_greater_than(reinvestment_rate, minimum: -1, name: 'reinvestment rate', error: DomainError)
|
|
104
102
|
|
|
105
103
|
last_period = cashflows.size - 1
|
|
106
104
|
future_positive =
|
|
@@ -156,8 +154,7 @@ module Finrb
|
|
|
156
154
|
# Finrb::Cashflow.xnpv(@transactions, 0.6).round(2) #=> -937.41
|
|
157
155
|
def xnpv(rate)
|
|
158
156
|
validate_dated_cashflows!
|
|
159
|
-
rate = Validation.
|
|
160
|
-
raise(DomainError, 'Rate must be greater than -1.') if rate <= -1
|
|
157
|
+
rate = Validation.decimal_greater_than(rate, minimum: -1, name: 'rate', error: DomainError)
|
|
161
158
|
|
|
162
159
|
sum do |t|
|
|
163
160
|
t.amount / ((rate + 1)**(date_diff(start, t.date) / days_in_period))
|
data/lib/finrb/config.rb
CHANGED
|
@@ -41,16 +41,16 @@ module Finrb
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def self.build_configuration(values)
|
|
44
|
-
eps = configuration_decimal(values.fetch(:eps), name: '
|
|
45
|
-
raise(ArgumentError, '
|
|
44
|
+
eps = configuration_decimal(values.fetch(:eps), name: 'solver tolerance')
|
|
45
|
+
raise(ArgumentError, 'solver tolerance must be positive.') unless eps.positive?
|
|
46
46
|
|
|
47
|
-
guess = configuration_decimal(values.fetch(:guess), name: 'guess')
|
|
48
|
-
raise(ArgumentError, 'guess must be greater than -1.') if guess <= -1
|
|
47
|
+
guess = configuration_decimal(values.fetch(:guess), name: 'rate guess')
|
|
48
|
+
raise(ArgumentError, 'rate guess must be greater than -1.') if guess <= -1
|
|
49
49
|
|
|
50
50
|
business_days = values.fetch(:business_days)
|
|
51
51
|
periodic_compound = values.fetch(:periodic_compound)
|
|
52
52
|
booleans = [business_days, periodic_compound].all? { |value| value.equal?(true) || value.equal?(false) }
|
|
53
|
-
raise(ArgumentError, '
|
|
53
|
+
raise(ArgumentError, 'business days and periodic compounding settings must be boolean.') unless booleans
|
|
54
54
|
|
|
55
55
|
Configuration.new(eps:, guess:, business_days:, periodic_compound:)
|
|
56
56
|
end
|
|
@@ -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
|
data/lib/finrb/rates.rb
CHANGED
|
@@ -17,10 +17,7 @@ module Finrb
|
|
|
17
17
|
infinite = value.infinite? if value.respond_to?(:infinite?)
|
|
18
18
|
return Flt::DecNum.infinity if [true, 1].include?(infinite)
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
raise(ArgumentError, 'compounding periods must be positive.') unless periods.positive?
|
|
22
|
-
|
|
23
|
-
periods
|
|
20
|
+
Validation.positive_decimal(value, name: 'compounding periods', message: 'compounding periods must be positive.')
|
|
24
21
|
end
|
|
25
22
|
private_class_method :compounding_periods
|
|
26
23
|
|
|
@@ -49,8 +46,7 @@ module Finrb
|
|
|
49
46
|
# Rate.to_nominal(0.06, 365) #=> Flt::DecNum('0.05827')
|
|
50
47
|
# @see https://www.miniwebtool.com/nominal-interest-rate-calculator/
|
|
51
48
|
def self.to_nominal(rate, periods)
|
|
52
|
-
rate = Validation.
|
|
53
|
-
raise(ArgumentError, 'effective rate must be greater than -1.') if rate <= -1
|
|
49
|
+
rate = Validation.decimal_greater_than(rate, minimum: -1, name: 'effective rate')
|
|
54
50
|
|
|
55
51
|
periods = compounding_periods(periods)
|
|
56
52
|
|
|
@@ -141,7 +137,7 @@ module Finrb
|
|
|
141
137
|
end
|
|
142
138
|
|
|
143
139
|
def duration=(value)
|
|
144
|
-
@duration = Validation.positive_integer(value, name: 'duration')
|
|
140
|
+
@duration = Validation.positive_integer(value, name: 'duration in months')
|
|
145
141
|
end
|
|
146
142
|
|
|
147
143
|
# set the effective interest rate
|
data/lib/finrb/ratios.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'decimal'
|
|
4
4
|
require_relative 'errors'
|
|
5
|
+
require_relative 'validation'
|
|
5
6
|
|
|
6
7
|
module Finrb
|
|
7
8
|
# Financial-statement, leverage, and per-share ratios.
|
|
@@ -25,9 +26,9 @@ module Finrb
|
|
|
25
26
|
# @example
|
|
26
27
|
# Finrb::Ratios.cash_ratio(cash=3000,ms=2000,cl=2000)
|
|
27
28
|
def self.cash_ratio(cash:, ms:, cl:)
|
|
28
|
-
cash =
|
|
29
|
-
ms =
|
|
30
|
-
cl =
|
|
29
|
+
cash = Validation.decimal(cash, name: 'cash')
|
|
30
|
+
ms = Validation.decimal(ms, name: 'marketable securities')
|
|
31
|
+
cl = Validation.non_zero_decimal(cl, name: 'current liabilities', error: DomainError)
|
|
31
32
|
|
|
32
33
|
((cash + ms) / cl)
|
|
33
34
|
end
|
|
@@ -39,8 +40,8 @@ module Finrb
|
|
|
39
40
|
# @example
|
|
40
41
|
# Finrb::Ratios.current_ratio(ca=8000,cl=2000)
|
|
41
42
|
def self.current_ratio(ca:, cl:)
|
|
42
|
-
ca =
|
|
43
|
-
cl =
|
|
43
|
+
ca = Validation.decimal(ca, name: 'current assets')
|
|
44
|
+
cl = Validation.non_zero_decimal(cl, name: 'current liabilities', error: DomainError)
|
|
44
45
|
|
|
45
46
|
(ca / cl)
|
|
46
47
|
end
|
|
@@ -52,8 +53,8 @@ module Finrb
|
|
|
52
53
|
# @example
|
|
53
54
|
# Finrb::Ratios.debt_ratio(td=6000,ta=20000)
|
|
54
55
|
def self.debt_ratio(td:, ta:)
|
|
55
|
-
td =
|
|
56
|
-
ta =
|
|
56
|
+
td = Validation.decimal(td, name: 'total debt')
|
|
57
|
+
ta = Validation.non_zero_decimal(ta, name: 'total assets', error: DomainError)
|
|
57
58
|
|
|
58
59
|
(td / ta)
|
|
59
60
|
end
|
|
@@ -81,15 +82,15 @@ module Finrb
|
|
|
81
82
|
# @example
|
|
82
83
|
# Finrb::Ratios.diluted_eps(ni=115600,pd=10000,cpd=10000,cdi=42000,tax=0.4,w=200000,cps=40000,cds=60000,iss=2500)
|
|
83
84
|
def self.diluted_eps(ni:, pd:, w:, cpd: 0, cdi: 0, tax: 0, cps: 0, cds: 0, iss: 0)
|
|
84
|
-
ni =
|
|
85
|
-
pd =
|
|
86
|
-
w =
|
|
87
|
-
cpd =
|
|
88
|
-
cdi =
|
|
89
|
-
tax =
|
|
90
|
-
cps =
|
|
91
|
-
cds =
|
|
92
|
-
iss =
|
|
85
|
+
ni = Validation.decimal(ni, name: 'net income')
|
|
86
|
+
pd = Validation.decimal(pd, name: 'preferred dividends')
|
|
87
|
+
w = Validation.positive_decimal(w, name: 'weighted average common shares', error: DomainError)
|
|
88
|
+
cpd = Validation.non_negative_decimal(cpd, name: 'convertible preferred dividends')
|
|
89
|
+
cdi = Validation.non_negative_decimal(cdi, name: 'convertible debt interest')
|
|
90
|
+
tax = Validation.decimal_between(tax, minimum: 0, maximum: 1, name: 'tax rate')
|
|
91
|
+
cps = Validation.non_negative_decimal(cps, name: 'convertible preferred shares')
|
|
92
|
+
cds = Validation.non_negative_decimal(cds, name: 'convertible debt shares')
|
|
93
|
+
iss = Validation.non_negative_decimal(iss, name: 'incremental option shares')
|
|
93
94
|
|
|
94
95
|
basic = (ni - pd) / w
|
|
95
96
|
diluted = (ni - pd + cpd + (cdi * (1 - tax))) / (w + cps + cds + iss)
|
|
@@ -105,9 +106,9 @@ module Finrb
|
|
|
105
106
|
# @example
|
|
106
107
|
# Finrb::Ratios.eps(ni=10000,pd=1000,w=11000)
|
|
107
108
|
def self.eps(ni:, pd:, w:)
|
|
108
|
-
ni =
|
|
109
|
-
pd =
|
|
110
|
-
w =
|
|
109
|
+
ni = Validation.decimal(ni, name: 'net income')
|
|
110
|
+
pd = Validation.decimal(pd, name: 'preferred dividends')
|
|
111
|
+
w = Validation.positive_decimal(w, name: 'weighted average common shares', error: DomainError)
|
|
111
112
|
|
|
112
113
|
((ni - pd) / w)
|
|
113
114
|
end
|
|
@@ -119,8 +120,8 @@ module Finrb
|
|
|
119
120
|
# @example
|
|
120
121
|
# Finrb::Ratios.financial_leverage(te=16000,ta=20000)
|
|
121
122
|
def self.financial_leverage(te:, ta:)
|
|
122
|
-
te =
|
|
123
|
-
ta =
|
|
123
|
+
te = Validation.non_zero_decimal(te, name: 'total equity', error: DomainError)
|
|
124
|
+
ta = Validation.decimal(ta, name: 'total assets')
|
|
124
125
|
|
|
125
126
|
(ta / te)
|
|
126
127
|
end
|
|
@@ -132,8 +133,8 @@ module Finrb
|
|
|
132
133
|
# @example
|
|
133
134
|
# Finrb::Ratios.gpm(gp=1000,rv=20000)
|
|
134
135
|
def self.gpm(gp:, rv:)
|
|
135
|
-
gp =
|
|
136
|
-
rv =
|
|
136
|
+
gp = Validation.decimal(gp, name: 'gross profit')
|
|
137
|
+
rv = Validation.non_zero_decimal(rv, name: 'revenue', error: DomainError)
|
|
137
138
|
|
|
138
139
|
(gp / rv)
|
|
139
140
|
end
|
|
@@ -146,14 +147,14 @@ module Finrb
|
|
|
146
147
|
# @example
|
|
147
148
|
# Finrb::Ratios.iss(amp=20,ep=15,n=10000)
|
|
148
149
|
def self.iss(amp:, ep:, n:)
|
|
149
|
-
amp =
|
|
150
|
-
ep =
|
|
151
|
-
n =
|
|
150
|
+
amp = Validation.positive_decimal(amp, name: 'average market price', error: DomainError)
|
|
151
|
+
ep = Validation.non_negative_decimal(ep, name: 'exercise price')
|
|
152
|
+
n = Validation.non_negative_decimal(n, name: 'option shares')
|
|
152
153
|
|
|
153
154
|
if amp > ep
|
|
154
155
|
((amp - ep) * n / amp)
|
|
155
156
|
else
|
|
156
|
-
raise(
|
|
157
|
+
raise(DomainError, 'Average market price must be greater than exercise price.')
|
|
157
158
|
end
|
|
158
159
|
end
|
|
159
160
|
|
|
@@ -164,8 +165,8 @@ module Finrb
|
|
|
164
165
|
# @example
|
|
165
166
|
# Finrb::Ratios.lt_d2e(ltd=8000,te=20000)
|
|
166
167
|
def self.lt_d2e(ltd:, te:)
|
|
167
|
-
ltd =
|
|
168
|
-
te =
|
|
168
|
+
ltd = Validation.decimal(ltd, name: 'long-term debt')
|
|
169
|
+
te = Validation.non_zero_decimal(te, name: 'total equity', error: DomainError)
|
|
169
170
|
|
|
170
171
|
(ltd / te)
|
|
171
172
|
end
|
|
@@ -177,8 +178,8 @@ module Finrb
|
|
|
177
178
|
# @example
|
|
178
179
|
# Finrb::Ratios.npm(ni=8000,rv=20000)
|
|
179
180
|
def self.npm(ni:, rv:)
|
|
180
|
-
ni =
|
|
181
|
-
rv =
|
|
181
|
+
ni = Validation.decimal(ni, name: 'net income')
|
|
182
|
+
rv = Validation.non_zero_decimal(rv, name: 'revenue', error: DomainError)
|
|
182
183
|
|
|
183
184
|
(ni / rv)
|
|
184
185
|
end
|
|
@@ -192,10 +193,10 @@ module Finrb
|
|
|
192
193
|
# @example
|
|
193
194
|
# Finrb::Ratios.quick_ratio(cash=3000,ms=2000,rc=1000,cl=2000)
|
|
194
195
|
def self.quick_ratio(cash:, ms:, rc:, cl:)
|
|
195
|
-
cash =
|
|
196
|
-
ms =
|
|
197
|
-
rc =
|
|
198
|
-
cl =
|
|
196
|
+
cash = Validation.decimal(cash, name: 'cash')
|
|
197
|
+
ms = Validation.decimal(ms, name: 'marketable securities')
|
|
198
|
+
rc = Validation.decimal(rc, name: 'receivables')
|
|
199
|
+
cl = Validation.non_zero_decimal(cl, name: 'current liabilities', error: DomainError)
|
|
199
200
|
|
|
200
201
|
((cash + ms + rc) / cl)
|
|
201
202
|
end
|
|
@@ -207,8 +208,8 @@ module Finrb
|
|
|
207
208
|
# @example
|
|
208
209
|
# Finrb::Ratios.total_d2e(td=6000,te=20000)
|
|
209
210
|
def self.total_d2e(td:, te:)
|
|
210
|
-
td =
|
|
211
|
-
te =
|
|
211
|
+
td = Validation.decimal(td, name: 'total debt')
|
|
212
|
+
te = Validation.non_zero_decimal(te, name: 'total equity', error: DomainError)
|
|
212
213
|
|
|
213
214
|
(td / te)
|
|
214
215
|
end
|
|
@@ -223,8 +224,8 @@ module Finrb
|
|
|
223
224
|
# @example
|
|
224
225
|
# s=[11000,4400,-3000];m=[12,9,4];Finrb::Ratios.was(ns=s,nm=m)
|
|
225
226
|
def self.was(ns:, nm:)
|
|
226
|
-
ns = wrap_array(ns).map { |value|
|
|
227
|
-
nm = wrap_array(nm).map { |value|
|
|
227
|
+
ns = wrap_array(ns).map { |value| Validation.decimal(value, name: 'share change') }
|
|
228
|
+
nm = wrap_array(nm).map { |value| Validation.decimal_between(value, minimum: 0, maximum: 12, name: 'months outstanding') }
|
|
228
229
|
|
|
229
230
|
m = ns.size
|
|
230
231
|
n = nm.size
|
|
@@ -234,7 +235,7 @@ module Finrb
|
|
|
234
235
|
sum += (ns[i] * nm[i])
|
|
235
236
|
end
|
|
236
237
|
else
|
|
237
|
-
raise(
|
|
238
|
+
raise(ArgumentError, 'Share changes and months outstanding must have equal lengths.')
|
|
238
239
|
end
|
|
239
240
|
sum /= 12
|
|
240
241
|
sum
|