import QuantLib as ql
import pandas as pd
= ql.Date(2, ql.May, 2024)
today = today ql.Settings.instance().evaluationDate
Vanilla bonds
We’ve already seen in a previous notebook how to instantiate a fixed-rate bond. Here we expand on that: we’ll see a number of possible calculations, as well as other kinds of bonds that can be created and priced. Let’s start again from a sample fixed-rate bond and see what we can do.
= ql.Schedule(
schedule 8, ql.February, 2023),
ql.Date(8, ql.February, 2028),
ql.Date(6, ql.Months),
ql.Period(
ql.TARGET(),
ql.Following,
ql.Following,
ql.DateGeneration.Backward,False,
)= 3
settlementDays = 10_000
faceAmount = [0.03]
coupons = ql.Thirty360(ql.Thirty360.BondBasis)
paymentDayCounter
= ql.FixedRateBond(
bond
settlementDays, faceAmount, schedule, coupons, paymentDayCounter )
Static information
First of all, even without using an engine or other market data, the bond is able to return some static information. We can ask it for its current settlement date, or for the settlement date corresponding to another evaluation date:
print(bond.settlementDate())
May 7th, 2024
print(bond.settlementDate(ql.Date(31, ql.May, 2027)))
June 3rd, 2027
The same goes for the amount accrued so far: we can call the corresponding method without a date, returning the amount accrued up to the current settlement date, or pass another date. Note that the accrued amount, like prices, is scaled based on a notional of 100, not the actual face amount of the bond.
print(bond.accruedAmount())
0.7416666666666627
print(bond.accruedAmount(ql.Date(9, ql.May, 2027)))
0.7583333333333275
In this case, the passed date is treated as a settlement date; we can verify that this is the case by passing the current settlement date and checking that the returned amount equals the one returned by the call without date.
print(bond.accruedAmount(bond.settlementDate()))
0.7416666666666627
And for a further check, we can cycle over the next year and visualize the accrued amount at each date, verifying that it makes sense; that is, that it increases up to the full coupon amount and then resets to zero semiannually (the frequency of the bond we created.)
= [
dates + i for i in range(365) if ql.TARGET().isBusinessDay(today + i)
today
]= [bond.accruedAmount(d) for d in dates]
accruals
from matplotlib import pyplot as plt
= plt.figure(figsize=(9, 4)).add_subplot(1, 1, 1)
ax for d in dates], accruals, "-"); ax.plot([d.to_date()
Cash flows
Again, we’ve already seen that we can perform cash-flow analysis on the bond. As a quick recap, the cashflows
method returns a sequence of cash flows, each one providing basic information. In C++, the return type would be std::vector<shared_ptr<CashFlow>>
, exported to Python as a list or tuple of CashFlow
instances; the latter is the base class for different kinds of coupons and other cash flows. Its interface contains little more than the amount of the cash flow and the date when it occurs; other information (such as, for instance, the rate paid by the bond coupons) is available from derived classes. The amounts are returned based on the actual face amount of the bond.
pd.DataFrame(for c in bond.cashflows()],
[(c.date(), c.amount()) =("date", "amount"),
columns=range(1, len(bond.cashflows()) + 1),
indexformat({"amount": "{:.2f}"}) ).style.
date | amount | |
---|---|---|
1 | August 8th, 2023 | 150.00 |
2 | February 8th, 2024 | 150.00 |
3 | August 8th, 2024 | 150.00 |
4 | February 10th, 2025 | 151.67 |
5 | August 8th, 2025 | 148.33 |
6 | February 9th, 2026 | 150.83 |
7 | August 10th, 2026 | 150.83 |
8 | February 8th, 2027 | 148.33 |
9 | August 9th, 2027 | 150.83 |
10 | February 8th, 2028 | 149.17 |
11 | February 8th, 2028 | 10000.00 |
As you can see, the returned cash flows include both the coupon and the final redemption (distinct from the final coupon paid on the same date.)
Access to more detailed information requires to downcast the CashFlow
instances. In C++, this would be done as
auto cfs = bond->cashflows();
auto coupon = ext::dynamic_pointer_cast<FixedRateCoupon>(cfs[0]);
but Python doesn’t have the concept of casting from one type to another. Therefore, the QuantLib bindings provide functions such as as_fixed_rate_coupon
or as_floating_rate_coupon
that perform the same operation. If the cast doesn’t succeed, the returned value is a null pointer in C++ or None
in Python. This gives us access to the methods defined in derived classes: we can use it to perform a more detailed cash-flow analysis, returning coupon-specific information when the cast succeeds and only basic information when it fails, as in the case of the bond redemptiom:
def coupon_info(cf):
= ql.as_coupon(cf)
c if not c:
return (cf.date(), None, None, cf.amount())
else:
return (c.date(), c.rate(), c.accrualPeriod(), c.amount())
pd.DataFrame(for c in bond.cashflows()],
[coupon_info(c) =("date", "rate", "accrual time", "amount"),
columns=range(1, len(bond.cashflows()) + 1),
indexformat({"amount": "{:.2f}", "rate": "{:.2%}"}) ).style.
date | rate | accrual time | amount | |
---|---|---|---|---|
1 | August 8th, 2023 | 3.00% | 0.500000 | 150.00 |
2 | February 8th, 2024 | 3.00% | 0.500000 | 150.00 |
3 | August 8th, 2024 | 3.00% | 0.500000 | 150.00 |
4 | February 10th, 2025 | 3.00% | 0.505556 | 151.67 |
5 | August 8th, 2025 | 3.00% | 0.494444 | 148.33 |
6 | February 9th, 2026 | 3.00% | 0.502778 | 150.83 |
7 | August 10th, 2026 | 3.00% | 0.502778 | 150.83 |
8 | February 8th, 2027 | 3.00% | 0.494444 | 148.33 |
9 | August 9th, 2027 | 3.00% | 0.502778 | 150.83 |
10 | February 8th, 2028 | 3.00% | 0.497222 | 149.17 |
11 | February 8th, 2028 | nan% | nan | 10000.00 |
Pricing
Calculating the price of the bond requires some market data, since we need to discount its cash flows. One way to do this is to use quoted yield information:
bond.cleanPrice(0.035,
ql.Thirty360(ql.Thirty360.BondBasis),
ql.Compounded,
ql.Semiannual, )
98.25259209893974
As I mentioned, the price is returned in base 100. At this time, there’s no support for bonds that are quoted in other units, e.g., around 1000 as some markets do.
Another way to calculate the price is to use a discount curve. Here I’ll use a simple flat rate for brevity, but any interest-rate term structure can be used; it could be, for instance, a Nelson-Siegel curve fitted on market prices. In this case, we’re not passing the discount curve to a bond method, as we did for the yield in the previous cell; instead, we pass it to a pricing engine and set the engine to the bond. This way, the bond will react if the curve changes. Additionally, we could keep hold of the term structure handle and relink it if necessary.
The engine we’re using is DiscountingBondEngine
, which collects the dates and amounts from the cash flows, discounts them accordingly and accumulates them. (For more complex bonds, such as callables or convertibles, we’d need different engines.)
= ql.FlatForward(0, ql.TARGET(), 0.04, ql.Actual360())
discount_curve = ql.DiscountingBondEngine(
engine
ql.YieldTermStructureHandle(discount_curve)
) bond.setPricingEngine(engine)
Once the pricing engine is set, we can ask the bond for its price and accrued amount without having to pass other arguments.
print(bond.cleanPrice())
print(bond.dirtyPrice())
print(bond.accruedAmount())
96.19296369916307
96.93463036582973
0.7416666666666627
The usual NPV
method is also implemented, but for bonds it can be confusing. Like dirtyPrice
, it returns the cumulative discounted value of the cashflows; but it differs from dirtyPrice
in a couple of points. First, its result is based on the face value of the bond; and second, it discounts the cash flows to the reference date of the discount curve (usually today) instead of the settlement date of the bond, as in the case of the price.
print(bond.NPV())
9688.079274968233
However, there is one case in which the NPV
method is useful. When it’s too close to maturity, the bond can no longer price: for the sample bond we’re using, we’re still getting results one week before its maturity…
= ql.Date(1, 2, 2028) ql.Settings.instance().evaluationDate
bond.cleanPrice()
99.9882359483094
…but a few days later, it returns a null price, even if the maturity has not been reached:
= ql.Date(4, 2, 2028) ql.Settings.instance().evaluationDate
bond.cleanPrice()
0.0
This is because the settlement date is now at the maturity of the bond, and therefore the latter is no longer tradable.
print(bond.maturityDate())
print(bond.settlementDate())
February 8th, 2028
February 9th, 2028
However, it’s still possible to calculate the NPV, which gives us the value of the cash flows still to receive.
bond.NPV()
10144.656928164271
Finally, and returning to the original evaluation date, it is also possible to obtain a yield from a price:
= today
ql.Settings.instance().evaluationDate
print(
bond.bondYield(98.08,
ql.Thirty360(ql.Thirty360.BondBasis),
ql.SimpleThenCompounded,
ql.Semiannual,
) )
0.03548806204795839
(In C++ the method is called yield
, but that’s a reserved keyword in Python.)
Duration and other figures
Some other calculations are not implemented as bond methods, but as static methods of the BondFunctions
class; for instance, the various kinds of duration and the convexity.
print(
ql.BondFunctions.duration(
bond,0.035,
ql.Actual360(),
ql.Compounded,
ql.Semiannual,
ql.Duration.Macaulay,
) )
3.6046062651997746
print(
ql.BondFunctions.duration(
bond,0.035,
ql.Actual360(),
ql.Compounded,
ql.Semiannual,
ql.Duration.Modified,
) )
3.542610580048918
print(
ql.BondFunctions.convexity(0.035, ql.Actual360(), ql.Compounded, ql.Semiannual
bond,
) )
14.76085604114641
Other kinds of fixed-rate bonds
The same FixedRateBond
class we used so far can also be instantiated with a sequence of several different coupon rates, making it possible to model step-up bonds:
= ql.Schedule(
schedule 8, ql.February, 2023),
ql.Date(8, ql.February, 2028),
ql.Date(6, ql.Months),
ql.Period(
ql.TARGET(),
ql.Following,
ql.Following,
ql.DateGeneration.Backward,False,
)= 3
settlementDays = 100
faceAmount = [0.03, 0.03, 0.04, 0.04, 0.05]
coupons = ql.Thirty360(ql.Thirty360.BondBasis)
paymentDayCounter
= ql.FixedRateBond(
bond
settlementDays, faceAmount, schedule, coupons, paymentDayCounter )
Here, as usual, we can see the corresponding cash flows.
pd.DataFrame(for c in bond.cashflows()],
[coupon_info(c) =("date", "rate", "accrual time", "amount"),
columns=range(1, len(bond.cashflows()) + 1),
indexformat({"amount": "{:.2f}", "rate": "{:.2%}"}) ).style.
date | rate | accrual time | amount | |
---|---|---|---|---|
1 | August 8th, 2023 | 3.00% | 0.500000 | 1.50 |
2 | February 8th, 2024 | 3.00% | 0.500000 | 1.50 |
3 | August 8th, 2024 | 4.00% | 0.500000 | 2.00 |
4 | February 10th, 2025 | 4.00% | 0.505556 | 2.02 |
5 | August 8th, 2025 | 5.00% | 0.494444 | 2.47 |
6 | February 9th, 2026 | 5.00% | 0.502778 | 2.51 |
7 | August 10th, 2026 | 5.00% | 0.502778 | 2.51 |
8 | February 8th, 2027 | 5.00% | 0.494444 | 2.47 |
9 | August 9th, 2027 | 5.00% | 0.502778 | 2.51 |
10 | February 8th, 2028 | 5.00% | 0.497222 | 2.49 |
11 | February 8th, 2028 | nan% | nan | 100.00 |
To specify varying notionals, instead, we need to use the AmortizingFixedRateBond
class:
= ql.Schedule(
schedule 8, ql.February, 2018),
ql.Date(8, ql.February, 2028),
ql.Date(1, ql.Years),
ql.Period(
ql.TARGET(),
ql.Following,
ql.Following,
ql.DateGeneration.Backward,False,
)= 3
settlementDays = [10000, 10000, 8000, 8000, 6000, 6000, 4000, 4000, 2000, 2000]
notionals = [0.03]
coupons = ql.Thirty360(ql.Thirty360.BondBasis)
paymentDayCounter
= ql.AmortizingFixedRateBond(
bond
settlementDays, notionals, schedule, coupons, paymentDayCounter
) bond.setPricingEngine(engine)
In this case, the cashflows include amortization payments:
pd.DataFrame(for c in bond.cashflows()],
[coupon_info(c) =("date", "rate", "accrual time", "amount"),
columns=range(1, len(bond.cashflows()) + 1),
indexformat({"amount": "{:.2f}", "rate": "{:.2%}"}) ).style.
date | rate | accrual time | amount | |
---|---|---|---|---|
1 | February 8th, 2019 | 3.00% | 1.000000 | 300.00 |
2 | February 10th, 2020 | 3.00% | 1.005556 | 301.67 |
3 | February 10th, 2020 | nan% | nan | 2000.00 |
4 | February 8th, 2021 | 3.00% | 0.994444 | 238.67 |
5 | February 8th, 2022 | 3.00% | 1.000000 | 240.00 |
6 | February 8th, 2022 | nan% | nan | 2000.00 |
7 | February 8th, 2023 | 3.00% | 1.000000 | 180.00 |
8 | February 8th, 2024 | 3.00% | 1.000000 | 180.00 |
9 | February 8th, 2024 | nan% | nan | 2000.00 |
10 | February 10th, 2025 | 3.00% | 1.005556 | 120.67 |
11 | February 9th, 2026 | 3.00% | 0.997222 | 119.67 |
12 | February 9th, 2026 | nan% | nan | 2000.00 |
13 | February 8th, 2027 | 3.00% | 0.997222 | 59.83 |
14 | February 8th, 2028 | 3.00% | 1.000000 | 60.00 |
15 | February 8th, 2028 | nan% | nan | 2000.00 |
It’s also possible to ask for the current notional at a given date…
25, 10, 2027))) bond.notional(bond.settlementDate(ql.Date(
2000.0
…defaulting, as usual, to the current settlement date:
bond.notional()
4000.0
Following common market practice, the price returned by the class is scaled so that it’s in base 100 relative to the current notional; the NPV, as usual, returns the cash value. For instance:
bond.NPV()
3910.55084058516
bond.cleanPrice()
97.07643264387757
Zero-coupon bonds
There’s not a lot to say about these bonds; they can be instantiated by means of a dedicated class…
= ql.ZeroCouponBond(
bond =3,
settlementDays=ql.TARGET(),
calendar=10_000,
faceAmount=ql.Date(8, ql.February, 2030),
maturityDate
) bond.setPricingEngine(engine)
…and offer the same features of fixed-rate bonds.
print(bond.cleanPrice())
79.162564713698
print(
bond.bondYield(79.50,
ql.Thirty360(ql.Thirty360.BondBasis),
ql.Compounded,
ql.Annual,
) )
0.040684510707855226
print(
ql.BondFunctions.duration(
bond,0.035,
ql.Actual360(),
ql.Compounded,
ql.Semiannual,
ql.Duration.Macaulay,
) )
5.841666666666667
Floating-rate bonds
Not surprisingly, floating-rate bonds require an interest-rate index to determine the coupon rates; in turn, the index requires a forecasting curve. I’m using another flat curve for brevity, but in a real-world case it would probably be a curve bootstrapped from market data.
= ql.FlatForward(0, ql.TARGET(), 0.025, ql.Actual360())
forecast_curve = ql.Euribor6M(ql.YieldTermStructureHandle(forecast_curve)) euribor
= ql.Schedule(
schedule 8, ql.February, 2023),
ql.Date(8, ql.February, 2028),
ql.Date(6, ql.Months),
ql.Period(
ql.TARGET(),
ql.Following,
ql.Following,
ql.DateGeneration.Backward,False,
)
= ql.FloatingRateBond(
bond =3,
settlementDays=10_000,
faceAmount=schedule,
schedule=euribor,
index=[0.001],
spreads=ql.Actual360(),
paymentDayCounter=ql.Following,
paymentConvention
)
bond.setPricingEngine(engine)
Once instantiated, they provide the same facilities as fixed-rate bonds; however, they might also require a past index fixing to calculate the amount of the current coupon.
try:
print(bond.cleanPrice())
except Exception as e:
print(f"Error: {e}")
Error: Missing Euribor6M Actual/360 fixing for February 6th, 2024
The missing fixings can be added through the index:
6, ql.February, 2023), 0.028)
euribor.addFixing(ql.Date(4, ql.August, 2023), 0.0283)
euribor.addFixing(ql.Date(6, ql.February, 2024), 0.0279)
euribor.addFixing(ql.Date(
print(bond.cleanPrice())
95.08004252605194
Coupon and accrual information work the same (with future coupon rates and accruals being forecast via the curve, rather than determined). Once downcast, the coupons can provide a bit more information about the index fixings.
def coupon_info(cf):
= ql.as_floating_rate_coupon(cf)
c if not c:
return (cf.date(), None, None, None, None, cf.amount())
else:
return (
c.date(),
c.fixingDate(),
c.indexFixing(),
c.rate(),
c.accrualPeriod(),
c.amount(),
)
pd.DataFrame(for c in bond.cashflows()],
[coupon_info(c) =(
columns"payment date",
"fixing date",
"index fixing",
"rate",
"accrual time",
"amount",
),=range(1, len(bond.cashflows()) + 1),
indexformat(
).style."amount": "{:.2f}", "rate": "{:.2%}", "index fixing": "{:.2%}"}
{ )
payment date | fixing date | index fixing | rate | accrual time | amount | |
---|---|---|---|---|---|---|
1 | August 8th, 2023 | February 6th, 2023 | 2.80% | 2.90% | 0.502778 | 145.81 |
2 | February 8th, 2024 | August 4th, 2023 | 2.83% | 2.93% | 0.511111 | 149.76 |
3 | August 8th, 2024 | February 6th, 2024 | 2.79% | 2.89% | 0.505556 | 146.11 |
4 | February 10th, 2025 | August 6th, 2024 | 2.52% | 2.62% | 0.516667 | 135.17 |
5 | August 8th, 2025 | February 6th, 2025 | 2.52% | 2.62% | 0.497222 | 130.05 |
6 | February 9th, 2026 | August 6th, 2025 | 2.52% | 2.62% | 0.513889 | 134.44 |
7 | August 10th, 2026 | February 5th, 2026 | 2.52% | 2.62% | 0.505556 | 132.25 |
8 | February 8th, 2027 | August 6th, 2026 | 2.52% | 2.62% | 0.505556 | 132.25 |
9 | August 9th, 2027 | February 4th, 2027 | 2.52% | 2.62% | 0.505556 | 132.25 |
10 | February 8th, 2028 | August 5th, 2027 | 2.52% | 2.62% | 0.508333 | 132.98 |
11 | February 8th, 2028 | None | nan% | nan% | nan | 10000.00 |
Also, floaters have a number of optional features; for instance, caps and floors. However, they require additional market information:
= ql.FloatingRateBond(
bond =3,
settlementDays=10_000,
faceAmount=schedule,
schedule=euribor,
index=ql.Actual360(),
paymentDayCounter=ql.Following,
paymentConvention=[0.0],
floors=[0.05],
caps
)
bond.setPricingEngine(
ql.DiscountingBondEngine(ql.YieldTermStructureHandle(discount_curve)) )
try:
print(bond.cleanPrice())
except Exception as e:
print(f"Error: {e}")
Error: pricer not set
Why is the error worded in this way? If you read the chapter on cash flows of Implementing QuantLib, you’ll see that floating-rate coupons (such as those created by the FloatingRateBond
constructor) can be set a pricer, more or less like instruments can be set a pricing engine. We didn’t see it so far because, if caps and floors are not enabled, coupons based on an IBOR index are set a default pricer than calculates their fixing from the forecast curve they hold.
Caps and floors, however, introduce optionality and thus require volatility data. That information is missing from the default pricer, so the coupons are not given one during construction; we have to create the pricer ourselves and pass it to the coupons.
= ql.ConstantOptionletVolatility(
volatility 0, ql.TARGET(), ql.Following, 0.25, ql.Actual360()
)= ql.BlackIborCouponPricer(
pricer
ql.OptionletVolatilityStructureHandle(volatility) )
ql.setCouponPricer(bond.cashflows(), pricer)print(bond.cleanPrice())
94.68340203601055
Looking at the cash flow information shows, for dates far enough in the future, that the effective rate paid by the coupon no longer equals the index fixing. This is due to the change in value coming from the caps and floors.
pd.DataFrame(for c in bond.cashflows()],
[coupon_info(c) =(
columns"payment date",
"fixing date",
"index fixing",
"rate",
"accrual time",
"amount",
),=range(1, len(bond.cashflows()) + 1),
indexformat(
).style."amount": "{:.2f}", "rate": "{:.4%}", "index fixing": "{:.4%}"}
{ )
payment date | fixing date | index fixing | rate | accrual time | amount | |
---|---|---|---|---|---|---|
1 | August 8th, 2023 | February 6th, 2023 | 2.8000% | 2.8000% | 0.502778 | 140.78 |
2 | February 8th, 2024 | August 4th, 2023 | 2.8300% | 2.8300% | 0.511111 | 144.64 |
3 | August 8th, 2024 | February 6th, 2024 | 2.7900% | 2.7900% | 0.505556 | 141.05 |
4 | February 10th, 2025 | August 6th, 2024 | 2.5162% | 2.5162% | 0.516667 | 130.00 |
5 | August 8th, 2025 | February 6th, 2025 | 2.5159% | 2.5154% | 0.497222 | 125.07 |
6 | February 9th, 2026 | August 6th, 2025 | 2.5161% | 2.5136% | 0.513889 | 129.17 |
7 | August 10th, 2026 | February 5th, 2026 | 2.5159% | 2.5073% | 0.505556 | 126.76 |
8 | February 8th, 2027 | August 6th, 2026 | 2.5160% | 2.4976% | 0.505556 | 126.27 |
9 | August 9th, 2027 | February 4th, 2027 | 2.5159% | 2.4851% | 0.505556 | 125.64 |
10 | February 8th, 2028 | August 5th, 2027 | 2.5160% | 2.4707% | 0.508333 | 125.60 |
11 | February 8th, 2028 | None | nan% | nan% | nan | 10000.00 |
Floating-rate bonds on risk-free rates
There is no specific class yet for floaters that pay compounded overnight rates such as SOFR, ESTR or SONIA. However, it’s possible to create the cashflows and use them to build an instance of the base Bond
class. The bond will figure out its redemption on its own.
= ql.FlatForward(0, ql.TARGET(), 0.02, ql.Actual360())
forecast_curve = ql.Estr(ql.YieldTermStructureHandle(forecast_curve)) estr
As usual, the index will need past fixings. In the real world, you’d read them from a database or an API. Here, I’ll feed it constant ones for brevity:
= ql.Date(8, ql.February, 2024)
d while d < today:
if estr.isValidFixingDate(d):
0.02)
estr.addFixing(d, += 1 d
Onwards with the bond construction:
= ql.Schedule(
schedule 8, ql.February, 2024),
ql.Date(8, ql.February, 2029),
ql.Date(1, ql.Years),
ql.Period(
ql.TARGET(),
ql.Following,
ql.Following,
ql.DateGeneration.Backward,False,
)
= ql.OvernightLeg(
coupons =schedule,
schedule=estr,
index=[10_000],
nominals=[0.0023],
spreads=2,
paymentLag )
In C++, the call to OvernightLeg
above would be written as
Leg coupons =
OvernightLeg(schedule, estr)
.withNotionals(10000)
.withSpreads(0.0023)
.withPaymentLeg(2);
and, as you might guess, several other parameters are available. Finally, we can create the bond as
= ql.Bond(
bond
settlementDays,
ql.TARGET(),0],
schedule[
coupons,
) bond.setPricingEngine(engine)
print(bond.cleanPrice())
92.07577763235304
This kind of bonds can also be used as a proxy for old-style floaters that need fall-back calculation after the cessation of LIBOR fixings.
A warning: duration of floating-rate bonds
If we try to pass a floating-rate bond we instantiated to the function provided for calculating bond durations, we run into a problem. For, say, a 1.5% semiannual yield, the result we get is:
= ql.FlatForward(0, ql.TARGET(), 0.015, ql.Actual360())
forecast_curve = ql.RelinkableYieldTermStructureHandle(forecast_curve)
forecast_handle = ql.Euribor6M(forecast_handle)
euribor
= ql.Schedule(
schedule 8, ql.February, 2024),
ql.Date(8, ql.February, 2028),
ql.Date(6, ql.Months),
ql.Period(
ql.TARGET(),
ql.Following,
ql.Following,
ql.DateGeneration.Backward,False,
)
= ql.FloatingRateBond(
bond =3,
settlementDays=100,
faceAmount=schedule,
schedule=euribor,
index=[0.001],
spreads=ql.Actual360(),
paymentDayCounter=ql.Following,
paymentConvention )
= 0.015
y0 = ql.InterestRate(y0, ql.Actual360(), ql.Compounded, ql.Semiannual)
y print(ql.BondFunctions.duration(bond, y, ql.Duration.Modified))
3.6491647768932602
which is about the time to maturity. Shouldn’t we get the time to next coupon instead?
The problem is that the function above is too generic. It calculates the modified duration as \(\displaystyle{-\frac{1}{P}\frac{dP}{dy}}\); however, it doesn’t know what kind of bond it has been passed and what kind of cash flows are paid, so it can only consider the yield for discounting and not for forecasting. If you looked into the C++ code, you’d see that the bond price \(P\) above is calculated as the sum of the discounted cash flows, as in the following:
= ql.SimpleQuote(y0)
y = ql.FlatForward(
yield_curve
bond.settlementDate(),
ql.QuoteHandle(y),
ql.Actual360(),
ql.Compounded,
ql.Semiannual,
)
= [c.date() for c in bond.cashflows()]
dates = [c.amount() for c in bond.cashflows()]
cfs = [yield_curve.discount(d) for d in dates]
discounts = sum(cf * b for cf, b in zip(cfs, discounts))
P
print(P)
101.43285359570949
Finally, the derivative \(\displaystyle{\frac{dP}{dy}}\) in the duration formula in approximated as \(\displaystyle{\frac{P(y+dy)-P(y-dy)}{2 dy}}\), so that we get:
= 1e-5
dy
+ dy)
y.setValue(y0 = [c.amount() for c in bond.cashflows()]
cfs_p = [yield_curve.discount(d) for d in dates]
discounts_p = sum(cf * b for cf, b in zip(cfs_p, discounts_p))
P_p print(P_p)
- dy)
y.setValue(y0 = [c.amount() for c in bond.cashflows()]
cfs_m = [yield_curve.discount(d) for d in dates]
discounts_m = sum(cf * b for cf, b in zip(cfs_m, discounts_m))
P_m print(P_m)
y.setValue(y0)
101.42915222219966
101.43655512613346
print(-(1 / P) * (P_p - P_m) / (2 * dy))
3.649164778159966
which is, within accuracy, the same figure returned by BondFunctions.duration
.
The reason is that the above doesn’t use the yield curve for forecasting, so it’s not really considering the bond as a floating-rate bond. It’s using it as if it were a fixed-rate bond, whose coupon rates happen to equal the current forecasts for the Euribor 6M fixings. This is clear if we look at the coupon amounts and discounts we stored during the calculation:
pd.DataFrame(list(zip(dates, cfs_p, discounts_p, cfs_m, discounts_m)),
=(
columns"date",
"amount (+)",
"discounts (+)",
"amount (-)",
"discounts (-)",
),=range(1, len(dates) + 1),
index )
date | amount (+) | discounts (+) | amount (-) | discounts (-) | |
---|---|---|---|---|---|
1 | August 8th, 2024 | 1.461056 | 0.996144 | 1.461056 | 0.996149 |
2 | February 10th, 2025 | 0.829678 | 0.988478 | 0.829678 | 0.988493 |
3 | August 8th, 2025 | 0.798344 | 0.981155 | 0.798344 | 0.981180 |
4 | February 9th, 2026 | 0.825201 | 0.973644 | 0.825201 | 0.973679 |
5 | August 10th, 2026 | 0.811772 | 0.966311 | 0.811772 | 0.966355 |
6 | February 8th, 2027 | 0.811772 | 0.959033 | 0.811772 | 0.959087 |
7 | August 9th, 2027 | 0.811772 | 0.951810 | 0.811772 | 0.951873 |
8 | February 8th, 2028 | 0.816248 | 0.944602 | 0.816248 | 0.944674 |
9 | February 8th, 2028 | 100.000000 | 0.944602 | 100.000000 | 0.944674 |
You can see how the discount factors changed when the yield was modified, but the coupon amounts stayed the same.
Unfortunately, there’s no easy way to modify the BondFunctions.duration
method so that it does what we expect. What we can do, instead, is to repeat the calculation above while setting up the bond and the curves so that the yield is used correctly. In particular, we have to link the forecast curve to the flat yield curve being modified; this might imply setting up a z-spread between forecast and discount curve, but I’ll gloss over this now. I’ll also set a pricing engine to the bond so it does the dirty price calculation for us.
forecast_handle.linkTo(yield_curve)
bond.setPricingEngine(
ql.DiscountingBondEngine(ql.YieldTermStructureHandle(yield_curve)) )
+ dy)
y.setValue(y0 = bond.dirtyPrice()
P_p = [c.amount() for c in bond.cashflows()]
cfs_p = [yield_curve.discount(d) for d in dates]
discounts_p print(P_p)
- dy)
y.setValue(y0 = bond.dirtyPrice()
P_m = [c.amount() for c in bond.cashflows()]
cfs_m = [yield_curve.discount(d) for d in dates]
discounts_m print(P_m)
y.setValue(y0)
101.41322141911154
101.41375522190961
Now the coupon amounts change with the yield (except, of course, the first coupon, whose amount was already fixed)…
pd.DataFrame(list(zip(dates, cfs_p, discounts_p, cfs_m, discounts_m)),
=(
columns"date",
"amount (+)",
"discounts (+)",
"amount (-)",
"discounts (-)",
),=range(1, len(dates) + 1),
index )
date | amount (+) | discounts (+) | amount (-) | discounts (-) | |
---|---|---|---|---|---|
1 | August 8th, 2024 | 1.461056 | 0.996144 | 1.461056 | 0.996149 |
2 | February 10th, 2025 | 0.827280 | 0.988478 | 0.826247 | 0.988493 |
3 | August 8th, 2025 | 0.796037 | 0.981155 | 0.795043 | 0.981180 |
4 | February 9th, 2026 | 0.822816 | 0.973644 | 0.821788 | 0.973679 |
5 | August 10th, 2026 | 0.809426 | 0.966311 | 0.808415 | 0.966355 |
6 | February 8th, 2027 | 0.809426 | 0.959033 | 0.808415 | 0.959087 |
7 | August 9th, 2027 | 0.809426 | 0.951810 | 0.808415 | 0.951873 |
8 | February 8th, 2028 | 0.813889 | 0.944602 | 0.812872 | 0.944674 |
9 | February 8th, 2028 | 100.000000 | 0.944602 | 100.000000 | 0.944674 |
…and the duration is calculated correctly, approximating the four months to the next coupon.
print(-(1 / P) * (P_p - P_m) / (2 * dy))
0.2631311153887885