bondy 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|