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.
@@ -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'
@@ -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.decimal(rate, name: 'rate')
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.decimal(finance_rate, name: 'finance_rate')
102
- reinvestment_rate = Validation.decimal(reinvestment_rate, name: 'reinvestment_rate')
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.decimal(rate, name: 'rate')
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: 'eps')
45
- raise(ArgumentError, 'eps must be positive.') unless eps.positive?
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, 'business_days and periodic_compound must be boolean.') unless booleans
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
- periods = Validation.decimal(value, name: 'compounding periods')
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.decimal(rate, name: 'rate')
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 = Flt::DecNum(cash.to_s)
29
- ms = Flt::DecNum(ms.to_s)
30
- cl = Flt::DecNum(cl.to_s)
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 = Flt::DecNum(ca.to_s)
43
- cl = Flt::DecNum(cl.to_s)
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 = Flt::DecNum(td.to_s)
56
- ta = Flt::DecNum(ta.to_s)
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 = Flt::DecNum(ni.to_s)
85
- pd = Flt::DecNum(pd.to_s)
86
- w = Flt::DecNum(w.to_s)
87
- cpd = Flt::DecNum(cpd.to_s)
88
- cdi = Flt::DecNum(cdi.to_s)
89
- tax = Flt::DecNum(tax.to_s)
90
- cps = Flt::DecNum(cps.to_s)
91
- cds = Flt::DecNum(cds.to_s)
92
- iss = Flt::DecNum(iss.to_s)
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 = Flt::DecNum(ni.to_s)
109
- pd = Flt::DecNum(pd.to_s)
110
- w = Flt::DecNum(w.to_s)
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 = Flt::DecNum(te.to_s)
123
- ta = Flt::DecNum(ta.to_s)
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 = Flt::DecNum(gp.to_s)
136
- rv = Flt::DecNum(rv.to_s)
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 = Flt::DecNum(amp.to_s)
150
- ep = Flt::DecNum(ep.to_s)
151
- n = Flt::DecNum(n.to_s)
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(Error, 'amp must larger than ep')
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 = Flt::DecNum(ltd.to_s)
168
- te = Flt::DecNum(te.to_s)
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 = Flt::DecNum(ni.to_s)
181
- rv = Flt::DecNum(rv.to_s)
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 = Flt::DecNum(cash.to_s)
196
- ms = Flt::DecNum(ms.to_s)
197
- rc = Flt::DecNum(rc.to_s)
198
- cl = Flt::DecNum(cl.to_s)
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 = Flt::DecNum(td.to_s)
211
- te = Flt::DecNum(te.to_s)
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| Flt::DecNum(value.to_s) }
227
- nm = wrap_array(nm).map { |value| Flt::DecNum(value.to_s) }
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(Error, 'length of ns and nm must be equal')
238
+ raise(ArgumentError, 'Share changes and months outstanding must have equal lengths.')
238
239
  end
239
240
  sum /= 12
240
241
  sum