istox 0.1.83 → 0.1.84
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/lib/istox/helpers/f_math.rb +10 -8
- data/lib/istox/quant/bond.rb +186 -47
- data/lib/istox/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 854004247090c0884aa24fa0c77208e6cad407509e24762291d1c1080e9d387c
|
4
|
+
data.tar.gz: e4a725fb85ecc5f84361da376613e2202a1e43bbcec39bbd831c3d1808f8fd5a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b207971e2dc4c7b7239f216c3a9275ba70bed62288b8f010f78058c508fa7041c0c521ae0d276ce2f93a8d29affea1f9ef206032792cee3b26a060b4e03f1f3a
|
7
|
+
data.tar.gz: c2f77e3c9af27b0a7327128496960a518b714d9f71876156e1a886ec7b0c1c87c56c45357bf46c50d79943cb5559ddc28eddf3051a99e0af76f16db016208068
|
data/Gemfile.lock
CHANGED
data/lib/istox/helpers/f_math.rb
CHANGED
@@ -44,20 +44,22 @@ module Istox
|
|
44
44
|
x = 0 if x.blank?
|
45
45
|
y = 0 if y.blank?
|
46
46
|
|
47
|
-
x = to_fixed(x)
|
48
|
-
y = to_fixed(y)
|
47
|
+
# x = to_fixed(x)
|
48
|
+
# y = to_fixed(y)
|
49
49
|
|
50
|
-
|
50
|
+
(::BigDecimal.new(x.to_s) / ::BigDecimal.new(y.to_s)).truncate(18).to_s
|
51
51
|
|
52
|
-
|
52
|
+
# return ::BigDecimal.new('1').to_s if x == y
|
53
53
|
|
54
|
-
|
54
|
+
# fixed1 = ::BigDecimal.new('1e18')
|
55
55
|
|
56
|
-
|
56
|
+
# return from_fixed(x.div(y, 100).mult(fixed1, 100)).to_s if x.div(y, 100).modulo(BigDecimal.new(10)).zero? && x.modulo(y).zero?
|
57
57
|
|
58
|
-
|
58
|
+
# r_y = fixed1.mult(fixed1, 100).truncate(0).div(y, 100).truncate(0)
|
59
59
|
|
60
|
-
result.truncate(
|
60
|
+
# result = from_fixed(x.mult(r_y, 100).truncate(0).div(fixed1, 100).truncate(0))
|
61
|
+
|
62
|
+
# result.truncate(18).to_s
|
61
63
|
end
|
62
64
|
|
63
65
|
def round_up(x, digits)
|
data/lib/istox/quant/bond.rb
CHANGED
@@ -1,93 +1,232 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
1
|
require 'date'
|
3
2
|
|
4
|
-
#
|
5
|
-
|
6
|
-
#
|
7
|
-
|
8
|
-
# bond = Istox::Quant::Bond.new(coupon: 0.0622, maturity_date: Date.parse('2016-08-22'), coupon_frequency: 4)
|
9
|
-
# bond.ytm(Date.parse('2015-09-07'), price: 100.8)
|
10
|
-
# It will not count exact yield, but it will approximate it - you can set maximum approximation error by adding approximation_error parameter to the method.
|
11
|
-
|
12
|
-
# You can also check interest payments dates:
|
13
|
-
|
14
|
-
# bond = Istox::Quant::Bond.new(coupon: 0.0622, maturity_date: Date.parse('2016-08-22'), coupon_frequency: 4)
|
15
|
-
# bond.interest_payments(Date.parse('2015-09-07'))
|
16
|
-
# Currently it will not skip weekends or other days without bond quotations, so it can be inaccurate.
|
17
|
-
|
18
|
-
# Also it's possible to check price on which it will generate given yield:
|
19
|
-
|
20
|
-
# bond = Istox::Quant::Bond.new(coupon: 0.0622, maturity_date: Date.parse('2016-08-22'), coupon_frequency: 4)
|
21
|
-
# bond.price(0.05156, Date.parse('2015-09-07'))
|
3
|
+
# https://en.wikipedia.org/wiki/Day_count_convention#30/360_Bond_Basis
|
4
|
+
# For 360 basis: 30/360 Bond Basis
|
5
|
+
# For 365 basis: Actual/Actual ICMA
|
22
6
|
|
7
|
+
# This class is modified for ISTOX use case based on open source Bondie
|
23
8
|
|
24
9
|
module Istox
|
25
10
|
module Quant
|
26
11
|
class Bond
|
27
12
|
|
28
|
-
DEFAULT_APPROXIMATION_ERROR = 0.
|
13
|
+
DEFAULT_APPROXIMATION_ERROR = 0.00001
|
14
|
+
|
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))
|
17
|
+
raise "Invalid maturity_date #{maturity_date}" if (maturity_date.nil? || maturity_date.methods.include?("strftime"))
|
18
|
+
raise "Invalid years #{years}" if (years.nil? || !years.is_a?(Integer))
|
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 })
|
21
|
+
raise "Invalid days_of_year #{days_of_year}" if (days_of_year != 365 && days_of_year != 360)
|
29
22
|
|
30
|
-
def initialize(coupon: nil, maturity_date: nil, coupon_frequency: nil, face_value: 100, days_of_year: 365)
|
31
23
|
@coupon = coupon
|
32
24
|
@maturity_date = maturity_date
|
25
|
+
@years = years
|
33
26
|
@coupon_frequency = coupon_frequency
|
34
27
|
@days_of_year = days_of_year
|
35
28
|
@face_value = face_value
|
29
|
+
@coupon_payment_dates = coupon_payment_dates.sort
|
30
|
+
@start_date = (@maturity_date-@years.years)
|
36
31
|
end
|
37
32
|
|
38
|
-
def price(ytm, date, fees: 0)
|
39
|
-
price_for_irr(irr_from_ytm(ytm), date, fees: fees)
|
33
|
+
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)
|
36
|
+
price.round(2)
|
40
37
|
end
|
41
38
|
|
42
|
-
def interest_payments(from_date)
|
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
|
48
|
+
|
49
|
+
def coupon_payments(from_date)
|
43
50
|
payments = []
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
51
|
+
|
52
|
+
@coupon_payment_dates.each do |payment_date|
|
53
|
+
if payment_date > from_date
|
54
|
+
payments << payment_date
|
55
|
+
end
|
48
56
|
end
|
57
|
+
|
49
58
|
payments.sort
|
50
59
|
end
|
51
60
|
|
52
|
-
def ytm(date, price: 100, fees: 0, approximation_error: DEFAULT_APPROXIMATION_ERROR)
|
53
|
-
ytm_down, ytm_up = ytm_limits(price, date, fees: fees)
|
54
|
-
approximate_ytm(ytm_down, ytm_up, price, date, fees: fees, approximation_error: approximation_error)
|
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)
|
55
64
|
end
|
56
65
|
|
57
66
|
|
58
67
|
private
|
59
68
|
|
60
|
-
def
|
61
|
-
|
69
|
+
def is_365?
|
70
|
+
@days_of_year == 365
|
71
|
+
end
|
72
|
+
|
73
|
+
def add_month(mydate, n)
|
74
|
+
if mydate.month != mydate.next_day.month
|
75
|
+
# month end
|
76
|
+
(mydate.next_day + n.month).prev_day
|
77
|
+
else
|
78
|
+
mydate.next_day + n.month
|
79
|
+
end
|
62
80
|
end
|
63
81
|
|
82
|
+
def is_month_end?(mydate)
|
83
|
+
mydate.month != mydate.next_day.month
|
84
|
+
end
|
85
|
+
|
86
|
+
def is_number?(val)
|
87
|
+
val.is_a?(Integer) || val.is_a?(Float) || val.is_a?(BigDecimal)
|
88
|
+
end
|
89
|
+
|
90
|
+
def irr_from_ytm(ytm)
|
91
|
+
(ytm/@coupon_frequency.to_d + 1) ** @coupon_frequency - 1
|
92
|
+
end
|
64
93
|
|
65
|
-
def approximate_ytm(ytm_down, ytm_up, price, date, fees: 0, approximation_error: DEFAULT_APPROXIMATION_ERROR)
|
94
|
+
def approximate_ytm(ytm_down, ytm_up, price, date, ex_coupon_date: nil, fees: 0, approximation_error: DEFAULT_APPROXIMATION_ERROR)
|
66
95
|
approx_ytm = (ytm_up + ytm_down) / 2.0
|
67
96
|
return approx_ytm if ((ytm_up - approx_ytm)/approx_ytm).abs <= approximation_error
|
68
|
-
p = price(approx_ytm, date, fees: fees)
|
97
|
+
p = price(approx_ytm, date, ex_coupon_date: ex_coupon_date, fees: fees)
|
69
98
|
ytm_down, ytm_up = p < price ? [ytm_down, approx_ytm] : [approx_ytm, ytm_up]
|
70
|
-
approximate_ytm(ytm_down, ytm_up, price, date, fees: fees, approximation_error: approximation_error)
|
99
|
+
approximate_ytm(ytm_down, ytm_up, price, date, ex_coupon_date: ex_coupon_date, fees: fees, approximation_error: approximation_error)
|
71
100
|
end
|
72
101
|
|
73
|
-
def ytm_limits(price, date, fees: 0)
|
102
|
+
def ytm_limits(price, date, ex_coupon_date: nil, fees: 0)
|
74
103
|
ytm_up = 0.1
|
75
|
-
ytm_up *= 10 while price(ytm_up, date, fees: fees) > price
|
104
|
+
ytm_up *= 10 while price(ytm_up, date, ex_coupon_date: ex_coupon_date,fees: fees) > price
|
76
105
|
# IRR will be never lower than -1, so YTM should be never lower than -coupon_frequency (see irr_from_ytm)
|
77
|
-
ytm_down = price(0, date, fees: fees) > price ? 0.0 : -@coupon_frequency.
|
106
|
+
ytm_down = price(0, date, ex_coupon_date: ex_coupon_date, fees: fees) > price ? 0.0 : -@coupon_frequency.to_d
|
78
107
|
[ytm_down, ytm_up]
|
79
108
|
end
|
80
109
|
|
81
|
-
def price_for_irr(irr, date, fees: 0)
|
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
|
+
def price_for_irr(irr, date, ex_coupon_date: nil, fees: 0)
|
82
122
|
raise "Date is after maturity_date!" if date > @maturity_date
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
123
|
+
if date <= @start_date
|
124
|
+
date = @start_date
|
125
|
+
end
|
126
|
+
last_coupon_payday = @coupon_payment_dates.last
|
127
|
+
payment_dates = coupon_payments(date)
|
128
|
+
|
129
|
+
if payment_dates.count == 0
|
130
|
+
# no more coupon payments, we do only 1 discount of accrued interest and face value to the current date
|
131
|
+
if is_365?
|
132
|
+
discount_factor = 1/(1+irr*(@maturity_date-date)/accrued_interest_discount_days)
|
133
|
+
else
|
134
|
+
discount_factor = 1/(1+irr*@coupon_frequency*day_count_factor(date, @maturity_date, nil))
|
135
|
+
end
|
136
|
+
discounted_accrued_interest = @coupon*@face_value*accrued_interest_factor*discount_factor
|
137
|
+
discounted_face_value = @face_value*discount_factor
|
138
|
+
price = discounted_accrued_interest + discounted_face_value
|
139
|
+
else
|
140
|
+
# we discount face value and coupons and accrued interest (coupon) at maturity (if any)
|
141
|
+
# to the first coupon payment left, and then discount the total to current date
|
142
|
+
value_at_first_coupon = 0
|
143
|
+
if payment_dates.include?(@maturity)
|
144
|
+
# last coupon payment is at maturity
|
145
|
+
discount_factor = 1/(1+irr)**(payment_dates.count-1)
|
146
|
+
value_at_first_coupon = @face_value*discount_factor
|
147
|
+
else
|
148
|
+
# last coupon is not at maturity, need to add accrued interest (coupon)
|
149
|
+
discount_factor = 1/((1+irr)**(payment_dates.count-1))/(1+irr*@coupon_frequency*accrued_interest_factor)
|
150
|
+
discounted_accrued_interest = @coupon*@face_value*accrued_interest_factor*discount_factor
|
151
|
+
discounted_face_value = @face_value*discount_factor
|
152
|
+
value_at_first_coupon = discounted_accrued_interest + discounted_face_value
|
153
|
+
end
|
154
|
+
|
155
|
+
# first coupon can be pro-rata
|
156
|
+
value_at_first_coupon += first_coupon(date, ex_coupon_date)
|
157
|
+
if payment_dates.count > 1
|
158
|
+
for n in 1..payment_dates.count-1 do
|
159
|
+
value_at_first_coupon += @coupon*@face_value/@coupon_frequency/(1+irr)**n
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
# discount value at first coupon date to present value
|
164
|
+
if is_365?
|
165
|
+
price = value_at_first_coupon/(1+irr*(payment_dates.first-date)/first_coupon_discount_days(date))/(1+fees)
|
166
|
+
else
|
167
|
+
price = value_at_first_coupon/(1+irr*@coupon_frequency*day_count_factor(date, payment_dates.first, nil))/(1+fees)
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
end
|
172
|
+
|
173
|
+
def first_coupon(date, ex_coupon_date)
|
174
|
+
if !ex_coupon_date.nil? && date > ex_coupon_date
|
175
|
+
# after ex coupon date, buyer won't receive next coupon
|
176
|
+
return 0
|
177
|
+
end
|
178
|
+
|
179
|
+
coupon = @coupon*@face_value/@coupon_frequency
|
180
|
+
if date <= @coupon_payment_dates.first
|
181
|
+
coupon = @coupon*@face_value*first_coupon_factor
|
182
|
+
end
|
183
|
+
coupon
|
184
|
+
end
|
185
|
+
|
186
|
+
def accrued_interest_discount_days
|
187
|
+
# the days between last coupon payment date and the next coupon payment date if maturity was longer
|
188
|
+
last_coupon_payday = @coupon_payment_dates.last
|
189
|
+
(add_month(last_coupon_payday, (12/@coupon_frequency).to_i) - last_coupon_payday).to_i
|
190
|
+
end
|
191
|
+
|
192
|
+
def first_coupon_discount_days(date)
|
193
|
+
# first coupon date is based on current date
|
194
|
+
payment_dates = coupon_payments(date)
|
195
|
+
first_coupon_date = payment_dates.first
|
196
|
+
(first_coupon_date - add_month(first_coupon_date, -(12/@coupon_frequency).to_i)).to_i
|
197
|
+
end
|
198
|
+
|
199
|
+
def first_coupon_factor
|
200
|
+
date1 = @start_date
|
201
|
+
date2 = @coupon_payment_dates.first
|
202
|
+
date3 = add_month(date2, -(12/@coupon_frequency).to_i)
|
203
|
+
if is_365?
|
204
|
+
(date2 - date1)/(date2 - date3)/@coupon_frequency
|
205
|
+
else
|
206
|
+
day_count_factor(date1, date2, date3)
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
def accrued_interest_factor
|
211
|
+
date1 = @coupon_payment_dates.last
|
212
|
+
date2 = @maturity_date
|
213
|
+
date3 = add_month(date1, (12/@coupon_frequency).to_i)
|
214
|
+
day_count_factor(date1, date2, date3)
|
90
215
|
end
|
216
|
+
|
217
|
+
def day_count_factor(date1, date2, date3)
|
218
|
+
if is_365?
|
219
|
+
(date2 - date1)/(date3 - date1)/@coupon_frequency
|
220
|
+
else
|
221
|
+
d1 = [date1.day, 30].min
|
222
|
+
d2 = date2.day
|
223
|
+
if d1 == 30
|
224
|
+
d2 = [d2, 30].min
|
225
|
+
end
|
226
|
+
(360*(date2.year - date1.year) + 30*(date2.month - date1.month) + d2 - d1)/360.to_d
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
91
230
|
end
|
92
231
|
end
|
93
232
|
end
|
data/lib/istox/version.rb
CHANGED