Using Curves with an Index and Inflation Instruments#
This page exemplifies the ways of constructing Curves dealing with inflation and inflation linked products. E.g. IndexFixedRateBond, ZCIS and IIRS.
Key Points
A
Seriesof index values uses real data, with a zero month lag and the month is indexed to the 1st of the month.A
Curvecan have anyindex_lagbut best practice is to set it to zero to be consistent withindex_fixings.A
Curvecan be calibrated by forecast RPI/CPI index values in aSolverusing theValueInstrument type.
Begin with a simple case without a Curve or any index_fixings#
This case uses an IndexFixedRateBond which has two coupon periods. The bond that is created below is fictional. It has the normal 3 month index_lag, ‘daily’ index_method for interpolation and the index_base for the Instrument is set to 381.0.
Its cashflows can be generated but are not fully formed becuase we are lacking information about the index: UK RPI.
[1]:
from rateslib import *
from pandas import Series, DataFrame
today = dt(2025, 5, 12)
ukti = IndexFixedRateBond(
effective=dt(2024, 5, 27),
termination=dt(2025, 5, 27),
fixed_rate=2.0,
notional=-10e6,
index_base=381.0,
index_method="daily",
index_lag=3,
spec="uk_gb"
)
[2]:
ukti.cashflows()
[2]:
| Type | Period | Ccy | Acc Start | Acc End | Payment | Convention | DCF | Notional | DF | ... | Rate | Spread | Real Cashflow | Index Base | Index Val | Index Ratio | Cashflow | NPV | FX Rate | NPV Ccy | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | IndexFixedPeriod | Regular | GBP | 2024-05-27 | 2024-11-27 | 2024-11-27 | ActActICMA | 0.5 | -10000000.0 | None | ... | 2.0 | NaN | 100000.0 | 381.0 | None | None | None | None | 1.0 | None |
| 1 | IndexFixedPeriod | Regular | GBP | 2024-11-27 | 2025-05-27 | 2025-05-27 | ActActICMA | 0.5 | -10000000.0 | None | ... | 2.0 | NaN | 100000.0 | 381.0 | None | None | None | None | 1.0 | None |
| 2 | IndexCashflow | Exchange | GBP | NaT | 2025-05-27 | 2025-05-27 | NaN | NaN | -10000000.0 | None | ... | NaN | NaN | 10000000.0 | 381.0 | None | None | None | None | 1.0 | None |
3 rows × 21 columns
Adding index_fixings as a Series#
Becuase this bond has a 3 month index_lag the most recent print required to determine all the cashflows is the RPI index for March 2025. In rateslib the RPI value for March must be indexed to 1st March, i.e. index_fixings as a Series must have a zero lag. The below are real published RPI prints for the UK. (Note that Bloomberg will index these to the end of the month instead of the start of the month)
[3]:
from pandas import DataFrame
RPI_series = DataFrame([
[dt(2024, 2, 1), 381.0],
[dt(2024, 3, 1), 383.0],
[dt(2024, 4, 1), 385.0],
[dt(2024, 5, 1), 386.4],
[dt(2024, 6, 1), 387.3],
[dt(2024, 7, 1), 387.5],
[dt(2024, 8, 1), 389.9],
[dt(2024, 9, 1), 388.6],
[dt(2024, 10, 1), 390.7],
[dt(2024, 11, 1), 390.9],
[dt(2024, 12, 1), 392.1],
[dt(2025, 1, 1), 391.7],
[dt(2025, 2, 1), 394.0],
[dt(2025, 3, 1), 395.3]
], columns=["month", "rate"]).set_index("month")["rate"]
RPI_series
[3]:
month
2024-02-01 381.0
2024-03-01 383.0
2024-04-01 385.0
2024-05-01 386.4
2024-06-01 387.3
2024-07-01 387.5
2024-08-01 389.9
2024-09-01 388.6
2024-10-01 390.7
2024-11-01 390.9
2024-12-01 392.1
2025-01-01 391.7
2025-02-01 394.0
2025-03-01 395.3
Name: rate, dtype: float64
If the bond is recreated supplying the index_fixings the cashflows will be fully formed. Additionally we can use the same RPI_series to set the index_base value.
For good order the index_base is expected to be (and will be visible in one of the columns in cashflows):
[4]:
ukti = IndexFixedRateBond(
effective=dt(2024, 5, 27),
termination=dt(2025, 5, 27),
fixed_rate=2.0,
notional=-10e6,
index_base=RPI_series,
index_method="daily",
index_lag=3,
index_fixings=RPI_series,
spec="uk_gb"
)
[5]:
ukti.cashflows()
[5]:
| Type | Period | Ccy | Acc Start | Acc End | Payment | Convention | DCF | Notional | DF | ... | Rate | Spread | Real Cashflow | Index Base | Index Val | Index Ratio | Cashflow | NPV | FX Rate | NPV Ccy | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | IndexFixedPeriod | Regular | GBP | 2024-05-27 | 2024-11-27 | 2024-11-27 | ActActICMA | 0.5 | -10000000.0 | None | ... | 2.0 | NaN | 100000.0 | 382.677419 | 388.773333 | 1.015930 | 1.015930e+05 | None | 1.0 | None |
| 1 | IndexFixedPeriod | Regular | GBP | 2024-11-27 | 2025-05-27 | 2025-05-27 | ActActICMA | 0.5 | -10000000.0 | None | ... | 2.0 | NaN | 100000.0 | 382.677419 | 395.090323 | 1.032437 | 1.032437e+05 | None | 1.0 | None |
| 2 | IndexCashflow | Exchange | GBP | NaT | 2025-05-27 | 2025-05-27 | NaN | NaN | -10000000.0 | None | ... | NaN | NaN | 10000000.0 | 382.677419 | 395.090323 | 1.032437 | 1.032437e+07 | None | 1.0 | None |
3 rows × 21 columns
Adding a discount Curve#
The npv of the cashflows, and of the bond are still not available becuase there is no discount curve. Let’s add one. Note that its initial date is, as usual, set to today.
[6]:
disc_curve = Curve({today: 1.0, dt(2029, 1, 1): 0.95})
There is now sufficient information to price any aspect of this bond becuase the index_fixings are determined and the discount Curve can value the future cashflows.
The prices shown below will be for the standard T+1 settlement under the uk_gb default spec.
[7]:
ukti.cashflows(curves=[None, disc_curve])
[7]:
| Type | Period | Ccy | Acc Start | Acc End | Payment | Convention | DCF | Notional | DF | ... | Rate | Spread | Real Cashflow | Index Base | Index Val | Index Ratio | Cashflow | NPV | FX Rate | NPV Ccy | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | IndexFixedPeriod | Regular | GBP | 2024-05-27 | 2024-11-27 | 2024-11-27 | ActActICMA | 0.5 | -10000000.0 | 0.000000 | ... | 2.0 | NaN | 100000.0 | 382.677419 | 388.773333 | 1.015930 | 1.015930e+05 | 0.000000e+00 | 1.0 | 0.000000e+00 |
| 1 | IndexFixedPeriod | Regular | GBP | 2024-11-27 | 2025-05-27 | 2025-05-27 | ActActICMA | 0.5 | -10000000.0 | 0.999422 | ... | 2.0 | NaN | 100000.0 | 382.677419 | 395.090323 | 1.032437 | 1.032437e+05 | 1.031840e+05 | 1.0 | 1.031840e+05 |
| 2 | IndexCashflow | Exchange | GBP | NaT | 2025-05-27 | 2025-05-27 | NaN | NaN | -10000000.0 | 0.999422 | ... | NaN | NaN | 10000000.0 | 382.677419 | 395.090323 | 1.032437 | 1.032437e+07 | 1.031840e+07 | 1.0 | 1.031840e+07 |
3 rows × 21 columns
[8]:
ukti.rate(curves=[None, disc_curve], metric="clean_price")
[8]:
np.float64(100.17305623199086)
[9]:
ukti.rate(curves=[None, disc_curve], metric="index_clean_price")
[9]:
np.float64(103.2686848600485)
Adding a forecast Index Curve#
Now we will add a forecast Index Curve. Rateslib allows Curves to be parametrised according to their own index_lag, but the most natural definition is to define a Curve with a zero index lag, consistent with the Series. This is more transparent.
Our Curve will start as of the last available RPI value date, indexed to that level. I.e. starting at 1st March with a base value of 395.3.
We calibrate the Curve, for this example, not with market instruments but instead directly with Index Values we wish to use.
[10]:
index_curve = Curve(
nodes={
dt(2025, 3, 1): 1.0,
dt(2025, 4, 1): 1.0,
dt(2025, 5, 1): 1.0,
dt(2025, 6, 1): 1.0,
dt(2025, 7, 1): 1.0,
},
index_lag=0,
index_base=395.3,
id="ic",
)
solver = Solver(
curves=[index_curve],
instruments=[
Value(effective=dt(2025, 4, 1), metric="index_value", curves="ic"),
Value(effective=dt(2025, 5, 1), metric="index_value", curves="ic"),
Value(effective=dt(2025, 6, 1), metric="index_value", curves="ic"),
Value(effective=dt(2025, 7, 1), metric="index_value", curves="ic"),
],
s=[396, 397.1, 398, 398.8],
instrument_labels=["Apr", "May", "Jun", "Jul"],
)
SUCCESS: `func_tol` reached after 3 iterations (levenberg_marquardt), `f_val`: 1.6235874018262206e-18, `time`: 0.0043s
An Instrument with mixed index_fixings and forecast fixings#
Now we can create an Instrument which requires both historical fixings and forecast values. Changing the dates of the fictional bond to end in, say, September 2025, requires the fixings forecast on the curve for June and July. Note we choose to add the curves directly at Instrument initialisation.
[11]:
ukti = IndexFixedRateBond(
effective=dt(2024, 9, 16),
termination=dt(2025, 9, 16),
fixed_rate=3.0,
notional=-15e6,
index_base=RPI_series,
index_method="daily",
index_lag=3,
index_fixings=RPI_series,
spec="uk_gb",
curves=[index_curve, disc_curve]
)
[12]:
ukti.cashflows()
[12]:
| Type | Period | Ccy | Acc Start | Acc End | Payment | Convention | DCF | Notional | DF | ... | Rate | Spread | Real Cashflow | Index Base | Index Val | Index Ratio | Cashflow | NPV | FX Rate | NPV Ccy | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | IndexFixedPeriod | Regular | GBP | 2024-09-16 | 2025-03-16 | 2025-03-17 | ActActICMA | 0.5 | -15000000.0 | 0.000000 | ... | 3.0 | NaN | 225000.0 | 387.4 | 391.906452 | 1.011633 | 2.276173e+05 | 0.000000e+00 | 1.0 | 0.000000e+00 |
| 1 | IndexFixedPeriod | Regular | GBP | 2025-03-16 | 2025-09-16 | 2025-09-16 | ActActICMA | 0.5 | -15000000.0 | 0.995114 | ... | 3.0 | NaN | 225000.0 | 387.4 | 398.400000 | 1.028394 | 2.313887e+05 | 2.302582e+05 | 1.0 | 2.302582e+05 |
| 2 | IndexCashflow | Exchange | GBP | NaT | 2025-09-16 | 2025-09-16 | NaN | NaN | -15000000.0 | 0.995114 | ... | NaN | NaN | 15000000.0 | 387.4 | 398.400000 | 1.028394 | 1.542592e+07 | 1.535055e+07 | 1.0 | 1.535055e+07 |
3 rows × 21 columns
Bonus: Risk to RPI prints.#
Actually the way we have constructed this Index Curve using the Solver means we can directly extract monetary sensitivities to the RPI index values
[13]:
ukti.delta(solver=solver)
[13]:
| local_ccy | gbp | ||
|---|---|---|---|
| display_ccy | gbp | ||
| type | solver | label | |
| instruments | d8438_ | Apr | 0.000000 |
| May | 0.000000 | ||
| Jun | 19554.222151 | ||
| Jul | 19554.222151 |
For the 15mm GBP bond owned here, for each unit of the RPI print that comes above the supposed values of 398.0 and 398.8 the PnL will increase by £19.5k. Thus a +0.1% MoM surpise in June shifts up the values in June and July both by about 0.4. This would be expected to affect the NPV by £15.6k.
[14]:
pv_0 = ukti.npv()
pv_0
[14]:
<Dual: 15580804.210107, (ic0, ic1, ic2, ...), [0.0, 0.0, 0.0, ...]>
[15]:
solver.s = s=[396, 397.1, 398.4, 399.2] # <-- Shift the Jun and Jul prints both up by 0.4, i.e. 0.1% MOM suprise in Jun.
solver.iterate()
SUCCESS: `func_tol` reached after 2 iterations (levenberg_marquardt), `f_val`: 1.419343797096256e-14, `time`: 0.0037s
[16]:
pv_1 = ukti.npv()
pv_1 - pv_0
[16]:
<Dual: 15643.374393, (ic0, ic1, ic2, ...), [0.0, 0.0, 0.0, ...]>
Other Instruments and Other Lags#
We can use the objects already created to price other Instruments. We directly construct an IndexFixedLeg below as an example with an index_lag of 2.
[17]:
ifl = IndexFixedLeg(
schedule=Schedule(dt(2024, 12, 1), "8m", "M"),
fixed_rate=1.0,
notional=-15e6,
convention="30360",
index_base=RPI_series,
index_fixings=RPI_series,
index_lag=2,
index_method="monthly",
currency="gbp"
)
The cashflows below show the index values beginning with the November 2024 RPI value progressing through to the known March 2025 value and then adopting the values forecast by the Curve.
[18]:
ifl.cashflows(curve=index_curve, disc_curve=disc_curve)
[18]:
| Type | Period | Ccy | Acc Start | Acc End | Payment | Convention | DCF | Notional | DF | ... | Rate | Spread | Real Cashflow | Index Base | Index Val | Index Ratio | Cashflow | NPV | FX Rate | NPV Ccy | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | IndexFixedPeriod | Regular | GBP | 2024-12-01 | 2025-01-01 | 2025-01-03 | 30360 | 0.083333 | -15000000.0 | 0.000000 | ... | 1.0 | None | 12500.0 | 390.7 | 390.9 | 1.000512 | 12506.398771 | 0.000000 | 1.0 | 0.000000 |
| 1 | IndexFixedPeriod | Regular | GBP | 2025-01-01 | 2025-02-01 | 2025-02-03 | 30360 | 0.083333 | -15000000.0 | 0.000000 | ... | 1.0 | None | 12500.0 | 390.7 | 392.1 | 1.003583 | 12544.791400 | 0.000000 | 1.0 | 0.000000 |
| 2 | IndexFixedPeriod | Regular | GBP | 2025-02-01 | 2025-03-01 | 2025-03-03 | 30360 | 0.083333 | -15000000.0 | 0.000000 | ... | 1.0 | None | 12500.0 | 390.7 | 391.7 | 1.002560 | 12531.993857 | 0.000000 | 1.0 | 0.000000 |
| 3 | IndexFixedPeriod | Regular | GBP | 2025-03-01 | 2025-04-01 | 2025-04-03 | 30360 | 0.083333 | -15000000.0 | 0.000000 | ... | 1.0 | None | 12500.0 | 390.7 | 394.0 | 1.008446 | 12605.579729 | 0.000000 | 1.0 | 0.000000 |
| 4 | IndexFixedPeriod | Regular | GBP | 2025-04-01 | 2025-05-01 | 2025-05-03 | 30360 | 0.083333 | -15000000.0 | 0.000000 | ... | 1.0 | None | 12500.0 | 390.7 | 395.3 | 1.011774 | 12647.171743 | 0.000000 | 1.0 | 0.000000 |
| 5 | IndexFixedPeriod | Regular | GBP | 2025-05-01 | 2025-06-01 | 2025-06-03 | 30360 | 0.083333 | -15000000.0 | 0.999152 | ... | 1.0 | None | 12500.0 | 390.7 | 396.0 | 1.013565 | 12669.567443 | 12658.822374 | 1.0 | 12658.822374 |
| 6 | IndexFixedPeriod | Regular | GBP | 2025-06-01 | 2025-07-01 | 2025-07-03 | 30360 | 0.083333 | -15000000.0 | 0.997997 | ... | 1.0 | None | 12500.0 | 390.7 | 397.1 | 1.016381 | 12704.760686 | 12679.307428 | 1.0 | 12679.307428 |
| 7 | IndexFixedPeriod | Regular | GBP | 2025-07-01 | 2025-08-01 | 2025-08-03 | 30360 | 0.083333 | -15000000.0 | 0.996804 | ... | 1.0 | None | 12500.0 | 390.7 | 398.4 | 1.019708 | 12746.352698 | 12705.616727 | 1.0 | 12705.616727 |
8 rows × 21 columns
[19]:
solver.delta(ifl.npv(curve=index_curve, disc_curve=disc_curve, local=True))
[19]:
| local_ccy | gbp | ||
|---|---|---|---|
| display_ccy | gbp | ||
| type | solver | label | |
| instruments | d8438_ | Apr | 31.966723 |
| May | 31.929759 | ||
| Jun | 31.891608 | ||
| Jul | 0.000000 |