Multi-reset coupons
This page lists what you need for a coupon that fixes its index more than once and pays once: the coupon and strip constructors, the four ways the resets combine, and how a short final sub-period reads the index.
Quick example
This values one six-month coupon on EURIBOR 3M, which resets twice, under each compounding method with a 100 basis point spread, then with no spread, then over five months, where the last sub-period is two months long. The example reads its quotes from the desk sheets; load them once and it runs as shown.
use chrono::NaiveDate;
use helixio_docs_sheets::{EUR_CLOSE, SOFR_CLOSE_DATE};
use helixrs::{
AnyCoupon, AnyIndex, CollateralSpec, Direction, Leg, MarketContext, MultiResetCoupon,
QuoteSheet, ResetCompounding, StubRule, index, 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 eur = CollateralSpec::Currency("EUR".parse()?);
let sheet = QuoteSheet::from_rows(EUR_CLOSE.iter().copied())?;
let market = MarketContext::as_of(close).with_recipe_quotes(&eur, &sheet)?;
let Some(AnyIndex::Ibor(euribor)) = index("EUR-EURIBOR-3M") else {
panic!("a registered term index");
};
// The coupon's rate over 5 January 2027 to `end`, read from its own table.
let rate =
|end: NaiveDate, method: ResetCompounding, spread: f64, stub: StubRule| -> Outcome<f64> {
let coupon = MultiResetCoupon::on_index(
&euribor,
date((2027, 1, 5)),
end,
end,
10_000_000.0,
"ACT/360".parse()?,
"EUR".parse()?,
"3M".parse()?,
1.0,
spread,
method,
&stub,
)?;
let leg = Leg::new(vec![AnyCoupon::MultiReset(coupon)], Direction::Receive);
let projections = leg.projections();
let table = market.priced(&leg.risk_factors(&eur, &projections), |view| {
Ok(leg_cashflow_records(&leg, &view.disc, view, &view.fixings, close)?)
})?;
Ok(table["rate"].numbers().expect("numbers")[0])
};
// Two quarterly resets, a 100 basis point spread, four ways of combining them.
let six_months = date((2027, 7, 5));
let methods = [
("straight", ResetCompounding::Straight),
("flat", ResetCompounding::Flat),
("spread_exclusive", ResetCompounding::SpreadExclusive),
("averaged", ResetCompounding::Averaged),
];
let mut rates = Vec::new();
for (name, method) in methods {
let value = rate(six_months, method, 0.01, StubRule::OwnAccrual)?;
println!("{name:<17} {value:.8}");
rates.push(value);
}
// With no spread there is nothing for the three compounding methods to treat
// differently, and they agree to round-off.
let mut bare = Vec::new();
for (name, method) in &methods[..3] {
let value = rate(six_months, *method, 0.0, StubRule::OwnAccrual)?;
println!("spread 0, {name:<17} {value:.8}");
bare.push(value);
}
// Five months of 3M resets leave a two-month final sub-period. Read over its own
// accrual it projects two months; read at the index tenor it projects three.
let five_months = date((2027, 6, 7));
let own = rate(
five_months,
ResetCompounding::Straight,
0.0,
StubRule::OwnAccrual,
)?;
let tenor = rate(
five_months,
ResetCompounding::Straight,
0.0,
StubRule::IndexTenor,
)?;
println!("own accrual {own:.8}");
println!("index tenor {tenor:.8}");
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import EUR_CLOSE, SOFR_CLOSE_DATE
market = hlx.MarketContext(SOFR_CLOSE_DATE).with_recipe_quotes("EUR", EUR_CLOSE)
def rate(end, *, compounding="straight", spread=0.0, stub="own_accrual"):
"""The coupon's rate over 5 January 2027 to `end`, read from its own table."""
coupon = hlx.legs.multi_reset_coupon(
"EUR", dt.date(2027, 1, 5), end, end, notional=10_000_000.0, index="EUR-EURIBOR-3M",
day_count="ACT/360", reset_tenor="3M", compounding=compounding, spread=spread, stub=stub,
)
return hlx.Leg([coupon], "Receive", "EUR").cashflow_records(market).to_dict()["rate"][0]
# Two quarterly resets, a 100 basis point spread, four ways of combining them.
SIX_MONTHS = dt.date(2027, 7, 5)
rates = {m: rate(SIX_MONTHS, compounding=m, spread=0.01) for m in ("straight", "flat", "spread_exclusive", "averaged")}
for method, value in rates.items():
print(f"{method:<17} {value:.8f}")
# With no spread there is nothing for the three compounding methods to treat
# differently, and they agree to round-off.
bare = [rate(SIX_MONTHS, compounding=m) for m in ("straight", "flat", "spread_exclusive")]
for method, value in zip(("straight", "flat", "spread_exclusive"), bare):
print(f"spread 0, {method:<17} {value:.8f}")
# Five months of 3M resets leave a two-month final sub-period. Read over its own
# accrual it projects two months; read at the index tenor it projects three.
FIVE_MONTHS = dt.date(2027, 6, 7)
own = rate(FIVE_MONTHS, stub="own_accrual")
tenor = rate(FIVE_MONTHS, stub="index_tenor")
print(f"own accrual {own:.8f}")
print(f"index tenor {tenor:.8f}")straight 0.03067746
flat 0.03063905
spread_exclusive 0.03061321
averaged 0.03056008
spread 0, straight 0.02061321
spread 0, flat 0.02061321
spread 0, spread_exclusive 0.02061321
own accrual 0.02059675
index tenor 0.02060354
With a spread the four methods order from straight down to averaged. With no spread the three compounding methods agree. The short final sub-period reads a different rate under each stub rule.
How it's priced
With sub-periods of accrual , rates and spread , the coupon's rate over the whole accrual is, under each method,
Flat compounding accrues each sub-period's amount at its rate plus the spread, and compounds what has built up at the rate alone, so the spread's own accrual doesn't compound. For two resets the methods differ by products of accruals: straight less flat is , flat less spread-exclusive is , and spread-exclusive less averaged is . With positive rates and spread,
and with no spread the three compounding methods are the same product. Where these coupons come from is in how cashflows work.
API
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| the coupon | MultiResetCoupon::on_index |
legs.multi_reset_coupon |
|
| method | how resets combine | ResetCompounding::Straight, Flat, SpreadExclusive, Averaged |
compounding= |
| short final sub-period | how it reads the index | StubRule::OwnAccrual, IndexTenor |
stub= |
| strip | one coupon per period | multi_reset_leg |
legs.multi_reset_leg |
legs.multi_reset_coupon(...):
| Argument | Type | Default | Meaning |
|---|---|---|---|
currency |
str |
required | the currency the coupon pays in |
accrual_start, accrual_end, payment_date |
date |
required | the coupon's dates |
notional |
float |
required | the size |
index |
str |
required | the term index, such as "EUR-EURIBOR-3M" |
day_count |
str |
required | the accrual day count |
reset_tenor |
str |
required | how often the index resets, such as "3M" |
compounding |
str |
"straight" |
"straight", "flat", "spread_exclusive" or "averaged" |
gearing, spread |
float |
1.0, 0.0 |
each sub-period's rate is gearing × fixing, and is spread |
stub |
str or pair |
"index_tenor" |
how a short final sub-period reads the index; see stub rules |
legs.multi_reset_leg(schedule, payment_dates, currency, ...) takes the same terms over a
schedule.
Defaults and limits
- Stub default: a multi-reset coupon reads a short final sub-period at the index's own tenor by default, where an IBOR strip's stub reads over its own accrual.
- Resets aren't exposed: the sub-periods are built inside the constructor; read the coupon's combined rate from its cashflow table.
See also
- Source:
crates/helixrs/src/cashflows/coupons.rs,crates/helixrs/src/cashflows/legs.rs - Related pages:
- Cross-currency swap, where a leg often pays less often than its index resets.
- From a schedule to a leg, for the stub rules.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.