From a schedule to a leg
This page lists what turns a schedule into a leg: the schedule's periods, payment dates under a lag, and the stub rules a short period reads its index by. It's the one place the stub rules are written down; every other page links here.
Quick example
This builds quarterly periods with a short first one, pays each period two TARGET business days after it ends, values the stub under each stub rule, shows an interpolated stub refused on a market that projects only one tenor, and reads a single two-month coupon both ways. 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, IborIndex, Leg, LegTerms, MarketContext, PaymentRule,
QuoteSheet, Schedule, StubRule, calendar, ibor_leg_on_index, ibor_leg_with_stubs, 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 euribor(code: &str) -> IborIndex {
let Some(AnyIndex::Ibor(index)) = index(code) else {
panic!("{code} is a registered term index");
};
index
}
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 target = calendar("EUTA")?;
// Quarterly periods from 20 November 2026 to 5 October 2027, measured back from
// the end: the first period is short.
let schedule = Schedule::generate(
date((2026, 11, 20)),
date((2027, 10, 5)),
"3M".parse()?,
target.as_ref(),
"ModFollowing".parse()?,
"None".parse()?,
"ShortInitial".parse()?,
false,
None,
None,
)?;
for (start, end) in schedule.periods() {
println!("{start} {end}");
}
// Each period pays two TARGET business days after it ends.
let paying = PaymentRule::business_days(2, target.clone());
for (_, end) in schedule.periods() {
let pay = paying.payment_date(end);
println!("{end} pays {pay}");
}
// The same strip under two stub rules. Projected over its own accrual, the
// stub reads a forward over its six weeks; read at the index tenor, it reads
// EURIBOR 3M's full window from its start. The next coupon is the same.
let terms = LegTerms {
notional: 10_000_000.0,
day_count: "ACT/360".parse()?,
currency: "EUR".parse()?,
direction: Direction::Receive,
payment: paying.clone(),
};
let three_month = euribor("EUR-EURIBOR-3M");
let strip = |rule: StubRule| -> Outcome<Leg> {
Ok(ibor_leg_with_stubs(
&schedule,
&terms,
&three_month,
1.0,
0.0,
&rule,
)?)
};
let rates = |leg: &Leg| -> Outcome<Vec<f64>> {
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").to_vec())
};
let own = rates(&strip(StubRule::OwnAccrual)?)?;
let tenor = rates(&strip(StubRule::IndexTenor)?)?;
println!("stub rate, own accrual {:.8}", own[0]);
println!("stub rate, index tenor {:.8}", tenor[0]);
println!("next coupon, both rules {:.8} {:.8}", own[1], tenor[1]);
// Interpolating between two tenors reads both: a market that projects only
// EURIBOR 3M cannot value it, and the refusal names the curve it lacks.
let blended = strip(StubRule::Interpolated {
shorter: euribor("EUR-EURIBOR-1M"),
longer: three_month.clone(),
})?;
let refused = blended
.npv_on(&market, &eur)
.expect_err("an interpolated stub needs both tenors projected");
let (kind, key) = refused.missing_input().expect("a missing input");
let missing = format!("{kind}({key})");
println!("refused: no {missing}");
// A single two-month coupon on EURIBOR 3M elects the same reading with its
// fixing window: by default it projects over its own accrual; with
// `StubRule::IndexTenor` it reads the index's three-month window from its
// start. Either way the amount accrues over the coupon's own two months.
let two_months = Schedule::from_dates(vec![date((2026, 10, 5)), date((2026, 12, 7))])?;
let single = LegTerms { payment: PaymentRule::none(), ..terms.clone() };
let projected = ibor_leg_on_index(&two_months, &single, &three_month, 1.0, 0.0);
let AnyCoupon::Ibor(coupon) = projected.coupons()[0].clone() else {
unreachable!("an IBOR leg's coupon")
};
let windowed = Leg::new(
vec![AnyCoupon::Ibor(coupon.with_stub(&three_month, &StubRule::IndexTenor)?)],
Direction::Receive,
);
let (own_window, index_window) = (rates(&projected)?[0], rates(&windowed)?[0]);
println!("own accrual {own_window:.8}");
println!("index tenor {index_window:.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)
target = hlx.Calendar("EUTA")
# Quarterly periods from 20 November 2026 to 5 October 2027, measured back from
# the end: the first period is short.
schedule = hlx.dates.schedule(dt.date(2026, 11, 20), dt.date(2027, 10, 5), "3M", target)
for start, end in schedule.periods():
print(f"{start} {end}")
# Each period pays two TARGET business days after it ends.
paying = schedule.paying(2, target)
for (_, end), pay in zip(schedule.periods(), paying):
print(f"{end} pays {pay}")
# The same strip under two stub rules. Projected over its own accrual, the
# stub reads a forward over its six weeks; read at the index tenor, it reads
# EURIBOR 3M's full window from its start. The next coupon is the same.
def strip(stub):
coupons = hlx.legs.ibor_leg(
schedule, paying, "EUR", notional=10_000_000.0, index="EUR-EURIBOR-3M", day_count="ACT/360", stub=stub
)
return hlx.Leg(coupons, "Receive", "EUR")
own = strip("own_accrual").cashflow_records(market).to_dict()["rate"]
tenor = strip("index_tenor").cashflow_records(market).to_dict()["rate"]
print(f"stub rate, own accrual {own[0]:.8f}")
print(f"stub rate, index tenor {tenor[0]:.8f}")
print(f"next coupon, both rules {own[1]:.8f} {tenor[1]:.8f}")
# Interpolating between two tenors reads both: a market that projects only
# EURIBOR 3M cannot value it, and the refusal names the curve it lacks.
blended = strip(("EUR-EURIBOR-1M", "EUR-EURIBOR-3M"))
try:
blended.npv(market)
except hlx.MissingMarketInputError as refused:
missing = f"{refused.kind}({refused.key})"
print(f"refused: no {missing}")
# A single two-month coupon on EURIBOR 3M elects the same reading with its
# fixing window: by default it projects over its own accrual; with
# fixing_window="index_tenor" it reads the index's three-month window from its
# start. Either way the amount accrues over the coupon's own two months.
def stub_rate(**kw):
coupon = hlx.legs.ibor_coupon(
"EUR", dt.date(2026, 10, 5), dt.date(2026, 12, 7), dt.date(2026, 12, 7),
notional=10_000_000.0, index="EUR-EURIBOR-3M", day_count="ACT/360", **kw,
)
return hlx.Leg([coupon], "Receive", "EUR").cashflow_records(market).to_dict()["rate"][0]
own_window, index_window = stub_rate(), stub_rate(fixing_window="index_tenor")
print(f"own accrual {own_window:.8f}")
print(f"index tenor {index_window:.8f}")2026-11-20 2027-01-05
2027-01-05 2027-04-05
2027-04-05 2027-07-05
2027-07-05 2027-10-05
2027-01-05 pays 2027-01-07
2027-04-05 pays 2027-04-07
2027-07-05 pays 2027-07-07
2027-10-05 pays 2027-10-07
stub rate, own accrual 0.02053401
stub rate, index tenor 0.02056213
next coupon, both rules 0.02055978 0.02055978
refused: no projection(EUR-EURIBOR-1M)
own accrual 0.02054396
index tenor 0.02056095
Only the stub's rate depends on the stub rule; the next coupon reads the same rate under both. The interpolated stub needs the one-month curve too, and the refusal names it.
How it's priced
A payment lag of business days on calendar pays period on , where is the period's end.
A stub of length between two published tenors , read by linear interpolation, fixes at
where and are the shorter and longer tenors' rates, so valuing it needs both projected. Why a stub needs a rule at all is in how cashflows work.
API
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| periods | the accrual periods | Schedule::generate, then Schedule::periods |
dates.schedule, then Schedule.periods |
| payment dates under a lag | PaymentRule::business_days |
Schedule.paying |
|
| business days added | Calendar::add_business_days |
Calendar.add_business_days |
|
| stub rule | how a stub reads its index | StubRule::OwnAccrual, IndexTenor, Interpolated |
stub= on legs.ibor_leg |
| one coupon's window | the same choice on a single coupon | IborCoupon::with_stub |
fixing_window= on legs.ibor_coupon |
| strip | coupons over the schedule | ibor_leg_with_stubs |
legs.ibor_leg |
Schedule.paying(...):
| Argument | Type | Default | Meaning |
|---|---|---|---|
lag_days |
int |
required | business days after each period's end; 0 pays on the end |
calendar |
Calendar |
required | the calendar the lag counts on |
business_day_convention |
str |
"Following" |
how a payment date landing on a closed day rolls |
The stub= argument of legs.ibor_leg and legs.multi_reset_coupon:
| Argument | Type | Default | Meaning |
|---|---|---|---|
stub |
str or tuple[str, str] |
"own_accrual" on ibor_leg, "index_tenor" on multi_reset_coupon |
one of the stub rules below |
Stub rules
| Rule | Python | Rust | The stub reads |
|---|---|---|---|
| own accrual | "own_accrual" |
StubRule::OwnAccrual |
a forward over its own dates |
| index tenor | "index_tenor" |
StubRule::IndexTenor |
the index's full tenor from its start, as a regular period does |
| interpolated | ("EUR-EURIBOR-1M", "EUR-EURIBOR-3M") |
StubRule::Interpolated { shorter, longer } |
the two tenors either side of its length, blended by days |
A single coupon makes the same choice with fixing_window= on legs.ibor_coupon: None (the
default) reads over its own accrual, "index_tenor" reads the index's full window from the accrual
start's value date, and "value_dates" reads the index's value dates for each boundary. A
(start, end) pair names the window outright. The amount always accrues over the accrual period.
Defaults and limits
- Where the stub goes:
dates.schedulemeasures periods back from the end, so the first period is the short one. Rust'sSchedule::generatetakes the stub position as an argument ("ShortInitial"here). - Regular periods: a stub rule changes only the stub. Every regular period reads its index over the index's own window, whichever rule you choose.
- Interpolated stubs need a registered term index for each tenor, and both projected in the market; a missing one is refused with the curve named.
projection="accrual_period"onlegs.ibor_legprojects every coupon over its own accrual, and can't be combined with an electedstub.- Multi-reset coupons read a short final sub-period by the same rules; see multi-reset coupons.
See also
- Source:
crates/helixrs/src/statics/schedules.rs,crates/helixrs/src/cashflows/legs.rs,crates/helixio/src/legs.rs - Related pages:
- Build a leg from a schedule, the task step by step.
- Legs and coupons, the strip constructors and the leg.
- Schedules and calendars, for generating periods and counting a lag.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.