Pricing Caps

In this example we will explore how to: 1. Price caps by settings a constant volatility and only using a single curve for discounting and forecasting. 2. Price caps by using two curves, one for forecasting and one for discounting. 3. Price caps by having an input volatility surface.

[1]:
import QuantLib as ql
import pandas as pd

We initialize by setting the valuation date and defining our forecasting and discounting curve to be used in pricing.

[2]:
# Begin by setting the valuation date of which the cap and the floor should be priced at
ql.Settings.instance().evaluationDate = ql.Date(1, 1, 2022)
# Then we initialize the curve we want to use for discounting and forecasting
discount_factors = [1, 0.965, 0.94]  # discount factors
dates = [
    ql.Date(1, 1, 2022),
    ql.Date(1, 1, 2023),
    ql.Date(1, 1, 2024),
]  # maturity dates of the discount factors
day_counter = ql.Actual360()
# Note that we will not strip a curve here, but simply use the discount factors and the dates defined above
# By default QuantLib DiscountCurve will log linearly interpolate between the points.
discount_curve = ql.DiscountCurve(dates, discount_factors, day_counter)
# The curve will note be linked in case we want to update the quotes later on
discount_handle = ql.YieldTermStructureHandle(discount_curve)

The next step involves creating an schedule of dates for which the optionlets of the caps will mature at.

[3]:
start_date = ql.Date(1, 1, 2022)
end_date = start_date + ql.Period(12, ql.Months)

# We define the schedule of the cap and floor
schedule = ql.Schedule(
    start_date,                 # Start date of payments
    end_date,                   # End date of payments
    ql.Period(3, ql.Months),    # frequency of payments
    ql.Sweden(),                # Calendar for adjusting for holidays
    ql.ModifiedFollowing,       # Business convention for adjusting for holidays
    ql.ModifiedFollowing,       # Business convention for adjusting for holidays
    ql.DateGeneration.Backward, # Date generation rule for generating the schedule
    False,                      # End of month rule
)

# Create a custom index to track the payments correctly, specifically fixing days.
custom_discount_index= ql.IborIndex(
    "MyIndex",
    ql.Period("3m"),
    0,
    ql.SEKCurrency(),
    ql.Sweden(),
    ql.ModifiedFollowing,
    False,
    ql.Actual360(),
    discount_handle,
)

The last step is to define the pricing engine to use for pricing. We can choose between:

  • BlackCapFloorEngine

  • BachelierEngine

  • AnalyticCapFloorEngine

  • TreeCapFloorEngine

In this example we will precede with BlackCapFloorEngine.

[4]:
# As you have noted by now, the pricing of caps and floors involves creating a floating leg
ibor_leg_discount = ql.IborLeg([1e6], schedule, custom_discount_index)
strike = [0.025]
cap_discount = ql.Cap(ibor_leg_discount, strike)

# The final step is to define a volatility surface, we will use a constant volatility for simplicity
volatility = ql.QuoteHandle(ql.SimpleQuote(0.5))

# Input our discounting and forecasting curve together with our volatility surface to the engine
engine = ql.BlackCapFloorEngine(discount_handle, volatility)
cap_discount.setPricingEngine(engine)
print(cap_discount.NPV())
10831.583434218297

At last we want to show our results of the seperate optionlets.

[5]:
schedule_dates = schedule.dates()

display_result = lambda _ : pd.DataFrame({
    'price': _.optionletsPrice(),
    'discount_factor': _.optionletsDiscountFactor(),
    'cap_rate': _.capRates(),
    'atm_forward': _.optionletsAtmForward(),
    'std_dev': _.optionletsStdDev(),
    'accrual_start': schedule_dates[:-1],
    'accrual_end' : schedule_dates[1:]
})

display_result(cap_discount)
[5]:
price discount_factor cap_rate atm_forward std_dev accrual_start accrual_end
0 2493.450264 0.991254 0.025 0.035290 0.037012 January 3rd, 2022 April 1st, 2022
1 2625.359083 0.982488 0.025 0.035296 0.248282 April 1st, 2022 July 1st, 2022
2 2846.309041 0.973515 0.025 0.035301 0.352097 July 1st, 2022 October 3rd, 2022
3 2866.465047 0.964931 0.025 0.035193 0.434000 October 3rd, 2022 January 2nd, 2023

Considering that we have used only one curve as discounting and forecasting, we will now add a second curve for forecasting.

[6]:
ql.Settings.instance().evaluationDate = ql.Date(1, 1, 2022)

# Similiar to the discount curve we declared previously
forward_rates = [0.04, 0.05, 0.06]
forward_curve = ql.ForwardCurve(dates, forward_rates, day_counter)
forward_handle = ql.YieldTermStructureHandle(forward_curve)
[7]:
# Create a new index that uses the forward curve for forecasting
custom_forward_index= ql.IborIndex(
    "MyIndex",
    ql.Period("3m"),
    0,
    ql.SEKCurrency(),
    ql.Sweden(),
    ql.ModifiedFollowing,
    False,
    ql.Actual360(),
    forward_handle, # Previously was set to discount_handle
)
[8]:
# Define a new ibor_leg & cap that uses the new index with forward estimation
ibor_leg_forward = ql.IborLeg([1e6], schedule, custom_forward_index)
cap_forward = ql.Cap(ibor_leg_forward, strike)

# Input our discounting and forecasting curve together with our volatility surface to the engine
engine_forward = ql.BlackCapFloorEngine(discount_handle, volatility)
cap_forward.setPricingEngine(engine_forward)
print(cap_forward.NPV())
25171.79621353972
[9]:
schedule_dates = schedule.dates()
display_result(cap_forward)
[9]:
price discount_factor cap_rate atm_forward std_dev accrual_start accrual_end
0 6132.002083 0.991254 0.025 0.050307 0.037012 January 3rd, 2022 April 1st, 2022
1 6289.142138 0.982488 0.025 0.050317 0.248282 April 1st, 2022 July 1st, 2022
2 6465.774497 0.973515 0.025 0.050328 0.352097 July 1st, 2022 October 3rd, 2022
3 6284.877495 0.964931 0.025 0.050429 0.434000 October 3rd, 2022 January 2nd, 2023

The last step is to consider to have a full volatility surface instead of a constant one.

[38]:
# Set the settlement day of the volatility surface
settlementDays = 0

# Define the expiries for the volatility surface
expiries = [ql.Period("3M"), ql.Period("6M"), ql.Period("9M"), ql.Period("1Y")]

# Define the strikes for the volatility surface
strikes = [0.010, 0.025, 0.03]

# Define the market quotes for the volatility surface
black_volatility = [[0.98, 0.792, 0.6873], [0.9301, 0.7401, 0.6403], [0.7926, 0.6424, 0.5602], [0.7126, 0.6024, 0.4902]]

# Create a new volatility surface
volatility_surface = ql.CapFloorTermVolSurface(
    settlementDays,
    ql.Sweden(),
    ql.ModifiedFollowing,
    expiries,
    strikes,
    black_volatility,
    day_counter,
)
# Strip the volatility surface for optionlets (caplets) as the input is based on caps
optionlet_surf = ql.OptionletStripper1(volatility_surface, custom_forward_index)

# Call strippedOptionletAdapter to create a handle for the volatility surface
ovs_handle = ql.OptionletVolatilityStructureHandle(
    ql.StrippedOptionletAdapter(optionlet_surf)
)

cap_volatility = ql.Cap(ibor_leg_forward, strike)
# Input our discounting and forecasting curve together with our volatility surface to the engine
engine_volatility = ql.BlackCapFloorEngine(discount_handle, ovs_handle)
cap_volatility.setPricingEngine(engine_volatility)
print(cap_volatility.NPV())
25340.288918668186
[39]:
display_result(cap_volatility)
[39]:
price discount_factor cap_rate atm_forward std_dev accrual_start accrual_end
0 6132.002083 0.991254 0.025 0.050307 0.000000 January 3rd, 2022 April 1st, 2022
1 6325.268247 0.982488 0.025 0.050317 0.372127 April 1st, 2022 July 1st, 2022
2 6526.008974 0.973515 0.025 0.050328 0.434983 July 1st, 2022 October 3rd, 2022
3 6357.009614 0.964931 0.025 0.050429 0.500385 October 3rd, 2022 January 2nd, 2023