Different approaches to numerical Theta

The idea of calculating the Theta numerically seems a simple one; change the time to maturity (which, in the case of QuantLib, means moving the evaluation date) while keeping everything else the same, and reprice the instrument. However, there are different ways of “keeping everything else the same”, and they lead to slightly different results. Let’s look at them.

import QuantLib as ql
import pandas as pd

today = ql.Date(29, ql.October, 2021)
ql.Settings.instance().evaluationDate = today

Setting a baseline

For the purposes of this notebook, we’ll look at a floating-rate bond on Euribor. For brevity, we’ll use a single curve for forecasting Euribor fixings and for discounting, and we’ll boostrap it over a set of interest-rate swaps. The argument I’ll make would remain the same if we were to use two different curves, as we should, or if we used a more diverse set of instruments for bootstrapping (including, for instance, a few interest-rate futures). Here is the construction of the curve: note that we’re not specifying its reference date explicitly, but as a number of business days and a calendar (in this case, 0 days, meaning that the reference date of the curve should equal the global evaluation date).

swap_data = [
    ("1Y", 0.137),
    ("2Y", 0.409),
    ("3Y", 0.674),
    ("5Y", 1.004),
    ("8Y", 1.258),
    ("10Y", 1.359),
    ("12Y", 1.420),
    ("15Y", 1.509),
    ("20Y", 1.574),
    ("25Y", 1.586),
    ("30Y", 1.579),
    ("35Y", 1.559),
    ("40Y", 1.514),
    ("45Y", 1.446),
    ("50Y", 1.425),
]

swap_helpers = [
    ql.SwapRateHelper(
        quote / 100.0,
        ql.Period(tenor),
        ql.TARGET(),
        ql.Annual,
        ql.Following,
        ql.Thirty360(ql.Thirty360.BondBasis),
        ql.Euribor6M(),
    )
    for tenor, quote in swap_data
]

swap_curve = ql.PiecewiseKrugerZero(
    0,
    ql.TARGET(),
    swap_helpers,
    ql.Actual360(),
)

Here are the resulting zero rates at the curve nodes, corresponding to the maturities of the input swaps.

todays_rates = pd.DataFrame(swap_curve.nodes(), columns=["node", "rate"])
todays_rates.style.format({"rate": "{:.4%}"})
  node rate
0 October 29th, 2021 0.1350%
1 November 2nd, 2022 0.1350%
2 November 2nd, 2023 0.4017%
3 November 4th, 2024 0.6624%
4 November 2nd, 2026 0.9901%
5 November 2nd, 2029 1.2446%
6 November 3rd, 2031 1.3470%
7 November 2nd, 2033 1.4085%
8 November 3rd, 2036 1.5007%
9 November 4th, 2041 1.5667%
10 November 2nd, 2046 1.5751%
11 November 2nd, 2051 1.5625%
12 November 2nd, 2056 1.5346%
13 November 2nd, 2061 1.4751%
14 November 2nd, 2066 1.3871%
15 November 2nd, 2071 1.3621%

Next, we build the bond. As I mentioned, we’re passing the same handle to the index for forecasting and to the engine for discounting; this is just to avoid repeating code in the rest of the notebook. We should use two different curves instead.

curve_handle = ql.RelinkableYieldTermStructureHandle(swap_curve)
index = ql.Euribor6M(curve_handle)
index.addFixing(ql.Date(7, ql.May, 2021), 0.01)

schedule = ql.MakeSchedule(
    effectiveDate=ql.Date(11, ql.May, 2021),
    terminationDate=ql.Date(11, ql.May, 2025),
    frequency=ql.Semiannual,
)

bond = ql.FloatingRateBond(
    settlementDays=3,
    faceAmount=10_000,
    schedule=schedule,
    index=index,
    paymentDayCounter=ql.Thirty360(ql.Thirty360.BondBasis),
    paymentConvention=ql.Following,
)

bond.setPricingEngine(ql.DiscountingBondEngine(curve_handle))

The price of the bond will work as the reference against which variations (and thus the Theta) will be measured.

P0 = bond.cleanPrice()
P0
99.98927601247395

Same market quotes

One way of “keeping everything else the same” is to simply move the evaluation date to the next business day; since the current date is a Friday, we’ll jump three days to the next Monday.

next_day = ql.TARGET().advance(today, 1, ql.Days)
next_day
Date(1,11,2021)
ql.Settings.instance().evaluationDate = next_day

Now, if we ask the bond for its price, the bond will reach for the curve and this will cause the latter to re-bootstrap. The reference date will move to the new evaluation date, the maturities of the input swaps will be recalculated accordingly, and the corresponding zero rates will be recalculated so that the curve once again reprices exactly the quoted swap rates—that have not changed. The bond price will change accordingly, and we can estimate the Theta (the Theta per day, to be exact) as the difference between the new price and the reference one.

bond.cleanPrice()
99.98208800779048
theta_per_day = bond.cleanPrice() - P0
theta_per_day
-0.0071880046834706945

We can also check the nodes of the new curve and compare them with the old ones. The dates have moved as expected, and some of the zero rates have changed.

new_rates = pd.DataFrame(swap_curve.nodes(), columns=["node", "rate"])
pd.concat([todays_rates, new_rates], axis=1).style.format(
    {"rate": "{:.4%}"}
)
  node rate node rate
0 October 29th, 2021 0.1350% November 1st, 2021 0.1350%
1 November 2nd, 2022 0.1350% November 3rd, 2022 0.1350%
2 November 2nd, 2023 0.4017% November 3rd, 2023 0.4024%
3 November 4th, 2024 0.6624% November 4th, 2024 0.6633%
4 November 2nd, 2026 0.9901% November 3rd, 2026 0.9910%
5 November 2nd, 2029 1.2446% November 5th, 2029 1.2454%
6 November 3rd, 2031 1.3470% November 3rd, 2031 1.3477%
7 November 2nd, 2033 1.4085% November 3rd, 2033 1.4091%
8 November 3rd, 2036 1.5007% November 3rd, 2036 1.5012%
9 November 4th, 2041 1.5667% November 4th, 2041 1.5671%
10 November 2nd, 2046 1.5751% November 5th, 2046 1.5754%
11 November 2nd, 2051 1.5625% November 3rd, 2051 1.5628%
12 November 2nd, 2056 1.5346% November 3rd, 2056 1.5348%
13 November 2nd, 2061 1.4751% November 3rd, 2061 1.4753%
14 November 2nd, 2066 1.3871% November 3rd, 2066 1.3872%
15 November 2nd, 2071 1.3621% November 3rd, 2071 1.3622%

The changes are due to the adjustments of the nodes around weekends and holidays; for instance, you can see that the distance between the 2023 and 2024 nodes changed from 368 days to 367, or that the distance between the 2029 and 2031 nodes changed from 731 to 728. This is perfectly fine: is a consequence of having chosed to keep the market quotes constant. However, we might decide to interpret the requirements in a different way.

Same zero rates

In particular, we might choose to keep the curve the same in the sense of translating it rigidly from the old evaluation date to the new one. This means taking the nodes of the old curve, shifting all the dates by the same three days, and keeping the rates the same; finally, we’ll pass them to a curve that uses the same interpolation between rates as the bootstrapped one.

shift = next_day - today
new_curve = ql.KrugerZeroCurve(
    [d + shift for d in todays_rates["node"]],
    todays_rates["rate"],
    swap_curve.dayCounter(),
)

If we check the new nodes, we can see that the zero rates are the same and so are the distances between nodes. Note that this curve won’t reprice the input swaps exactly; but again, this is ok: it’s a consequence of the legitimate choice of interpreting “keeping everything else the same” as a rigid translation of the curve.

new_rates = pd.DataFrame(new_curve.nodes(), columns=["node", "rate"])
pd.concat([todays_rates, new_rates], axis=1).style.format(
    {"rate": "{:.4%}"}
)
  node rate node rate
0 October 29th, 2021 0.1350% November 1st, 2021 0.1350%
1 November 2nd, 2022 0.1350% November 5th, 2022 0.1350%
2 November 2nd, 2023 0.4017% November 5th, 2023 0.4017%
3 November 4th, 2024 0.6624% November 7th, 2024 0.6624%
4 November 2nd, 2026 0.9901% November 5th, 2026 0.9901%
5 November 2nd, 2029 1.2446% November 5th, 2029 1.2446%
6 November 3rd, 2031 1.3470% November 6th, 2031 1.3470%
7 November 2nd, 2033 1.4085% November 5th, 2033 1.4085%
8 November 3rd, 2036 1.5007% November 6th, 2036 1.5007%
9 November 4th, 2041 1.5667% November 7th, 2041 1.5667%
10 November 2nd, 2046 1.5751% November 5th, 2046 1.5751%
11 November 2nd, 2051 1.5625% November 5th, 2051 1.5625%
12 November 2nd, 2056 1.5346% November 5th, 2056 1.5346%
13 November 2nd, 2061 1.4751% November 5th, 2061 1.4751%
14 November 2nd, 2066 1.3871% November 5th, 2066 1.3871%
15 November 2nd, 2071 1.3621% November 5th, 2071 1.3621%

If we link this new curve to the handle we’re using, we can get a new bond price and thus a new Theta:

curve_handle.linkTo(new_curve)
bond.cleanPrice()
99.98218981727182
theta_per_day = bond.cleanPrice() - P0
theta_per_day
-0.007086195202134604

Same coupon rates

There is a third possibility. Both approaches so far will cause the expected coupon rates to change, because the curve will shift while the coupon period remains the same. We can check this by going back to the old evaluation date and curve, extracting the rates by using a small helper function, and then going back to the new ones and doing the same.

ql.Settings.instance().evaluationDate = today
curve_handle.linkTo(swap_curve)
def cashflows():
    data = []
    for cf in bond.cashflows():
        c = ql.as_coupon(cf)
        if c is None:
            data.append((cf.date(), None, cf.amount()))
        else:
            data.append((c.date(), c.rate(), c.amount()))
    return pd.DataFrame(data, columns=["date", "rate", "amount"])
base_cf = cashflows()
base_cf.style.format({"rate": "{:.4%}", "amount": "{:.4f}"})
  date rate amount
0 November 11th, 2021 1.0000% 50.0000
1 May 11th, 2022 0.1351% 6.7538
2 November 11th, 2022 0.1357% 6.7870
3 May 11th, 2023 0.4667% 23.3328
4 November 11th, 2023 0.9142% 45.7086
5 May 11th, 2024 1.1098% 55.4894
6 November 11th, 2024 1.2803% 64.0170
7 May 11th, 2025 1.3579% 67.8955
8 May 11th, 2025 nan% 10000.0000
ql.Settings.instance().evaluationDate = next_day
curve_handle.linkTo(new_curve)
new_cf = cashflows()
pd.concat([base_cf, new_cf[["rate", "amount"]]], axis=1).style.format(
    {"rate": "{:.4%}", "amount": "{:.4f}"}
)
  date rate amount rate amount
0 November 11th, 2021 1.0000% 50.0000 1.0000% 50.0000
1 May 11th, 2022 0.1351% 6.7538 0.1351% 6.7538
2 November 11th, 2022 0.1357% 6.7870 0.1354% 6.7686
3 May 11th, 2023 0.4667% 23.3328 0.4567% 22.8342
4 November 11th, 2023 0.9142% 45.7086 0.9111% 45.5543
5 May 11th, 2024 1.1098% 55.4894 1.1051% 55.2546
6 November 11th, 2024 1.2803% 64.0170 1.2796% 63.9818
7 May 11th, 2025 1.3579% 67.8955 1.3554% 67.7718
8 May 11th, 2025 nan% 10000.0000 nan% 10000.0000

As expected, some of the coupon rates change. What if we built a curve with the new reference date but that returns the same forward rates as the old one over a given period? This would be a third interpretation of “keeping everything else the same”, and an equally sensible one.

To do that, we first create a curve which is the same as the old one but with a reference date that doesn’t move…

base_curve = new_curve = ql.KrugerZeroCurve(
    todays_rates["node"], todays_rates["rate"], swap_curve.dayCounter()
)

…and then we use the ImpliedTermStructure class, which returns a curve with a new reference date but with the same expected rates between two dates.

new_curve = ql.ImpliedTermStructure(
    ql.YieldTermStructureHandle(base_curve), next_day
)

We can link this new curve to our handle and check that the coupons remain unchanged:

curve_handle.linkTo(new_curve)
new_cf = cashflows()
pd.concat([base_cf, new_cf[["rate", "amount"]]], axis=1).style.format(
    {"rate": "{:.4%}", "amount": "{:.4f}"}
)
  date rate amount rate amount
0 November 11th, 2021 1.0000% 50.0000 1.0000% 50.0000
1 May 11th, 2022 0.1351% 6.7538 0.1351% 6.7538
2 November 11th, 2022 0.1357% 6.7870 0.1357% 6.7870
3 May 11th, 2023 0.4667% 23.3328 0.4667% 23.3328
4 November 11th, 2023 0.9142% 45.7086 0.9142% 45.7086
5 May 11th, 2024 1.1098% 55.4894 1.1098% 55.4894
6 November 11th, 2024 1.2803% 64.0170 1.2803% 64.0170
7 May 11th, 2025 1.3579% 67.8955 1.3579% 67.8955
8 May 11th, 2025 nan% 10000.0000 nan% 10000.0000

And finally, we can reprice the bond and recalculate a third version of the Theta:

bond.cleanPrice()
99.98207313531329
theta_per_day = bond.cleanPrice() - P0
theta_per_day
-0.007202877160665366

Note that, while the forward rates stay the same, the zero rates corresponding to the coupon dates—that is, the numbers going into the \(\exp(-rT)\) formula to give you discount factors—must change. As the saying goes in Italy, the blanket is short: you can cover your shoulders or your feet, but not both.

data = []
for cf in bond.cashflows():
    if cf.date() > bond.settlementDate():
        data.append(
            (
                cf.date(),
                base_curve.zeroRate(
                    cf.date(), base_curve.dayCounter(), ql.Continuous
                ).rate(),
                new_curve.zeroRate(
                    cf.date(), new_curve.dayCounter(), ql.Continuous
                ).rate(),
            )
        )
pd.DataFrame(
    data, columns=["date", "zero rate", "new zero rate"]
).style.format({"zero rate": "{:.5%}", "new zero rate": "{:.5%}"})
  date zero rate new zero rate
0 November 11th, 2021 0.13503% 0.13503%
1 May 11th, 2022 0.13503% 0.13503%
2 November 11th, 2022 0.13535% 0.13536%
3 May 11th, 2023 0.24245% 0.24303%
4 November 11th, 2023 0.40816% 0.40927%
5 May 11th, 2024 0.54499% 0.54633%
6 November 11th, 2024 0.66623% 0.66767%
7 May 11th, 2025 0.76258% 0.76404%
8 May 11th, 2025 0.76258% 0.76404%

Choices, choices

So, which one is the right Theta? Well, I’m not sure there is one; the choice will be yours, probably based on how the Theta will be used in your application or on your desks, or on whether it should be compared to figures coming from elsewhere. As usual, any choice will likely be a trade-off.