import QuantLib as ql
today = ql.Date(16, ql.March, 2026)
ql.Settings.instance().evaluationDate = today
bps = 1e-4Spread calculations
We have already seen an example of z-spread calculation in the first notebook of the guide. In this notebook, we’ll show how to calculate a few other spreads, as well as a couple of functions that can be used in the particular case of a fixed-rate bond.
I’ll start by building a simple bond.
schedule = ql.MakeSchedule(
effectiveDate=ql.Date(26, ql.May, 2020),
terminationDate=ql.Date(26, ql.May, 2040),
frequency=ql.Annual,
calendar=ql.UnitedStates(ql.UnitedStates.GovernmentBond),
convention=ql.Following,
backwards=True,
)
settlement_days = 3
face_amount = 1_000_000.0
coupons = [0.02]
day_counter = ql.Thirty360(ql.Thirty360.BondBasis)
bond = ql.FixedRateBond(
settlement_days,
face_amount,
schedule,
coupons,
day_counter,
)The i-spread
The i-spread of the bond is the difference between its yield and an interpolated swap rate corresponding to its maturity date. There is no built-in function in QuantLib that returns it, but it can be calculated as follows.
Given a quoted price, the yield can be calculated without having to define a discount curve:
price = ql.BondPrice(98.08, ql.BondPrice.Clean)
y = bond.bondYield(
price,
day_counter,
ql.SimpleThenCompounded,
ql.Annual,
)
y0.021578895425796538
The interpolated swap rate can also be calculated from simple market data. Here we have a series of quoted OIS rates:
ois_data = dict(
[
("1Y", 0.137),
("2Y", 0.409),
("3Y", 0.674),
("5Y", 1.004),
("8Y", 1.258),
("10Y", 1.359),
("12Y", 1.420),
("15Y", 1.509),
("20Y", 1.574),
("25Y", 1.586),
("30Y", 1.579),
("35Y", 1.559),
("40Y", 1.514),
("45Y", 1.446),
("50Y", 1.425),
]
)Given the maturity of the bond (a bit more than 14 years) we’ll have to interpolate linearly between the 12-years and the 15-years rate—assuming, of course, that no other quoted rates are available in this range.
t1 = day_counter.yearFraction(today, today + ql.Period("12Y"))
t2 = day_counter.yearFraction(today, today + ql.Period("15Y"))r1 = ois_data["12Y"] / 100
r2 = ois_data["15Y"] / 100f = ql.LinearInterpolation([t1, t2], [r1, r2])T = day_counter.yearFraction(today, bond.maturityDate())
T14.202777777777778
R = f(T)
R0.01485349074074074
And here is our spread, shown in basis points for convenience:
i_spread = y - R
i_spread / bps67.25404685055797
The z-spread
The calculation of the z-spread requires a base interest-rate curve. In this case, we’ll use a risk-free curve derived from the quoted OIS shown in the previous section…
index = ql.Sofr()
settlement_days = 2
ois_helpers = []
for tenor in ois_data:
q = ql.SimpleQuote(ois_data[tenor] / 100)
ois_helpers.append(
ql.OISRateHelper(
settlement_days,
ql.Period(tenor),
ql.QuoteHandle(q),
index,
paymentFrequency=ql.Annual,
)
)
sofr_curve = ql.PiecewiseLogCubicDiscount(
0,
ql.UnitedStates(ql.UnitedStates.GovernmentBond),
ois_helpers,
ql.Actual360(),
)
sofr_handle = ql.YieldTermStructureHandle(sofr_curve)…but the base curve might be any other one; for instance, a government curve fitted on quoted bond prices.
In the case of fixed-rate bonds, the library provides a function to calculate the z-spread directly:
z = ql.BondFunctions.zSpread(
bond,
price,
sofr_curve,
day_counter,
ql.SimpleThenCompounded,
ql.Annual,
)
z / bps64.75673883414345
However, there’s a snag. The function above takes the day-count convention we want to use for the spread, but due to limitations of the implementation the argument is ignored: passing a different convention returns the same result.
ql.BondFunctions.zSpread(
bond,
price,
sofr_curve,
ql.Actual360(),
ql.SimpleThenCompounded,
ql.Annual,
) / bps64.75673883414345
In fact, we’re in the process of deprecating the signature above and replacing it with one that doesn’t include the day-count convention.
The returned rate uses the same day-count convention of the base curve. If we want to express it according to a different convention, we can use the methods of the InterestRate class to convert it into an equivalent rate, given a start and end date; for instance, today and one year from now.
ql.InterestRate(
z, sofr_curve.dayCounter(), ql.SimpleThenCompounded, ql.Annual
).equivalentRate(
day_counter,
ql.SimpleThenCompounded,
ql.Annual,
today,
today + ql.Period("1Y"),
).rate() / bps65.65908427374367
In the case of a less simple bond (for instance, a floater), the function above won’t work and we’ll have to resort to a more generic calculation; that is, create a discount curve by adding a spread over the base curve…
spread = ql.SimpleQuote(0.0)
discount_curve = ql.ZeroSpreadedTermStructure(
sofr_handle, ql.QuoteHandle(spread), ql.SimpleThenCompounded, ql.Annual
)
bond.setPricingEngine(
ql.DiscountingBondEngine(ql.YieldTermStructureHandle(discount_curve))
)…and using a root solver to find the spread for which the bond price (as calculated off this curve) equals the quoted price.
def discrepancy(s):
spread.setValue(s)
return bond.cleanPrice() - price.amount()
solver = ql.Brent()
accuracy = 1e-5
guess = 0.015
step = 1e-4
solver.solve(discrepancy, accuracy, guess, step) / bps64.75637679007006
In this case, the bond was a fixed-rate one so we can compare the result with the one returned by the zSpread function shown above. As you can see, this calculation returns the same rate within numerical accuracy; this shows that this calculation, too, uses the same day-count convention as the base curve.
The option-adjusted spread
Finally, we have a third kind of spread, which is defined for bonds that include optionality. In the case of callable bonds, the library provides an OAS method that returns the option-adjusted spread. In order to use it, you’ll have to set the bond up so that it’s priced on a trinomial tree based on a short-rate model: Hull-White, for instance.
bond = ql.CallableFixedRateBond(
settlement_days,
face_amount,
schedule,
coupons,
day_counter,
ql.Following,
100.0,
schedule[0],
[
ql.Callability(
ql.BondPrice(100.0, ql.BondPrice.Clean),
ql.Callability.Call,
schedule[15],
)
],
)
bond.setPricingEngine(
ql.TreeCallableFixedRateBondEngine(
ql.HullWhite(sofr_handle, a=0.1, sigma=0.01), 100
)
)As expected, the same target price returns a tighter spread.
bond.OAS(
price.amount(),
sofr_handle,
day_counter,
ql.SimpleThenCompounded,
ql.Annual,
) / bps48.39174863100259