Inflation bonds
Welcome, dear reader.
Here is the latest addition to A QuantLib Guide: a notebook on inflation bonds.
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.
Inflation bonds
import QuantLib as ql
import pandas as pd
today = ql.Date(19, ql.September, 2021)
ql.Settings.instance().evaluationDate = today
For the purposes of this notebook, I’ll create an inflation curve by interpolating a few zero rates, which could also happen in actual practice if rates are provided by an external system. Alternatively, it can be bootstrapped as already shown.
As usual, since inflation indexes are published with a lag, the curve starts in the past; in this case, we’ll assume we already have August’s fixing, but the base date would go as far back as July otherwise.
base_date = ql.Date(1, ql.August, 2021)
dates = [
base_date,
base_date + ql.Period(5, ql.Years),
base_date + ql.Period(10, ql.Years),
]
rates = [0.02, 0.023, 0.025]
inflation_curve = ql.ZeroInflationTermStructureHandle(
ql.ZeroInflationCurve(
today,
dates,
rates,
ql.Monthly,
ql.Actual360(),
)
)
I’ll also create an inflation index; its historical fixings will be stored, while the curve above will be used to forecast future fixings.
index = ql.EUHICP(inflation_curve)
index.addFixing(ql.Date(1, 1, 2019), 102.2)
index.addFixing(ql.Date(1, 2, 2019), 102.3)
index.addFixing(ql.Date(1, 3, 2019), 102.5)
index.addFixing(ql.Date(1, 4, 2019), 102.6)
index.addFixing(ql.Date(1, 5, 2019), 102.7)
index.addFixing(ql.Date(1, 6, 2019), 102.7)
index.addFixing(ql.Date(1, 7, 2019), 102.7)
index.addFixing(ql.Date(1, 8, 2019), 103.2)
index.addFixing(ql.Date(1, 9, 2019), 102.5)
index.addFixing(ql.Date(1, 10, 2019), 102.4)
index.addFixing(ql.Date(1, 11, 2019), 102.3)
index.addFixing(ql.Date(1, 12, 2019), 102.5)
index.addFixing(ql.Date(1, 1, 2020), 102.7)
index.addFixing(ql.Date(1, 2, 2020), 102.5)
index.addFixing(ql.Date(1, 3, 2020), 102.6)
index.addFixing(ql.Date(1, 4, 2020), 102.5)
index.addFixing(ql.Date(1, 5, 2020), 102.3)
index.addFixing(ql.Date(1, 6, 2020), 102.4)
index.addFixing(ql.Date(1, 7, 2020), 102.3)
index.addFixing(ql.Date(1, 8, 2020), 102.5)
index.addFixing(ql.Date(1, 9, 2020), 101.9)
index.addFixing(ql.Date(1, 10, 2020), 102)
index.addFixing(ql.Date(1, 11, 2020), 102)
index.addFixing(ql.Date(1, 12, 2020), 102.3)
index.addFixing(ql.Date(1, 1, 2021), 102.9)
index.addFixing(ql.Date(1, 2, 2021), 103)
index.addFixing(ql.Date(1, 3, 2021), 103.3)
index.addFixing(ql.Date(1, 4, 2021), 103.7)
index.addFixing(ql.Date(1, 5, 2021), 103.6)
index.addFixing(ql.Date(1, 6, 2021), 103.8)
index.addFixing(ql.Date(1, 7, 2021), 104.2)
index.addFixing(ql.Date(1, 8, 2021), 104.7)
Simple inflation bonds
Currently, the library only provides the CPIBond class. It models
bonds paying inflation-based coupons and redemption. The $i$-th coupon
pays an amount
$C = N \times \left(I_i/I_0\right) \times \Delta T \times r$, where $N$
is the notional, $\Delta T$ is the accrual period of the coupon, $r$ is
a given fixed rate, $I_0$ is a base CPI value (the same for all
coupons), and $I_i$ is the value of the index at the maturity $t_i$ of
the coupon minus an observation lag. The redemption is
$R = N \times \left(I_N/I_0\right)$, where $I_N$ is the value of the
index at the maturity $t_N$ of the bond minus the observation lag.
The parameter of the constructor are those of most bonds (a schedule, a day-count convention, the notional, the settlement days) plus the base CPI value, the fixed rate, the observation lag, and the interpolation type (flat of linear.)
schedule = ql.Schedule(
ql.Date(8, ql.May, 2020),
ql.Date(8, ql.May, 2026),
ql.Period(6, ql.Months),
ql.TARGET(),
ql.Following,
ql.Following,
ql.DateGeneration.Backward,
False,
)
settlement_days = 3
face_amount = 100.0
growth_only = False
base_cpi = 102.0
observation_lag = ql.Period(3, ql.Months)
fixed_rate = 0.02
bond = ql.CPIBond(
settlement_days,
face_amount,
growth_only,
base_cpi,
observation_lag,
index,
ql.CPI.Flat,
schedule,
[fixed_rate],
ql.Thirty360(ql.Thirty360.BondBasis),
)
(Never mind the growth_only parameter; it’s already deprecated in the
underlying C++ library, so you can omit it if you’re using it from that
language, and will be removed in one of the next releases of the Python
module. You can always set it to False).
As usual, we can use a helper function (as_cpi_coupon) to downcast the
bond coupons and get additional information:
coupon_data = []
for cf in bond.cashflows():
c = ql.as_cpi_coupon(cf)
if c is not None:
coupon_data.append(
(c.date(), c.rate(), c.indexFixing(), c.amount())
)
else:
coupon_data.append((cf.date(), None, None, cf.amount()))
df = pd.DataFrame(
coupon_data, columns=("date", "rate", "index fixing", "amount")
)
df.style.format(
{"rate": "{:.4%}", "index fixing": "{:.2f}", "amount": "{:.4f}"}
)
| date | rate | index fixing | amount |
|---|---|---|---|
| November 9th, 2020 | 2.0098% | 102.50 | 1.0105 |
| May 10th, 2021 | 2.0196% | 103.00 | 1.0154 |
| November 8th, 2021 | 2.0529% | 104.70 | 1.0151 |
| May 9th, 2022 | 2.0741% | 105.78 | 1.0428 |
| November 8th, 2022 | 2.0958% | 106.89 | 1.0421 |
| May 8th, 2023 | 2.1187% | 108.06 | 1.0594 |
| November 8th, 2023 | 2.1422% | 109.25 | 1.0711 |
| May 8th, 2024 | 2.1669% | 110.51 | 1.0834 |
| November 8th, 2024 | 2.1923% | 111.81 | 1.0961 |
| May 8th, 2025 | 2.2189% | 113.16 | 1.1094 |
| November 10th, 2025 | 2.2461% | 114.55 | 1.1355 |
| May 8th, 2026 | 2.2747% | 116.01 | 1.1247 |
| May 8th, 2026 | nan% | nan | 113.7354 |
And as usual, we can set a discounting engine to the bond and ask for its price:
discount_curve = ql.YieldTermStructureHandle(
ql.FlatForward(today, 0.01, ql.Actual365Fixed())
)
bond.setPricingEngine(ql.DiscountingBondEngine(discount_curve))
print(bond.cleanPrice())
print(bond.dirtyPrice())
print(bond.accruedAmount())
118.368287509748
119.11456201955193
0.7462745098039215
Pricing conventions
Note that the price of the bond is returned as the discounted sum of the
cash flows as defined above. In some markets, though, the convention is
to quote as bond price the value above divided by the increase $I_S/I_0$
of the inflation from the start of the bond to its current settlement
date. The CPIBond class still doesn’t provide this feature; if that’s
the required convention, we’ll have to adjust for the inflation factor
manually, as in:
current_cpi = ql.CPI.laggedFixing(
index, bond.settlementDate(), observation_lag, ql.CPI.Flat
)
inflation_factor = current_cpi / base_cpi
print(bond.cleanPrice() / inflation_factor)
116.3156582465732
More exotic bonds
Bonds other than simple CPI bonds (as defined above) can be built using the basic facilities provided by the library; but as we’ll see, this has drawbacks.
As an example, let’s take an Italian inflation bond I happened to come across a while ago. At each coupon maturity, it pays $C = N \times \left(I_i/I_{i-1}\right) \times \Delta T \times r$, that is, a fixed rate multiplied by the increase of the inflation over the life of the coupon (with an observation lag, as usual), and it also pays a principal payment $P = N \times \left(I_i/I_{i-1} - 1\right)$. However, if the inflation decreases over the life of the coupon, the payoff changes: there is no principal payment, and the coupon is simply the fixed-rate coupon $C = N \times \Delta T \times r$. In this case, the base value $I_{i-1}$ will be used for the next coupon instead.
There is no such bond in the library, but we can try building its cash flows. Here are the basic bond parameters:
schedule = ql.Schedule(
ql.Date(11, 4, 2019),
ql.Date(11, 4, 2026),
ql.Period(6, ql.Months),
ql.TARGET(),
ql.Unadjusted,
ql.Unadjusted,
ql.DateGeneration.Backward,
False,
)
settlement_days = 2
face_amount = 100000
observation_lag = ql.Period(3, ql.Months)
fixed_rate = 0.004
day_counter = ql.Thirty360(ql.Thirty360.BondBasis)
First, the coupons. The base CPI value for the first coupon is the interpolated fixing of the index at its start; for the ones after that, it’s the fixing at the end of the previous coupon. However, if the fixing at the end is lower than the fixing at the start, we replace the coupon with a fixed-rate one and keep the base CPI value so it can be used for the next coupon.
coupons = []
base_cpi = ql.EUHICP(inflation_curve).fixing(schedule[0] - observation_lag)
interpolation = ql.CPI.Linear
cpi_pricer = ql.CPICouponPricer()
for i in range(1, len(schedule)):
start_date = schedule[i - 1]
end_date = schedule[i]
payment_date = ql.TARGET().adjust(end_date)
c = ql.CPICoupon(
base_cpi,
payment_date,
face_amount,
start_date,
end_date,
index,
observation_lag,
interpolation,
day_counter,
fixed_rate,
)
c.setPricer(cpi_pricer)
if c.baseCPI() <= c.indexFixing():
# normal case
coupons.append(c)
base_cpi = c.indexFixing()
else:
# use a fixed-rate coupon with the same dates;
# also don't update base CPI
cf = ql.FixedRateCoupon(
c.date(),
face_amount,
fixed_rate,
day_counter,
c.accrualStartDate(),
c.accrualEndDate(),
c.referencePeriodStart(),
c.referencePeriodEnd(),
)
coupons.append(cf)
Next, the principal payments. We can model them using the
ZeroInflationCashFlow class;. Again, we have to keep track of whether
the inflation increases or decreases over the period and adjust the cash
flows accordingly.
redemptions = []
growth_only = True
skipped_months = 0
for i in range(len(coupons) - 1):
start_date = schedule[i - skipped_months]
end_date = schedule[i + 1]
payment_date = coupons[i].date()
cf = ql.ZeroInflationCashFlow(
face_amount,
index,
interpolation,
start_date,
end_date,
observation_lag,
payment_date,
growth_only,
)
if cf.amount() > 0:
redemptions.append(cf)
skipped_months = 0
else:
redemptions.append(ql.SimpleCashFlow(0.0, payment_date))
skipped_months = skipped_months + 1
The final principal payment includes the redemption:
growth_only = False
payment_date = coupons[-1].date()
cf = ql.ZeroInflationCashFlow(
face_amount,
index,
interpolation,
schedule[-2 - skipped_months],
schedule[-1],
observation_lag,
payment_date,
growth_only,
)
if cf.amount() > face_amount:
redemptions.append(cf)
else:
redemptions.append(ql.SimpleCashFlow(face_amount, payment_date))
We can now build the bond by passing the cash flows we created:
cashflows = sorted(coupons + redemptions, key=lambda c: c.date())
issue_date = ql.Date(11, 4, 2019)
maturity_date = cashflows[-1].date()
bond = ql.Bond(
settlement_days,
ql.TARGET(),
face_amount,
maturity_date,
issue_date,
cashflows,
)
The following shows the cashflows of the bond. You can see a few cases (that is, in April and October 2020) where the inflation decreased with respect to the base CPI, and therefore the principal payments reverted to 0 and the interest payment reverted to a fixed-rate coupon.
coupon_data = []
for cf in bond.cashflows():
c = ql.as_cpi_coupon(cf)
if c is not None:
coupon_data.append(
(
c.date(),
c.rate(),
c.baseCPI(),
c.indexFixing(),
c.amount(),
"interest",
)
)
else:
c = ql.as_fixed_rate_coupon(cf)
if c is not None:
index_fixing = ql.CPI.laggedFixing(
index, c.date(), observation_lag, interpolation
)
coupon_data.append(
(c.date(), c.rate(), None, None, c.amount(), "interest")
)
else:
coupon_data.append(
(cf.date(), None, None, None, cf.amount(), "principal")
)
df = pd.DataFrame(
coupon_data,
columns=("date", "rate", "base CPI", "fixing", "amount", "type"),
)
df.style.format(
{
"rate": "{:.4%}",
"base CPI": "{:.2f}",
"fixing": "{:.2f}",
"amount": "{:.2f}",
}
)
| date | rate | base CPI | fixing | amount | type |
|---|---|---|---|---|---|
| Oct 11 2019 | 0.4026% | 102.20 | 102.86 | 201.29 | interest |
| Oct 11 2019 | nan% | nan | nan | 614.24 | principal |
| Apr 14 2020 | 0.4000% | nan | nan | 200.00 | interest |
| Apr 14 2020 | nan% | nan | nan | 0.00 | principal |
| Oct 12 2020 | 0.4000% | nan | nan | 200.00 | interest |
| Oct 12 2020 | nan% | nan | nan | 0.00 | principal |
| Apr 12 2021 | 0.4003% | 102.86 | 102.93 | 200.14 | interest |
| Apr 12 2021 | nan% | nan | nan | 70.04 | principal |
| Oct 11 2021 | 0.4055% | 102.93 | 104.36 | 202.77 | interest |
| Oct 11 2021 | nan% | nan | nan | 1387.26 | principal |
| Apr 11 2022 | 0.4050% | 104.36 | 105.66 | 202.48 | interest |
| Apr 11 2022 | nan% | nan | nan | 1242.20 | principal |
| Oct 11 2022 | nan% | nan | nan | 1040.17 | principal |
| Oct 11 2022 | 0.4042% | 105.66 | 106.76 | 202.08 | interest |
| Apr 11 2023 | 0.4044% | 106.76 | 107.92 | 202.18 | interest |
| Apr 11 2023 | nan% | nan | nan | 1091.80 | principal |
| Oct 11 2023 | 0.4044% | 107.92 | 109.11 | 202.20 | interest |
| Oct 11 2023 | nan% | nan | nan | 1099.75 | principal |
| Apr 11 2024 | 0.4046% | 109.11 | 110.37 | 202.31 | interest |
| Apr 11 2024 | nan% | nan | nan | 1152.59 | principal |
| Oct 11 2024 | 0.4047% | 110.37 | 111.65 | 202.33 | interest |
| Oct 11 2024 | nan% | nan | nan | 1165.84 | principal |
| Apr 11 2025 | 0.4049% | 111.65 | 113.01 | 202.43 | interest |
| Apr 11 2025 | nan% | nan | nan | 1213.53 | principal |
| Oct 13 2025 | 0.4049% | 113.01 | 114.39 | 202.44 | interest |
| Oct 13 2025 | nan% | nan | nan | 1219.01 | principal |
| Apr 13 2026 | 0.4051% | 114.39 | 115.84 | 202.55 | interest |
| Apr 13 2026 | nan% | nan | nan | 101274.29 | principal |
An important drawback
Unfortunately, building a bond this way goes against the grain of the library. By creating coupons this way, we’re freezing their base CPI values — even for future coupons, whose base CPI are just a forecast that depend on the inflation curve — and we’re pre-determining whether a given principal payment will or won’t happen. Thus, the bond we’re building won’t work with the usual bump-and-reprice methods we’re used to; these half-predetermined coupons won’t react properly to the change in the inflation curve. If the latter changes, we’ll need to rebuild the coupons and the bond from scratch instead.
To work properly, these kind of coupon should be implemented in the library; they should determine their base CPI from the curve, and should somehow be linked to the previous coupon so they can know whether they should reuse its base CPI.
See you next time!