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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'calendars'
4
+ require 'date'
5
+
6
+ module Finrb
7
+ # Immutable date schedule for recurring financial payments.
8
+ class Schedule
9
+ FREQUENCY_MONTHS = { monthly: 1, quarterly: 3, semiannual: 6, annual: 12 }.freeze
10
+ STUB_CONVENTIONS = %i[none short_final].freeze
11
+ public_constant :FREQUENCY_MONTHS, :STUB_CONVENTIONS
12
+
13
+ # Shared date rules used by both the class constructor and period builder.
14
+ module DateRules
15
+ module_function
16
+
17
+ def normalize(date, name)
18
+ raise(ArgumentError, "#{name} must be a Date.") unless date.instance_of?(Date)
19
+
20
+ Date.new(date.year, date.month, date.day, Date::GREGORIAN)
21
+ end
22
+
23
+ def anchored_date(anchor, month_offset)
24
+ month_start = Date.new(anchor.year, anchor.month, 1, Date::GREGORIAN) >> month_offset
25
+ next_month_start = month_start >> 1
26
+ month_end = next_month_start - 1
27
+ anchor_month_end = (Date.new(anchor.year, anchor.month, 1, Date::GREGORIAN) >> 1) - 1
28
+ day = anchor.day == anchor_month_end.day ? month_end.day : [anchor.day, month_end.day].min
29
+ Date.new(month_start.year, month_start.month, day, Date::GREGORIAN)
30
+ end
31
+ end
32
+ private_constant :DateRules
33
+
34
+ # Immutable dates and index for one accrual/payment period.
35
+ class Period
36
+ attr_reader :index, :accrual_start_date, :unadjusted_payment_date, :payment_date, :stub
37
+
38
+ def initialize(index:, accrual_start_date:, unadjusted_payment_date:, payment_date:, stub: nil)
39
+ @index = index
40
+ @accrual_start_date = accrual_start_date
41
+ @unadjusted_payment_date = unadjusted_payment_date
42
+ @payment_date = payment_date
43
+ @stub = stub
44
+ freeze
45
+ end
46
+
47
+ def short_final_stub?
48
+ stub == :short_final
49
+ end
50
+ end
51
+
52
+ attr_reader :start_date, :maturity_date, :frequency, :stub, :calendar, :business_day_convention, :periods, :payment_dates, :unadjusted_payment_dates
53
+
54
+ def self.from_months(start_date:, term_months:, frequency: :monthly, stub: :none, calendar: nil, business_day_convention: nil)
55
+ raise(ArgumentError, 'term_months must be a positive integer.') unless term_months.is_a?(Integer) && term_months.positive?
56
+
57
+ start_date = DateRules.normalize(start_date, 'start_date')
58
+ maturity_date = DateRules.anchored_date(start_date, term_months)
59
+ new(start_date:, maturity_date:, frequency:, stub:, calendar:, business_day_convention:)
60
+ end
61
+
62
+ def initialize(start_date:, maturity_date:, frequency: :monthly, stub: :none, calendar: nil, business_day_convention: nil)
63
+ @start_date = DateRules.normalize(start_date, 'start_date')
64
+ @maturity_date = DateRules.normalize(maturity_date, 'maturity_date')
65
+ raise(ArgumentError, 'maturity_date must be after start_date.') if @maturity_date <= @start_date
66
+ raise(ArgumentError, "frequency must be one of #{FREQUENCY_MONTHS.keys.join(', ')}.") unless FREQUENCY_MONTHS.key?(frequency)
67
+ raise(ArgumentError, "stub must be one of #{STUB_CONVENTIONS.join(', ')}.") unless STUB_CONVENTIONS.include?(stub)
68
+
69
+ validate_calendar_options!(calendar, business_day_convention)
70
+ @frequency = frequency
71
+ @stub = stub
72
+ @calendar = calendar
73
+ @business_day_convention = business_day_convention
74
+ @unadjusted_payment_dates = build_unadjusted_payment_dates.freeze
75
+ @periods = build_periods.freeze
76
+ @payment_dates = @periods.map(&:payment_date).freeze
77
+ freeze
78
+ end
79
+
80
+ private
81
+
82
+ def validate_calendar_options!(calendar, convention)
83
+ raise(ArgumentError, 'business_day_convention requires a calendar.') if calendar.nil? && !convention.nil?
84
+ return if calendar.nil?
85
+
86
+ raise(ArgumentError, 'calendar must be a Finrb::Calendars::Base instance.') unless calendar.is_a?(Calendars::Base)
87
+ raise(ArgumentError, "business_day_convention must be one of #{Calendars::Base::CONVENTIONS.join(', ')}.") unless Calendars::Base::CONVENTIONS.include?(convention)
88
+ end
89
+
90
+ def build_unadjusted_payment_dates
91
+ interval = FREQUENCY_MONTHS.fetch(frequency)
92
+ dates = []
93
+ month_offset = interval
94
+ loop do
95
+ regular_date = DateRules.anchored_date(start_date, month_offset)
96
+ if regular_date < maturity_date
97
+ dates << regular_date
98
+ month_offset += interval
99
+ elsif regular_date == maturity_date
100
+ dates << regular_date
101
+ break
102
+ else
103
+ raise(ArgumentError, 'maturity_date does not align with frequency; pass stub: :short_final to allow a short final period.') unless stub == :short_final
104
+
105
+ dates << maturity_date
106
+ break
107
+ end
108
+ end
109
+ dates
110
+ end
111
+
112
+ def build_periods
113
+ previous_payment_date = start_date
114
+ unadjusted_payment_dates.each_with_index.map do |unadjusted_date, index|
115
+ regular_date = DateRules.anchored_date(start_date, FREQUENCY_MONTHS.fetch(frequency) * (index + 1))
116
+ period_stub = :short_final if index == unadjusted_payment_dates.length - 1 && regular_date != unadjusted_date
117
+ payment_date = calendar ? calendar.adjust(unadjusted_date, convention: business_day_convention) : unadjusted_date
118
+ raise(ArgumentError, 'calendar adjustment must produce dates strictly after the prior payment date.') if payment_date <= previous_payment_date
119
+
120
+ period = Period.new(index:, accrual_start_date: previous_payment_date, unadjusted_payment_date: unadjusted_date, payment_date:, stub: period_stub)
121
+ previous_payment_date = payment_date
122
+ period
123
+ end
124
+ end
125
+ end
126
+ end
data/lib/finrb/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Finrb
4
- VERSION = '1.2.0'
4
+ VERSION = '1.3.0'
5
5
  public_constant :VERSION
6
6
  end
data/lib/finrb.rb CHANGED
@@ -1,13 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'finrb/accounting'
4
+ require 'finrb/calendars'
4
5
  require 'finrb/cashflows'
5
6
  require 'finrb/config'
7
+ require 'finrb/day_count'
6
8
  require 'finrb/decimal'
7
9
  require 'finrb/errors'
8
10
  require 'finrb/precision'
9
11
  require 'finrb/ratios'
10
12
  require 'finrb/returns'
13
+ require 'finrb/schedule'
11
14
  require 'finrb/tvm'
12
15
  require 'finrb/version'
13
16
  require 'finrb/yields'
@@ -23,6 +26,7 @@ FinrbError = Finrb::Error
23
26
  # * *rate* represents the interest rate _per period_.
24
27
  module Finrb
25
28
  autoload :Amortization, 'finrb/amortization'
29
+ autoload :FixedRateBond, 'finrb/fixed_rate_bond'
26
30
  autoload :Rate, 'finrb/rates'
27
31
  autoload :Transaction, 'finrb/transaction'
28
32
  end
data/sig/finrb.rbs CHANGED
@@ -42,9 +42,104 @@ module Finrb
42
42
  class DomainError < Error
43
43
  end
44
44
 
45
+ module Calendars
46
+ type convention = :following | :modified_following | :preceding | :modified_preceding | :half_month_modified_following | :nearest | :unadjusted
47
+
48
+ class Base
49
+ CONVENTIONS: Array[Symbol]
50
+
51
+ def initialize: (?additional_holidays: Array[Date], ?removed_holidays: Array[Date]) -> void
52
+ def additional_holidays: () -> Array[Date]
53
+ def removed_holidays: () -> Array[Date]
54
+ def business_day?: (Date) -> bool
55
+ def holiday?: (Date) -> bool
56
+ def holiday_names: (Date) -> Array[String]
57
+ def holidays_between: (Date, Date, ?include_weekends: bool) -> Hash[Date, Array[String]]
58
+ def adjust: (Date, convention: convention) -> Date
59
+ def advance: (Date, business_days: Integer, ?convention: convention) -> Date
60
+ def ==: (untyped) -> bool
61
+ def eql?: (untyped) -> bool
62
+ def hash: () -> Integer
63
+ end
64
+
65
+ class USFederalReserve < Base
66
+ SUPPORTED_START_DATE: Date
67
+ SUPPORTED_END_DATE: Date
68
+ SUPPORTED_DATE_RANGE: Range[Date]
69
+ def name: () -> String
70
+ end
71
+
72
+ class IsraelTase < Base
73
+ SUPPORTED_START_DATE: Date
74
+ SUPPORTED_END_DATE: Date
75
+ SUPPORTED_DATE_RANGE: Range[Date]
76
+ TRADING_WEEK_CHANGE: Date
77
+ def name: () -> String
78
+ end
79
+ end
80
+
81
+ module DayCount
82
+ type convention = :actual_365_fixed | :actual_360 | :actual_actual_icma
83
+ CONVENTIONS: Array[convention]
84
+ DEFAULT: convention
85
+
86
+ def self.year_fraction: (Date, Date, ?convention: convention, ?reference_period_start: Date?, ?reference_period_end: Date?, ?frequency: Integer?) -> decimal
87
+ end
88
+
89
+ class Schedule
90
+ type frequency = :monthly | :quarterly | :semiannual | :annual
91
+ type stub = :none | :short_final
92
+
93
+ FREQUENCY_MONTHS: Hash[frequency, Integer]
94
+ STUB_CONVENTIONS: Array[stub]
95
+
96
+ class Period
97
+ def initialize: (index: Integer, accrual_start_date: Date, unadjusted_payment_date: Date, payment_date: Date, ?stub: stub?) -> void
98
+ def index: () -> Integer
99
+ def accrual_start_date: () -> Date
100
+ def unadjusted_payment_date: () -> Date
101
+ def payment_date: () -> Date
102
+ def stub: () -> stub?
103
+ def short_final_stub?: () -> bool
104
+ end
105
+
106
+ def self.from_months: (start_date: Date, term_months: Integer, ?frequency: frequency, ?stub: stub, ?calendar: Calendars::Base?, ?business_day_convention: Calendars::convention?) -> Schedule
107
+ def initialize: (start_date: Date, maturity_date: Date, ?frequency: frequency, ?stub: stub, ?calendar: Calendars::Base?, ?business_day_convention: Calendars::convention?) -> void
108
+ def start_date: () -> Date
109
+ def maturity_date: () -> Date
110
+ def frequency: () -> frequency
111
+ def stub: () -> stub
112
+ def calendar: () -> Calendars::Base?
113
+ def business_day_convention: () -> Calendars::convention?
114
+ def periods: () -> Array[Period]
115
+ def payment_dates: () -> Array[Date]
116
+ def unadjusted_payment_dates: () -> Array[Date]
117
+ end
118
+
45
119
  class InvalidCashflowError < Error
46
120
  end
47
121
 
122
+ class FixedRateBond
123
+ type price_type = :clean | :dirty
124
+ PRICE_TYPES: Array[price_type]
125
+
126
+ def initialize: (face_value: number, coupon_rate: number, issue_date: Date, maturity_date: Date, ?frequency: Schedule::frequency, ?calendar: Calendars::Base?, ?business_day_convention: Calendars::convention?) -> void
127
+ def face_value: () -> decimal
128
+ def coupon_rate: () -> decimal
129
+ def issue_date: () -> Date
130
+ def maturity_date: () -> Date
131
+ def frequency: () -> Schedule::frequency
132
+ def schedule: () -> Schedule
133
+ def calendar: () -> Calendars::Base?
134
+ def business_day_convention: () -> Calendars::convention?
135
+ def day_count: () -> :actual_actual_icma
136
+ def cashflows: (settlement_date: Date) -> Array[Transaction]
137
+ def accrued_interest: (settlement_date: Date) -> decimal
138
+ def dirty_price: (settlement_date: Date, yield_rate: number) -> decimal
139
+ def clean_price: (settlement_date: Date, yield_rate: number) -> decimal
140
+ def yield_to_maturity: (settlement_date: Date, price: number, ?price_type: price_type, ?guess: number) -> decimal
141
+ end
142
+
48
143
  module Precision
49
144
  def self.money: (number) -> decimal
50
145
  def self.rate: (number) -> decimal
@@ -94,8 +189,14 @@ module Finrb
94
189
  end
95
190
 
96
191
  class Amortization
192
+ type frequency = :monthly | :quarterly | :semiannual | :annual
193
+ type stub = :none | :short_final
194
+
195
+ FREQUENCY_MONTHS: Hash[frequency, Integer]
196
+ STUB_CONVENTIONS: Array[stub]
197
+
97
198
  class Entry
98
- def initialize: (period: Integer, opening_balance: number, payment: number, interest: number, principal: number, additional_payment: number, balloon_payment: number, interest_only: bool, closing_balance: number) -> void
199
+ def initialize: (period: Integer, opening_balance: number, payment: number, interest: number, principal: number, additional_payment: number, balloon_payment: number, interest_only: bool, closing_balance: number, ?date: Date?) -> void
99
200
  def period: () -> Integer
100
201
  def opening_balance: () -> decimal
101
202
  def payment: () -> decimal
@@ -106,15 +207,16 @@ module Finrb
106
207
  def interest_only: () -> bool
107
208
  def interest_only?: () -> bool
108
209
  def closing_balance: () -> decimal
210
+ def date: () -> Date?
109
211
  def ==: (untyped) -> bool
110
212
  def eql?: (untyped) -> bool
111
213
  def hash: () -> Integer
112
- def to_h: () -> Hash[Symbol, Integer | decimal]
214
+ def to_h: () -> Hash[Symbol, Integer | decimal | bool | Date]
113
215
  end
114
216
 
115
217
  def self.payment: (number, number, Integer, ?balloon: number) -> decimal
116
218
 
117
- def initialize: (number, *Rate, ?balloon: number, ?interest_only_periods: Integer, ?origination_fee: number, ?finance_origination_fee: bool) ?{ (Transaction) -> number } -> void
219
+ def initialize: (number, *Rate, ?balloon: number, ?interest_only_periods: Integer, ?origination_fee: number, ?finance_origination_fee: bool, ?start_date: Date?, ?calendar: Calendars::Base?, ?business_day_convention: Calendars::convention?, ?day_count: DayCount::convention, ?frequency: frequency, ?stub: stub) ?{ (Transaction) -> number } -> void
118
220
  def ==: (untyped) -> bool
119
221
  def balance: () -> decimal
120
222
  def balloon: () -> decimal
@@ -128,7 +230,14 @@ module Finrb
128
230
  def principal: () -> decimal
129
231
  def rates: () -> Array[Rate]
130
232
  def schedule: () -> Array[Entry]
233
+ def start_date: () -> Date?
234
+ def calendar: () -> Calendars::Base?
235
+ def business_day_convention: () -> Calendars::convention?
236
+ def day_count: () -> DayCount::convention
237
+ def frequency: () -> frequency
238
+ def stub: () -> stub
131
239
  def additional_payments: () -> Array[decimal]
240
+ def cashflow_yield: (?number) -> Rate
132
241
  def duration: () -> Integer
133
242
  def inspect: () -> String
134
243
  def interest: () -> Array[decimal]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: finrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nadir Cohen
@@ -271,19 +271,27 @@ files:
271
271
  - lib/finrb.rb
272
272
  - lib/finrb/accounting.rb
273
273
  - lib/finrb/amortization.rb
274
+ - lib/finrb/calendars.rb
275
+ - lib/finrb/calendars/base.rb
276
+ - lib/finrb/calendars/hebrew_calendar.rb
277
+ - lib/finrb/calendars/israel_tase.rb
278
+ - lib/finrb/calendars/us_federal_reserve.rb
274
279
  - lib/finrb/cashflows.rb
275
280
  - lib/finrb/config.rb
276
281
  - lib/finrb/core_ext.rb
277
282
  - lib/finrb/core_ext/array.rb
278
283
  - lib/finrb/core_ext/numeric.rb
284
+ - lib/finrb/day_count.rb
279
285
  - lib/finrb/decimal.rb
280
286
  - lib/finrb/errors.rb
287
+ - lib/finrb/fixed_rate_bond.rb
281
288
  - lib/finrb/numerical/brent.rb
282
289
  - lib/finrb/numerical/rate_search.rb
283
290
  - lib/finrb/precision.rb
284
291
  - lib/finrb/rates.rb
285
292
  - lib/finrb/ratios.rb
286
293
  - lib/finrb/returns.rb
294
+ - lib/finrb/schedule.rb
287
295
  - lib/finrb/transaction.rb
288
296
  - lib/finrb/tvm.rb
289
297
  - lib/finrb/validation.rb
@@ -313,7 +321,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
313
321
  - !ruby/object:Gem::Version
314
322
  version: '0'
315
323
  requirements: []
316
- rubygems_version: 4.0.19
324
+ rubygems_version: 4.0.22
317
325
  specification_version: 4
318
326
  summary: Precision-first financial mathematics for Ruby
319
327
  test_files: []