Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

A few questions regarding fixed rate convertible bonds #2139

Open
bkhoor opened this issue Jan 17, 2025 · 0 comments
Open

A few questions regarding fixed rate convertible bonds #2139

bkhoor opened this issue Jan 17, 2025 · 0 comments

Comments

@bkhoor
Copy link

bkhoor commented Jan 17, 2025

I am working with the ConvertibleFixedCouponBond object (v1.23) and roughly following the below outlined steps:
http://gouthamanbalaraman.com/blog/value-convertible-bond-quantlib-python.html

I had a few miscellaneous questions with examples regarding some of the output I am seeing.

  1. Does sending in both a dividend schedule and dividend yield double count the dividend? Should we really only be sending one or the other?

In the below example, it seems that the implied conversion option value calculated by subtracting the value of the straight bond (or bond floor) from the convertible bond price is much less than the directly computed value of the equity call option using the VanillaOption pricer. I was able to tie out the bond floor by pricing just the bond portion using CallableFixedRateBond, so it seems that there is something with how the call option on the equity is being priced that is different. If I either leave the dividend schedule empty or dividend yield at zero then the option price is much closer. Although there is still a gap in the final option values, it seems like discounting using the dividend yield and subtracting out dividend may be double dinging in the equity option valuation, so wanted to make sure I was not missing something else.

import QuantLib as ql

###### inputs
spot_price = 85.0 # deep out of the money underlying price
strike = 100.0
vol = 0.4
spread = 0.01
coupon = 0.005
issue_date = ql.Date(29,1,2024)
exercise_date = ql.Date(31, 3, 2031)
maturity_date= ql.Date(1, 4, 2031)
currency = 'USD'
calendar =  ql.UnitedStates(ql.UnitedStates.Settlement)
day_count = ql.Thirty360()
frequency = 2 #1 - Annual, 2- Semi, 4- Quarterly
face_amount = 100.0
redemption = 100.0
conversion_ratio = redemption/strike
settlement_days = 1
evaluation_date = ql.Date(7,1,2025)
settlement_date = calendar.advance(evaluation_date, settlement_days,
                                   ql.Days, ql.ModifiedFollowing)

ql.Settings.instance().evaluationDate = evaluation_date


###### set up rates curve (used in options only)
rates = [0.043290488, 0.04303, 0.041422, 0.041093, 0.041094, 0.041174, 0.041264, 0.041368, 0.041479]
maturities = [ql.Date(7,1,2025), ql.Date(1,4,2025), ql.Date(1,4,2026), ql.Date(1,4,2027), ql.Date(1,4,2028),
              ql.Date(1,4,2029), ql.Date(1,4,2030), ql.Date(1,4,2031), ql.Date(1,4,2032)]
term_structure = ql.ZeroCurve(maturities, rates, ql.ActualActual(ql.ActualActual.ISDA), calendar)
term_structure.enableExtrapolation()
yield_ts_handle = ql.YieldTermStructureHandle(term_structure)


###### set up Convertible Component
schedule = ql.MakeSchedule(issue_date, maturity_date,  ql.Period(frequency))

put_schedule = ql.CallabilitySchedule()

put_dates = [ql.Date(1,4, 2028)]
put_price = 100.0

for put_date in put_dates:
    put_price  = ql.BondPrice(put_price,
                                            ql.BondPrice.Clean)
    put_schedule.append(ql.Callability(put_price,
                                        ql.Callability.Put,
                                        put_date)
                        )


dividend_schedule = ql.DividendSchedule() # No dividends
dividend_yield = 0.011063
dividend_amount = dividend_yield*spot_price
next_dividend_date = ql.Date(12,6,2025)
dividend_amount = spot_price*dividend_yield
for i in range(5):
    date = calendar.advance(next_dividend_date, i, ql.Years)
    dividend_schedule.append(
        ql.FixedDividend(dividend_amount, date)
    )

credit_spread_handle = ql.QuoteHandle(ql.SimpleQuote(spread))
exercise = ql.EuropeanExercise(exercise_date)


convertible_bond = ql.ConvertibleFixedCouponBond(exercise,
                                                 conversion_ratio,
                                                 dividend_schedule,
                                                 put_schedule,
                                                 credit_spread_handle,
                                                 issue_date,
                                                 settlement_days,
                                                 [coupon],
                                                 day_count,
                                                 schedule,
                                                 redemption)

spot_price_handle = ql.QuoteHandle(ql.SimpleQuote(spot_price))
dividend_schedule = ql.DividendSchedule() # No dividends
dividend_ts_handle = ql.YieldTermStructureHandle(ql.FlatForward(evaluation_date, dividend_yield, day_count))
volatility_ts_handle = ql.BlackVolTermStructureHandle(ql.BlackConstantVol(evaluation_date, calendar, vol, day_count))
bsm_process = ql.BlackScholesMertonProcess(spot_price_handle,
                                           dividend_ts_handle,
                                           yield_ts_handle,
                                           volatility_ts_handle)

time_steps = 1000
engine = ql.BinomialConvertibleEngine(bsm_process, "crr", time_steps)

convertible_bond.setPricingEngine(engine)

convert_price = convertible_bond.cleanPrice()

print("Convertible Bond Price: "+ str(convert_price))

###### Call option on stock
T = (maturity_date - issue_date)/365
r = yield_ts_handle.zeroRate(exercise_date,day_count,2).rate()
exercise = ql.AmericanExercise(evaluation_date, exercise_date)
payoff = ql.PlainVanillaPayoff(ql.Option.Call, strike)
american_option = ql.VanillaOption(payoff, exercise)

american_option.setPricingEngine(ql.BinomialVanillaEngine(bsm_process, "crr", time_steps))
call_value = american_option.NPV() * conversion_ratio
print("European Call Price on Stock using QuantLib is: %s" % (call_value))



# ####### Straight bond for Underlying
callable_bond = ql.CallableFixedRateBond(settlement_days,
                                         100.0,
                                         schedule,
                                         [coupon],
                                         day_count,
                                         ql.Following,
                                         redemption,
                                         evaluation_date,
                                         put_schedule)

rates_model = ql.HullWhite(yield_ts_handle, 0.0275, 0.0112)
pricing_engine = ql.TreeCallableFixedRateBondEngine(rates_model, 100)

callable_bond.setPricingEngine(pricing_engine)

straight_bond_price = callable_bond.cleanPriceOAS(spread,
                                                  yield_ts_handle,
                                                  day_count,
                                                  ql.Compounded,
                                                  frequency)

print("Implied Straight Bond Price: "+ str(straight_bond_price))
print("Implied Option Value: %s" % (convert_price - straight_bond_price ))

  1. Does the binomial convertible engine only use the flat rate that corresponds to the maturity of the bond when discounting? If so, is there another engine that will use the full term structure when discounting?

From what I've seen on the C++ side, it seems that regardless of the zero curve's term structure, the pricing engine will take the rate corresponding to the maturity of the bond from the yield handle and use it to create a flat risk free curve handle for discounting. Continuing with the above example, this can be seen when looking at the key rate durations. Most of the sensitivity when breaking out KRDs is close to maturity (6-7 years out) although the effective duration of the bond and sum of the partials are much lower. It seems that although we shock at each point on the yield curve, it is really being treated as a parallel shift since the only rate used is the one corresponding to maturity and that is why the shock falls into the farther bucket. Please let me know if this is accurate and/or if there are any suggestions to get around this.

####Calculate OAD
shocks = [ql.SimpleQuote(0.0) for t in range(len(maturities))]

shocked_curve = ql.SpreadedLinearZeroInterpolatedTermStructure(yield_ts_handle,
                                                               [ql.QuoteHandle(q) for q in shocks],
                                                               maturities)
shocked_curve.enableExtrapolation()
shocked_curve_handle = ql.YieldTermStructureHandle(shocked_curve)
yield_shock = 0.01

#prce before yield curve shock
price = convert_price

#apply positive yield shock and reprice cvt
for i in range(len(maturities)):
    shocks[i].setValue(yield_shock)
bsm_process = ql.BlackScholesMertonProcess(spot_price_handle,
                                           dividend_ts_handle,
                                           shocked_curve_handle,
                                           volatility_ts_handle)

engine = ql.BinomialConvertibleEngine(bsm_process, "crr", time_steps)
convertible_bond.setPricingEngine(engine)
price_up = convertible_bond.cleanPrice()

#apply negative yield shock and reprice cvt
for i in range(len(maturities)):
    shocks[i].setValue(-yield_shock)
bsm_process = ql.BlackScholesMertonProcess(spot_price_handle,
                                           dividend_ts_handle,
                                           shocked_curve_handle,
                                           volatility_ts_handle)

engine = ql.BinomialConvertibleEngine(bsm_process, "crr", time_steps)
convertible_bond.setPricingEngine(engine)
price_down = convertible_bond.cleanPrice()


#clear shocks
for i in range(len(maturities)):
    shocks[i].setValue(0.0)
bsm_process = ql.BlackScholesMertonProcess(spot_price_handle,
                                           dividend_ts_handle,
                                           shocked_curve_handle,
                                           volatility_ts_handle)

#reset engine
engine = ql.BinomialConvertibleEngine(bsm_process, "crr", time_steps)
convertible_bond.setPricingEngine(engine)

#calculate duration
oad = (price_down -  price_up)/(2 * price * yield_shock)
print("Convertible bond OAD is: %s" % (oad))




#Now look at partials
krd_list = []
price = convert_price

#apply positive yield shock and reprice at each tenor
for i in range(len(maturities)):
    shocks[i].setValue(yield_shock)
    bsm_process = ql.BlackScholesMertonProcess(spot_price_handle,
                                                dividend_ts_handle,
                                                shocked_curve_handle,
                                                volatility_ts_handle)

    engine = ql.BinomialConvertibleEngine(bsm_process, "crr", time_steps)
    convertible_bond.setPricingEngine(engine)
    price_up = convertible_bond.cleanPrice()


    shocks[i].setValue(-yield_shock)
    bsm_process = ql.BlackScholesMertonProcess(spot_price_handle,
                                                dividend_ts_handle,
                                                shocked_curve_handle,
                                                volatility_ts_handle)

    engine = ql.BinomialConvertibleEngine(bsm_process, "crr", time_steps)
    convertible_bond.setPricingEngine(engine)
    price_down = convertible_bond.cleanPrice()

    shocks[i].setValue(0.0)
    bsm_process = ql.BlackScholesMertonProcess(spot_price_handle,
                                                dividend_ts_handle,
                                                shocked_curve_handle,
                                                volatility_ts_handle)

    engine = ql.BinomialConvertibleEngine(bsm_process, "crr", time_steps)
    convertible_bond.setPricingEngine(engine)

    #calculate duration
    krd = (price_down -  price_up)/(2 * price * yield_shock)
    krd_list.append(krd)
    print(f"Convertible bond KRD at {maturities[i]} is: {krd}")

I appreciate any suggestions or thoughts with any of the above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant