Esc

    The API is under testing and may change before release.

    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.

    Rust
    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(())
    }
    Python
    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.

    Rust
    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(())
    }
    Python
    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.

    Rust
    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(())
    }
    Python
    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.

    Rust
    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(())
    }
    Python
    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.

    Rust
    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(())
    }
    Python
    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.