Valuing a caplet and a cap¶
A caplet protects its holder against a floating rate above an agreed strike. We price protection against SOFR above 4.5% on USD 1 million, then reconstruct the Black price from curve and volatility queries. A quarterly cap strip extends the calculation across several periods.
The illustrative market is dated 24 February 2026. The caplet fixes in one year, accrues for three months and pays at accrual end. Both forward accrual and expiry time use Actual/360 here. The contract uses one rate observation per period. For forward \(F\), strike \(K\), Black volatility \(\sigma\), expiry \(t\) and accrual \(\tau\),
Black pricing requires positive forward and strike inputs. Setup.
Computational study. Canonical notebook · Companion files and setup. Download this notebook.
This page renders saved outputs; the documentation build does not execute it. Run the canonical notebook from its own directory with the checkout-built binding to reproduce the computation. Each study records its market inputs and numerical assumptions.
import pandas as pd
import quantsupport as qs
from market_data import load_market
market = load_market()
ref = market.quotes.reference_date
notional = 1_000_000
strike = 0.045
number_of_caplets = 8
period_months = 3
A single period and a quarterly strip¶
The caplet pays notional × accrual × max(SOFR − strike, 0). We specify its
fixing, accrual and payment dates explicitly. A CapFloor generates a strip
of quarterly periods between its start and maturity dates.
Both products use an absolute strike of 0.045, or 4.5%. The SOFR curve
supplies forwards and discount factors, while the Black surface supplies
volatility at each fixing date and strike. Python dispatches these products
to the native Black closed-form pricers.
fixing = ref + "1Y"
payment = fixing + "3M"
caplet = qs.CapletFloorlet(
identifier="USD-CAPLET-1Y",
fixing_date=fixing,
start_accrual_date=fixing,
end_accrual_date=payment,
payment_date=payment,
strike=strike,
currency=qs.Currency.USD,
market_index=qs.MarketIndex.SOFR,
payoff_type=qs.CapletFloorletType.Caplet,
)
cap = qs.CapFloor(
identifier="USD-CAP-1Y-3Y",
start_date=fixing,
maturity_date=ref + "3Y",
strike=strike,
notional=notional,
currency=qs.Currency.USD,
market_index=qs.MarketIndex.SOFR,
cap_floor_type=qs.CapFloorType.Cap,
frequency=qs.Frequency.Quarterly,
)
context = qs.PricingContext(
quotes=market.quotes,
curves=market.curves,
csa_policy=market.csa_policy,
volatility_surfaces=market.volatility_surfaces,
)
with context:
caplet_result = context.evaluate(
qs.Trade(caplet, ref, notional=notional),
[qs.Request.Value, qs.Request.Sensitivities],
)
cap_result = context.evaluate(
qs.Trade(cap, ref), [qs.Request.Value, qs.Request.Sensitivities]
)
pd.DataFrame(
[
{"product": "single caplet", "pv_usd": caplet_result.price},
{"product": "quarterly cap strip", "pv_usd": cap_result.price},
]
)
| product | pv_usd | |
|---|---|---|
| 0 | single caplet | 834.330964 |
| 1 | quarterly cap strip | 6067.398522 |
The risk table includes interest-rate and volatility quote labels. Scale each
family using its quotation units: a one-basis-point rate move is 0.0001,
and one absolute Black volatility percentage point is 0.01. We separate
these families in the final report.
caplet_result.sensitivities.sort_values(
"value", key=lambda series: series.abs(), ascending=False
).head(10)
| pillar | value | |
|---|---|---|
| 30 | OIS_USD_SOFR_2Y | 196958.793948 |
| 29 | OIS_USD_SOFR_1Y | -95220.527119 |
| 7 | CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_S... | 3888.965205 |
| 27 | FixedRateDeposit_USD_SOFR_1D | -2.317296 |
| 25 | CapletFloorlet_USD_SOFR_3M_7Y_Absolute_0.045_S... | 0.000000 |
| 20 | CapletFloorlet_USD_SOFR_3M_5Y_Absolute_0.055_S... | 0.000000 |
| 21 | CapletFloorlet_USD_SOFR_3M_6M_Absolute_0.035_S... | 0.000000 |
| 22 | CapletFloorlet_USD_SOFR_3M_6M_Absolute_0.045_S... | 0.000000 |
| 23 | CapletFloorlet_USD_SOFR_3M_6M_Absolute_0.055_S... | 0.000000 |
| 24 | CapletFloorlet_USD_SOFR_3M_7Y_Absolute_0.035_S... | 0.000000 |
print("Both option values are positive; the cap strip contains multiple caplets.")
Both option values are positive; the cap strip contains multiple caplets.
Black pricing, parity and the caplet sum¶
Caplet–floorlet parity relates the options to the discounted forward payoff: \(C-P=NP(0,T)\tau(F-K)\). We calculate the Black caplet price and compare the native caplet and floorlet values with this identity.
We then construct each quarterly period of the cap explicitly. Summing their values checks the generated strip schedule as well as the option calculation.
import math
def normal_cdf(value):
return (1 + math.erf(value / math.sqrt(2))) / 2
with context:
curve = context.curve(qs.MarketIndex.SOFR)
surface = context.volatility_surface(qs.MarketIndex.SOFR)
forward = curve.forward_rate(
fixing, payment, qs.Compounding.Simple, qs.Frequency.Annual
)
discount = curve.discount_factor(payment)
sigma = surface.volatility(fixing, strike)
expiry_time = qs.DayCounter.Actual360.year_fraction(ref, fixing)
accrual = qs.DayCounter.Actual360.year_fraction(fixing, payment)
standard_deviation = sigma * math.sqrt(expiry_time)
d1 = (math.log(forward / strike) + standard_deviation**2 / 2) / standard_deviation
d2 = d1 - standard_deviation
black_price = (
notional
* discount
* accrual
* (forward * normal_cdf(d1) - strike * normal_cdf(d2))
)
floorlet = qs.CapletFloorlet(
identifier="PARITY-FLOORLET",
fixing_date=fixing,
start_accrual_date=fixing,
end_accrual_date=payment,
payment_date=payment,
strike=strike,
currency=qs.Currency.USD,
market_index=qs.MarketIndex.SOFR,
payoff_type=qs.CapletFloorletType.Floorlet,
)
floorlet_price = context.evaluate(
qs.Trade(floorlet, ref, notional=notional), [qs.Request.Value]
).price
strip = []
component_requests = [qs.Request.Value]
for period in range(number_of_caplets):
start = fixing + qs.Period(period_months * period, qs.TimeUnit.Months)
end = fixing + qs.Period(period_months * (period + 1), qs.TimeUnit.Months)
component = qs.CapletFloorlet(
identifier=f"CAPLET-{period + 1}",
fixing_date=start,
start_accrual_date=start,
end_accrual_date=end,
payment_date=end,
strike=strike,
currency=qs.Currency.USD,
market_index=qs.MarketIndex.SOFR,
payoff_type=qs.CapletFloorletType.Caplet,
)
component_trade = qs.Trade(component, ref, notional=notional)
price = context.evaluate(component_trade, component_requests).price
strip.append(
{"fixing_date": str(start), "payment_date": str(end), "pv_usd": price}
)
print(
f"Forward {forward:.6%}; Black vol {sigma:.2%}; native/manual caplet USD {black_price:,.6f}."
)
print(f"{number_of_caplets} caplets sum to USD {cap_result.price:,.6f}.")
pd.DataFrame(strip)
Forward 4.175676%; Black vol 29.00%; native/manual caplet USD 834.330964.
8 caplets sum to USD 6,067.398522.
| fixing_date | payment_date | pv_usd | |
|---|---|---|---|
| 0 | 2027-02-24 | 2027-05-24 | 834.330964 |
| 1 | 2027-05-24 | 2027-08-24 | 898.368560 |
| 2 | 2027-08-24 | 2027-11-24 | 958.735768 |
| 3 | 2027-11-24 | 2028-02-24 | 1000.646019 |
| 4 | 2028-02-24 | 2028-05-24 | 571.899568 |
| 5 | 2028-05-24 | 2028-08-24 | 584.505071 |
| 6 | 2028-08-24 | 2028-11-24 | 603.429747 |
| 7 | 2028-11-24 | 2029-02-24 | 615.482826 |
Reporting rate and volatility risk¶
The caplet can have value even when the current forward is below strike, because the option pays on future rates above the strike. Its sensitivity report reflects both the forward and discount curves and the volatility surface. We classify the inputs from their configurations and report a monetary change for a one-basis-point rate move or one volatility point.
risk = caplet_result.sensitivities.copy()
rate_labels = set(market.curves[0].to_dict()["quotes"])
risk["input_family"] = risk["pillar"].map(
lambda label: "rate +1 bp" if label in rate_labels else "Black vol +1 point"
)
risk["pnl_usd"] = risk["value"] * risk["pillar"].map(
lambda label: 1e-4 if label in rate_labels else 0.01
)
risk.loc[risk["pnl_usd"].abs() > 1e-8, ["pillar", "input_family", "pnl_usd"]]
| pillar | input_family | pnl_usd | |
|---|---|---|---|
| 7 | CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_S... | Black vol +1 point | 38.889652 |
| 27 | FixedRateDeposit_USD_SOFR_1D | rate +1 bp | -0.000232 |
| 29 | OIS_USD_SOFR_1Y | rate +1 bp | -9.522053 |
| 30 | OIS_USD_SOFR_2Y | rate +1 bp | 19.695879 |
Continue with curves and volatility to revalue the contracts under separate rate and volatility shocks and compare their effects with these local derivatives.