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 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
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ /README.*
10
+ /_minted-*/*
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.2.2
4
+ before_install: gem install bundler -v 1.10.6
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in bondy.gemspec
4
+ gemspec
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
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
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
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
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
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bondy"
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,2 @@
1
+ require 'bondy/core_ext/numeric'
2
+ require 'bondy/core_ext/date'
@@ -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
@@ -0,0 +1,3 @@
1
+ module Bondy
2
+ VERSION = "0.3.0"
3
+ 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: []