istox 0.2.1 → 0.2.2

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: 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