istox 0.2.1 → 0.2.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 177c19f4e1837c38552a01345f4df0ed0b6d75489c3ff7ac7cee4e6ae5300e9d
4
- data.tar.gz: bc1e0d56a7c66ebbbb11f2408e8dfbbec8fad3bb056eafd08ca89aa3b7af712c
3
+ metadata.gz: 5d4f2a0587825e06d3ff6a6a3ad516f20b90f1ca7e59e11d686b03c892b8c441
4
+ data.tar.gz: 3b6f8c0f67615b5165f9030343e0713f2b4562109632f93306ebb6a412d87aeb
5
5
  SHA512:
6
- metadata.gz: 3b4c6ea1b0f4dade5ce454996d6d72c454560d641a4bba722385db12da19e8531bc6fcab904cb372b664ec3952047510942b92da16bf3ab3dcc28bbde685d603
7
- data.tar.gz: 5d5e23ac455354401407c93507526fb616e4d7fba272e4883f7a142c86f0bff8c282101aec833b63ae810e90f914c8a273825306673de9c40f6e96908a6123fd
6
+ metadata.gz: 6fb65f68fed5082be9d4b8ecd73547a06cc77224baf3e57296974768351127c0d39dc51d063c7855d38921bb7ca4925a1edd36eb097747ceb7da83bd1568379f
7
+ data.tar.gz: 0be64c5925913cd5fad7242bc0604bf2f553f3d69a5969c1d3165571967d570d8cd2380d8b57b9e15b1af230b2c2f8a516c0bdd59ce59b9a8f9688d948b7406c
@@ -12,45 +12,87 @@ module Istox
12
12
 
13
13
  DEFAULT_APPROXIMATION_ERROR = 0.00001
14
14
 
15
- def initialize(coupon: nil, maturity_date: nil, years: nil, coupon_frequency: nil, coupon_payment_dates: nil, face_value: 100, days_of_year: 365)
16
- raise "Invalid coupon #{coupon}" if (coupon.nil? || !is_number?(coupon))
15
+ def initialize(coupon: nil, maturity_date: nil, start_date: nil, coupon_frequency: nil, coupon_payment_dates: nil, face_value: 100, days_of_year: 365)
16
+ raise "Invalid coupon #{coupon}" if (coupon.nil? || !is_number?(coupon)) || coupon < 0
17
17
  raise "Invalid maturity_date #{maturity_date}" if (maturity_date.nil? || maturity_date.methods.include?("strftime"))
18
- raise "Invalid years #{years}" if (years.nil? || !is_number?(years))
19
- raise "Invalid coupon_frequency #{days_of_year}" if (coupon_frequency.nil? || !is_number?(coupon))
20
- raise "Invalid coupon_payment_dates #{coupon_payment_dates}" if (coupon_payment_dates.nil? || coupon_payment_dates.count == 0 || coupon_payment_dates.any? { |date| date > maturity_date.to_date })
18
+ raise "Invalid start_date #{start_date}" if (start_date.nil? || start_date.methods.include?("strftime"))
19
+ raise "Invalid coupon_frequency #{coupon_frequency}" if (coupon_frequency.nil? || !coupon_frequency.is_a?(Integer) || coupon_frequency < 0)
20
+ raise "Invalid coupon_payment_dates #{coupon_payment_dates}" if (coupon_payment_dates.nil? || (coupon_payment_dates.count == 0 && coupon_frequency != 0) || coupon_payment_dates.any? { |date| date > maturity_date.to_date })
21
21
  raise "Invalid days_of_year #{days_of_year}" if (days_of_year != 365 && days_of_year != 360)
22
+ raise "start_date is not before maturity_date" if start_date>=maturity_date
22
23
 
23
24
  @coupon = coupon.to_d
24
25
  @maturity_date = maturity_date.to_date
25
- @years = years
26
- @coupon_frequency = coupon_frequency.to_d
26
+ @coupon_frequency = coupon_frequency.to_i # if this is 0, it means zero coupon
27
27
  @days_of_year = days_of_year.to_d
28
- @face_value = face_value
29
- @coupon_payment_dates = coupon_payment_dates.map(&:to_date).sort
30
- @start_date = (@maturity_date-(years*12).to_i.months)
28
+ @face_value = face_value.to_d
29
+ @coupon_payment_dates = coupon_payment_dates.map(&:to_date).uniq.sort
30
+ # note here we work out the start date based on maturity date and nunber of years
31
+ @start_date = start_date.to_date
32
+
33
+ @pay_accrued_interest = false
34
+ @coupon_payment_dates_include_accrued_interest = false
35
+ if !is_zero_coupon?
36
+ if @coupon_payment_dates.include?(@maturity_date)
37
+ # maturity date is a coupon payment date, check if this should
38
+ # be accrued interest or last normal coupon
39
+ if @coupon_payment_dates.count > 1
40
+ previous_coupon_date = @coupon_payment_dates[@coupon_payment_dates.count-2]
41
+ next_coupon_date = add_month(previous_coupon_date, -(12/@coupon_frequency).to_i)
42
+ # If maturity date is a normal coupon payment, the theorecical next_coupon_date
43
+ # calculated from previous coupon payment should be the maturity date, to be safe,
44
+ # we allow 3 days difference
45
+ if (next_coupon_date - @maturity_date).abs <= 3
46
+ @pay_accrued_interest = false
47
+ @coupon_payment_dates_include_accrued_interest = false
48
+ else
49
+ @pay_accrued_interest = true
50
+ @coupon_payment_dates_include_accrued_interest = true
51
+ end
52
+ else
53
+ # maturity date is only coupon payment date, shouldn't be accrued interest!
54
+ @pay_accrued_interest = false
55
+ @coupon_payment_dates_include_accrued_interest = false
56
+ end
57
+ else
58
+ # maturity date is not included in coupon payment date, consider
59
+ # this needs to pay accrued interest
60
+ @pay_accrued_interest = true
61
+ @coupon_payment_dates_include_accrued_interest = false
62
+ end
63
+ end
64
+
65
+ log.info "Bond info: start_date=#{@start_date} maturity_date=#{@maturity_date} days_of_years=#{days_of_year} coupon=#{@coupon} coupon_frequency=#{coupon_frequency} face_value=#{@face_value} coupon_payment_dates=#{@coupon_payment_dates} pay_accrued_interest=#{@pay_accrued_interest} coupon_payment_dates_include_accrued_interest=#{@coupon_payment_dates_include_accrued_interest}"
31
66
  end
32
67
 
33
68
  def price(ytm, date, ex_coupon_date: nil, fees: 0)
34
- # price_for_irr(irr_from_ytm(ytm), date, fees: fees)
35
- price = price_for_irr(ytm/@coupon_frequency, date, ex_coupon_date: ex_coupon_date, fees: fees)
69
+ irr = ytm
70
+ irr = ytm/@coupon_frequency if !is_zero_coupon?
71
+ price = price_for_irr(irr, date, ex_coupon_date: ex_coupon_date, fees: fees)
36
72
  price
37
73
  end
38
74
 
39
- # def interest_payments(from_date)
40
- # payments = []
41
- # date = @maturity_date
42
- # while date >= from_date
43
- # payments << date
44
- # date = date.prev_month(12/@coupon_frequency)
45
- # end
46
- # payments.sort
47
- # end
75
+ def ytm(date, ex_coupon_date: nil, price: 100, fees: 0, approximation_error: DEFAULT_APPROXIMATION_ERROR)
76
+ if !is_zero_coupon? && price == @face_value && date <= @start_date
77
+ # for non zero coupon bond, if price is face value and date is at or before start date
78
+ # YTM is simply the coupon rate
79
+ return @coupon
80
+ end
81
+ ytm_down, ytm_up = ytm_limits(price, date, ex_coupon_date: ex_coupon_date, fees: fees)
82
+ approximate_ytm(ytm_down, ytm_up, price, date, ex_coupon_date: ex_coupon_date, fees: fees, approximation_error: approximation_error)
83
+ end
84
+
85
+
86
+ private
48
87
 
49
88
  def coupon_payments(from_date)
50
89
  payments = []
51
90
 
52
91
  @coupon_payment_dates.each do |payment_date|
53
- if payment_date > from_date
92
+ # if from_date falls on coupon payment date or maturity date, we
93
+ # still add them to make it easy for calculation later
94
+ # just that the first coupon will not pay actually (i.e. 0)
95
+ if payment_date >= from_date && payment_date <= @maturity_date
54
96
  payments << payment_date
55
97
  end
56
98
  end
@@ -58,29 +100,25 @@ module Istox
58
100
  payments.sort
59
101
  end
60
102
 
61
- def ytm(date, ex_coupon_date: nil, price: 100, fees: 0, approximation_error: DEFAULT_APPROXIMATION_ERROR)
62
- ytm_down, ytm_up = ytm_limits(price, date, ex_coupon_date: ex_coupon_date, fees: fees)
63
- approximate_ytm(ytm_down, ytm_up, price, date, ex_coupon_date: ex_coupon_date, fees: fees, approximation_error: approximation_error)
64
- end
65
-
66
-
67
- private
68
-
69
103
  def is_365?
70
104
  @days_of_year == 365.to_d
71
105
  end
72
106
 
73
- def add_month(mydate, n)
74
- if mydate.month != mydate.next_day.month
107
+ def is_zero_coupon?
108
+ @coupon == 0
109
+ end
110
+
111
+ def add_month(my_date, n)
112
+ if is_month_end?(my_date)
75
113
  # month end
76
- (mydate.next_day + n.month).prev_day
114
+ (my_date.next_day + n.month).prev_day
77
115
  else
78
- mydate.next_day + n.month
116
+ my_date.next_day + n.month
79
117
  end
80
118
  end
81
119
 
82
- def is_month_end?(mydate)
83
- mydate.month != mydate.next_day.month
120
+ def is_month_end?(my_date)
121
+ my_date.month != my_date.next_day.month
84
122
  end
85
123
 
86
124
  def is_number?(val)
@@ -107,111 +145,160 @@ module Istox
107
145
  [ytm_down, ytm_up]
108
146
  end
109
147
 
110
- # def price_for_irr(irr, date, fees: 0)
111
- # raise "Date is after maturity_date!" if date > @maturity_date
112
- # last = date
113
- # interest_payments(date).map do |payday|
114
- # interest = @coupon * @face_value * ((payday-last)/@days_of_year)
115
- # interest += @face_value if payday == @maturity_date
116
- # last = payday
117
- # interest / ((1+irr) ** ((payday-date)/@days_of_year))
118
- # end.inject(:+) / (1+fees)
119
- # end
120
-
121
148
  def price_for_irr(irr, date, ex_coupon_date: nil, fees: 0)
122
149
  date = date.to_date
123
150
  raise "Date is after maturity_date!" if date > @maturity_date
151
+ # if today is maturity, price is face value (without including any coupon/accrued interest)
152
+ # BizOps said we will always have an ex_coupon_date when it's close
153
+ # to coupon payment date but not too early, so the fomula needs to
154
+ # accept nil value. Even if we have ex_coupon_date as maturity date,
155
+ # buyer is not eligible to receive the coupon, so no need to check ex_coupon_date here
156
+ if date == @maturity_date
157
+ return @face_value
158
+ end
159
+
124
160
  if date <= @start_date
125
161
  date = @start_date
126
162
  end
127
- last_coupon_payday = @coupon_payment_dates.last
128
- payment_dates = coupon_payments(date)
129
163
 
130
- if payment_dates.count == 0
131
- # no more coupon payments, we do only 1 discount of accrued interest and face value to the current date
164
+ # it's supporting 365 actual days only at the moment
165
+ if is_zero_coupon?
166
+ total_days = @maturity_date - date
167
+ no_of_years = total_days / 365
168
+ remainder_days = total_days % 365
169
+
170
+ return @face_value/(1+irr)**no_of_years/(1+irr*remainder_days/365)
171
+ end
172
+
173
+ payment_dates = coupon_payments(date)
174
+
175
+ no_regular_coupon_before_maturity = false
176
+ if payment_dates.count == 0 || (payment_dates.count == 1 && payment_dates.first == @maturity_date)
177
+ no_regular_coupon_before_maturity = true
178
+ end
179
+
180
+ if no_regular_coupon_before_maturity
181
+ discount_factor = nil
132
182
  if is_365?
133
- discount_factor = 1.0/(1+irr*(@maturity_date-date)/accrued_interest_discount_days)
183
+ previous_coupon_date = previous_coupon_date_before_maturity
184
+ next_coupon_date = add_month(previous_coupon_date, (12/@coupon_frequency).to_i)
185
+ discount_factor = 1.0/(1+irr*(@maturity_date-date)/(next_coupon_date-previous_coupon_date))
134
186
  else
135
187
  discount_factor = 1.0/(1+irr*@coupon_frequency*day_count_factor(date, @maturity_date, nil))
136
188
  end
137
- discounted_accrued_interest = @coupon*@face_value*accrued_interest_factor*discount_factor
138
- discounted_face_value = @face_value*discount_factor
139
- price = discounted_accrued_interest + discounted_face_value
189
+
190
+ if @pay_accrued_interest
191
+ # accrued interest + face value discounted to the current date
192
+ discounted_accrued_interest = 0
193
+ if ex_coupon_date.nil? || ex_coupon_date == @maturity_date
194
+ discounted_accrued_interest = @coupon*@face_value*accrued_interest_factor*discount_factor
195
+ end
196
+ discounted_face_value = @face_value*discount_factor
197
+ price = discounted_accrued_interest + discounted_face_value
198
+ return price
199
+ else
200
+ discounted_last_coupon = 0
201
+ if ex_coupon_date.nil? || ex_coupon_date == @maturity_date
202
+ discounted_last_coupon = @coupon*@face_value/@coupon_frequency*discount_factor
203
+ end
204
+ discounted_face_value = @face_value*discount_factor
205
+ price = discounted_last_coupon + discounted_face_value
206
+ return price
207
+ end
140
208
  else
141
- # we discount face value and coupons and accrued interest (coupon) at maturity (if any)
142
- # to the first coupon payment left, and then discount the total to current date
209
+ # there are at least 1 more regular coupon payment before maturity
210
+ # we discount face value and coupons or accrued interest if any
211
+ # to the first coupon payment date left, and then discount the total to current date
143
212
  value_at_first_coupon = 0.to_d
144
- if payment_dates.include?(@maturity)
145
- # last coupon payment is at maturity
146
- discount_factor = 1.0/(1+irr)**(payment_dates.count-1)
147
- value_at_first_coupon = @face_value*discount_factor
148
- else
149
- period = @coupon_payment_dates.include?(date) ? payment_dates.count : payment_dates.count-1
150
- # last coupon is not at maturity, need to add accrued interest (coupon)
151
- discount_factor = 1.0/((1+irr)**period)/(1+irr*@coupon_frequency*accrued_interest_factor)
213
+ # this is the discount period excluding the one at maturity date
214
+ # e.g. payment_dates has date1, date2, maturity, we need to discount
215
+ # to date1, excluding the period from date2 to maturity, period = 1 (i.e. from date2 to date1)
216
+ no_of_discount_period = 0
217
+ if @pay_accrued_interest
218
+ if @coupon_payment_dates_include_accrued_interest
219
+ no_of_discount_period = payment_dates.count-2
220
+ else
221
+ no_of_discount_period = payment_dates.count-1
222
+ end
223
+
224
+ discount_factor = 1.0/((1+irr)**no_of_discount_period)/(1+irr*@coupon_frequency*accrued_interest_factor)
152
225
  discounted_accrued_interest = @coupon*@face_value*accrued_interest_factor*discount_factor
153
226
  discounted_face_value = @face_value*discount_factor
154
227
  value_at_first_coupon = discounted_accrued_interest + discounted_face_value
155
- end
228
+ else
229
+ # last coupon payment is at maturity
230
+ no_of_discount_period = payment_dates.count-1
231
+ discount_factor = 1.0/(1+irr)**(no_of_discount_period+1)
232
+ discounted_last_coupon = @coupon*@face_value/@coupon_frequency*discount_factor
233
+ discounted_face_value = @face_value*discount_factor
234
+ value_at_first_coupon = discounted_last_coupon + discounted_face_value
235
+ end
156
236
 
157
- # first coupon can be pro-rata
158
- value_at_first_coupon += first_coupon(date, ex_coupon_date)
159
- if payment_dates.count >= 1
160
- if @coupon_payment_dates.include?(date)
161
- # today is on one of the coupon payment date, the coupon will not be payed today
162
- # discount all coupon payments to first coupon date
163
- for n in 1..payment_dates.count do
164
- value_at_first_coupon += @coupon*@face_value/@coupon_frequency/(1+irr)**n
165
- end
166
- else
167
- # discount all coupon payments excluding first one to first coupon date
168
- for n in 1..payment_dates.count-1 do
169
- value_at_first_coupon += @coupon*@face_value/@coupon_frequency/(1+irr)**n
170
- end
171
- end
237
+ if no_of_discount_period >= 1
238
+ # discount all coupon payments excluding first one to first coupon date
239
+ for n in 1..no_of_discount_period do
240
+ value_at_first_coupon += @coupon*@face_value/@coupon_frequency/(1+irr)**n
241
+ end
172
242
  end
173
243
 
244
+ # first coupon, can be pro-rated, check the logic in first_coupon
245
+ value_at_first_coupon += first_coupon(date, ex_coupon_date)
246
+
174
247
  if @coupon_payment_dates.include?(date)
175
248
  # today is one of the coupon payment, no need to discount
176
249
  price = value_at_first_coupon
177
250
  else
178
251
  # discount value at first coupon date to present value
179
252
  if is_365?
180
- price = value_at_first_coupon/(1+irr*(payment_dates.first-date)/first_coupon_discount_days(date))/(1+fees)
253
+ previous_coupon_date = add_month(payment_dates.first, -(12/@coupon_frequency).to_i)
254
+ price = value_at_first_coupon/(1+irr*(payment_dates.first-date)/(payment_dates.first-previous_coupon_date))/(1+fees)
181
255
  else
182
256
  price = value_at_first_coupon/(1+irr*@coupon_frequency*day_count_factor(date, payment_dates.first, nil))/(1+fees)
183
257
  end
184
258
  end
259
+ return price
185
260
  end
186
-
187
261
  end
188
262
 
189
263
  def first_coupon(date, ex_coupon_date)
190
- if @coupon_payment_dates.include?(date) || (!ex_coupon_date.nil? && date > ex_coupon_date.to_date)
191
- # if today is on coupon payment date, we won't include the coupon
192
- # as by default it's passing ex_coupon_date in our calculation
193
- # after ex coupon date, buyer won't receive next coupon
264
+ # BizOps said we will always have an ex_coupon_date when it's close
265
+ # to coupon payment date but not too early, so the fomula needs to
266
+ # accept nil value.
267
+ if !ex_coupon_date.nil? && date >= ex_coupon_date.to_date
268
+ # on or after ex coupon date, buyer won't receive next coupon
269
+ return 0.to_d
270
+ elsif @coupon_payment_dates.include?(date)
271
+ # if today is a coupon payment date, for sure it will pass ex_coupon_date
272
+ # and buyer won't receive this coupon. In case ex_coupon_date is nil,
273
+ # that's the same assumption, so first_coupon will be 0
194
274
  return 0.to_d
195
275
  end
196
276
 
197
277
  coupon = @coupon*@face_value/@coupon_frequency
198
- if date <= @coupon_payment_dates.first
278
+ # For the very first coupon, we need to check if it needs to be pro-rated
279
+ if date <= @coupon_payment_dates.first && @pay_accrued_interest
199
280
  coupon = @coupon*@face_value*first_coupon_factor
200
281
  end
201
282
  coupon
202
283
  end
203
284
 
204
- def accrued_interest_discount_days
205
- # the days between last coupon payment date and the next coupon payment date if maturity was longer
206
- last_coupon_payday = @coupon_payment_dates.last
207
- (add_month(last_coupon_payday, (12/@coupon_frequency).to_i) - last_coupon_payday).to_i
208
- end
285
+ def previous_coupon_date_before_maturity
286
+ previous_coupon_date = @start_date
287
+ if @coupon_payment_dates.include?(@maturity_date)
288
+ # maturity date is in coupon payment dates, get the previous coupon date
289
+ # if there is no more date, use the default start date
290
+ if @coupon_payment_dates.count > 1
291
+ previous_coupon_date = @coupon_payment_dates[@coupon_payment_dates.count-2]
292
+ end
293
+ else
294
+ # maturity date is not in coupon payment dates, get the last coupon date
295
+ # if there is no more date, use the default start date
296
+ if @coupon_payment_dates.count > 0
297
+ previous_coupon_date = @coupon_payment_dates.last
298
+ end
299
+ end
209
300
 
210
- def first_coupon_discount_days(date)
211
- # first coupon date is based on current date
212
- payment_dates = coupon_payments(date)
213
- first_coupon_date = payment_dates.first
214
- (first_coupon_date - add_month(first_coupon_date, -(12/@coupon_frequency).to_i)).to_i
301
+ return previous_coupon_date
215
302
  end
216
303
 
217
304
  def first_coupon_factor
@@ -227,6 +314,13 @@ module Istox
227
314
 
228
315
  def accrued_interest_factor
229
316
  date1 = @coupon_payment_dates.last
317
+ if @coupon_payment_dates_include_accrued_interest
318
+ if @coupon_payment_dates.count > 1
319
+ date1 = @coupon_payment_dates[@coupon_payment_dates.count-2]
320
+ else
321
+ raise "coupon_payment_dates count < 2 and we have accrued interest payment date included!!!"
322
+ end
323
+ end
230
324
  date2 = @maturity_date
231
325
  date3 = add_month(date1, (12/@coupon_frequency).to_i)
232
326
  day_count_factor(date1, date2, date3)
@@ -234,8 +328,10 @@ module Istox
234
328
 
235
329
  def day_count_factor(date1, date2, date3)
236
330
  if is_365?
331
+ # Actual/365
237
332
  (date2 - date1)/(date3 - date1)/@coupon_frequency
238
333
  else
334
+ # 30/360
239
335
  d1 = [date1.day, 30].min
240
336
  d2 = date2.day
241
337
  if d1 == 30
data/lib/istox/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Istox
2
- VERSION = '0.2.1'.freeze
2
+ VERSION = '0.2.4'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: istox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Siong Leng
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-03-10 00:00:00.000000000 Z
11
+ date: 2021-04-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: amazing_print