finrb 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/CONTRIBUTING.md +4 -4
- data/README.md +79 -8
- data/lib/finrb/amortization.rb +161 -25
- data/lib/finrb/calendars/base.rb +161 -0
- data/lib/finrb/calendars/hebrew_calendar.rb +77 -0
- data/lib/finrb/calendars/israel_tase.rb +96 -0
- data/lib/finrb/calendars/us_federal_reserve.rb +82 -0
- data/lib/finrb/calendars.rb +6 -0
- data/lib/finrb/day_count.rb +62 -0
- data/lib/finrb/fixed_rate_bond.rb +140 -0
- data/lib/finrb/schedule.rb +126 -0
- data/lib/finrb/version.rb +1 -1
- data/lib/finrb.rb +4 -0
- data/sig/finrb.rbs +112 -3
- metadata +10 -2
|
@@ -0,0 +1,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
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.
|
|
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.
|
|
324
|
+
rubygems_version: 4.0.22
|
|
317
325
|
specification_version: 4
|
|
318
326
|
summary: Precision-first financial mathematics for Ruby
|
|
319
327
|
test_files: []
|