bondy 0.3.0

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