How dates and conventions work
Before you can price anything, a trade has to know its dates: when it starts, when each period ends, when each fixing is taken, and how much of a year each period counts as. This page explains the market habits behind those dates, and why helixio splits them the way it does. Where a calculation can be rebuilt from simpler calls, you'll see it rebuilt by hand, so you can trust what the short way does.
What every date shares
Markets quote lengths, not dates, and leave the rest to a standard: which centres must be open, how a date that lands on a holiday moves, and how a period counts as a fraction of a year. helixio keeps each of those as its own small piece (a calendar, a tenor, a day count, a schedule, an index), and a template bundles the pieces a market uses together. The reference pages list what each piece takes; this page explains why they exist.
Calendars
Each financial centre publishes its own holidays. A trade in two currencies usually needs both centres open, and an FX value date needs the settlement currency's centre open as well as the market where the lag is counted. Desks also keep their own closures: an office holiday, a system outage, or a market the library doesn't ship.
A calendar answers one question from data: is this day a business day? Everything else it does (rolling a date, advancing by a tenor, counting the business days between two dates) is arithmetic on top of that one question. The program below rebuilds all three from the predicate alone over every date of 2031, and checks that the rebuild never disagrees with the library. The short way is on the calendars reference.
use chrono::{Datelike, Duration, NaiveDate};
use helixrs::{BusinessDayConvention, Calendar, Period, add_period, calendar, last_day_of_month};
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
fn following(calendar: &dyn Calendar, mut day: NaiveDate) -> NaiveDate {
while !calendar.is_business_day(day) {
day += Duration::days(1);
}
day
}
fn preceding(calendar: &dyn Calendar, mut day: NaiveDate) -> NaiveDate {
while !calendar.is_business_day(day) {
day -= Duration::days(1);
}
day
}
fn modified_following(calendar: &dyn Calendar, day: NaiveDate) -> NaiveDate {
let rolled = following(calendar, day);
if rolled.month() == day.month() { rolled } else { preceding(calendar, day) }
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let new_york = calendar("USNY").expect("a registered business centre");
let ny = new_york.as_ref();
let year_start = date(2031, 1, 1);
let year_end = date(2032, 1, 1);
let one_day = Duration::days(1);
// Every date of 2031, rolled three ways by walking the predicate, agrees
// with `adjust`. The count of dates that moved is the count of closed dates.
let mut moved = 0_i64;
let (mut off_following, mut off_preceding, mut off_modified) = (0_i64, 0_i64, 0_i64);
let mut day = year_start;
while day < year_end {
off_following += i64::from(following(ny, day) != ny.adjust(day, BusinessDayConvention::Following));
off_preceding += i64::from(preceding(ny, day) != ny.adjust(day, BusinessDayConvention::Preceding));
off_modified += i64::from(
modified_following(ny, day) != ny.adjust(day, BusinessDayConvention::ModifiedFollowing),
);
moved += i64::from(following(ny, day) != day);
day += one_day;
}
let days_in_year = (year_end - year_start).num_days();
println!("dates checked under three conventions {days_in_year}");
println!("dates a convention moved {moved}");
println!("dates the rebuild disagrees on {}", off_following + off_preceding + off_modified);
// An advance is calendar arithmetic, `add_period`, then one roll. From each
// month end of 2031 the two routes agree for a three-month tenor.
let three_months: Period = "3M".parse().expect("a tenor");
println!("{:<12} {:<12} {:<12}", "from", "add 3M", "rolled");
let mut off_advance = 0_i64;
for month in 1..=12 {
let start = last_day_of_month(date(2031, month, 1));
let shifted = add_period(start, three_months);
let rolled = modified_following(ny, shifted);
off_advance += i64::from(
rolled != ny.advance(start, three_months, BusinessDayConvention::ModifiedFollowing, false),
);
if month % 3 == 0 {
println!("{:<12} {:<12} {:<12}", start.to_string(), shifted.to_string(), rolled.to_string());
}
}
println!("month ends where the advance differs {off_advance}");
// The half-open count is the number of open dates in [start, end). Counting
// month by month and adding the months reproduces the year.
let mut total = 0_i64;
let mut off_count = 0_i64;
for month in 1..=12 {
let start = date(2031, month, 1);
let end = if month == 12 { date(2032, 1, 1) } else { date(2031, month + 1, 1) };
let mut count = 0_i64;
let mut day = start;
while day < end {
count += i64::from(ny.is_business_day(day));
day += one_day;
}
off_count += i64::from(count != ny.business_days_between(start, end));
total += count;
}
let year_count = ny.business_days_between(year_start, year_end);
println!("sum of twelve monthly counts {total}");
println!("count over the year {year_count}");
println!("months whose count differs {off_count}");
Ok(())
}import datetime as dt
import helixio as hlx
NEW_YORK = hlx.Calendar("USNY")
YEAR_START = dt.date(2031, 1, 1)
YEAR_END = dt.date(2032, 1, 1)
ONE_DAY = dt.timedelta(days=1)
def following(day: dt.date) -> dt.date:
while not NEW_YORK.is_business_day(day):
day += ONE_DAY
return day
def preceding(day: dt.date) -> dt.date:
while not NEW_YORK.is_business_day(day):
day -= ONE_DAY
return day
def modified_following(day: dt.date) -> dt.date:
rolled = following(day)
return rolled if rolled.month == day.month else preceding(day)
# Every date of 2031, rolled three ways by walking the predicate, agrees with
# `adjust`. The count of dates that moved is the count of closed dates.
moved = 0
off_following = off_preceding = off_modified = 0
day = YEAR_START
while day < YEAR_END:
off_following += int(following(day) != NEW_YORK.adjust(day, "Following"))
off_preceding += int(preceding(day) != NEW_YORK.adjust(day, "Preceding"))
off_modified += int(modified_following(day) != NEW_YORK.adjust(day, "ModFollowing"))
moved += int(following(day) != day)
day += ONE_DAY
print(f"dates checked under three conventions {(YEAR_END - YEAR_START).days}")
print(f"dates a convention moved {moved}")
print(f"dates the rebuild disagrees on {off_following + off_preceding + off_modified}")
# An advance is calendar arithmetic, `dates.add_period`, then one roll. From
# each month end of 2031 the two routes agree for a three-month tenor.
print(f"{'from':<12} {'add 3M':<12} {'rolled':<12}")
off_advance = 0
month = 1
while month <= 12:
start = hlx.dates.last_day_of_month(dt.date(2031, month, 1))
shifted = hlx.dates.add_period(start, "3M")
rolled = modified_following(shifted)
off_advance += int(rolled != NEW_YORK.advance(start, "3M", "ModFollowing"))
if month % 3 == 0:
print(f"{start!s:<12} {shifted!s:<12} {rolled!s:<12}")
month += 1
print(f"month ends where the advance differs {off_advance}")
# The half-open count is the number of open dates in [start, end). Counting
# month by month and adding the months reproduces the year.
total = 0
off_count = 0
month = 1
while month <= 12:
start = dt.date(2031, month, 1)
end = dt.date(2032, 1, 1) if month == 12 else dt.date(2031, month + 1, 1)
count = 0
day = start
while day < end:
count += int(NEW_YORK.is_business_day(day))
day += ONE_DAY
off_count += int(count != NEW_YORK.business_days_between(start, end))
total += count
month += 1
year_count = NEW_YORK.business_days_between(YEAR_START, YEAR_END)
print(f"sum of twelve monthly counts {total}")
print(f"count over the year {year_count}")
print(f"months whose count differs {off_count}")dates checked under three conventions 365
dates a convention moved 115
dates the rebuild disagrees on 0
from add 3M rolled
2031-03-31 2031-06-30 2031-06-30
2031-06-30 2031-09-30 2031-09-30
2031-09-30 2031-12-30 2031-12-30
2031-12-31 2032-03-31 2032-03-31
month ends where the advance differs 0
sum of twelve monthly counts 250
count over the year 250
months whose count differs 0
Dates and periods
Markets quote lengths: a deposit runs 3M, a swap 10Y, and a forward-starting swap 2Y2Y, two
years forward and then two years long. Futures and IMM swaps are dated on the third Wednesday of
March, June, September and December. Credit default swaps pay on the twentieth of those months,
and their standard maturities roll twice a year, on 20 March and 20 September.
A tenor in helixio is a value, not a string. Two tenors are equal when they last the same time, so
12M is 1Y. Ordering is only partial, because a month has no fixed number of days: 1M and
30D can't be put in order. Adding a tenor to a date moves the month and then clamps the day to
the target month's length, which is why 31 January plus one month is the end of February. The
program below rebuilds that rule and checks it over three years of dates under five tenors. The
short way is on the dates and periods reference.
use std::cmp::Ordering;
use chrono::{Datelike, Duration, NaiveDate};
use helixrs::{Period, add_period, last_day_of_month};
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
fn tenor(spelling: &str) -> Period {
spelling.parse().expect("a tenor spelling")
}
fn compare(left: Period, right: Period) -> &'static str {
match left.partial_cmp(&right) {
Some(Ordering::Equal) => "equal",
Some(Ordering::Less) => "shorter",
Some(Ordering::Greater) => "longer",
None => "undecidable",
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Equality is about duration, not spelling: 12M is 1Y and 7D is 1W.
let pairs = [("12M", "1Y"), ("7D", "1W"), ("6M", "1Y"), ("1M", "30D")];
let verdicts: Vec<&str> = pairs.iter().map(|(l, r)| compare(tenor(l), tenor(r))).collect();
for ((left, right), verdict) in pairs.iter().zip(&verdicts) {
println!("{left:>3} vs {right:<3} {verdict}");
}
let total = (tenor("3M") + tenor("9M")).expect("one unit family");
println!("3M + 9M = {total}");
// `add_period` rebuilt from its rule: move the month index, then clamp the
// day to the target month's length. Every day of 2024 to 2026 under five
// tenors.
let spellings = ["1M", "3M", "6M", "1Y", "-1M"];
let mut checked = 0_i32;
let mut mismatches = 0_i32;
let mut day = date(2024, 1, 1);
while day < date(2027, 1, 1) {
for spelling in spellings {
let months = tenor(spelling).in_months().expect("a month tenor");
let index = day.year() * 12 + day.month0() as i32 + months;
let target = date(index.div_euclid(12), index.rem_euclid(12) as u32 + 1, 1);
let clamped = day.day().min(last_day_of_month(target).day());
mismatches += i32::from(add_period(day, tenor(spelling)) != date(target.year(), target.month(), clamped));
checked += 1;
}
day += Duration::days(1);
}
println!("dates checked: {checked}, differing from add_period: {mismatches}");
Ok(())
}import datetime as dt
import helixio as hlx
dates = hlx.dates
Period = dates.Period
def compare(left: Period, right: Period) -> str:
"""Equal, shorter or longer where the two tenors can be ordered."""
if left == right:
return "equal"
try:
return "shorter" if left < right else "longer"
except ValueError:
# A month has no fixed number of days, so a month tenor and a day
# tenor whose day ranges overlap cannot be ordered.
return "undecidable"
# Equality is about duration, not spelling: 12M is 1Y and 7D is 1W.
pairs = [("12M", "1Y"), ("7D", "1W"), ("6M", "1Y"), ("1M", "30D")]
verdicts = [compare(Period(left), Period(right)) for left, right in pairs]
for (left, right), verdict in zip(pairs, verdicts):
print(f"{left:>3} vs {right:<3} {verdict}")
total = Period("3M") + Period("9M")
print(f"3M + 9M = {total}")
# `add_period` rebuilt from its rule: move the month index, then clamp the day
# to the target month's length. Every day of 2024 to 2026 under five tenors.
spellings = ("1M", "3M", "6M", "1Y", "-1M")
checked = 0
mismatches = 0
day = dt.date(2024, 1, 1)
while day < dt.date(2027, 1, 1):
for spelling in spellings:
months = Period(spelling).in_months()
index = day.year * 12 + (day.month - 1) + months
target = dt.date(index // 12, index % 12 + 1, 1)
clamped = min(day.day, dates.last_day_of_month(target).day)
mismatches += int(dates.add_period(day, Period(spelling)) != target.replace(day=clamped))
checked += 1
day += dt.timedelta(days=1)
print(f"dates checked: {checked}, differing from add_period: {mismatches}")12M vs 1Y equal
7D vs 1W equal
6M vs 1Y shorter
1M vs 30D undecidable
3M + 9M = 1Y
dates checked: 5480, differing from add_period: 0
Day counts
Each market fixes the convention its instruments accrue on. Money markets use ACT/360 in dollars and euros and ACT/365.FIXED in sterling. Fixed legs use 30/360 variants: 30/360 for US swaps, 30E/360 in Europe, and 30U/360 for US agency and corporate bonds that follow the SIFMA February rule. Government bonds use ACT/ACT.ICMA, which measures a coupon against its regular coupon period. Brazilian instruments use BUS/252: business days over 252.
The conventions differ mostly at month ends, which is where the 30/360 family's day-of-month rules disagree with each other. The program below rebuilds 30/360, 30E/360 and 30E/360.ISDA, then ACT/360, ACT/365.FIXED and ACT/ACT.ISDA, and BUS/252, from their definitions, over every pair of month-end and mid-month dates from 2026 to 2028, and checks every result against the library. The short way, and the formulas, are on the day counts reference.
use chrono::{Datelike, Duration, NaiveDate, Weekday};
use helixrs::{DayCountFraction, calendar, last_day_of_month};
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
fn thirty_360(start: NaiveDate, end: NaiveDate, rule: &str) -> f64 {
let (mut d1, mut d2) = (start.day() as i32, end.day() as i32);
match rule {
"30/360" => {
if d1 == 31 {
d1 = 30;
}
if d2 == 31 && d1 == 30 {
d2 = 30;
}
}
"30E/360" => {
d1 = d1.min(30);
d2 = d2.min(30);
}
// 30E/360.ISDA: every month end counts as the 30th.
_ => {
if start == last_day_of_month(start) {
d1 = 30;
}
if end == last_day_of_month(end) {
d2 = 30;
}
}
}
let days = 360 * (end.year() - start.year())
+ 30 * (end.month() as i32 - start.month() as i32)
+ (d2 - d1);
f64::from(days) / 360.0
}
fn act_act_isda(start: NaiveDate, end: NaiveDate) -> f64 {
let year_length = |year: i32| (date(year + 1, 1, 1) - date(year, 1, 1)).num_days() as f64;
if start.year() == end.year() {
return (end - start).num_days() as f64 / year_length(start.year());
}
let first = (date(start.year() + 1, 1, 1) - start).num_days() as f64 / year_length(start.year());
let last = (end - date(end.year(), 1, 1)).num_days() as f64 / year_length(end.year());
first + last + f64::from(end.year() - start.year() - 1)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Period ends on the 15th and on each month end, 2026 to 2028: the dates
// where the 30/360 rules differ from each other.
let mut anchors = Vec::new();
for year in [2026, 2027, 2028] {
for month in 1..=12 {
anchors.push(date(year, month, 15));
anchors.push(last_day_of_month(date(year, month, 1)));
}
}
let rules = ["30/360", "30E/360", "30E/360.ISDA"];
let mut checked = 0;
let mut off_thirty = 0;
for &start in &anchors {
for &end in &anchors {
if end <= start {
continue;
}
for rule in rules {
let convention: DayCountFraction = rule.parse().expect("a day-count code");
off_thirty += i32::from(convention.year_fraction(start, end) != thirty_360(start, end, rule));
checked += 1;
}
}
}
println!("30/360 family: {checked} periods checked, {off_thirty} differ from the library");
let samples = [
(date(2026, 1, 31), date(2026, 3, 31)),
(date(2026, 2, 28), date(2026, 8, 31)),
(date(2026, 3, 15), date(2026, 3, 31)),
];
println!("{:<11} {:<11} {:>7} {:>8} {:>9}", "start", "end", "30/360", "30E/360", "30E.ISDA");
for (start, end) in samples {
let cells: Vec<String> =
rules.iter().map(|rule| format!("{:.0}", 360.0 * thirty_360(start, end, rule))).collect();
println!(
"{:<11} {:<11} {:>7} {:>8} {:>9}",
start.to_string(),
end.to_string(),
cells[0],
cells[1],
cells[2]
);
}
// The actual-day conventions: days over a fixed denominator, or split at
// each year boundary under ACT/ACT.ISDA.
let mut actual_checked = 0;
let (mut off_act360, mut off_act365, mut off_isda) = (0, 0, 0);
for &start in &anchors {
for &end in &anchors {
if end <= start {
continue;
}
let days = (end - start).num_days() as f64;
off_act360 += i32::from(DayCountFraction::Act360.year_fraction(start, end) != days / 360.0);
off_act365 += i32::from(DayCountFraction::Act365Fixed.year_fraction(start, end) != days / 365.0);
off_isda += i32::from(DayCountFraction::ActActIsda.year_fraction(start, end) != act_act_isda(start, end));
actual_checked += 1;
}
}
println!(
"ACT/360, ACT/365.FIXED, ACT/ACT.ISDA: {actual_checked} periods checked, {} differ",
off_act360 + off_act365 + off_isda
);
// BUS/252 is a business-day count over 252. Its context-free reading counts the
// business days of Brazil's national calendar, BRBD, because the convention is
// Brazil's; given a calendar, it counts that calendar's own half-open days.
let national = calendar("BRBD").expect("a registered business centre");
let sao_paulo = calendar("BRSP").expect("a registered business centre");
let (start, end) = (date(2026, 2, 15), date(2026, 8, 31));
let mut weekdays = 0_i64;
let mut day = start;
while day < end {
weekdays += i64::from(!matches!(day.weekday(), Weekday::Sat | Weekday::Sun));
day += Duration::days(1);
}
let national_days = national.business_days_between(start, end);
let business_days = sao_paulo.business_days_between(start, end);
println!("weekdays {weekdays}, BRBD business days {national_days}, BRSP business days {business_days}");
let bus_252 = DayCountFraction::Bus252.year_fraction_with_calendar(start, end, sao_paulo.as_ref());
println!("BUS/252 on BRSP: by hand {:.10}, library {bus_252:.10}", business_days as f64 / 252.0);
Ok(())
}import datetime as dt
import helixio as hlx
DayCountFraction = hlx.dates.DayCountFraction
last_day = hlx.dates.last_day_of_month
def thirty_360(start: dt.date, end: dt.date, rule: str) -> float:
d1, d2 = start.day, end.day
if rule == "30/360":
d1 = 30 if d1 == 31 else d1
d2 = 30 if d2 == 31 and d1 == 30 else d2
elif rule == "30E/360":
d1, d2 = min(d1, 30), min(d2, 30)
else: # 30E/360.ISDA: every month end counts as the 30th
d1 = 30 if start == last_day(start) else d1
d2 = 30 if end == last_day(end) else d2
days = 360 * (end.year - start.year) + 30 * (end.month - start.month) + (d2 - d1)
return days / 360.0
def act_act_isda(start: dt.date, end: dt.date) -> float:
def year_length(year: int) -> float:
return float((dt.date(year + 1, 1, 1) - dt.date(year, 1, 1)).days)
if start.year == end.year:
return (end - start).days / year_length(start.year)
first = (dt.date(start.year + 1, 1, 1) - start).days / year_length(start.year)
last = (end - dt.date(end.year, 1, 1)).days / year_length(end.year)
return first + last + float(end.year - start.year - 1)
# Period ends on the 15th and on each month end, 2026 to 2028: the dates where
# the 30/360 rules differ from each other.
anchors = []
for year in (2026, 2027, 2028):
for month in range(1, 13):
anchors.append(dt.date(year, month, 15))
anchors.append(last_day(dt.date(year, month, 1)))
rules = ("30/360", "30E/360", "30E/360.ISDA")
checked = 0
off_thirty = 0
for start in anchors:
for end in anchors:
if end <= start:
continue
for rule in rules:
off_thirty += int(DayCountFraction(rule).year_fraction(start, end) != thirty_360(start, end, rule))
checked += 1
print(f"30/360 family: {checked} periods checked, {off_thirty} differ from the library")
samples = [
(dt.date(2026, 1, 31), dt.date(2026, 3, 31)),
(dt.date(2026, 2, 28), dt.date(2026, 8, 31)),
(dt.date(2026, 3, 15), dt.date(2026, 3, 31)),
]
print(f"{'start':<11} {'end':<11} {'30/360':>7} {'30E/360':>8} {'30E.ISDA':>9}")
for start, end in samples:
cells = [f"{360 * thirty_360(start, end, rule):.0f}" for rule in rules]
print(f"{start!s:<11} {end!s:<11} {cells[0]:>7} {cells[1]:>8} {cells[2]:>9}")
# The actual-day conventions: days over a fixed denominator, or split at each
# year boundary under ACT/ACT.ISDA.
actual_checked = 0
off_act360 = off_act365 = off_isda = 0
for start in anchors:
for end in anchors:
if end <= start:
continue
days = (end - start).days
off_act360 += int(DayCountFraction.Act360.year_fraction(start, end) != days / 360.0)
off_act365 += int(DayCountFraction.Act365Fixed.year_fraction(start, end) != days / 365.0)
off_isda += int(DayCountFraction.ActActIsda.year_fraction(start, end) != act_act_isda(start, end))
actual_checked += 1
print(f"ACT/360, ACT/365.FIXED, ACT/ACT.ISDA: {actual_checked} periods checked, "
f"{off_act360 + off_act365 + off_isda} differ")
# BUS/252 is a business-day count over 252. Its context-free reading counts the
# business days of Brazil's national calendar, BRBD, because the convention is
# Brazil's; given a calendar, it counts that calendar's own half-open days.
NATIONAL, SAO_PAULO = hlx.Calendar("BRBD"), hlx.Calendar("BRSP")
START, END = dt.date(2026, 2, 15), dt.date(2026, 8, 31)
weekdays = 0
day = START
while day < END:
weekdays += int(day.weekday() < 5)
day += dt.timedelta(days=1)
national = NATIONAL.business_days_between(START, END)
business_days = SAO_PAULO.business_days_between(START, END)
print(f"weekdays {weekdays}, BRBD business days {national}, BRSP business days {business_days}")
bus_252 = DayCountFraction.Bus252.year_fraction_with_calendar(START, END, SAO_PAULO)
print(f"BUS/252 on BRSP: by hand {business_days / 252.0:.10f}, library {bus_252:.10f}")30/360 family: 7668 periods checked, 0 differ from the library
start end 30/360 30E/360 30E.ISDA
2026-01-31 2026-03-31 60 60 60
2026-02-28 2026-08-31 183 182 180
2026-03-15 2026-03-31 16 15 15
ACT/360, ACT/365.FIXED, ACT/ACT.ISDA: 2556 periods checked, 0 differ
weekdays 140, BRBD business days 134, BRSP business days 134
BUS/252 on BRSP: by hand 0.5317460317, library 0.5317460317
Schedules
A term sheet states a schedule by its ends, its frequency, its calendar and convention, and what to do with a remainder that doesn't fit the frequency: a short or long stub at the start or the end, or stated first and last regular dates. A schedule seeded on a month end usually rolls on month ends throughout. CDS schedules follow their own rules, dated on the twentieth of IMM months.
Every boundary is measured from one fixed anchor and then adjusted, rather than stepped from the previous adjusted date, so a holiday early in a schedule never drifts the dates after it. See the schedules reference for the arguments and the edge cases.
Indices
An overnight index publishes once per business day, the morning after: SOFR for 1 October is published on 2 October. A term index such as EURIBOR fixes two TARGET business days before the period it applies to starts, and quotes over its own tenor from that value date. A desk valuing at a close therefore holds every print before the close and none for the close itself.
That gives every product the same observation rule. A fixing before the valuation date must have been published, or valuation stops. A fixing on the valuation date reads the print if there is one and is projected from the curve otherwise. A fixing after it is always projected, even if a value was stored against it. The program below rebuilds an index's fixing and maturity dates from its own conventions for every business day of 2026, then writes the observation rule out by hand. The short way is on the indices reference.
use chrono::NaiveDate;
use helixrs::{AnyIndex, FixingKey, FixingStore, MarketContext, index};
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// A term index fixes `fixing_lag_days` business days before the accrual
// starts, on its fixing calendar, and quotes over its tenor rolled by its
// own convention. Rebuilt for every business day of 2026.
let Some(AnyIndex::Ibor(euribor)) = index("EUR-EURIBOR-3M") else {
panic!("a term index")
};
let calendar = &euribor.fixing_calendar;
let lag = i64::from(euribor.fixing_lag_days);
let starts = calendar.business_days(date(2026, 1, 1), date(2026, 12, 31));
let (mut off_fixing, mut off_maturity) = (0, 0);
for &start in &starts {
off_fixing += i32::from(euribor.fixing_date(start) != calendar.add_business_days(start, -lag));
off_maturity += i32::from(
euribor.maturity_date(start)
!= calendar.advance(start, euribor.tenor, euribor.business_day_convention, euribor.end_of_month),
);
}
let checked = starts.len() as i32;
println!(
"{}: {checked} accrual starts in 2026, fixing and window rebuilt, {} differ",
euribor.code(),
off_fixing + off_maturity
);
// The rule: a reset before the valuation date must be published; a reset on
// it reads the print if there is one and is projected otherwise; a reset
// after it is projected, even if a value was stored against it.
let as_of = date(2026, 1, 2);
let store = FixingStore::from_observations_checked(&[
(FixingKey::from("USD-SOFR"), date(2025, 12, 31), 0.0433),
(FixingKey::from("USD-SOFR"), date(2026, 1, 5), 0.0440),
])
.expect("registered keys");
let fixings = store.view::<f64>();
let mut outcomes = Vec::new();
for day in [date(2025, 12, 30), date(2025, 12, 31), as_of, date(2026, 1, 5)] {
let relation = if day < as_of { "before" } else if day == as_of { "on" } else { "after" };
let outcome = match fixings.observe("USD-SOFR", day, as_of) {
Ok(Some(rate)) => format!("read {rate:.4}"),
Ok(None) => "projected".to_owned(),
Err(_) => "missing fixing".to_owned(),
};
println!("{day} {relation:<7} {outcome}");
outcomes.push(outcome);
}
Ok(())
}import datetime as dt
import helixio as hlx
# A term index fixes `fixing_lag_days` business days before the accrual starts,
# on its fixing calendar, and quotes over its tenor rolled by its own
# convention. Rebuilt for every business day of 2026.
EURIBOR = hlx.Index("EUR-EURIBOR-3M")
calendar = EURIBOR.fixing_calendar()
lag = EURIBOR.fixing_lag_days()
starts = calendar.business_days(dt.date(2026, 1, 1), dt.date(2026, 12, 31))
off_fixing = off_maturity = 0
for start in starts:
off_fixing += int(EURIBOR.fixing_date(start) != calendar.add_business_days(start, -lag))
off_maturity += int(
EURIBOR.maturity_date(start)
!= calendar.advance(start, EURIBOR.tenor(), EURIBOR.business_day_convention(), EURIBOR.end_of_month())
)
checked = len(starts)
print(f"{EURIBOR.code()}: {checked} accrual starts in 2026, fixing and window rebuilt, "
f"{off_fixing + off_maturity} differ")
# The rule: a reset before the valuation date must be published; a reset on it
# reads the print if there is one and is projected otherwise; a reset after it
# is projected, even if a value was stored against it.
AS_OF = dt.date(2026, 1, 2)
market = hlx.MarketContext(AS_OF).with_fixings(
[
("USD-SOFR", dt.date(2025, 12, 31), 0.0433),
("USD-SOFR", dt.date(2026, 1, 5), 0.0440),
]
)
def observe(day: dt.date) -> str:
if day < AS_OF:
rate = market.try_fixing("USD-SOFR", day)
return "missing fixing" if rate is None else f"read {rate:.4f}"
if day == AS_OF:
rate = market.try_fixing("USD-SOFR", day)
return "projected" if rate is None else f"read {rate:.4f}"
return "projected"
outcomes = []
for day in (dt.date(2025, 12, 30), dt.date(2025, 12, 31), AS_OF, dt.date(2026, 1, 5)):
relation = "before" if day < AS_OF else ("on" if day == AS_OF else "after")
outcome = observe(day)
outcomes.append(outcome)
print(f"{day} {relation:<7} {outcome}")EUR-EURIBOR-3M: 256 accrual starts in 2026, fixing and window rebuilt, 0 differ
2025-12-30 before missing fixing
2025-12-31 before read 0.0433
2026-01-02 on projected
2026-01-05 after projected
Conventions and templates
A broker quotes "5Y SOFR at 4.71". The quote leaves almost everything unsaid, because the market has a standard: a spot start two business days on, annual payments on both legs, ACT/360, a two-day payment lag, compounded SOFR. A template records that standard under the code a desk would use, and the library ships templates for overnight and term swaps, deposits, FRAs, futures, cross-currency swaps, non-deliverable forwards and swaps, repos and more.
Because a template is only conventions, a swap's dates can be rebuilt from it: count the spot lag on the calendar, add the tenor, roll the end, and add the payment lag. The program below does exactly that and checks each date against the swap the template builds. The short way is on the conventions and templates reference.
use chrono::{Datelike, Duration, NaiveDate};
use helixrs::statics::templates::registry;
use helixrs::{AnyTemplate, BusinessDayConvention, add_period, swap_builder_on_tenor};
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let valuation = date(2026, 9, 25); // a Friday
let sofr = registry::lookup("USD-SOFR-OIS").expect("a registered template");
let AnyTemplate::Ois(conventions) = &sofr else {
panic!("USD-SOFR-OIS resolves to an overnight swap template");
};
let calendar = &conventions.calendar;
let spot_lag = conventions.spot_lag.multiplier;
let payment_lag = conventions.payment_lag.multiplier;
// Spot: step forward one day at a time and count only business days.
let mut spot = valuation;
let mut counted = 0;
while counted < spot_lag {
spot += Duration::days(1);
if calendar.is_business_day(spot) {
counted += 1;
}
}
// End: spot plus three years lands on a Saturday at a month end. Modified
// Following rolls it back to the Friday rather than into the next month.
let unadjusted_end = add_period(spot, "3Y".parse().expect("a period"));
let end = calendar.adjust(unadjusted_end, conventions.business_day_convention);
let mut pays = end;
let mut lagged = 0;
while lagged < payment_lag {
pays += Duration::days(1);
if calendar.is_business_day(pays) {
lagged += 1;
}
}
let swap = swap_builder_on_tenor(valuation, "3Y".parse().expect("a tenor"), &sofr, "USD-SOFR")
.expect("a swap template")
.with_notional(100_000_000.0)
.with_fixed_rate(0.0385)
.build()
.expect("a swap");
let legs = swap.legs().expect("a swap has legs");
let last = &legs[0].coupons()[legs[0].coupons().len() - 1];
// Two business days from a Friday cross one weekend, so spot is four
// calendar days on, and the count stopped at exactly the lag.
let spot_gap = i32::try_from((spot - valuation).num_days()).expect("a few days");
println!("spot, counted by hand {spot}");
println!("spot gap, calendar days {spot_gap}");
println!("3Y end, unadjusted {unadjusted_end}");
println!("3Y end, Modified Following {end}");
println!("last payment, lag {payment_lag} {pays}");
// The template's own swap: its last coupon accrues to the same end and pays on the same day.
println!("template's last accrual end {}", last.accrual_end().expect("an accrual period"));
println!("template's last payment {}", last.payment_date());
Ok(())
}import datetime as dt
import helixio as hlx
VALUATION = dt.date(2026, 9, 25) # a Friday
SPOT_LAG = 2 # business days, as the template's spot lag states
PAYMENT_LAG = 2 # business days after each accrual end
sofr = hlx.templates.get("USD-SOFR-OIS")
calendar = sofr.calendar()
# Spot: step forward one day at a time and count only business days.
spot = VALUATION
counted = 0
while counted < SPOT_LAG:
spot += dt.timedelta(days=1)
if calendar.is_business_day(spot):
counted += 1
# End: spot plus three years lands on a Saturday at a month end. Modified
# Following rolls it back to the Friday rather than into the next month.
unadjusted_end = hlx.dates.add_period(spot, "3Y")
end = calendar.adjust(unadjusted_end, hlx.dates.BusinessDayConvention.ModifiedFollowing)
pays = calendar.add_business_days(end, PAYMENT_LAG)
swap = sofr.vanilla_swap(VALUATION, "3Y", fixed_rate=0.0385, notional=100_000_000)
last = swap.legs()[0].coupons()[-1]
# Two business days from a Friday cross one weekend, so spot is four calendar
# days on, and the count stopped at exactly the lag.
spot_gap = (spot - VALUATION).days
print(f"spot, counted by hand {spot}")
print(f"spot gap, calendar days {spot_gap}")
print(f"3Y end, unadjusted {unadjusted_end}")
print(f"3Y end, Modified Following {end}")
print(f"last payment, lag {PAYMENT_LAG} {pays}")
# The template's own swap: its last coupon accrues to the same end and pays on the same day.
print(f"template's last accrual end {last.accrual_end()}")
print(f"template's last payment {last.payment_date()}")spot, counted by hand 2026-09-29
spot gap, calendar days 4
3Y end, unadjusted 2029-09-29
3Y end, Modified Following 2029-09-28
last payment, lag 2 2029-10-02
template's last accrual end 2029-09-28
template's last payment 2029-10-02
Meeting schedules
The Federal Reserve, the European Central Bank and the Bank of England each publish their meeting dates a year ahead. A decision takes effect by the bank's rule: the Fed's the next business day, the ECB's the following Wednesday, the Bank of England's the decision day itself. Overnight rate markets quote swaps that cover exactly one window, from one decision taking effect to the next.
A window needs a decision to close it, so the last decision a schedule knows opens no window until a later one is known. That's why a desk pins next year's dates as soon as a bank publishes them; see pin a meeting schedule and the meeting schedules reference.
Listed futures
Three-month contracts (CME SOFR, ICE SONIA, Eurex €STR and EURIBOR) cover an IMM quarter, from the third Wednesday of the delivery month to the third Wednesday three months later. One-month contracts, such as CME one-month SOFR, cover the calendar month. Each stops trading on a day the exchange's rule fixes, and quotes in a tick that can narrow as the contract nears expiry: CME three-month SOFR moves from half a basis point to a quarter of one in the months before its last trading day. See the listed futures reference.
Errors
A production run values many trades on one market. Some will fail for reasons the run can live with, such as a currency whose curve wasn't marked or a seasoned trade whose prints weren't loaded. The run needs to tell those apart from a defect, report what was missing, and value the rest. That's why helixio's errors form a tree you can catch by layer: a missing market input is a different kind of failure from a refused spelling. See value a book and collect what failed and the errors reference.
Vocabularies
Conventions have many spellings in the wild: MODFOLLOWING, Modified Following,
ModFollowing. A library that accepted any string and guessed would turn a typo into a silently
different trade. A closed vocabulary turns it into a refusal that lists the accepted values. See
the vocabularies reference.
See also
- Source:
crates/helixrs/src/statics/ - Related pages:
- Load the desk sheets, if you copy an example that uses them.
- Rates conventions, the generated catalog of every index and template convention.
- The mental model, for where dates and conventions sit in the chain from quotes to values.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.