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 +4 -4
- data/lib/istox/quant/bond.rb +194 -98
- data/lib/istox/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5d4f2a0587825e06d3ff6a6a3ad516f20b90f1ca7e59e11d686b03c892b8c441
|
4
|
+
data.tar.gz: 3b6f8c0f67615b5165f9030343e0713f2b4562109632f93306ebb6a412d87aeb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6fb65f68fed5082be9d4b8ecd73547a06cc77224baf3e57296974768351127c0d39dc51d063c7855d38921bb7ca4925a1edd36eb097747ceb7da83bd1568379f
|
7
|
+
data.tar.gz: 0be64c5925913cd5fad7242bc0604bf2f553f3d69a5969c1d3165571967d570d8cd2380d8b57b9e15b1af230b2c2f8a516c0bdd59ce59b9a8f9688d948b7406c
|
data/lib/istox/quant/bond.rb
CHANGED
@@ -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,
|
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
|
19
|
-
raise "Invalid coupon_frequency #{
|
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
|
-
@
|
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
|
-
|
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
|
-
|
35
|
-
|
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
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
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
|
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
|
74
|
-
|
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
|
-
(
|
114
|
+
(my_date.next_day + n.month).prev_day
|
77
115
|
else
|
78
|
-
|
116
|
+
my_date.next_day + n.month
|
79
117
|
end
|
80
118
|
end
|
81
119
|
|
82
|
-
def is_month_end?(
|
83
|
-
|
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
|
-
|
131
|
-
|
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
|
-
|
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
|
-
|
138
|
-
|
139
|
-
|
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
|
-
#
|
142
|
-
#
|
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
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
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
|
-
|
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
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
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
|
-
|
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
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
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
|
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
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
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
|
-
|
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
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.
|
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-
|
11
|
+
date: 2021-04-08 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: amazing_print
|