CMS coupons
This page lists what you need to price a constant-maturity-swap (CMS) coupon: the rate functions, the coupon and strip constructors, and the market inputs they read. A CMS coupon pays a swap rate, the par rate of a swap of fixed tenor, observed at the coupon's fixing and paid at its payment date. A CMS spread coupon pays the difference of two such rates.
Quick example
This reads the 10-year EUR swap rate fixing in October 2027 and its convexity-adjusted CMS rate, prices a CMS call and put, a 10Y-less-2Y spread rate, and the same 10-year rate as a coupon in a leg. The example reads its quotes from the desk sheets; load them once and it runs as shown. The swaption 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::{
AnyCoupon, CmsCoupon, CollateralSpec, Direction, Leg, MarketContext, OptionKind, QuoteSheet,
RateVolSurface, SwapIndex, VolConvention, YieldCurve, cms_option, cms_rate,
cms_spread_coupon_rate, leg_cashflow_records,
};
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 (fixing, payment, reversion) = (date((2027, 10, 1)), date((2028, 1, 5)), 0.01);
// Curves from the sheet; swaption vols, which no recipe builds, joined with the
// market's verb: normal vols by expiry and swap tenor, both in years.
let swaption_vols = RateVolSurface::new(
vec![0.5, 1.0, 2.0, 5.0],
vec![2.0, 10.0, 20.0],
&[
vec![0.0070, 0.0068, 0.0066],
vec![0.0072, 0.0070, 0.0068],
vec![0.0075, 0.0072, 0.0070],
vec![0.0078, 0.0074, 0.0071],
],
VolConvention::Normal,
)?;
let sheet = QuoteSheet::from_rows(EUR_CLOSE.iter().copied())?;
let market = MarketContext::as_of(close)
.with_recipe_quotes(&CollateralSpec::Currency("EUR".parse()?), &sheet)?
.with_swaption_vols("EUR".parse()?, swaption_vols);
let ten = SwapIndex::new("EUR-EURIBOR-3M-IRS", "10Y".parse()?)?;
let two = SwapIndex::new("EUR-EURIBOR-3M-IRS", "2Y".parse()?)?;
let projection = ten.projection();
let factors = ten.risk_factors(&projection);
// The 10Y swap rate fixing on 1 October 2027, paid on 5 January 2028.
let ten_year = market.priced(&factors, |view| cms_rate(&ten, fixing, payment, reversion, view))?;
let (forward, cms) = (ten_year.forward, ten_year.rate);
println!("forward swap rate {forward:.8}");
println!("CMS rate {cms:.8}");
// A CMS call and put at one strike, replicated off the same smile.
let strike = 0.026;
let option = |kind| {
market.priced(&factors, |view| cms_option(&ten, fixing, payment, reversion, strike, kind, view))
};
let (call, put) = (option(OptionKind::Call)?, option(OptionKind::Put)?);
println!("call {call:.8}");
println!("put {put:.8}");
println!("call less put {:.8}", call - put);
println!("CMS less K {:.8}", cms - strike);
// A 10Y-less-2Y spread coupon. With no option on it the correlation does not
// enter, and it pays the difference of the two adjusted rates.
let cms_2y = market.priced(&factors, |view| cms_rate(&two, fixing, payment, reversion, view))?.rate;
let spread = market.priced(&factors, |view| {
cms_spread_coupon_rate(&ten, &two, fixing, payment, reversion, 0.8, 1.0, 0.0, None, view)
})?;
println!("2Y CMS rate {cms_2y:.8}");
println!("10Y less 2Y {spread:.8}");
// The same rate as a coupon in a leg: accruing from 5 October 2027, it fixes two
// TARGET days before, on 1 October, and its table carries the rate above.
let coupon = CmsCoupon::on_index(
&ten,
date((2027, 10, 5)),
payment,
payment,
10_000_000.0,
1.0,
0.0,
"ACT/360".parse()?,
reversion,
);
let leg = Leg::new(vec![AnyCoupon::Cms(coupon)], Direction::Receive);
let eur = CollateralSpec::Currency("EUR".parse()?);
let projections = leg.projections();
let row = market.priced(&leg.risk_factors(&eur, &projections), |view| {
Ok(leg_cashflow_records(&leg, &view.disc, view, &view.fixings, close)?)
})?;
let fixes = row["fixing_date"].dates().expect("dates")[0].expect("a fixing date");
let rate = row["rate"].numbers().expect("numbers")[0];
let npv = leg.npv_on(&market, &eur)?;
println!("fixes on {fixes}");
println!("rate {rate:.8}");
println!("npv {npv:.2}");
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import EUR_CLOSE, SOFR_CLOSE_DATE
INDEX, FIXING, PAYMENT, REVERSION = "EUR-EURIBOR-3M-IRS", dt.date(2027, 10, 1), dt.date(2028, 1, 5), 0.01
# Curves from the sheet; swaption vols, which no recipe builds, joined with the
# market's verb: normal vols by expiry and swap tenor, both in years.
swaption_vols = hlx.vols.RateVolSurface(
[0.5, 1.0, 2.0, 5.0],
[2.0, 10.0, 20.0],
[[0.0070, 0.0068, 0.0066], [0.0072, 0.0070, 0.0068], [0.0075, 0.0072, 0.0070], [0.0078, 0.0074, 0.0071]],
vol_convention="normal",
)
market = (
hlx.MarketContext(SOFR_CLOSE_DATE)
.with_recipe_quotes("EUR", EUR_CLOSE)
.with_swaption_vols("EUR", swaption_vols)
)
# The 10Y swap rate fixing on 1 October 2027, paid on 5 January 2028.
forward, cms = hlx.rates.cms_rate(market, INDEX, "10Y", FIXING, PAYMENT, REVERSION)
print(f"forward swap rate {forward:.8f}")
print(f"CMS rate {cms:.8f}")
# A CMS call and put at one strike, replicated off the same smile.
STRIKE = 0.026
call = hlx.rates.cms_option(market, INDEX, "10Y", FIXING, PAYMENT, REVERSION, strike=STRIKE, kind="Call")
put = hlx.rates.cms_option(market, INDEX, "10Y", FIXING, PAYMENT, REVERSION, strike=STRIKE, kind="Put")
print(f"call {call:.8f}")
print(f"put {put:.8f}")
print(f"call less put {call - put:.8f}")
print(f"CMS less K {cms - STRIKE:.8f}")
# A 10Y-less-2Y spread coupon. With no option on it the correlation does not
# enter, and it pays the difference of the two adjusted rates.
_, cms_2y = hlx.rates.cms_rate(market, INDEX, "2Y", FIXING, PAYMENT, REVERSION)
spread = hlx.rates.cms_spread_coupon_rate(market, INDEX, "10Y", "2Y", FIXING, PAYMENT, REVERSION, correlation=0.8)
print(f"2Y CMS rate {cms_2y:.8f}")
print(f"10Y less 2Y {spread:.8f}")
# The same rate as a coupon in a leg: accruing from 5 October 2027, it fixes two
# TARGET days before, on 1 October, and its table carries the rate above.
coupon = hlx.legs.cms_coupon(
INDEX, "10Y", dt.date(2027, 10, 5), PAYMENT, PAYMENT, notional=10_000_000.0, day_count="ACT/360"
)
leg = hlx.Leg([coupon], "Receive", "EUR")
row = leg.cashflow_records(market).to_dict()
npv = leg.npv(market)
print(f"fixes on {row['fixing_date'][0]}")
print(f"rate {row['rate'][0]:.8f}")
print(f"npv {npv:.2f}")forward swap rate 0.02666239
CMS rate 0.02690566
call 0.00326702
put 0.00236136
call less put 0.00090566
CMS less K 0.00090566
2Y CMS rate 0.02331825
10Y less 2Y 0.00358741
fixes on 2027-10-01
rate 0.02690566
npv 67062.74
The CMS rate sits above the forward swap rate. The call less the put is the CMS rate less the strike, the spread rate is the 10-year CMS rate less the 2-year one, and the coupon fixes two TARGET days before it accrues, at the rate the function gives.
How it's priced
Let be the index's forward swap rate, with annuity . Paid at rather than on the swap's own dates, its expectation under the payment measure exceeds the forward. Under the linear terminal swap rate model, , and the adjusted rate is
with the fixing date and the variance read off the swaption smile. The slope is positive, so whenever the rate has any variance. A call and a put at are replicated off the same smile, so
A spread coupon with no option on it is linear in the two rates, so the correlation doesn't enter. Why a CMS rate is worth more than the forward is in how cashflows work.
API
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| index | a swap index: template and tenor | SwapIndex::new |
named by template and tenor |
| , | the forward and the adjusted rate | cms_rate → CmsRate { forward, rate } |
rates.cms_rate → (forward, rate) |
| , | CMS caplet and floorlet, as rates | cms_option with OptionKind |
rates.cms_option with kind= |
| coupon rate | what a coupon pays, with any option | cms_coupon_rate |
rates.cms_coupon_rate |
| spread rate | cms_spread_coupon_rate |
rates.cms_spread_coupon_rate |
|
| coupon | a CMS coupon | CmsCoupon::on_index |
legs.cms_coupon |
| strips | one coupon per period | cms_leg, cms_spread_leg |
legs.cms_leg, legs.cms_spread_leg |
| vols | the swaption smile | MarketContext::with_swaption_vols |
MarketContext.with_swaption_vols |
| correlation | the market's mark for a pair | MarketContext::with_cms_spread_correlation |
MarketContext.with_cms_spread_correlation |
rates.cms_rate(...) and rates.cms_option(...):
| Argument | Type | Default | Meaning |
|---|---|---|---|
market |
MarketContext |
required | curves and the currency's swaption vols |
template |
str |
required | the swap index's template, such as "EUR-EURIBOR-3M-IRS" |
tenor |
str |
required | the swap's tenor, such as "10Y" |
fixing |
date |
required | when the rate is observed |
payment |
date |
required | when it's paid |
mean_reversion |
float |
required | the model's mean reversion; 0.01 is the coupon default |
strike |
float |
required (option) | the option's strike |
kind |
str |
required (option) | "Call" or "Put" |
legs.cms_coupon(...):
| Argument | Type | Default | Meaning |
|---|---|---|---|
template, tenor |
str |
required | the swap index |
accrual_start, accrual_end, payment_date |
date |
required | the coupon's dates; it fixes the index's lag before accrual_start |
notional |
float |
required | the size |
day_count |
str |
required | the accrual day count |
gearing, spread |
float |
1.0, 0.0 |
the coupon pays gearing × CMS + spread |
mean_reversion |
float |
0.01 |
as above |
legs.cms_spread_leg adds minus_tenor, the second rate's tenor, and correlation, a number or
the name of the market's mark for the pair.
Defaults and limits
- Vols: a CMS coupon reads the currency's swaption vols even with no option on it, for the convexity adjustment. A market without them refuses the coupon with the surface named.
- Correlation matters only for a spread coupon with an option on it. It's either a number in
the contract or the market's mark, set with
with_cms_spread_correlation. - Options on the coupon: caps, floors and digitals attach as on any floating leg; see coupon options.
See also
- Source:
crates/helixrs/src/market/cms.rs,crates/helixrs/src/cashflows/coupons.rs - Related pages:
- CMS swap, the traded product.
- Swaptions, for the smile the adjustment reads.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.