istox 0.2.1 → 0.2.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 177c19f4e1837c38552a01345f4df0ed0b6d75489c3ff7ac7cee4e6ae5300e9d
4
- data.tar.gz: bc1e0d56a7c66ebbbb11f2408e8dfbbec8fad3bb056eafd08ca89aa3b7af712c
3
+ metadata.gz: 2518cababd563f81557ac460906f73cbae0be78c6e570170a513f3f7b690dcd1
4
+ data.tar.gz: ff2dc59cac13cc28cbf01228f796e7f67631ff8220a9e167f1304527f7a73201
5
5
  SHA512:
6
- metadata.gz: 3b4c6ea1b0f4dade5ce454996d6d72c454560d641a4bba722385db12da19e8531bc6fcab904cb372b664ec3952047510942b92da16bf3ab3dcc28bbde685d603
7
- data.tar.gz: 5d5e23ac455354401407c93507526fb616e4d7fba272e4883f7a142c86f0bff8c282101aec833b63ae810e90f914c8a273825306673de9c40f6e96908a6123fd
6
+ metadata.gz: 8661709ddff2d71dc54205e52ad0bf864b4707d806e57741f2b5f866acddbe66ca0df3a34a3371df80ec0c7b0b32b57f4d5d29908400ad210a6260625a0dc029
7
+ data.tar.gz: 1a0bf97e6c7efb5f11dea2042069643a07b78c7c6585e9db1f3be1c71d0f80ff1f3506c437538cfd440be0421d4664b01b2194fc3dd241c26da1f2be768a466f
@@ -13,44 +13,81 @@ module Istox
13
13
  DEFAULT_APPROXIMATION_ERROR = 0.00001
14
14
 
15
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))
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 years #{years}" if (years.nil? || !is_number?(years)) || years < 0
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
22
 
23
23
  @coupon = coupon.to_d
24
24
  @maturity_date = maturity_date.to_date
25
- @years = years
26
- @coupon_frequency = coupon_frequency.to_d
25
+ @years = years.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
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
30
31
  @start_date = (@maturity_date-(years*12).to_i.months)
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=#{@start_date} maturity=#{@maturity_date} years=#{@years} 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
+ ytm_down, ytm_up = ytm_limits(price, date, ex_coupon_date: ex_coupon_date, fees: fees)
77
+ approximate_ytm(ytm_down, ytm_up, price, date, ex_coupon_date: ex_coupon_date, fees: fees, approximation_error: approximation_error)
78
+ end
79
+
80
+
81
+ private
48
82
 
49
83
  def coupon_payments(from_date)
50
84
  payments = []
51
85
 
52
86
  @coupon_payment_dates.each do |payment_date|
53
- if payment_date > from_date
87
+ # if from_date falls on coupon payment date or maturity date, we
88
+ # still add them to make it easy for calculation later
89
+ # just that the first coupon will not pay actually (i.e. 0)
90
+ if payment_date >= from_date && payment_date <= @maturity_date
54
91
  payments << payment_date
55
92
  end
56
93
  end
@@ -58,29 +95,25 @@ module Istox
58
95
  payments.sort
59
96
  end
60
97
 
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
98
  def is_365?
70
99
  @days_of_year == 365.to_d
71
100
  end
72
101
 
73
- def add_month(mydate, n)
74
- if mydate.month != mydate.next_day.month
102
+ def is_zero_coupon?
103
+ @coupon == 0
104
+ end
105
+
106
+ def add_month(my_date, n)
107
+ if is_month_end?(my_date)
75
108
  # month end
76
- (mydate.next_day + n.month).prev_day
109
+ (my_date.next_day + n.month).prev_day
77
110
  else
78
- mydate.next_day + n.month
111
+ my_date.next_day + n.month
79
112
  end
80
113
  end
81
114
 
82
- def is_month_end?(mydate)
83
- mydate.month != mydate.next_day.month
115
+ def is_month_end?(my_date)
116
+ my_date.month != my_date.next_day.month
84
117
  end
85
118
 
86
119
  def is_number?(val)
@@ -107,111 +140,158 @@ module Istox
107
140
  [ytm_down, ytm_up]
108
141
  end
109
142
 
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
143
  def price_for_irr(irr, date, ex_coupon_date: nil, fees: 0)
122
144
  date = date.to_date
123
145
  raise "Date is after maturity_date!" if date > @maturity_date
146
+ # if today is maturity, price is face value (without including any coupon/accrued interest)
147
+ # BizOps said we will always have an ex_coupon_date when it's close
148
+ # to coupon payment date but not too early, so the fomula needs to
149
+ # accept nil value. Even if we have ex_coupon_date as maturity date,
150
+ # buyer is not eligible to receive the coupon, so no need to check ex_coupon_date here
151
+ if date == @maturity_date
152
+ return @face_value
153
+ end
154
+
124
155
  if date <= @start_date
125
156
  date = @start_date
126
157
  end
127
- last_coupon_payday = @coupon_payment_dates.last
128
- payment_dates = coupon_payments(date)
129
158
 
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
159
+ # it's supporting 365 actual days only at the moment
160
+ if is_zero_coupon?
161
+ total_days = @maturity_date - date
162
+ no_of_years = total_days / 365
163
+ remainder_days = total_days % 365
164
+
165
+ return @face_value/(1+irr)**no_of_years/(1+irr*remainder_days/365)
166
+ end
167
+
168
+ payment_dates = coupon_payments(date)
169
+
170
+ no_regular_coupon_before_maturity = false
171
+ if payment_dates.count == 0 || (payment_dates.count == 1 && payment_dates.first == @maturity_date)
172
+ no_regular_coupon_before_maturity = true
173
+ end
174
+
175
+ if no_regular_coupon_before_maturity
176
+ discount_factor = nil
132
177
  if is_365?
133
- discount_factor = 1.0/(1+irr*(@maturity_date-date)/accrued_interest_discount_days)
178
+ previous_coupon_date = previous_coupon_date_before_maturity
179
+ next_coupon_date = add_month(previous_coupon_date, (12/@coupon_frequency).to_i)
180
+ discount_factor = 1.0/(1+irr*(@maturity_date-date)/(next_coupon_date-previous_coupon_date))
134
181
  else
135
182
  discount_factor = 1.0/(1+irr*@coupon_frequency*day_count_factor(date, @maturity_date, nil))
136
183
  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
184
+
185
+ if @pay_accrued_interest
186
+ # accrued interest + face value discounted to the current date
187
+ discounted_accrued_interest = 0
188
+ if ex_coupon_date.nil? || ex_coupon_date == @maturity_date
189
+ discounted_accrued_interest = @coupon*@face_value*accrued_interest_factor*discount_factor
190
+ end
191
+ discounted_face_value = @face_value*discount_factor
192
+ price = discounted_accrued_interest + discounted_face_value
193
+ return price
194
+ else
195
+ discounted_last_coupon = 0
196
+ if ex_coupon_date.nil? || ex_coupon_date == @maturity_date
197
+ discounted_last_coupon = @coupon*@face_value/@coupon_frequency*discount_factor
198
+ end
199
+ discounted_face_value = @face_value*discount_factor
200
+ price = discounted_last_coupon + discounted_face_value
201
+ return price
202
+ end
140
203
  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
204
+ # there are at least 1 more regular coupon payment before maturity
205
+ # we discount face value and coupons or accrued interest if any
206
+ # to the first coupon payment date left, and then discount the total to current date
143
207
  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)
208
+ # this is the discount period excluding the one at maturity date
209
+ # e.g. payment_dates has date1, date2, maturity, we need to discount
210
+ # to date1, excluding the period from date2 to maturity, period = 1 (i.e. from date2 to date1)
211
+ no_of_discount_period = 0
212
+ if @pay_accrued_interest
213
+ if @coupon_payment_dates_include_accrued_interest
214
+ no_of_discount_period = payment_dates.count-2
215
+ else
216
+ no_of_discount_period = payment_dates.count-1
217
+ end
218
+
219
+ discount_factor = 1.0/((1+irr)**no_of_discount_period)/(1+irr*@coupon_frequency*accrued_interest_factor)
152
220
  discounted_accrued_interest = @coupon*@face_value*accrued_interest_factor*discount_factor
153
221
  discounted_face_value = @face_value*discount_factor
154
222
  value_at_first_coupon = discounted_accrued_interest + discounted_face_value
155
- end
223
+ else
224
+ # last coupon payment is at maturity
225
+ no_of_discount_period = payment_dates.count-1
226
+ discount_factor = 1.0/(1+irr)**(no_of_discount_period+1)
227
+ discounted_last_coupon = @coupon*@face_value/@coupon_frequency*discount_factor
228
+ discounted_face_value = @face_value*discount_factor
229
+ value_at_first_coupon = discounted_last_coupon + discounted_face_value
230
+ end
156
231
 
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
232
+ if no_of_discount_period >= 1
233
+ # discount all coupon payments excluding first one to first coupon date
234
+ for n in 1..no_of_discount_period do
235
+ value_at_first_coupon += @coupon*@face_value/@coupon_frequency/(1+irr)**n
236
+ end
172
237
  end
173
238
 
239
+ # first coupon, can be pro-rated, check the logic in first_coupon
240
+ value_at_first_coupon += first_coupon(date, ex_coupon_date)
241
+
174
242
  if @coupon_payment_dates.include?(date)
175
243
  # today is one of the coupon payment, no need to discount
176
244
  price = value_at_first_coupon
177
245
  else
178
246
  # discount value at first coupon date to present value
179
247
  if is_365?
180
- price = value_at_first_coupon/(1+irr*(payment_dates.first-date)/first_coupon_discount_days(date))/(1+fees)
248
+ previous_coupon_date = add_month(payment_dates.first, -(12/@coupon_frequency).to_i)
249
+ price = value_at_first_coupon/(1+irr*(payment_dates.first-date)/(payment_dates.first-previous_coupon_date))/(1+fees)
181
250
  else
182
251
  price = value_at_first_coupon/(1+irr*@coupon_frequency*day_count_factor(date, payment_dates.first, nil))/(1+fees)
183
252
  end
184
253
  end
254
+ return price
185
255
  end
186
-
187
256
  end
188
257
 
189
258
  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
259
+ # BizOps said we will always have an ex_coupon_date when it's close
260
+ # to coupon payment date but not too early, so the fomula needs to
261
+ # accept nil value.
262
+ if !ex_coupon_date.nil? && date >= ex_coupon_date.to_date
263
+ # on or after ex coupon date, buyer won't receive next coupon
264
+ return 0.to_d
265
+ elsif @coupon_payment_dates.include?(date)
266
+ # if today is a coupon payment date, for sure it will pass ex_coupon_date
267
+ # and buyer won't receive this coupon. In case ex_coupon_date is nil,
268
+ # that's the same assumption, so first_coupon will be 0
194
269
  return 0.to_d
195
270
  end
196
271
 
197
272
  coupon = @coupon*@face_value/@coupon_frequency
198
- if date <= @coupon_payment_dates.first
273
+ # For the very first coupon, we need to check if it needs to be pro-rated
274
+ if date <= @coupon_payment_dates.first && @pay_accrued_interest
199
275
  coupon = @coupon*@face_value*first_coupon_factor
200
276
  end
201
277
  coupon
202
278
  end
203
279
 
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
209
-
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
280
+ def previous_coupon_date_before_maturity
281
+ previous_coupon_date = @start_date
282
+ if @coupon_payment_dates.include?(@maturity_date)
283
+ # maturity date is in coupon payment dates, get the previous coupon date
284
+ # if there is no more date, use the default start date
285
+ if @coupon_payment_dates.count > 1
286
+ previous_coupon_date = @coupon_payment_dates[@coupon_payment_dates.count-2]
287
+ end
288
+ else
289
+ # maturity date is not in coupon payment dates, get the last coupon date
290
+ # if there is no more date, use the default start date
291
+ if @coupon_payment_dates.count > 0
292
+ previous_coupon_date = @coupon_payment_dates.last
293
+ end
294
+ end
215
295
  end
216
296
 
217
297
  def first_coupon_factor
@@ -227,6 +307,13 @@ module Istox
227
307
 
228
308
  def accrued_interest_factor
229
309
  date1 = @coupon_payment_dates.last
310
+ if @coupon_payment_dates_include_accrued_interest
311
+ if @coupon_payment_dates.count > 1
312
+ date1 = @coupon_payment_dates[@coupon_payment_dates.count-2]
313
+ else
314
+ raise "coupon_payment_dates count < 2 and we have accrued interest payment date included!!!"
315
+ end
316
+ end
230
317
  date2 = @maturity_date
231
318
  date3 = add_month(date1, (12/@coupon_frequency).to_i)
232
319
  day_count_factor(date1, date2, date3)
@@ -234,8 +321,10 @@ module Istox
234
321
 
235
322
  def day_count_factor(date1, date2, date3)
236
323
  if is_365?
324
+ # Actual/365
237
325
  (date2 - date1)/(date3 - date1)/@coupon_frequency
238
326
  else
327
+ # 30/360
239
328
  d1 = [date1.day, 30].min
240
329
  d2 = date2.day
241
330
  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.2'.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.2
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-03-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: amazing_print