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\),

\[C=NP(0,T)\tau[F\Phi(d_1)-K\Phi(d_2)],\quad d_1=\frac{\log(F/K)+\sigma^2t/2}{\sigma\sqrt t},\quad d_2=d_1-\sigma\sqrt t.\]

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.