BondPricing.jl (12201B)
1 # OTHER FUNCTIONS TO WORK WITH BONDS ... NOT DIRECTLY RELATED TO TREASURIES ... 2 """ 3 bond_yield_excel(settlement, maturity, rate, price, redemption; 4 frequency=2, basis=0) -> Float64 5 6 Calculate the yield to maturity of a bond using Excel-compatible YIELD function interface. 7 8 This function provides an Excel-compatible API for calculating bond yield to maturity, 9 matching the behavior and parameter conventions of Excel's `YIELD()` function. It 10 internally converts the date-based inputs to the time-to-maturity format required 11 by the underlying `bond_yield()` function. 12 13 # Arguments 14 - `settlement::Date`: Settlement date of the bond (when the bond is purchased) 15 - `maturity::Date`: Maturity date of the bond (when principal is repaid) 16 - `rate::Real`: Annual coupon rate as a decimal (e.g., 0.0575 for 5.75%) 17 - `price::Real`: Bond's price per 100 of face value 18 - `redemption::Real`: Redemption value per 100 of face value (typically 100) 19 20 # Keyword Arguments 21 - `frequency::Integer=2`: Number of coupon payments per year 22 - `1` = Annual 23 - `2` = Semiannual (default) 24 - `4` = Quarterly 25 - `basis::Integer=0`: Day count basis for calculating time periods 26 - `0` = 30/360 (default) 27 - `1` = Actual/actual 28 - `2` = Actual/360 29 - `3` = Actual/365 30 - `4` = European 30/360 31 32 # Returns 33 - `Float64`: Annual yield to maturity as a decimal (e.g., 0.065 for 6.5%) 34 35 # Excel Compatibility 36 This function replicates Excel's `YIELD(settlement, maturity, rate, price, redemption, frequency, basis)` 37 function with identical parameter meanings and calculation methodology. 38 39 # Example (Excel Documentation Case) 40 ```julia 41 using Dates 42 43 # Excel example data: 44 settlement = Date(2008, 2, 15) # 15-Feb-08 Settlement date 45 maturity = Date(2016, 11, 15) # 15-Nov-16 Maturity date 46 rate = 0.0575 # 5.75% Percent coupon 47 price = 95.04287 # Price per 100 face value 48 redemption = 100.0 # 100 Redemption value 49 frequency = 2 # Semiannual frequency 50 basis = 0 # 30/360 basis 51 52 # Calculate yield (matches Excel YIELD function) 53 ytm = bond_yield_excel(settlement, maturity, rate, price, redemption, 54 frequency=frequency, basis=basis) 55 # Result: 0.065 (6.5%) 56 57 # Equivalent Excel formula: =YIELD(A2,A3,A4,A5,A6,A7,A8) 58 # where cells contain the values above 59 ``` 60 61 # Additional Examples 62 ```julia 63 # Corporate bond with quarterly payments 64 settlement = Date(2024, 1, 15) 65 maturity = Date(2029, 1, 15) 66 ytm = bond_yield_excel(settlement, maturity, 0.045, 98.50, 100.0, 67 frequency=4, basis=1) 68 69 # Government bond with annual payments, actual/365 basis 70 ytm = bond_yield_excel(Date(2024, 3, 1), Date(2034, 3, 1), 71 0.0325, 102.25, 100.0, frequency=1, basis=3) 72 ``` 73 74 # Notes 75 - Settlement date must be before maturity date 76 - Price and redemption are typically quoted per 100 of face value 77 - Uses actual coupon dates and the specified day-count basis, matching Excel's computation 78 - Results should match Excel's YIELD function within numerical precision 79 80 # Throws 81 - `ArgumentError`: If settlement ≥ maturity date 82 - Convergence errors from underlying numerical root-finding 83 84 See also: [`bond_yield`](@ref) 85 """ 86 function bond_yield_excel( 87 settlement::Date, maturity::Date, rate::Real, price::Real, redemption::Real; 88 frequency = 2, basis = 0) 89 90 if settlement >= maturity 91 throw(ArgumentError("Settlement ($settlement) must be before maturity ($maturity)")) 92 end 93 94 # Compute coupon schedule by working backwards from maturity 95 period_months = div(12, frequency) 96 97 # Find next coupon date after settlement 98 next_coupon = maturity 99 while next_coupon - Month(period_months) > settlement 100 next_coupon -= Month(period_months) 101 end 102 prev_coupon = next_coupon - Month(period_months) 103 104 # Count remaining coupons (from next_coupon to maturity, inclusive) 105 N = 0 106 d = next_coupon 107 while d <= maturity 108 N += 1 109 d += Month(period_months) 110 end 111 112 # Day count fractions using the specified basis 113 A = _day_count_days(prev_coupon, settlement, basis) # accrued days 114 E = _day_count_days(prev_coupon, next_coupon, basis) # days in coupon period 115 DSC = E - A # Excel defines DSC = E - A to ensure consistency 116 117 α = DSC / E # fraction of period until next coupon 118 coupon = redemption * rate / frequency 119 120 # Excel's YIELD pricing formula 121 function price_from_yield(y) 122 if y <= 0 123 return Inf 124 end 125 126 dr = y / frequency 127 128 if N == 1 129 # Special case: single remaining coupon 130 return (redemption + coupon) / (1 + α * dr) - coupon * A / E 131 end 132 133 # General case: N > 1 coupons 134 # PV of coupon annuity: ∑(k=1..N) coupon/(1+dr)^(k-1+α) = coupon*(1+dr)^(1-α)/dr * [1-(1+dr)^(-N)] 135 pv_coupons = coupon * (1 + dr)^(1 - α) * (1 - (1 + dr)^(-N)) / dr 136 # PV of redemption 137 pv_redemption = redemption / (1 + dr)^(N - 1 + α) 138 # Subtract accrued interest 139 return pv_coupons + pv_redemption - coupon * A / E 140 end 141 142 price_diff(y) = price_from_yield(y) - price 143 144 try 145 return Roots.find_zero(price_diff, (1e-6, 2.0), Roots.Brent()) 146 catch e 147 if isa(e, ArgumentError) && occursin("not a bracketing interval", sprint(showerror, e)) 148 @warn "Brent failed: falling back to Order1" exception=e 149 return Roots.find_zero(price_diff, rate, Roots.Order1()) 150 else 151 rethrow(e) 152 end 153 end 154 end 155 156 """ 157 bond_yield(price, face_value, coupon_rate, years_to_maturity, frequency; 158 method=:brent, bracket=(0.001, 1.0)) -> Float64 159 160 Calculate the yield to maturity (YTM) of a bond given its market price and characteristics. 161 162 This function uses numerical root-finding to determine the discount rate that equates the 163 present value of all future cash flows (coupon payments and principal repayment) to the 164 current market price of the bond. The calculation properly handles bonds with fractional 165 periods remaining until maturity and accounts for accrued interest. 166 167 # Arguments 168 - `price::Real`: Current market price of the bond 169 - `face_value::Real`: Par value or face value of the bond (principal amount) 170 - `coupon_rate::Real`: Annual coupon rate as a decimal (e.g., 0.05 for 5%) 171 - `years_to_maturity::Real`: Time to maturity in years (can be fractional) 172 - `frequency::Integer`: Number of coupon payments per year (e.g., 2 for semi-annual, 4 for quarterly) 173 174 # Keyword Arguments 175 - `method::Symbol=:brent`: Root-finding method (currently only :brent is implemented) 176 - `bracket::Tuple{Float64,Float64}=(0.001, 1.0)`: Initial bracket for yield search as (lower_bound, upper_bound) 177 178 # Returns 179 - `Float64`: The yield to maturity as an annual rate (decimal form) 180 181 # Algorithm Details 182 The function calculates bond price using the standard present value formula: 183 - For whole coupon periods: discounts each coupon payment to present value 184 - For fractional periods: applies fractional discounting and adjusts for accrued interest 185 - Handles the special case where yield approaches zero (no discounting) 186 - Uses the Brent method for robust numerical root-finding 187 188 The price calculation accounts for: 189 1. Present value of remaining coupon payments 190 2. Present value of principal repayment 191 3. Accrued interest adjustments for fractional periods 192 193 # Examples 194 ```julia 195 # Calculate YTM for a 5% annual coupon bond, 1000 face value, 3.5 years to maturity, 196 # semi-annual payments, currently priced at 950 197 ytm = bond_yield(950, 1000, 0.05, 3.5, 2) 198 199 # 10-year quarterly coupon bond 200 ytm = bond_yield(1050, 1000, 0.06, 10.0, 4) 201 202 # Bond very close to maturity (0.25 years) 203 ytm = bond_yield(998, 1000, 0.04, 0.25, 2) 204 ``` 205 206 # Notes 207 - The yield returned is the effective annual rate compounded at the specified frequency 208 - For bonds trading at a premium (price > face_value), expect YTM < coupon_rate 209 - For bonds trading at a discount (price < face_value), expect YTM > coupon_rate 210 - The function assumes the next coupon payment occurs exactly one period from now 211 - Requires the `Roots.jl` package for numerical root-finding 212 213 # Throws 214 - May throw convergence errors if the root-finding algorithm fails to converge 215 - Will return `Inf` for invalid yields (≤ 0) 216 217 See also: [`bond_yield_excel`](@ref) 218 """ 219 function bond_yield(price, face_value, coupon_rate, years_to_maturity, frequency; 220 method=:brent, bracket=(0.001, 1.0)) 221 222 total_periods = years_to_maturity * frequency 223 whole_periods = floor(Int, total_periods) # Complete coupon periods 224 fractional_period = total_periods - whole_periods # Partial period 225 226 coupon_payment = (face_value * coupon_rate) / frequency 227 228 function price_diff(y) 229 if y <= 0 230 return Inf 231 end 232 233 discount_rate = y / frequency 234 calculated_price = 0.0 235 236 if discount_rate == 0 237 # Zero yield case 238 calculated_price = coupon_payment * whole_periods + face_value 239 if fractional_period > 0 240 # Add accrued interest for partial period 241 calculated_price += coupon_payment * fractional_period 242 end 243 else 244 # Present value of whole coupon payments 245 if whole_periods > 0 246 pv_coupons = coupon_payment * (1 - (1 + discount_rate)^(-whole_periods)) / discount_rate 247 calculated_price += pv_coupons / (1 + discount_rate)^fractional_period 248 end 249 250 # Present value of principal (always discounted by full period) 251 pv_principal = face_value / (1 + discount_rate)^total_periods 252 calculated_price += pv_principal 253 254 # Subtract accrued interest (what buyer owes seller) 255 if fractional_period > 0 256 accrued_interest = coupon_payment * fractional_period 257 calculated_price -= accrued_interest 258 end 259 end 260 261 return calculated_price - price 262 end 263 264 try 265 return Roots.find_zero(price_diff, bracket, Roots.Brent()) 266 catch e 267 if isa(e, ArgumentError) && occursin("not a bracketing interval", sprint(showerror, e)) 268 # Fall back to a derivative-free method using an initial guess 269 @warn "Brent failed: falling back to Order1" exception=e 270 return Roots.find_zero(price_diff, 0.02, Roots.Order1()) 271 else 272 rethrow(e) 273 end 274 end 275 276 end 277 278 279 """ 280 _day_count_days(d1, d2, basis) -> Int 281 282 Count the number of days between two dates using the specified day-count convention. 283 Used internally for bond yield calculations. 284 285 - `basis=0`: 30/360 (US) 286 - `basis=1`: Actual/actual 287 - `basis=2`: Actual/360 288 - `basis=3`: Actual/365 289 - `basis=4`: European 30/360 290 """ 291 function _day_count_days(d1::Date, d2::Date, basis::Int) 292 if basis == 0 # 30/360 US 293 day1, mon1, yr1 = Dates.day(d1), Dates.month(d1), Dates.year(d1) 294 day2, mon2, yr2 = Dates.day(d2), Dates.month(d2), Dates.year(d2) 295 if day1 == 31; day1 = 30; end 296 if day2 == 31 && day1 >= 30; day2 = 30; end 297 return 360 * (yr2 - yr1) + 30 * (mon2 - mon1) + (day2 - day1) 298 elseif basis == 4 # European 30/360 299 day1, mon1, yr1 = Dates.day(d1), Dates.month(d1), Dates.year(d1) 300 day2, mon2, yr2 = Dates.day(d2), Dates.month(d2), Dates.year(d2) 301 if day1 == 31; day1 = 30; end 302 if day2 == 31; day2 = 30; end 303 return 360 * (yr2 - yr1) + 30 * (mon2 - mon1) + (day2 - day1) 304 else # basis 1, 2, 3: actual days 305 return Dates.value(d2 - d1) 306 end 307 end 308 309 function _date_difference(start_date, end_date; basis=1) 310 days = _day_count_days(start_date, end_date, basis) 311 if basis == 0 312 return days / 360 313 elseif basis == 1 314 return days / 365.25 315 elseif basis == 2 316 return days / 360 317 elseif basis == 3 318 return days / 365 319 else 320 error("Invalid basis: $basis") 321 end 322 end 323 # -------------------------------------------------------------------------------------------------- 324