istox 0.1.83 → 0.1.84
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/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