FinanceRoutines.jl

Financial data routines for Julia
Log | Files | Refs | README | LICENSE

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