bondy 0.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 +7 -0
- data/.gitignore +10 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +63 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/bondy.gemspec +37 -0
- data/exe/bondy +3 -0
- data/lib/bondy.rb +15 -0
- data/lib/bondy/annuity.rb +30 -0
- data/lib/bondy/bond.rb +684 -0
- data/lib/bondy/cash_flow_point.rb +62 -0
- data/lib/bondy/core_ext.rb +2 -0
- data/lib/bondy/core_ext/date.rb +74 -0
- data/lib/bondy/core_ext/numeric.rb +15 -0
- data/lib/bondy/version.rb +3 -0
- metadata +148 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: c4bd8591753225fb2dd5eee19be53942e7055ad9
|
4
|
+
data.tar.gz: 5f999a6867d35f3a5d11b31f4c259cf8503aad02
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 52045214b394a7764f473c7ceaf690c8e4bd2ff4c06e088a6c9f03229df77bb428611e6b3f0388314591319a91a85ec0ca49ba7de9ce3703b1063525ef62481e
|
7
|
+
data.tar.gz: c9f62d6dc535cff97213c8a71fd88bb1891791d56b9b1f4866ca9104d7b17be2332629c85537d2328c2db7a8a4bca985f61ea803fe33840698509cc27a7a8b9a
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
bondy (0.3.0)
|
5
|
+
fat_core
|
6
|
+
|
7
|
+
GEM
|
8
|
+
remote: https://rubygems.org/
|
9
|
+
specs:
|
10
|
+
activesupport (5.1.4)
|
11
|
+
concurrent-ruby (~> 1.0, >= 1.0.2)
|
12
|
+
i18n (~> 0.7)
|
13
|
+
minitest (~> 5.1)
|
14
|
+
tzinfo (~> 1.1)
|
15
|
+
byebug (9.1.0)
|
16
|
+
coderay (1.1.2)
|
17
|
+
concurrent-ruby (1.0.5)
|
18
|
+
damerau-levenshtein (1.3.0)
|
19
|
+
diff-lcs (1.3)
|
20
|
+
fat_core (4.2.2)
|
21
|
+
activesupport
|
22
|
+
damerau-levenshtein
|
23
|
+
i18n (0.9.1)
|
24
|
+
concurrent-ruby (~> 1.0)
|
25
|
+
method_source (0.9.0)
|
26
|
+
minitest (5.11.1)
|
27
|
+
pry (0.11.3)
|
28
|
+
coderay (~> 1.1.0)
|
29
|
+
method_source (~> 0.9.0)
|
30
|
+
pry-byebug (3.5.1)
|
31
|
+
byebug (~> 9.1)
|
32
|
+
pry (~> 0.10)
|
33
|
+
rake (10.5.0)
|
34
|
+
rspec (3.7.0)
|
35
|
+
rspec-core (~> 3.7.0)
|
36
|
+
rspec-expectations (~> 3.7.0)
|
37
|
+
rspec-mocks (~> 3.7.0)
|
38
|
+
rspec-core (3.7.1)
|
39
|
+
rspec-support (~> 3.7.0)
|
40
|
+
rspec-expectations (3.7.0)
|
41
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
42
|
+
rspec-support (~> 3.7.0)
|
43
|
+
rspec-mocks (3.7.0)
|
44
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
45
|
+
rspec-support (~> 3.7.0)
|
46
|
+
rspec-support (3.7.0)
|
47
|
+
thread_safe (0.3.6)
|
48
|
+
tzinfo (1.2.4)
|
49
|
+
thread_safe (~> 0.1)
|
50
|
+
|
51
|
+
PLATFORMS
|
52
|
+
ruby
|
53
|
+
|
54
|
+
DEPENDENCIES
|
55
|
+
bondy!
|
56
|
+
bundler (~> 1.10)
|
57
|
+
pry
|
58
|
+
pry-byebug
|
59
|
+
rake (~> 10.0)
|
60
|
+
rspec
|
61
|
+
|
62
|
+
BUNDLED WITH
|
63
|
+
1.16.0.pre.3
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "bondy"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/bondy.gemspec
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
lib = File.expand_path('../lib', __FILE__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
require 'bondy/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = 'bondy'
|
7
|
+
spec.version = Bondy::VERSION
|
8
|
+
spec.authors = ['Daniel E. Doherty']
|
9
|
+
spec.email = ['ded-law@ddoherty.net']
|
10
|
+
|
11
|
+
spec.summary = 'Bond financial calculations.'
|
12
|
+
spec.description = 'Library for performing bond calculations'
|
13
|
+
spec.homepage = 'https://github.com/ddoherty03/fat_table'
|
14
|
+
|
15
|
+
# Prevent pushing this gem to RubyGems.org by setting 'allowed_push_host', or
|
16
|
+
# delete this section to allow pushing this gem to any host.
|
17
|
+
if spec.respond_to?(:metadata)
|
18
|
+
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
19
|
+
else
|
20
|
+
raise 'RubyGems 2.0 or newer is required to protect against public gem pushes.'
|
21
|
+
end
|
22
|
+
|
23
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
24
|
+
f.match(%r{^(test|spec|features)/})
|
25
|
+
end
|
26
|
+
spec.bindir = 'exe'
|
27
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
28
|
+
spec.require_paths = ['lib']
|
29
|
+
|
30
|
+
spec.add_dependency 'fat_core'
|
31
|
+
|
32
|
+
spec.add_development_dependency 'bundler', '~> 1.10'
|
33
|
+
spec.add_development_dependency 'pry'
|
34
|
+
spec.add_development_dependency 'pry-byebug'
|
35
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
36
|
+
spec.add_development_dependency 'rspec'
|
37
|
+
end
|
data/exe/bondy
ADDED
data/lib/bondy.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
|
3
|
+
require 'fat_core'
|
4
|
+
|
5
|
+
require 'bondy/version'
|
6
|
+
require 'bondy/core_ext'
|
7
|
+
require 'bondy/cash_flow_point'
|
8
|
+
require 'bondy/annuity'
|
9
|
+
require 'bondy/bond'
|
10
|
+
|
11
|
+
require 'byebug'
|
12
|
+
|
13
|
+
module Bondy
|
14
|
+
# Your code goes here...
|
15
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Bondy
|
2
|
+
class Annuity
|
3
|
+
attr_reader :periods, :amount
|
4
|
+
|
5
|
+
def initialize(periods: 1, amount: 0.0)
|
6
|
+
@periods = periods
|
7
|
+
@amount = amount
|
8
|
+
end
|
9
|
+
|
10
|
+
def to_s
|
11
|
+
"Annuity[#{@periods} periods of #{@amount}]"
|
12
|
+
end
|
13
|
+
|
14
|
+
# Return present value using rate as the
|
15
|
+
# per-period discount rate of this annuity
|
16
|
+
def present_value(rate: nil)
|
17
|
+
raise ArgumentError,
|
18
|
+
"Annuity\#present_value require rate: keyword param" unless rate
|
19
|
+
return periods * amount if rate == 0.0
|
20
|
+
if rate < 0.0
|
21
|
+
# If rate negative, find future value
|
22
|
+
k = (1.0 + rate.abs)
|
23
|
+
else
|
24
|
+
k = 1.0 / (1.0 + rate)
|
25
|
+
end
|
26
|
+
pv = k * (1.0 - k**periods) / (1.0 - k)
|
27
|
+
pv * amount
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/lib/bondy/bond.rb
ADDED
@@ -0,0 +1,684 @@
|
|
1
|
+
module Bondy
|
2
|
+
class Bond
|
3
|
+
attr_reader :maturity, :coupon, :term, :issue_date, :face, :frq, :eom
|
4
|
+
|
5
|
+
def initialize(maturity: nil, issue_date: nil, term: 30,
|
6
|
+
face: 1000.0, coupon: 0.0, frq: 2, eom: false)
|
7
|
+
@maturity = maturity ? Date.ensure(maturity) : nil
|
8
|
+
@issue_date = issue_date ? Date.ensure(issue_date) : nil
|
9
|
+
@term = term.to_i if term
|
10
|
+
|
11
|
+
# Compute @maturity, @issue_date, and @term
|
12
|
+
# At least one must be defined
|
13
|
+
# The following is a rather tedious walk through all the
|
14
|
+
# possibilites of which of the three are defined.
|
15
|
+
if issue_date.nil? && term.nil? && maturity.nil?
|
16
|
+
raise ArgumentError,
|
17
|
+
"Bond.new must supply at least maturity: parameter
|
18
|
+
(assumes 30-year term) or supply two
|
19
|
+
of maturity:, term:, and issue_date:"
|
20
|
+
elsif maturity && (issue_date.nil? && term.nil?)
|
21
|
+
# Maturity defined, compute term and issue
|
22
|
+
@term = 30
|
23
|
+
@issue_date = Date.new(@maturity.year - @term,
|
24
|
+
@maturity.month, @maturity.day)
|
25
|
+
elsif issue_date && (maturity.nil? && term.nil?)
|
26
|
+
# Issue defined, compute maturity and term
|
27
|
+
@term = 30
|
28
|
+
@maturity = Date.new(@issue_date.year + @term,
|
29
|
+
@issue_date.month, @issue_date.day)
|
30
|
+
elsif term && (maturity.nil? && issue_date.nil?)
|
31
|
+
# Term defined, compute maturity and issue
|
32
|
+
# Assume issue is today
|
33
|
+
@term = term.to_i
|
34
|
+
@issue_date = Date.today
|
35
|
+
@maturity = Date.new(@issue_date.year + @term,
|
36
|
+
@issue_date.month, @issue_date.day)
|
37
|
+
elsif (maturity && term) && !issue_date
|
38
|
+
# Maturity and term defined, compute issue
|
39
|
+
@issue_date = Date.new(@maturity.year - @term,
|
40
|
+
@maturity.month, @maturity.day)
|
41
|
+
elsif (issue_date && term) && !maturity
|
42
|
+
# Issue and term defined, compute maturity
|
43
|
+
@maturity = Date.new(@issue_date.year + @term,
|
44
|
+
@issue_date.month, @issue_date.day)
|
45
|
+
|
46
|
+
elsif (issue_date && maturity) && term
|
47
|
+
# Issue and maturity defined, compute term
|
48
|
+
if @maturity.month == @issue_date.month &&
|
49
|
+
@maturity.day == @issue_date.day
|
50
|
+
# Make @term and integer if month and day are the same
|
51
|
+
@term = @maturity.year - @issue_date.year
|
52
|
+
else
|
53
|
+
# Else, punt
|
54
|
+
@term = (@maturity - @issue_date) / 365.25
|
55
|
+
end
|
56
|
+
elsif issue_date && maturity && term
|
57
|
+
# All three defined
|
58
|
+
nil
|
59
|
+
else
|
60
|
+
# None defined
|
61
|
+
raise(ArgumentError,
|
62
|
+
'Bond.new must define at least one of :maturity, :issue, or :term.')
|
63
|
+
end
|
64
|
+
# Now check @term, @maturity, and @issue_date for sanity
|
65
|
+
unless @maturity > @issue_date
|
66
|
+
raise(ArgumentError,
|
67
|
+
"Bond maturity #{@maturity} must be later than issue #{@issue_date}.")
|
68
|
+
end
|
69
|
+
unless @term > 0 && @term <= 100
|
70
|
+
raise(ArgumentError,
|
71
|
+
"Bond term (#{@term}) not credible. Use life of Bond in years.")
|
72
|
+
end
|
73
|
+
|
74
|
+
#################################################
|
75
|
+
# Coupon
|
76
|
+
if coupon < 0.0 || coupon > 1.0
|
77
|
+
raise(ArgumentError,
|
78
|
+
'Nonsense coupon rate (#{coupon}). Use decimals, not percentages.')
|
79
|
+
end
|
80
|
+
@coupon = coupon
|
81
|
+
|
82
|
+
# Face
|
83
|
+
if face <= 0
|
84
|
+
raise(ArgumentError, "Face of bond must be positive (#{face}).")
|
85
|
+
end
|
86
|
+
@face = face
|
87
|
+
|
88
|
+
# Frequency
|
89
|
+
unless [1, 2, 3, 4, 6, 12].include?(frq)
|
90
|
+
raise(ArgumentError,
|
91
|
+
"Coupon frequency (#{frq}) must be a divisor of 12.")
|
92
|
+
end
|
93
|
+
@frq = frq
|
94
|
+
|
95
|
+
# Eom
|
96
|
+
# eom is true only if coupons are always paid on the last day of the
|
97
|
+
# month. Normally, in the US, bonds pay interest on a fixed day of
|
98
|
+
# the month, e.g., the 1st or the 15th, and eom is false for these.
|
99
|
+
# Its effect is to modify the day-count convention.
|
100
|
+
# See Bond#factor below.
|
101
|
+
@eom = eom
|
102
|
+
end
|
103
|
+
|
104
|
+
def to_s
|
105
|
+
"Bond[#{face}, Due #{maturity}, Coup #{coupon}, #{frq} per yr, Iss #{issue_date}.]"
|
106
|
+
end
|
107
|
+
|
108
|
+
# Return the amount of each coupon payment.
|
109
|
+
def coupon_amount
|
110
|
+
face * coupon / frq
|
111
|
+
end
|
112
|
+
|
113
|
+
# Return the dates of each coupon payment on or after the date
|
114
|
+
# ~on_or_after~.
|
115
|
+
def coupon_dates(on_or_after: issue_date)
|
116
|
+
return [] if on_or_after >= maturity
|
117
|
+
dates = []
|
118
|
+
cur_date = on_or_after
|
119
|
+
while cur_date < maturity
|
120
|
+
cur_date = next_coupon_date(cur_date)
|
121
|
+
dates << cur_date
|
122
|
+
end
|
123
|
+
dates
|
124
|
+
end
|
125
|
+
|
126
|
+
def cash_flows(on_or_after: issue_date)
|
127
|
+
flows = []
|
128
|
+
coupon_dates(on_or_after).each do |dt|
|
129
|
+
flows << CashFlowPoint.new(date: dt, amount: coupon_amount)
|
130
|
+
end
|
131
|
+
flows << CashFlowPoint.new(date: maturity, amount: face)
|
132
|
+
flows
|
133
|
+
end
|
134
|
+
|
135
|
+
def price(yld: 0.0, settle_date: issue_date, convention: 0,
|
136
|
+
clean: true, verbose: false)
|
137
|
+
settle_date = Date.ensure(settle_date)
|
138
|
+
if settle_date > maturity
|
139
|
+
raise ArgumentError,
|
140
|
+
"Settlement date #{settle_date} later than maturity date #{maturity}."
|
141
|
+
end
|
142
|
+
|
143
|
+
# Convention for how interest is calculated between interest
|
144
|
+
# payment dates. See factor() and coupon_factor() below.
|
145
|
+
# 0 -- US (NASD) 30/360 (default)
|
146
|
+
# 1 -- Actual/actual
|
147
|
+
# 2 -- Actual/360
|
148
|
+
# 3 -- Actual/365
|
149
|
+
# 4 -- European 30/360
|
150
|
+
# See the details in the Bond#factor function below
|
151
|
+
unless [0, 1, 2, 3, 4].include?(convention)
|
152
|
+
raise(ArgumentError, 'Day count convention must be 0, 1, 2, 3, or 4.')
|
153
|
+
end
|
154
|
+
|
155
|
+
# PV of coupons paid after first coupon date
|
156
|
+
# Yield per period, discount rate
|
157
|
+
next_coup_date = next_coupon_date(settle_date)
|
158
|
+
prior_coup_date = prior_coupon_date(settle_date)
|
159
|
+
r = yld / frq
|
160
|
+
|
161
|
+
accrued_fraction = factor(date: settle_date, convention: convention)
|
162
|
+
accrued_coupon = coupon_amount * accrued_fraction
|
163
|
+
|
164
|
+
if verbose
|
165
|
+
puts "\n#####################################################"
|
166
|
+
puts 'Bond Price:'
|
167
|
+
puts 'Calculating clean price.' if clean
|
168
|
+
puts 'Calculating dirty price.' if !clean
|
169
|
+
puts "Face Value: #{face}"
|
170
|
+
puts "Coupon Rate: #{coupon}"
|
171
|
+
puts "Per period coupon payment: \$#{coupon_amount}"
|
172
|
+
puts "Yield: #{yld}"
|
173
|
+
puts "Per period yield: #{r}"
|
174
|
+
puts "Frequency: #{frq}"
|
175
|
+
puts "Convention: #{convention}"
|
176
|
+
puts "Prior Coupon: #{prior_coup_date}"
|
177
|
+
puts "Settlement: #{settle_date.iso}"
|
178
|
+
puts "Accrual fraction: #{accrued_fraction}"
|
179
|
+
puts "Next Coupon: #{next_coup_date.iso}"
|
180
|
+
puts "Maturity date: #{maturity.iso}"
|
181
|
+
puts "Periods from prior coupon to maturity: #{periods_remaining(settle_date)}"
|
182
|
+
end
|
183
|
+
|
184
|
+
if periods_remaining(settle_date) <= 1
|
185
|
+
yield_to_maturity = r * (1.0 - accrued_fraction)
|
186
|
+
dirty_price = (face + coupon_amount) / (1.0 + yield_to_maturity)
|
187
|
+
price = dirty_price - accrued_coupon
|
188
|
+
if verbose
|
189
|
+
puts 'Using single-period calculation'
|
190
|
+
puts "Buyer gets at maturity: #{face + coupon_amount}"
|
191
|
+
puts "Seller portion of coupon paid at settlement: #{accrued_coupon}"
|
192
|
+
puts "Yield to Maturity: #{yield_to_maturity}"
|
193
|
+
puts "Dirty price: #{dirty_price}"
|
194
|
+
end
|
195
|
+
else
|
196
|
+
# We calculate the PV of the coupon stream and the face value due at
|
197
|
+
# maturity as of the date of the prior coupon.
|
198
|
+
pv_coup = Annuity.new(periods: periods_remaining(settle_date),
|
199
|
+
amount: coupon_amount)
|
200
|
+
.present_value(rate: r)
|
201
|
+
pv_face =
|
202
|
+
CashFlowPoint.new(amount: face, date: maturity)
|
203
|
+
.value(as_of: prior_coup_date, rate: yld, frq: frq)
|
204
|
+
|
205
|
+
pv_at_prior = pv_coup + pv_face
|
206
|
+
# In order to get the value at the settlement date, the value at the prior
|
207
|
+
# coupon date needs to be increased by the required yield for the
|
208
|
+
# fractional period to the settlement date.
|
209
|
+
if settle_date > prior_coup_date
|
210
|
+
dirty_price =
|
211
|
+
CashFlowPoint.new(amount: pv_at_prior,
|
212
|
+
date: prior_coup_date)
|
213
|
+
.value(as_of: settle_date, rate: yld, frq: frq)
|
214
|
+
else
|
215
|
+
dirty_price = pv_at_prior
|
216
|
+
end
|
217
|
+
price = dirty_price - accrued_coupon
|
218
|
+
if verbose
|
219
|
+
puts "Present value of coupon stream at prior coupon date: #{pv_coup}\n"
|
220
|
+
puts "Present value of face amount at prior coupon date: #{pv_face}"
|
221
|
+
puts "Present value of bond amount at prior coupon date: #{pv_at_prior}"
|
222
|
+
puts "Present value of bond amount at settlement date: #{dirty_price}"
|
223
|
+
puts "Accrued coupon at settlement: #{accrued_coupon}"
|
224
|
+
puts "The dirty bond price is #{dirty_price}"
|
225
|
+
puts "The clean bond price is #{price}\n"
|
226
|
+
end
|
227
|
+
end
|
228
|
+
price = dirty_price unless clean
|
229
|
+
puts "Bond price: #{price}" if verbose
|
230
|
+
price
|
231
|
+
end
|
232
|
+
|
233
|
+
def yld(price: face, settle_date: maturity, convention: 0, verbose: false)
|
234
|
+
raise ArgumentError, "Negative price (#{price})." if price < 0
|
235
|
+
settle_date = Date.ensure(settle_date)
|
236
|
+
|
237
|
+
unless [0, 1, 2, 3, 4].include?(convention)
|
238
|
+
raise ArgumentError, 'Rounding convention must be 0, 1, 2, 3, or 4.'
|
239
|
+
end
|
240
|
+
|
241
|
+
if verbose
|
242
|
+
puts ""
|
243
|
+
puts "#####################################################"
|
244
|
+
puts "Yield:"
|
245
|
+
puts "Settlement: #{settle_date}"
|
246
|
+
puts "Maturity date: #{maturity}"
|
247
|
+
puts "Face Value: #{face}"
|
248
|
+
puts "Coupon Rate: #{coupon}"
|
249
|
+
puts "Frequency: #{frq}"
|
250
|
+
puts "Convention: #{convention}"
|
251
|
+
puts "Price: #{price}"
|
252
|
+
end
|
253
|
+
|
254
|
+
|
255
|
+
if periods_remaining(settle_date) <= 1
|
256
|
+
# Use direct calculation if one or no periods left.
|
257
|
+
prior_coup_date = prior_coupon_date(settle_date)
|
258
|
+
days_from_prior_to_settle = settle_date - prior_coup_date
|
259
|
+
days_from_prior_to_maturity = maturity - prior_coup_date
|
260
|
+
days_from_settle_to_maturity = maturity - settle_date
|
261
|
+
accrued_fraction = factor(date: settle_date, convention: convention)
|
262
|
+
unaccrued_fraction = 1.0 - accrued_fraction
|
263
|
+
sellers_coupon = coupon_amount * accrued_fraction
|
264
|
+
buyers_coupon = coupon_amount * unaccrued_fraction
|
265
|
+
|
266
|
+
if unaccrued_fraction != 0.0
|
267
|
+
annualization_factor = frq / unaccrued_fraction
|
268
|
+
yld = (face + coupon_amount) / (price + sellers_coupon) - 1.0
|
269
|
+
elsif days_from_settle_to_maturity > 0
|
270
|
+
# Here, the buyer is paying for the face amount with no coupon
|
271
|
+
# because he is buying close enough to maturity that, based on the
|
272
|
+
# interest rate convention, all of the coupon goes to the seller.
|
273
|
+
# Still there is at least one day remaining before maturity, so a
|
274
|
+
# yield can be computed based on an assumed number of days in the
|
275
|
+
# year.
|
276
|
+
if [0, 2, 4].include?(convention)
|
277
|
+
days_in_year = 360
|
278
|
+
elsif convention == 3
|
279
|
+
days_in_year = 365
|
280
|
+
else
|
281
|
+
# Actual days, depending on leap year
|
282
|
+
days_in_year = settle_date.is_leap_year ? 366 : 365;
|
283
|
+
end
|
284
|
+
yld = face / price - 1.0
|
285
|
+
annualization_factor = days_in_year / days_from_settle_to_maturity
|
286
|
+
else
|
287
|
+
# Settlement on the maturity date. This should not be reached
|
288
|
+
# because settlement on maturity returns undef and logs an error
|
289
|
+
# above. But just as a place-holder in case this comes up later,
|
290
|
+
# this is how I would handle it. Here, the buyer is paying for
|
291
|
+
# the $face amount with no coupon because he is buying close
|
292
|
+
# enough to maturity that, based on the interest rate convention,
|
293
|
+
# all of the coupon goes to the seller. So, if the buyer is paying
|
294
|
+
# more than face, he's getting a infinite negative yield, if he's
|
295
|
+
# paying less than face, he's getting an infinite positive yield,
|
296
|
+
# and if he's paying face, he's getting a zero yield.
|
297
|
+
if price > face
|
298
|
+
# Negative infinite yield
|
299
|
+
yld = -1.00
|
300
|
+
elsif price < face
|
301
|
+
yld = 1.00
|
302
|
+
else
|
303
|
+
yld = 0.0
|
304
|
+
end
|
305
|
+
# Arbitrary big factor for infinite yields
|
306
|
+
annualization_factor = 1000.0
|
307
|
+
end
|
308
|
+
|
309
|
+
if verbose
|
310
|
+
puts "Using direct calculation for #{periods_remaining(settle_date)} periods"
|
311
|
+
puts "Days from prior coupon to settlement: #{days_from_prior_to_settle}"
|
312
|
+
puts "Days from settlement to maturity: #{days_from_settle_to_maturity}"
|
313
|
+
puts "Days from prior coupon to maturity: #{days_from_prior_to_maturity}"
|
314
|
+
puts "Accrued fraction: #{accrued_fraction}"
|
315
|
+
puts "Unaccrued fraction: #{unaccrued_fraction}"
|
316
|
+
puts "Full coupon: #{coupon_amount}"
|
317
|
+
puts "Seller's portion of coupon: #{sellers_coupon}"
|
318
|
+
puts "Buyer's portion of coupon: #{buyers_coupon}"
|
319
|
+
puts "Raw yield: #{yld}"
|
320
|
+
puts "Annualization factor: #{annualization_factor}"
|
321
|
+
end
|
322
|
+
yld *= annualization_factor
|
323
|
+
else
|
324
|
+
# Use binary search to find yield that produces a bond price
|
325
|
+
# equal to $price, within the desired toleration, expressed
|
326
|
+
# as the number of decimal places accuracy.
|
327
|
+
low = -9_999.0
|
328
|
+
high = 10_000.00
|
329
|
+
|
330
|
+
# Loop until the computed price is within places decimal places of the
|
331
|
+
# given price or until we hit max_iter iterations.
|
332
|
+
places = 7
|
333
|
+
max_iter = 100
|
334
|
+
iterations = 0
|
335
|
+
until low.nearly?(high, places) || iterations >= max_iter
|
336
|
+
iterations += 1
|
337
|
+
mid = (low + high) / 2.0
|
338
|
+
computed_price = self.price(yld: mid, settle_date: settle_date,
|
339
|
+
convention: convention, verbose: verbose)
|
340
|
+
if verbose
|
341
|
+
printf("Iter [%02d]: yld [%0.*f, <%0.*f>, %0.*f]; price [%0*.*f]; target [%0*.*f].\n",
|
342
|
+
iterations, places, low, places, mid, places, high, places + 4,
|
343
|
+
places, computed_price, places + 4, places, price)
|
344
|
+
end
|
345
|
+
if computed_price > price
|
346
|
+
low = mid
|
347
|
+
else
|
348
|
+
high = mid
|
349
|
+
end
|
350
|
+
end
|
351
|
+
mid
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
355
|
+
def macaulay_duration(yld: 100.0, settle_date: maturity, convention: 0,
|
356
|
+
verbose: false)
|
357
|
+
# Return the "Macaulay duration" of a bond, which is
|
358
|
+
# calculated as the weighted average of each cash
|
359
|
+
# payment, each weighted by the number of years to maturity
|
360
|
+
# and discounted back to the (settlement) date, which is
|
361
|
+
# the date as of which the duration is being measured.
|
362
|
+
|
363
|
+
settle_date = Date.ensure(settle_date)
|
364
|
+
|
365
|
+
unless [0, 1, 2, 3, 4].include?(convention)
|
366
|
+
raise ArgumentError, 'Day count convention must be 0, 1, 2, 3, or 4.'
|
367
|
+
end
|
368
|
+
|
369
|
+
# For a zero-coupon bond, the duration is simply the number
|
370
|
+
# of years to maturity since the sole payment occurs at
|
371
|
+
# maturity. Deal with this simple case specially.
|
372
|
+
return months_diff(maturity, settle_date) / 12 if coupon == 0.0
|
373
|
+
|
374
|
+
if verbose
|
375
|
+
puts "\nMacaulay Duration:"
|
376
|
+
puts "Settlement: #{settle_date}"
|
377
|
+
puts "Maturity date: #{maturity}"
|
378
|
+
puts "Face Value: #{face}"
|
379
|
+
puts "Coupon Rate: #{coupon}"
|
380
|
+
puts "Yield: #{yld}"
|
381
|
+
puts "Frequency: #{frq}"
|
382
|
+
end
|
383
|
+
|
384
|
+
# Yield per period, discount rate
|
385
|
+
r = yld / frq
|
386
|
+
|
387
|
+
# Compute moments of coupons
|
388
|
+
moment = 0
|
389
|
+
cdate = next_coupon_date(settle_date)
|
390
|
+
while cdate && cdate <= maturity
|
391
|
+
ytc = cdate.month_diff(settle_date) / 12.0
|
392
|
+
cf_coup = CashFlowPoint.new(amount: coupon_amount, date: cdate)
|
393
|
+
pv_coup = cf_coup.value(as_of: settle_date, rate: yld, frq: frq)
|
394
|
+
moment += ytc * pv_coup
|
395
|
+
if verbose
|
396
|
+
puts "Coup: #{cdate}; Amt: #{coupon_amount}: PV: #{pv_coup}; Yrs: #{ytc}"
|
397
|
+
end
|
398
|
+
cdate = next_coupon_date(cdate)
|
399
|
+
end
|
400
|
+
|
401
|
+
# Add moment of face
|
402
|
+
ytm = maturity.month_diff(settle_date) / 12.0
|
403
|
+
pvm = CashFlowPoint.new(amount: face, date: maturity)
|
404
|
+
.value(as_of: settle_date, rate: yld, frq: frq)
|
405
|
+
moment += ytm * pvm
|
406
|
+
if verbose
|
407
|
+
puts "Maturity on #{maturity}: Amount; face: PV: #{pvm}; Yrs: #{ytm}"
|
408
|
+
puts "Computed moment is #{moment}"
|
409
|
+
end
|
410
|
+
|
411
|
+
# Duration is ratio of moments to price
|
412
|
+
mprice = price(yld: yld, settle_date: settle_date, convention: convention)
|
413
|
+
puts "Computed price is #{mprice}" if verbose
|
414
|
+
|
415
|
+
# Debug
|
416
|
+
if verbose
|
417
|
+
puts "Per period yld: #{r}"
|
418
|
+
puts "Per period coupon payment: \$#{coupon_amount}"
|
419
|
+
puts "The Macaulay duration is #{moment / mprice}\n"
|
420
|
+
end
|
421
|
+
|
422
|
+
moment / mprice
|
423
|
+
end
|
424
|
+
|
425
|
+
# Return the "Modified duration" of a bond, which is calculated as the
|
426
|
+
# Macaulay duration divided by (1 + $yld/$frq).
|
427
|
+
def modified_duration(yld: 100.0, settle_date: Date.today, convention: 0,
|
428
|
+
verbose: false)
|
429
|
+
settle_date = Date.ensure(settle_date)
|
430
|
+
|
431
|
+
unless [0, 1, 2, 3, 4].include?(convention)
|
432
|
+
raise ArgumentError, 'Day count convention must be 0, 1, 2, 3, or 4.'
|
433
|
+
end
|
434
|
+
|
435
|
+
if verbose
|
436
|
+
puts "\n#####################################################"
|
437
|
+
puts 'Modified Duration:'
|
438
|
+
puts "Settlement: #{settle_date}"
|
439
|
+
puts "Maturity date: #{maturity}"
|
440
|
+
puts "Face Value: #{face}"
|
441
|
+
puts "Coupon Rate: #{coupon}"
|
442
|
+
puts "Yield: #{yld}"
|
443
|
+
puts "Frequency: #{frq}"
|
444
|
+
end
|
445
|
+
|
446
|
+
macd = macaulay_duration(yld: yld, settle_date: settle_date,
|
447
|
+
convention: convention, verbose: verbose)
|
448
|
+
macd / (1 + yld / frq)
|
449
|
+
end
|
450
|
+
|
451
|
+
private
|
452
|
+
|
453
|
+
# Returns fraction of *annual* coupon that has been
|
454
|
+
# accrued by date2 for this bond, following the day-count
|
455
|
+
# convention, convention.
|
456
|
+
def factor(date: nil, convention: 0)
|
457
|
+
raise ArgumentError, 'factor requires date: argument' unless date
|
458
|
+
date2 = Date.ensure(date)
|
459
|
+
|
460
|
+
# Return the fraction of the year that has elapsed on
|
461
|
+
# date2, typically the settlement date. date1 is the
|
462
|
+
# date on which the period begins, usually the prior
|
463
|
+
# coupon date date2 is the date of payment or settlement
|
464
|
+
# date date3 is the date on which the period ends,
|
465
|
+
# usually the next coupon date Source: Wikipedia "Day
|
466
|
+
# count conventions"
|
467
|
+
#
|
468
|
+
# Note: date3 is not used in any of the conventions programmed for
|
469
|
+
# here, but it may be used in other conventions. See the link
|
470
|
+
# above for where it might apply.
|
471
|
+
|
472
|
+
date1 = prior_coupon_date(date2)
|
473
|
+
# date3 = next_coupon_date(date2)
|
474
|
+
|
475
|
+
# Convention for how interest is calculated between
|
476
|
+
# interest payment dates. This has to do with the
|
477
|
+
# fraction by which the annual coupon is multiplied to
|
478
|
+
# pay for a partial period. The numerator is the number
|
479
|
+
# of days that have elapsed; The demoniator is the
|
480
|
+
# number of days in the entire year. These
|
481
|
+
# involve assumptions about the number of days in the
|
482
|
+
# months that fall within the partial period. They will
|
483
|
+
# assume either that every month consists of 30 days,
|
484
|
+
# even if the particular period in which the payment
|
485
|
+
# occurs contains February, July, and August, or they
|
486
|
+
# will take into account the actual number of days in
|
487
|
+
# the particular period.
|
488
|
+
|
489
|
+
# comv =
|
490
|
+
# 0 -- US (NASD) 30/360 (default)
|
491
|
+
# 1 -- Actual/actual
|
492
|
+
# 2 -- Actual/360
|
493
|
+
# 3 -- Actual/365
|
494
|
+
# 4 -- European 30/360
|
495
|
+
|
496
|
+
# The following are extracted from the dates for the
|
497
|
+
# 30/360 methods (0 and 4)
|
498
|
+
d1 = date1.day
|
499
|
+
d2 = date2.day
|
500
|
+
# d3 = date3.day
|
501
|
+
m1 = date1.month
|
502
|
+
m2 = date2.month
|
503
|
+
# m3 = date3.month
|
504
|
+
y1 = date1.year
|
505
|
+
y2 = date2.year
|
506
|
+
# y3 = date3.year
|
507
|
+
|
508
|
+
case convention
|
509
|
+
when 0
|
510
|
+
#############################################################
|
511
|
+
# From www.eclipsesoftware.biz/DayCountConventions.html#x3_01a
|
512
|
+
#############################################################
|
513
|
+
# 3.01a - 30U/360
|
514
|
+
# The adjustment to Date1 and Date2:
|
515
|
+
# * If security is EOM and (d1 = last-day-of-February) and (d2 = last-day-of-February), then change d2 to 30.
|
516
|
+
# * If security is EOM and (d1 = last-day-of-February), then change d1 to 30.
|
517
|
+
# * If d2 = 31 and d1 is 30 or 31, then change d2 to 30.
|
518
|
+
# * If d1 = 31, then change d1 to 30.
|
519
|
+
# This is the convention in the U.S. for corporate, municipal, and some US Agency bonds.
|
520
|
+
# This convention is referred to as:
|
521
|
+
# Term Sources
|
522
|
+
# 30/360 [SIFMA_SSCM]
|
523
|
+
# 30U/360 [SWX_AI]
|
524
|
+
# US [SWX_AI]
|
525
|
+
if eom && date1.last_of_feb? && date2.last_of_feb?
|
526
|
+
d2 = 30
|
527
|
+
end
|
528
|
+
if eom && date1.last_of_feb?
|
529
|
+
d1 = 30
|
530
|
+
end
|
531
|
+
if d2 == 31 && (d1 == 30 || d1 == 31)
|
532
|
+
d2 = 30
|
533
|
+
end
|
534
|
+
if d1 == 31
|
535
|
+
d1 = 30
|
536
|
+
end
|
537
|
+
num = 360.0 * (y2 - y1) + 30.0 * (m2 - m1) + (d2 - d1)
|
538
|
+
den = 360.0
|
539
|
+
fact = (num / den)
|
540
|
+
when 1
|
541
|
+
# Actual Conventions
|
542
|
+
|
543
|
+
# This category of conventions uses the actual number
|
544
|
+
# of calendar days in the accrual period. The
|
545
|
+
# differences come in the value of Den.
|
546
|
+
|
547
|
+
# N is simply the number of days between Date1 and
|
548
|
+
# Date2. For example, the number of days between
|
549
|
+
# 2-Nov-2007 and 15-Nov-2007 is 13. Just
|
550
|
+
# subtract. This is notated as JulianDays(Date1,
|
551
|
+
# Date2).
|
552
|
+
|
553
|
+
# Actual/Actual This convention splits the accrual
|
554
|
+
# period into that portion in a leap year and that in
|
555
|
+
# a non-leap year. You include the first date in the
|
556
|
+
# period and exclude the ending one.
|
557
|
+
#
|
558
|
+
# The calculation is:
|
559
|
+
# * Fact = ( {Nnl in non-leap year / 365} + {Nly in leap year / 366} )
|
560
|
+
# Example
|
561
|
+
# Date Value
|
562
|
+
# Date1 15-Dec-2007
|
563
|
+
# Date2 10-Jan-2008
|
564
|
+
#
|
565
|
+
# This results in the values:
|
566
|
+
# Term Calculation Value
|
567
|
+
# Nnl JulianDays(15-Dec-2007, 1-Jan-2008) 17
|
568
|
+
# Nly JulianDays(1-Jan-2008, 10-Jan-2008) 9
|
569
|
+
# Fact ( {17 / 365} + {9 / 366} ) 0.07116551
|
570
|
+
if date1.is_leap? == date2.is_leap?
|
571
|
+
# Both date1 and date2 are in same kind of year
|
572
|
+
num = (date2 - date1).to_f
|
573
|
+
den = 365.0
|
574
|
+
den = 366.0 if date1.is_leap?
|
575
|
+
fact = (num / den)
|
576
|
+
else
|
577
|
+
# One is in a leap, the other not
|
578
|
+
num1 = (Date.new(date2.year, 1, 1) - date1).to_f
|
579
|
+
den1 = 365.0
|
580
|
+
den1 = 366.0 if date1.is_leap?
|
581
|
+
num2 = (date2 - Date.new(date2.year, 1, 1)).to_f
|
582
|
+
den2 = 365.0
|
583
|
+
den2 = 366.0 if date2.is_leap?
|
584
|
+
fact = (num1 / den1) + (num2 / den2)
|
585
|
+
end
|
586
|
+
when 2
|
587
|
+
# A.04 - Act/360
|
588
|
+
# Den:
|
589
|
+
# * 360
|
590
|
+
|
591
|
+
# This results in the calculation:
|
592
|
+
# AI = CR * (N / 360).
|
593
|
+
# This convention is referred to as:
|
594
|
+
#
|
595
|
+
# Term Sources
|
596
|
+
# Actual/360 [ISDA_4.16_2006], [ISDA_4.16_2000], [SIFMA_SSCM],
|
597
|
+
# [SIA_SSCM], [SWX_AI], [EBF_MA]
|
598
|
+
# Act/360 [ISDA_4.16_2006], [ISDA_4.16_2000]
|
599
|
+
# A/360 [ISDA_4.16_2006]
|
600
|
+
# French [SWX_AI]
|
601
|
+
num = (date2 - date1).to_f
|
602
|
+
den = 360.0
|
603
|
+
fact = num / den
|
604
|
+
when 3
|
605
|
+
# A.03 - Act/365 (Fixed)
|
606
|
+
# Den:
|
607
|
+
# * 365
|
608
|
+
# This results in the calculation:
|
609
|
+
# AI = CR * (N / 365).
|
610
|
+
num = (date2 - date1).to_f
|
611
|
+
den = 365.0
|
612
|
+
fact = num / den
|
613
|
+
when 4
|
614
|
+
# 3.03 - 30E/360 ISDA
|
615
|
+
# The adjustment to Date1 and Date2:
|
616
|
+
# * If d1 = last day of the month, then change d1 to 30.
|
617
|
+
# * If d2 = last day of the month, then change d2 to 30.
|
618
|
+
# However, do not make this adjustment if Date2 = MatDt and d2 = February.
|
619
|
+
|
620
|
+
# The d2 qualification for February is often omitted
|
621
|
+
# when this convention is discussed. We believe this
|
622
|
+
# is an oversight, not a variant.
|
623
|
+
|
624
|
+
# This convention is referred to as:
|
625
|
+
# Term Sources
|
626
|
+
# 30E/360 (ISDA) [ISDA_4.16_2006]
|
627
|
+
# 30E/360 [ISDA_4.16_2000]
|
628
|
+
# Eurobond Basis [ISDA_4.16_2000]
|
629
|
+
# German [SWX_AI]
|
630
|
+
# German Master [EBF_MA]
|
631
|
+
# 360/360 [EBF_MA]
|
632
|
+
unless date2 == maturity && date2.month == 2
|
633
|
+
if date1.last_of_month?
|
634
|
+
d1 = 30.0
|
635
|
+
end
|
636
|
+
if date2.last_of_month?
|
637
|
+
d2 = 30.0
|
638
|
+
end
|
639
|
+
end
|
640
|
+
num = 360.0 * (y2 - y1) + 30.0 * (m2 - m1) + (d2 - d1)
|
641
|
+
den = 360.0
|
642
|
+
fact = num / den
|
643
|
+
else
|
644
|
+
raise ArgumentError "invalid convention: #{convention}"
|
645
|
+
end
|
646
|
+
fact * frq
|
647
|
+
end
|
648
|
+
|
649
|
+
# Return the next coupon date *after* the given date
|
650
|
+
def next_coupon_date(date)
|
651
|
+
raise ArgumentError, "No next coupon date past #{date}" if date > maturity
|
652
|
+
return nil if date == maturity
|
653
|
+
|
654
|
+
next_coup_date = date + 1
|
655
|
+
# Step forward by days until we hit date with the same month-day as
|
656
|
+
# maturity
|
657
|
+
next_coup_date += 1 while next_coup_date.day != maturity.day
|
658
|
+
# Step forward by months until we hit a coupon month
|
659
|
+
until maturity.month_diff(next_coup_date).floor % months_per_period == 0
|
660
|
+
next_coup_date = next_coup_date >> 1
|
661
|
+
end
|
662
|
+
next_coup_date
|
663
|
+
end
|
664
|
+
|
665
|
+
# Return the prior coupon date *on or before* the given date
|
666
|
+
def prior_coupon_date(date)
|
667
|
+
if date <= issue_date
|
668
|
+
raise ArgumentError, "No prior coupon date before #{date}"
|
669
|
+
end
|
670
|
+
next_coupon_date(date) << months_per_period
|
671
|
+
end
|
672
|
+
|
673
|
+
def months_per_period
|
674
|
+
12 / frq
|
675
|
+
end
|
676
|
+
|
677
|
+
# Number of periods remaining until maturity, including the period
|
678
|
+
# containing the given date.
|
679
|
+
def periods_remaining(date)
|
680
|
+
return 0 if date == maturity
|
681
|
+
maturity.month_diff(prior_coupon_date(date)) / months_per_period
|
682
|
+
end
|
683
|
+
end
|
684
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module Bondy
|
2
|
+
class CashFlowPoint
|
3
|
+
attr_reader :date, :amount
|
4
|
+
|
5
|
+
def initialize(amount: 0.0, date: Date.today)
|
6
|
+
@amount = amount
|
7
|
+
@date = Date.ensure(date)
|
8
|
+
end
|
9
|
+
|
10
|
+
def to_s
|
11
|
+
"CFP[#{@amount} @ #{@date}]"
|
12
|
+
end
|
13
|
+
|
14
|
+
# Return the value of this cash flow on a particular as_of date at an annual
|
15
|
+
# rate, rate, with frq compunding periods per year frq = 0 means simple
|
16
|
+
# interest---no compounding.
|
17
|
+
def value(as_of: Date.today, rate: 0.0, frq: 1)
|
18
|
+
as_of = Date.ensure(as_of)
|
19
|
+
|
20
|
+
# Check frq for sanity
|
21
|
+
unless [0, 1, 2, 3, 4, 6, 12].include?(frq)
|
22
|
+
raise(ArgumentError,
|
23
|
+
"Compounding frequency (#{frq}) not a divisor of 12. Suspect.")
|
24
|
+
end
|
25
|
+
|
26
|
+
# Rate per period
|
27
|
+
r =
|
28
|
+
if frq == 0
|
29
|
+
rate
|
30
|
+
else
|
31
|
+
rate / frq
|
32
|
+
end
|
33
|
+
|
34
|
+
# Number of periods
|
35
|
+
n =
|
36
|
+
if frq == 0
|
37
|
+
as_of.month_diff(date) / 12.0
|
38
|
+
else
|
39
|
+
as_of.month_diff(date) / (12.0 / frq)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Now the calculation
|
43
|
+
if frq == 0
|
44
|
+
# Simple interest
|
45
|
+
if n >= 0
|
46
|
+
# Payout date before value date---future value
|
47
|
+
amount * (1.0 + r * n)
|
48
|
+
else
|
49
|
+
# Payout date after value date---present value
|
50
|
+
amount / (1.0 + r * -n)
|
51
|
+
end
|
52
|
+
else
|
53
|
+
# Compund interest
|
54
|
+
if r < 0.0
|
55
|
+
amount / ((1.0 + r.abs)**n)
|
56
|
+
else
|
57
|
+
amount * (1.0 + r)**n
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
class Date
|
4
|
+
def self.ensure(arg)
|
5
|
+
case arg
|
6
|
+
when String
|
7
|
+
parse(arg)
|
8
|
+
when Date
|
9
|
+
arg
|
10
|
+
else
|
11
|
+
raise ArgumentError, "can't convert #{arg} to a Date"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.excel(i)
|
16
|
+
k = i < 60 ? 1 : 0
|
17
|
+
Date.new(1899, 12, 30) + i + k
|
18
|
+
end
|
19
|
+
|
20
|
+
def month_diff(other, whole: false)
|
21
|
+
other = Date.ensure(other)
|
22
|
+
# Put dates in d0, d1 order
|
23
|
+
if self < other
|
24
|
+
k = -1
|
25
|
+
d0 = self
|
26
|
+
d1 = other
|
27
|
+
else
|
28
|
+
k = 1
|
29
|
+
d0 = other
|
30
|
+
d1 = self
|
31
|
+
end
|
32
|
+
# If both dates are last of month, return only
|
33
|
+
# whole months
|
34
|
+
whole = true if d0.last_of_month? && d1.last_of_month?
|
35
|
+
|
36
|
+
# Count number of years
|
37
|
+
m = (d1.year - d0.year) * 12
|
38
|
+
m += (d1.month - d0.month)
|
39
|
+
m += (d1.day - d0.day) / 30.0 unless whole
|
40
|
+
k * m
|
41
|
+
end
|
42
|
+
|
43
|
+
def is_leap?
|
44
|
+
y = year
|
45
|
+
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
46
|
+
end
|
47
|
+
|
48
|
+
def last_of_feb?
|
49
|
+
return false unless month == 2
|
50
|
+
if is_leap?
|
51
|
+
day == 29 ? true : false
|
52
|
+
else
|
53
|
+
day == 28 ? true : false
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def last_of_month?
|
58
|
+
if [1, 3, 5, 7, 8, 10, 12].include?(month) && day == 31
|
59
|
+
true
|
60
|
+
elsif [4, 6, 9, 11].include?(month) && day == 30
|
61
|
+
true
|
62
|
+
elsif last_of_feb?
|
63
|
+
true
|
64
|
+
else
|
65
|
+
false
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def whole_month_diff(d)
|
72
|
+
month_diff(d, whole: true)
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class Numeric
|
2
|
+
def excel_date
|
3
|
+
k = to_i <= 60 ? 1 : 0
|
4
|
+
Date.new(1899, 12, 30) + to_i + k
|
5
|
+
end
|
6
|
+
end
|
7
|
+
|
8
|
+
class Float
|
9
|
+
# Compare floating point number for equality in $POINTS places
|
10
|
+
def nearly?(other, places = 7)
|
11
|
+
tX = sprintf("%0.#{places}f", self)
|
12
|
+
tY = sprintf("%0.#{places}f", other)
|
13
|
+
tX == tY
|
14
|
+
end
|
15
|
+
end
|
metadata
ADDED
@@ -0,0 +1,148 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: bondy
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.3.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Daniel E. Doherty
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-01-14 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: fat_core
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.10'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.10'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: pry
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: pry-byebug
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rake
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '10.0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '10.0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rspec
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
description: Library for performing bond calculations
|
98
|
+
email:
|
99
|
+
- ded-law@ddoherty.net
|
100
|
+
executables:
|
101
|
+
- bondy
|
102
|
+
extensions: []
|
103
|
+
extra_rdoc_files: []
|
104
|
+
files:
|
105
|
+
- ".gitignore"
|
106
|
+
- ".rspec"
|
107
|
+
- ".travis.yml"
|
108
|
+
- Gemfile
|
109
|
+
- Gemfile.lock
|
110
|
+
- README.org
|
111
|
+
- Rakefile
|
112
|
+
- bin/console
|
113
|
+
- bin/setup
|
114
|
+
- bondy.gemspec
|
115
|
+
- exe/bondy
|
116
|
+
- lib/bondy.rb
|
117
|
+
- lib/bondy/annuity.rb
|
118
|
+
- lib/bondy/bond.rb
|
119
|
+
- lib/bondy/cash_flow_point.rb
|
120
|
+
- lib/bondy/core_ext.rb
|
121
|
+
- lib/bondy/core_ext/date.rb
|
122
|
+
- lib/bondy/core_ext/numeric.rb
|
123
|
+
- lib/bondy/version.rb
|
124
|
+
homepage: https://github.com/ddoherty03/fat_table
|
125
|
+
licenses: []
|
126
|
+
metadata:
|
127
|
+
allowed_push_host: https://rubygems.org
|
128
|
+
post_install_message:
|
129
|
+
rdoc_options: []
|
130
|
+
require_paths:
|
131
|
+
- lib
|
132
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
133
|
+
requirements:
|
134
|
+
- - ">="
|
135
|
+
- !ruby/object:Gem::Version
|
136
|
+
version: '0'
|
137
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
138
|
+
requirements:
|
139
|
+
- - ">="
|
140
|
+
- !ruby/object:Gem::Version
|
141
|
+
version: '0'
|
142
|
+
requirements: []
|
143
|
+
rubyforge_project:
|
144
|
+
rubygems_version: 2.6.13
|
145
|
+
signing_key:
|
146
|
+
specification_version: 4
|
147
|
+
summary: Bond financial calculations.
|
148
|
+
test_files: []
|