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!