Bonds and CDS curves
Hello again.
Following the notebook I posted a couple of months ago on default probability curves, here is another short one in which they are used for pricing bonds. As usual, the notebook is also available in A QuantLib Guide.
Subscribe to my Substack to receive my posts in your inbox, or follow me on Twitter or LinkedIn if you want to be notified of new posts, or subscribe via RSS if you’re the tech type: the buttons for all that are in the footer. Also, I’m available for training, both online and (when possible) on-site: visit my Training page for more information.
Bonds and CDS curves
import QuantLib as ql
today = ql.Date(27, ql.April, 2025)
ql.Settings.instance().evaluationDate = today
What if you don’t have a sensible discount curve available for a corporate issue, but you have a CDS curve instead?
That’s not optimal, of course. There can be a significant basis between the CDS and bond markets (for instance, because of their different liquidity); make sure that you’re aware of any such issues before doing your calculations.
This said, let’s take a sample fixed-rate bond:
schedule = ql.Schedule(
    ql.Date(8, ql.February, 2024),
    ql.Date(8, ql.February, 2034),
    ql.Period(6, ql.Months),
    ql.UnitedStates(ql.UnitedStates.GovernmentBond),
    ql.Following,
    ql.Following,
    ql.DateGeneration.Backward,
    False,
)
settlement_days = 3
face_amount = 10_000
coupons = [0.03]
payment_day_counter = ql.Thirty360(ql.Thirty360.BondBasis)
bond = ql.FixedRateBond(
    settlement_days, face_amount, schedule, coupons, payment_day_counter
)
A risky bond engine
The RiskyBondEngine class makes it possible to calculate a price by
discounting its coupons at the risk-free rate, but also weighing them by
the probability that they are actually paid; that is, the probability
that the issuer doesn’t default. We’ll need a risk-free curve…
dates, rates = zip(
    *[
        (ql.Date(27, 4, 2025), 0.02022),
        (ql.Date(27, 7, 2025), 0.02064),
        (ql.Date(27, 10, 2025), 0.02041),
        (ql.Date(27, 4, 2026), 0.02163),
        (ql.Date(27, 4, 2027), 0.02463),
        (ql.Date(27, 4, 2028), 0.02718),
        (ql.Date(27, 4, 2029), 0.02905),
        (ql.Date(27, 4, 2030), 0.03067),
        (ql.Date(27, 4, 2031), 0.03161),
        (ql.Date(27, 4, 2032), 0.03232),
        (ql.Date(27, 4, 2033), 0.03305),
        (ql.Date(27, 4, 2034), 0.03358),
        (ql.Date(27, 4, 2035), 0.03402),
        (ql.Date(27, 4, 2040), 0.03565),
        (ql.Date(27, 4, 2045), 0.03619),
        (ql.Date(27, 4, 2050), 0.03619),
        (ql.Date(27, 4, 2055), 0.03620),
    ]
)
risk_free_curve = ql.ZeroCurve(dates, rates, ql.Actual365Fixed())
…and a default probability curve, that can be bootstrapped from CDS quotes.
cds_data = [
    (ql.Period(1, ql.Years), 0.004),
    (ql.Period(2, ql.Years), 0.008),
    (ql.Period(3, ql.Years), 0.013),
    (ql.Period(5, ql.Years), 0.021),
    (ql.Period(7, ql.Years), 0.027),
    (ql.Period(10, ql.Years), 0.034),
]
fixed_rate = 0.01
recovery_rate = 0.4
cds_settlement_days = 1
upfront_settlement_days = 3
helpers = [
    ql.UpfrontCdsHelper(
        quote,
        fixed_rate,
        tenor,
        cds_settlement_days,
        ql.UnitedStates(ql.UnitedStates.GovernmentBond),
        ql.Quarterly,
        ql.ModifiedFollowing,
        ql.DateGeneration.CDS2015,
        ql.Actual360(),
        recovery_rate,
        ql.YieldTermStructureHandle(risk_free_curve),
        upfront_settlement_days,
    )
    for tenor, quote in cds_data
]
probability_curve = ql.PiecewiseFlatHazardRate(
    today, helpers, ql.Actual360()
)
Once we have those, we can instantiate the engine, set it to the bond, and retrieve the results we want:
bond.setPricingEngine(
    ql.RiskyBondEngine(
        ql.DefaultProbabilityTermStructureHandle(probability_curve),
        recovery_rate,
        ql.YieldTermStructureHandle(risk_free_curve),
    )
)
bond.cleanPrice()
87.59096931191402
Back to rates
If we want to translate this into a corresponding credit spread to be applied on top of the risk-free curve, we can create a discount curve with a spread yet to be determined:
credit_spread = ql.SimpleQuote(0.0)
discount_curve = ql.ZeroSpreadedTermStructure(
    ql.YieldTermStructureHandle(risk_free_curve),
    ql.QuoteHandle(credit_spread),
)
Then, we can use the CDS-based price as a target…
target_price = bond.cleanPrice()
…and set a new engine to the bond. Next we define a function that, given a value for the spread, calculates the corresponding bond price and returns its difference from the target price; we can then pass it to a solver that finds its zero, i.e., the spread for which the bond price is the same as the CDS-based price.
bond.setPricingEngine(
    ql.DiscountingBondEngine(ql.YieldTermStructureHandle(discount_curve))
)
def objective_function(s):
    credit_spread.setValue(s)
    return bond.cleanPrice() - target_price
solver = ql.Brent()
spread = solver.solve(objective_function, 1e-7, 0.005, 0.0001)
spread
0.013749869755662938
We can check that the price is indeed the same, within numerical tolerance:
credit_spread.setValue(spread)
bond.cleanPrice()
87.59096931265552
We can also express the spread as a difference between bond yields; namely, the yield that we can calculate based on the target price…
y0 = bond.bondYield(
    ql.BondPrice(target_price, ql.BondPrice.Clean),
    payment_day_counter,
    ql.Compounded,
    ql.Semiannual,
)
y0
0.047452630996704104
…and the one we can obtain from a risk-free bond price, that we can obtain by using the risk-free curve for discounting:
bond.setPricingEngine(
    ql.DiscountingBondEngine(ql.YieldTermStructureHandle(risk_free_curve))
)
risk_free_price = bond.cleanPrice()
risk_free_price
97.4066704451667
y1 = bond.bondYield(
    ql.BondPrice(risk_free_price, ql.BondPrice.Clean),
    payment_day_counter,
    ql.Compounded,
    ql.Semiannual,
)
y1
0.03343150448799134
The difference between the two yields is comparable to the z-spread we calculated earlier; given the different conventions (the z-spread is continuously compounded) we didn’t expect them to be the same.
y0 - y1
0.014021126508712761
See you next time!