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