import QuantLib as ql
import pandas as pdInflation bonds
today = ql.Date(19, ql.September, 2021)
ql.Settings.instance().evaluationDate = todayFor 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 | |
|---|---|---|---|---|
| 0 | November 9th, 2020 | 2.0098% | 102.50 | 1.0105 |
| 1 | May 10th, 2021 | 2.0196% | 103.00 | 1.0154 |
| 2 | November 8th, 2021 | 2.0529% | 104.70 | 1.0151 |
| 3 | May 9th, 2022 | 2.0741% | 105.78 | 1.0428 |
| 4 | November 8th, 2022 | 2.0958% | 106.89 | 1.0421 |
| 5 | May 8th, 2023 | 2.1187% | 108.06 | 1.0594 |
| 6 | November 8th, 2023 | 2.1422% | 109.25 | 1.0711 |
| 7 | May 8th, 2024 | 2.1669% | 110.51 | 1.0834 |
| 8 | November 8th, 2024 | 2.1923% | 111.81 | 1.0961 |
| 9 | May 8th, 2025 | 2.2189% | 113.16 | 1.1094 |
| 10 | November 10th, 2025 | 2.2461% | 114.55 | 1.1355 |
| 11 | May 8th, 2026 | 2.2747% | 116.01 | 1.1247 |
| 12 | 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 + 1The 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", "index fixing", "amount", "type"),
)
df.style.format(
{
"rate": "{:.4%}",
"base CPI": "{:.2f}",
"index fixing": "{:.2f}",
"amount": "{:.2f}",
}
)| date | rate | base CPI | index fixing | amount | type | |
|---|---|---|---|---|---|---|
| 0 | October 11th, 2019 | 0.4026% | 102.20 | 102.86 | 201.29 | interest |
| 1 | October 11th, 2019 | nan% | nan | nan | 614.24 | principal |
| 2 | April 14th, 2020 | 0.4000% | nan | nan | 200.00 | interest |
| 3 | April 14th, 2020 | nan% | nan | nan | 0.00 | principal |
| 4 | October 12th, 2020 | 0.4000% | nan | nan | 200.00 | interest |
| 5 | October 12th, 2020 | nan% | nan | nan | 0.00 | principal |
| 6 | April 12th, 2021 | 0.4003% | 102.86 | 102.93 | 200.14 | interest |
| 7 | April 12th, 2021 | nan% | nan | nan | 70.04 | principal |
| 8 | October 11th, 2021 | 0.4055% | 102.93 | 104.36 | 202.77 | interest |
| 9 | October 11th, 2021 | nan% | nan | nan | 1387.26 | principal |
| 10 | April 11th, 2022 | 0.4050% | 104.36 | 105.66 | 202.48 | interest |
| 11 | April 11th, 2022 | nan% | nan | nan | 1242.20 | principal |
| 12 | October 11th, 2022 | nan% | nan | nan | 1040.17 | principal |
| 13 | October 11th, 2022 | 0.4042% | 105.66 | 106.76 | 202.08 | interest |
| 14 | April 11th, 2023 | 0.4044% | 106.76 | 107.92 | 202.18 | interest |
| 15 | April 11th, 2023 | nan% | nan | nan | 1091.80 | principal |
| 16 | October 11th, 2023 | 0.4044% | 107.92 | 109.11 | 202.20 | interest |
| 17 | October 11th, 2023 | nan% | nan | nan | 1099.75 | principal |
| 18 | April 11th, 2024 | 0.4046% | 109.11 | 110.37 | 202.31 | interest |
| 19 | April 11th, 2024 | nan% | nan | nan | 1152.59 | principal |
| 20 | October 11th, 2024 | 0.4047% | 110.37 | 111.65 | 202.33 | interest |
| 21 | October 11th, 2024 | nan% | nan | nan | 1165.84 | principal |
| 22 | April 11th, 2025 | 0.4049% | 111.65 | 113.01 | 202.43 | interest |
| 23 | April 11th, 2025 | nan% | nan | nan | 1213.53 | principal |
| 24 | October 13th, 2025 | 0.4049% | 113.01 | 114.39 | 202.44 | interest |
| 25 | October 13th, 2025 | nan% | nan | nan | 1219.01 | principal |
| 26 | April 13th, 2026 | 0.4051% | 114.39 | 115.84 | 202.55 | interest |
| 27 | April 13th, 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.