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 +4 -4
- data/lib/istox/quant/bond.rb +186 -97
- 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: 2518cababd563f81557ac460906f73cbae0be78c6e570170a513f3f7b690dcd1
|
4
|
+
data.tar.gz: ff2dc59cac13cc28cbf01228f796e7f67631ff8220a9e167f1304527f7a73201
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8661709ddff2d71dc54205e52ad0bf864b4707d806e57741f2b5f866acddbe66ca0df3a34a3371df80ec0c7b0b32b57f4d5d29908400ad210a6260625a0dc029
|
7
|
+
data.tar.gz: 1a0bf97e6c7efb5f11dea2042069643a07b78c7c6585e9db1f3be1c71d0f80ff1f3506c437538cfd440be0421d4664b01b2194fc3dd241c26da1f2be768a466f
|
data/lib/istox/quant/bond.rb
CHANGED
@@ -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 #{
|
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.
|
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
|
-
|
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
|
-
# 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
|
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
|
74
|
-
|
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
|
-
(
|
109
|
+
(my_date.next_day + n.month).prev_day
|
77
110
|
else
|
78
|
-
|
111
|
+
my_date.next_day + n.month
|
79
112
|
end
|
80
113
|
end
|
81
114
|
|
82
|
-
def is_month_end?(
|
83
|
-
|
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
|
-
|
131
|
-
|
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
|
-
|
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
|
-
|
138
|
-
|
139
|
-
|
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
|
-
#
|
142
|
-
#
|
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
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
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
|
-
|
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
|
-
|
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
|
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
|
-
|
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
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
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
|
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
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
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
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.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-
|
11
|
+
date: 2021-03-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: amazing_print
|