Coupon options
This page lists the options a floating coupon can carry on its own rate: a cap, a floor, a collar, and a digital. It covers the two methods that attach them to a leg, their arguments, and the vols the options are valued off.
Quick example
This caps, floors and collars a two-year EURIBOR 3M leg, then puts a digital call and a digital put on it. Each result is printed beside the identity it obeys, an identity that holds whatever the options are worth. The example reads its quotes from the desk sheets; load them once and it runs as shown. The cap vols are stated in the program, because no recipe builds them.
use chrono::NaiveDate;
use helixio_docs_sheets::{EUR_CLOSE, SOFR_CLOSE_DATE};
use helixrs::{
AnyIndex, Collar, CollateralSpec, Digital, DigitalStrike, Direction, Leg, LegTerms, MarketContext, PaymentRule,
QuoteSheet, RateVolSurface, Schedule, VolConvention, calendar, fixed_rate_leg,
ibor_leg_on_index, index,
};
type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
fn date((year, month, day): (i32, u32, u32)) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
fn main() -> Outcome<()> {
let close = date(SOFR_CLOSE_DATE);
let eur = CollateralSpec::Currency("EUR".parse()?);
// Curves from the sheet; the caplet vols, which no recipe builds, joined with
// the market's own verb: normal vols on a grid of expiries in years, for the
// index's three-month tenor.
let cap_vols = RateVolSurface::new(
vec![0.25, 1.0, 2.0, 5.0],
vec![0.25],
&[vec![0.0060], vec![0.0070], vec![0.0075], vec![0.0080]],
VolConvention::Normal,
)?;
let sheet = QuoteSheet::from_rows(EUR_CLOSE.iter().copied())?;
let market = MarketContext::as_of(close)
.with_recipe_quotes(&eur, &sheet)?
.with_cap_vols("EUR".parse()?, cap_vols);
let target = calendar("EUTA")?;
let quarterly = Schedule::generate(
date((2026, 10, 5)),
date((2028, 10, 5)),
"3M".parse()?,
target.as_ref(),
"ModFollowing".parse()?,
"None".parse()?,
"ShortInitial".parse()?,
false,
None,
None,
)?;
let terms = LegTerms {
notional: 10_000_000.0,
day_count: "ACT/360".parse()?,
currency: "EUR".parse()?,
direction: Direction::Receive,
payment: PaymentRule::none(),
};
let Some(AnyIndex::Ibor(euribor)) = index("EUR-EURIBOR-3M") else {
panic!("a registered term index");
};
let plain = ibor_leg_on_index(&quarterly, &terms, &euribor, 1.0, 0.0);
let declares = |leg: &Leg| {
let projections = leg.projections();
let needs = leg.risk_factors(&eur, &projections).needs();
needs.iter().map(|(role, key)| format!("{role} {key}")).collect::<Vec<_>>().join(", ")
};
let collared = |cap: Option<f64>, floor: Option<f64>| -> Outcome<Leg> {
Ok(plain.clone().with_collar(Collar::new(cap, floor)?))
};
println!("plain leg declares {}", declares(&plain));
println!(
"capped leg declares {}",
declares(&collared(Some(0.02), None)?)
);
let npv = |leg: &Leg| -> Outcome<f64> { Ok(leg.npv_on(&market, &eur)?) };
// A cap and a floor at the same strike, against the plain leg and a fixed leg
// at that strike on the same periods.
let strike = 0.0215;
let capped = npv(&collared(Some(strike), None)?)?;
let floored = npv(&collared(None, Some(strike))?)?;
let fixed = npv(&fixed_rate_leg(&quarterly, &terms, strike))?;
let base = npv(&plain)?;
println!("capped {capped:.2}");
println!("floored {floored:.2}");
println!("plain {base:.2}");
println!("fixed {fixed:.2}");
println!("capped + floored {:.2}", capped + floored);
println!("plain + fixed {:.2}", base + fixed);
// A 2.00% floor and a 2.30% cap on every coupon.
let both = npv(&collared(Some(0.0230), Some(0.0200))?)?;
let pieces = npv(&collared(Some(0.0230), None)?)? + npv(&collared(None, Some(0.0200))?)? - base;
println!("collared {both:.2}");
println!("pieces {pieces:.2}");
// A digital call and a digital put at 2.15%, each paying 1.00% of the period's
// accrual when the rate ends past the strike, bought, and naked: the coupon
// pays only the digital, not its own rate.
let paying_one_percent = DigitalStrike { strike, payoff: Some(0.01), long: true };
let digital = |call: Option<DigitalStrike>, put: Option<DigitalStrike>| -> Outcome<Leg> {
Ok(plain.clone().with_digital(Digital::new(call, put, true, Digital::DEFAULT_GAP)?))
};
let call = npv(&digital(Some(paying_one_percent), None)?)?;
let put = npv(&digital(None, Some(paying_one_percent))?)?;
let one_percent = npv(&fixed_rate_leg(&quarterly, &terms, 0.01))?;
println!("digital call {call:.2}");
println!("digital put {put:.2}");
println!("call plus put {:.2}", call + put);
println!("fixed at 1.00% {one_percent:.2}");
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import EUR_CLOSE, SOFR_CLOSE_DATE
# Curves from the sheet; the caplet vols, which no recipe builds, joined with
# the market's own verb: normal vols on a grid of expiries in years, for the
# index's three-month tenor.
cap_vols = hlx.vols.RateVolSurface(
[0.25, 1.0, 2.0, 5.0], [0.25], [[0.0060], [0.0070], [0.0075], [0.0080]], vol_convention="normal"
)
market = (
hlx.MarketContext(SOFR_CLOSE_DATE)
.with_recipe_quotes("EUR", EUR_CLOSE)
.with_cap_vols("EUR", cap_vols)
)
target = hlx.Calendar("EUTA")
quarterly = hlx.dates.schedule(dt.date(2026, 10, 5), dt.date(2028, 10, 5), "3M", target)
paying = quarterly.paying(0, target)
plain = hlx.Leg(
hlx.legs.ibor_leg(quarterly, paying, "EUR", notional=10_000_000.0, index="EUR-EURIBOR-3M", day_count="ACT/360"),
"Receive",
"EUR",
)
print(f"plain leg declares {', '.join(f'{role} {key}' for role, key in plain.market_data_needs())}")
print(f"capped leg declares {', '.join(f'{role} {key}' for role, key in plain.with_collar(cap=0.02).market_data_needs())}")
# A cap and a floor at the same strike, against the plain leg and a fixed leg
# at that strike on the same periods.
STRIKE = 0.0215
capped = plain.with_collar(cap=STRIKE).npv(market)
floored = plain.with_collar(floor=STRIKE).npv(market)
fixed = hlx.Leg(
hlx.legs.fixed_rate_leg(quarterly, paying, "EUR", notional=10_000_000.0, fixed_rate=STRIKE, day_count="ACT/360"),
"Receive",
"EUR",
).npv(market)
print(f"capped {capped:.2f}")
print(f"floored {floored:.2f}")
print(f"plain {plain.npv(market):.2f}")
print(f"fixed {fixed:.2f}")
print(f"capped + floored {capped + floored:.2f}")
print(f"plain + fixed {plain.npv(market) + fixed:.2f}")
# A 2.00% floor and a 2.30% cap on every coupon.
collared = plain.with_collar(cap=0.0230, floor=0.0200).npv(market)
pieces = plain.with_collar(cap=0.0230).npv(market) + plain.with_collar(floor=0.0200).npv(market) - plain.npv(market)
print(f"collared {collared:.2f}")
print(f"pieces {pieces:.2f}")
# A digital call and a digital put at 2.15%, each paying 1.00% of the period's
# accrual when the rate ends past the strike, bought, and naked: the coupon
# pays only the digital, not its own rate.
call = plain.with_digital(call=(STRIKE, 0.01, True), naked=True).npv(market)
put = plain.with_digital(put=(STRIKE, 0.01, True), naked=True).npv(market)
one_percent = hlx.Leg(
hlx.legs.fixed_rate_leg(quarterly, paying, "EUR", notional=10_000_000.0, fixed_rate=0.01, day_count="ACT/360"),
"Receive",
"EUR",
).npv(market)
print(f"digital call {call:.2f}")
print(f"digital put {put:.2f}")
print(f"call plus put {call + put:.2f}")
print(f"fixed at 1.00% {one_percent:.2f}")plain leg declares discount EUR, projection EUR-EURIBOR-3M
capped leg declares discount EUR, projection EUR-EURIBOR-3M, cap_vol EUR
capped 374623.39
floored 471434.06
plain 419208.38
fixed 426849.07
capped + floored 846057.45
plain + fixed 846057.45
collared 422789.20
pieces 422789.20
digital call 80817.97
digital put 117716.48
call plus put 198534.45
fixed at 1.00% 198534.45
The capped leg also declares that it reads the cap vols. The capped and floored legs add up to the plain leg plus the fixed leg at the strike, the collar equals its two pieces less the plain leg, and the digital call and put together equal a fixed leg at their payoff.
How it's priced
A coupon capped at pays and one floored at pays . Since for every outcome , summing over the coupons gives
whatever vol the options are valued at. A collar with pays , so .
A cash-or-nothing digital call at pays a rate when , and the put pays when . Still to fix, each is replicated by a spread of two caplets (floorlets for the put) a gap wide, centred on the strike:
By put-call parity the two spreads add up to exactly , so a naked call and put together are a fixed leg at . Why a desk puts options inside a coupon is in how cashflows work.
API
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| , | a cap, a floor, or both | Leg::with_collar with Collar::new |
Leg.with_collar |
| digital | a payoff past a strike | Leg::with_digital with Digital::new and DigitalStrike |
Leg.with_digital |
| cap vols | caplet vols by expiry | MarketContext::with_cap_vols with a RateVolSurface |
MarketContext.with_cap_vols with vols.RateVolSurface |
| a fixed leg at the strike | fixed_rate_leg |
legs.fixed_rate_leg |
|
| needs | what a leg declares it reads | Leg::risk_factors, then RiskFactors::needs |
Leg.market_data_needs |
Leg.with_collar(...):
| Argument | Type | Default | Meaning |
|---|---|---|---|
cap |
float or None |
None |
the rate every coupon is capped at |
floor |
float or None |
None |
the rate every coupon is floored at |
Leg.with_digital(...):
| Argument | Type | Default | Meaning |
|---|---|---|---|
call |
(strike, payoff, long) or None |
None |
pays when the rate ends above strike; payoff is the cash rate paid, or None for asset-or-nothing (pays the coupon's rate); long is True when bought |
put |
(strike, payoff, long) or None |
None |
the same, paying when the rate ends below strike |
naked |
bool |
False |
True pays only the options, not the coupon's own rate |
gap |
float |
1e-4 |
the width of the replicating spread, on the rate |
In Rust the same terms are DigitalStrike { strike, payoff, long } and
Digital::new(call, put, naked, gap), with Digital::DEFAULT_GAP for one basis point.
Defaults and limits
- Which coupons:
with_payoffacts on every IBOR, overnight, CMS and year-on-year inflation coupon on the leg, and on every CPI redemption, whose bounds are then annualised zero-coupon rates from its base date.with_digitalacts on IBOR, overnight and CMS coupons only. Fixed coupons are left alone. - Vols: an option still to fix on an IBOR or overnight coupon is valued off the currency's cap vols, one on a CMS coupon off the swaption smile, and one on an inflation coupon or a CPI redemption off the index's CPI vols. The leg declares the surface it reads, and a market without it refuses the valuation with the surface named.
- A printed reset pays its rate clamped by the cap and floor, or the digital's payoff, with no vol read.
- Refused: a digital with neither side, a gap that isn't positive, or a strike or payoff that isn't finite.
- Inflation: caps and floors on year-on-year coupons use the same methods, valued off YoY vols; see inflation caps and floors.
See also
- Source:
crates/helixrs/src/cashflows/coupons.rs,crates/helixrs/src/cashflows/legs.rs,crates/helixrs/src/marketdata/rate_vol.rs - Related pages:
- Cap and floor, the traded product.
- Legs and coupons, the leg the options attach to.
- CMS coupons, for options on a swap rate.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.