Skip to main content

rustyqlib/core/
calendar.rs

1//! Holiday calendars, business-day conventions and schedule generation.
2//!
3//! Holidays are computed from rules (Easter algorithm, nth-weekday-of-month,
4//! observance shifts), not stored as date lists, so any year works. The
5//! named calendars cover the markets an equity derivatives book usually
6//! needs — weekends-only, TARGET (EUR), NYSE (US equities), UK bank
7//! holidays — and [`Calendar::Custom`] takes an explicit holiday list for
8//! anything else. One-off closures (mourning days, exchange incidents) are
9//! not modelled; add them through `Custom`.
10//!
11//! ```
12//! use chrono::NaiveDate;
13//! use rustyqlib::core::calendar::{BusinessDayConvention, Calendar};
14//!
15//! let nyse = Calendar::UsNyse;
16//! let good_friday = NaiveDate::from_ymd_opt(2026, 4, 3).unwrap();
17//! assert!(!nyse.is_business_day(good_friday));
18//! // settle T+2 over a holiday weekend
19//! let trade = NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
20//! assert_eq!(
21//!     nyse.add_business_days(trade, 2),
22//!     NaiveDate::from_ymd_opt(2026, 4, 6).unwrap()
23//! );
24//! let _ = BusinessDayConvention::ModifiedFollowing;
25//! ```
26
27use chrono::{Datelike, Duration, Months, NaiveDate, Weekday};
28use serde::{Deserialize, Serialize};
29use std::collections::BTreeSet;
30
31use crate::core::errors::RustyQLibError;
32
33// ── Business-day conventions ────────────────────────────────────────────
34
35/// How a date falling on a non-business day is adjusted.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum BusinessDayConvention {
39    /// Leave the date as it is.
40    Unadjusted,
41    /// Move to the next business day.
42    #[default]
43    Following,
44    /// Move to the next business day unless that crosses into the next
45    /// calendar month, in which case move to the preceding business day.
46    ModifiedFollowing,
47    /// Move to the previous business day.
48    Preceding,
49    /// Move to the previous business day unless that crosses into the
50    /// previous calendar month, in which case move to the following one.
51    ModifiedPreceding,
52}
53
54// ── Calendars ───────────────────────────────────────────────────────────
55
56/// A holiday calendar: weekends plus market-specific holidays.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "type", rename_all = "snake_case")]
59pub enum Calendar {
60    /// Saturdays and Sundays only.
61    WeekendsOnly,
62    /// TARGET (Trans-European Automated Real-time Gross settlement):
63    /// the euro-area settlement calendar.
64    Target,
65    /// New York Stock Exchange trading calendar.
66    UsNyse,
67    /// England-and-Wales bank holidays (regular rules; one-off royal or
68    /// millennium holidays are not modelled).
69    UkSettlement,
70    /// Weekends plus an explicit list of extra holidays.
71    Custom { holidays: BTreeSet<NaiveDate> },
72}
73
74impl Calendar {
75    /// Saturday or Sunday.
76    pub fn is_weekend(date: NaiveDate) -> bool {
77        matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
78    }
79
80    /// A non-weekend holiday on this calendar.
81    pub fn is_holiday(&self, date: NaiveDate) -> bool {
82        if Self::is_weekend(date) {
83            return false;
84        }
85        match self {
86            Calendar::WeekendsOnly => false,
87            Calendar::Target => is_target_holiday(date),
88            Calendar::UsNyse => is_nyse_holiday(date),
89            Calendar::UkSettlement => is_uk_holiday(date),
90            Calendar::Custom { holidays } => holidays.contains(&date),
91        }
92    }
93
94    /// Neither a weekend nor a holiday.
95    pub fn is_business_day(&self, date: NaiveDate) -> bool {
96        !Self::is_weekend(date) && !self.is_holiday(date)
97    }
98
99    /// Adjust a date to a business day under the given convention.
100    pub fn adjust(&self, date: NaiveDate, convention: BusinessDayConvention) -> NaiveDate {
101        use BusinessDayConvention::*;
102        if convention == Unadjusted || self.is_business_day(date) {
103            return date;
104        }
105        match convention {
106            Following => self.next_business_day(date),
107            Preceding => self.previous_business_day(date),
108            ModifiedFollowing => {
109                let next = self.next_business_day(date);
110                if next.month() != date.month() {
111                    self.previous_business_day(date)
112                } else {
113                    next
114                }
115            }
116            ModifiedPreceding => {
117                let prev = self.previous_business_day(date);
118                if prev.month() != date.month() {
119                    self.next_business_day(date)
120                } else {
121                    prev
122                }
123            }
124            Unadjusted => date,
125        }
126    }
127
128    /// The first business day strictly after weekends/holidays from `date`
129    /// (returns `date` itself when it is already a business day).
130    fn next_business_day(&self, mut date: NaiveDate) -> NaiveDate {
131        while !self.is_business_day(date) {
132            date += Duration::days(1);
133        }
134        date
135    }
136
137    fn previous_business_day(&self, mut date: NaiveDate) -> NaiveDate {
138        while !self.is_business_day(date) {
139            date -= Duration::days(1);
140        }
141        date
142    }
143
144    /// Move `n` business days (n may be negative). `n = 0` returns the
145    /// date unchanged, even on a holiday. The classic settlement-lag
146    /// helper: `calendar.add_business_days(trade_date, 2)` is T+2.
147    pub fn add_business_days(&self, date: NaiveDate, n: i64) -> NaiveDate {
148        let mut d = date;
149        let step = if n >= 0 { 1 } else { -1 };
150        let mut remaining = n.abs();
151        while remaining > 0 {
152            d += Duration::days(step);
153            if self.is_business_day(d) {
154                remaining -= 1;
155            }
156        }
157        d
158    }
159
160    /// Advance by a calendar period and adjust the result. Month/year
161    /// arithmetic clamps to the end of month (Jan 31 + 1M = Feb 28/29)
162    /// before adjustment.
163    pub fn advance(
164        &self,
165        date: NaiveDate,
166        period: Period,
167        convention: BusinessDayConvention,
168    ) -> NaiveDate {
169        let moved = match period {
170            Period::Days(n) => return self.add_business_days(date, n),
171            Period::Weeks(n) => date + Duration::weeks(n),
172            Period::Months(n) => add_months_signed(date, n),
173            Period::Years(n) => add_months_signed(date, 12 * n),
174        };
175        self.adjust(moved, convention)
176    }
177
178    /// Business days in the half-open interval `(from, to]`; negative when
179    /// `to < from`.
180    pub fn business_days_between(&self, from: NaiveDate, to: NaiveDate) -> i64 {
181        if to < from {
182            return -self.business_days_between(to, from);
183        }
184        let mut count = 0;
185        let mut d = from;
186        while d < to {
187            d += Duration::days(1);
188            if self.is_business_day(d) {
189                count += 1;
190            }
191        }
192        count
193    }
194}
195
196/// A calendar period for [`Calendar::advance`]. `Days` counts **business**
197/// days; the others move in calendar time and then adjust.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum Period {
200    Days(i64),
201    Weeks(i64),
202    Months(i32),
203    Years(i32),
204}
205
206fn add_months_signed(date: NaiveDate, n: i32) -> NaiveDate {
207    if n >= 0 {
208        date + Months::new(n as u32)
209    } else {
210        date - Months::new((-n) as u32)
211    }
212}
213
214// ── Holiday rules ───────────────────────────────────────────────────────
215
216/// Easter Sunday by the Meeus/Jones/Butcher Gregorian algorithm.
217pub fn easter_sunday(year: i32) -> NaiveDate {
218    let a = year % 19;
219    let b = year / 100;
220    let c = year % 100;
221    let d = b / 4;
222    let e = b % 4;
223    let f = (b + 8) / 25;
224    let g = (b - f + 1) / 3;
225    let h = (19 * a + b - d - g + 15) % 30;
226    let i = c / 4;
227    let k = c % 4;
228    let l = (32 + 2 * e + 2 * i - h - k) % 7;
229    let m = (a + 11 * h + 22 * l) / 451;
230    let month = (h + l - 7 * m + 114) / 31;
231    let day = ((h + l - 7 * m + 114) % 31) + 1;
232    NaiveDate::from_ymd_opt(year, month as u32, day as u32).expect("valid Easter date")
233}
234
235/// The `n`-th (1-based) given weekday of a month.
236fn nth_weekday(year: i32, month: u32, weekday: Weekday, n: u32) -> NaiveDate {
237    let first = NaiveDate::from_ymd_opt(year, month, 1).expect("valid month start");
238    let offset = (7 + weekday.num_days_from_monday() as i64
239        - first.weekday().num_days_from_monday() as i64)
240        % 7;
241    first + Duration::days(offset + 7 * (n as i64 - 1))
242}
243
244/// The last given weekday of a month.
245fn last_weekday(year: i32, month: u32, weekday: Weekday) -> NaiveDate {
246    let first_next = if month == 12 {
247        NaiveDate::from_ymd_opt(year + 1, 1, 1)
248    } else {
249        NaiveDate::from_ymd_opt(year, month + 1, 1)
250    }
251    .expect("valid month start");
252    let last = first_next - Duration::days(1);
253    let offset = (7 + last.weekday().num_days_from_monday() as i64
254        - weekday.num_days_from_monday() as i64)
255        % 7;
256    last - Duration::days(offset)
257}
258
259/// TARGET holidays: New Year, Good Friday, Easter Monday, Labour Day,
260/// Christmas, Boxing Day (Dec 26, since 2000).
261fn is_target_holiday(date: NaiveDate) -> bool {
262    let (y, m, d) = (date.year(), date.month(), date.day());
263    if (m == 1 && d == 1) || (m == 5 && d == 1) || (m == 12 && d == 25) {
264        return true;
265    }
266    if m == 12 && d == 26 && y >= 2000 {
267        return true;
268    }
269    let easter = easter_sunday(y);
270    date == easter - Duration::days(2) || date == easter + Duration::days(1)
271}
272
273/// NYSE trading holidays (regular rules): New Year (Sunday observed on
274/// Monday), Martin Luther King Jr. Day (since 1998), Washington's
275/// Birthday, Good Friday, Memorial Day, Juneteenth (since 2022),
276/// Independence Day, Labor Day, Thanksgiving, Christmas. Saturday
277/// holidays for New Year are not observed (exchange convention);
278/// Saturday Independence Day / Christmas are observed on Friday.
279fn is_nyse_holiday(date: NaiveDate) -> bool {
280    let (y, m, d) = (date.year(), date.month(), date.day());
281    let wd = date.weekday();
282
283    // New Year's Day: Jan 1, or Jan 2 when the 1st is a Sunday
284    if m == 1 && (d == 1 || (d == 2 && wd == Weekday::Mon)) {
285        return true;
286    }
287    // MLK Day: third Monday of January, since 1998
288    if y >= 1998 && m == 1 && date == nth_weekday(y, 1, Weekday::Mon, 3) {
289        return true;
290    }
291    // Washington's Birthday: third Monday of February
292    if m == 2 && date == nth_weekday(y, 2, Weekday::Mon, 3) {
293        return true;
294    }
295    // Good Friday
296    if date == easter_sunday(y) - Duration::days(2) {
297        return true;
298    }
299    // Memorial Day: last Monday of May
300    if m == 5 && date == last_weekday(y, 5, Weekday::Mon) {
301        return true;
302    }
303    // Juneteenth: June 19 (Fri if Sat, Mon if Sun), since 2022
304    if y >= 2022 && observed_on(date, 6, 19) {
305        return true;
306    }
307    // Independence Day: July 4 (Fri if Sat, Mon if Sun)
308    if observed_on(date, 7, 4) {
309        return true;
310    }
311    // Labor Day: first Monday of September
312    if m == 9 && date == nth_weekday(y, 9, Weekday::Mon, 1) {
313        return true;
314    }
315    // Thanksgiving: fourth Thursday of November
316    if m == 11 && date == nth_weekday(y, 11, Weekday::Thu, 4) {
317        return true;
318    }
319    // Christmas: Dec 25 (Fri if Sat, Mon if Sun)
320    if observed_on(date, 12, 25) {
321        return true;
322    }
323    false
324}
325
326/// Whether `date` is the observed weekday for the fixed holiday
327/// `month`/`day`: the day itself on a weekday, the preceding Friday when
328/// it falls on Saturday, the following Monday when it falls on Sunday.
329fn observed_on(date: NaiveDate, month: u32, day: u32) -> bool {
330    let holiday = match NaiveDate::from_ymd_opt(date.year(), month, day) {
331        Some(d) => d,
332        None => return false,
333    };
334    let observed = match holiday.weekday() {
335        Weekday::Sat => holiday - Duration::days(1),
336        Weekday::Sun => holiday + Duration::days(1),
337        _ => holiday,
338    };
339    date == observed
340}
341
342/// England-and-Wales bank holidays (regular rules): New Year (observed),
343/// Good Friday, Easter Monday, early-May bank holiday, spring bank
344/// holiday, summer bank holiday, Christmas and Boxing Day (both observed
345/// past the weekend).
346fn is_uk_holiday(date: NaiveDate) -> bool {
347    let (y, m, d) = (date.year(), date.month(), date.day());
348    let wd = date.weekday();
349
350    // New Year's Day, observed on Monday when Jan 1 is a weekend
351    if m == 1
352        && (d == 1
353            || (d == 2 && wd == Weekday::Mon)
354            || (d == 3 && wd == Weekday::Mon))
355    {
356        return true;
357    }
358    let easter = easter_sunday(y);
359    if date == easter - Duration::days(2) || date == easter + Duration::days(1) {
360        return true;
361    }
362    // early-May bank holiday: first Monday of May
363    if m == 5 && date == nth_weekday(y, 5, Weekday::Mon, 1) {
364        return true;
365    }
366    // spring bank holiday: last Monday of May
367    if m == 5 && date == last_weekday(y, 5, Weekday::Mon) {
368        return true;
369    }
370    // summer bank holiday: last Monday of August
371    if m == 8 && date == last_weekday(y, 8, Weekday::Mon) {
372        return true;
373    }
374    // Christmas and Boxing Day: Dec 25/26, shifted past a weekend so two
375    // weekdays are always taken (25th Sat -> 27th/28th, 25th Sun -> 27th/28th, ...)
376    if m == 12 {
377        let christmas = NaiveDate::from_ymd_opt(y, 12, 25).expect("valid date");
378        let (obs_christmas, obs_boxing) = match christmas.weekday() {
379            Weekday::Fri => (25, 28), // Boxing Day Sat -> Mon 28
380            Weekday::Sat => (27, 28), // Mon 27 and Tue 28
381            Weekday::Sun => (27, 28), // Boxing Mon 26? convention: Mon 26 is Boxing observed, Tue 27 Christmas observed; use 26/27
382            _ => (25, 26),
383        };
384        // Sunday Christmas: Boxing Day (Mon 26) and substitute Christmas (Tue 27)
385        if christmas.weekday() == Weekday::Sun {
386            return d == 26 || d == 27;
387        }
388        return d == obs_christmas || d == obs_boxing;
389    }
390    false
391}
392
393// ── Schedules ───────────────────────────────────────────────────────────
394
395/// Direction of periodic date generation. Backward (from termination) is
396/// the market default: the stub, if any, lands at the front.
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
398#[serde(rename_all = "snake_case")]
399pub enum DateGeneration {
400    #[default]
401    Backward,
402    Forward,
403}
404
405/// A periodic date schedule: unadjusted anchor dates rolled from a period,
406/// then business-day adjusted. Used for autocallable observation dates,
407/// coupon schedules and averaging fixings.
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct Schedule {
410    /// Adjusted, strictly increasing dates, ending at the (adjusted)
411    /// termination date. The effective date itself is not included.
412    pub dates: Vec<NaiveDate>,
413}
414
415impl Schedule {
416    /// Generate a schedule from `effective` (exclusive) to `termination`
417    /// (inclusive) every `months` months.
418    ///
419    /// Backward generation rolls anchor dates back from termination, so a
420    /// remainder shorter than a full period becomes a short **front** stub;
421    /// forward generation rolls from the effective date and leaves a short
422    /// **back** stub. Anchor dates are adjusted with `convention`;
423    /// duplicates after adjustment collapse.
424    pub fn generate(
425        effective: NaiveDate,
426        termination: NaiveDate,
427        months: u32,
428        calendar: &Calendar,
429        convention: BusinessDayConvention,
430        generation: DateGeneration,
431    ) -> Result<Schedule, RustyQLibError> {
432        if months == 0 {
433            return Err(RustyQLibError::invalid_input(
434                "schedule",
435                "the period must be at least one month",
436            ));
437        }
438        if termination <= effective {
439            return Err(RustyQLibError::invalid_input(
440                "schedule",
441                format!("termination {termination} must be after the effective date {effective}"),
442            ));
443        }
444
445        let mut anchors: Vec<NaiveDate> = Vec::new();
446        match generation {
447            DateGeneration::Backward => {
448                let mut k = 0u32;
449                loop {
450                    k += months;
451                    let date = termination - Months::new(k);
452                    if date <= effective {
453                        break;
454                    }
455                    anchors.push(date);
456                }
457                anchors.reverse();
458                anchors.push(termination);
459            }
460            DateGeneration::Forward => {
461                let mut k = 0u32;
462                loop {
463                    k += months;
464                    let date = effective + Months::new(k);
465                    if date >= termination {
466                        break;
467                    }
468                    anchors.push(date);
469                }
470                anchors.push(termination);
471            }
472        }
473
474        let mut dates: Vec<NaiveDate> = anchors
475            .into_iter()
476            .map(|d| calendar.adjust(d, convention))
477            .collect();
478        dates.dedup();
479        // adjustment must not push a date past the adjusted termination
480        let last = *dates.last().expect("schedule has at least one date");
481        dates.retain(|d| *d <= last);
482        dates.dedup();
483
484        Ok(Schedule { dates })
485    }
486
487    /// Year fractions of every schedule date from `valuation` under the
488    /// given day count.
489    pub fn year_fractions(
490        &self,
491        valuation: NaiveDate,
492        day_count: crate::core::daycount::DayCountConvention,
493    ) -> Vec<f64> {
494        self.dates
495            .iter()
496            .map(|d| day_count.year_fraction(valuation, *d))
497            .collect()
498    }
499
500    pub fn len(&self) -> usize {
501        self.dates.len()
502    }
503
504    pub fn is_empty(&self) -> bool {
505        self.dates.is_empty()
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    fn d(y: i32, m: u32, day: u32) -> NaiveDate {
514        NaiveDate::from_ymd_opt(y, m, day).unwrap()
515    }
516
517    #[test]
518    fn easter_matches_known_years() {
519        assert_eq!(easter_sunday(2024), d(2024, 3, 31));
520        assert_eq!(easter_sunday(2025), d(2025, 4, 20));
521        assert_eq!(easter_sunday(2026), d(2026, 4, 5));
522        assert_eq!(easter_sunday(2027), d(2027, 3, 28));
523        assert_eq!(easter_sunday(2030), d(2030, 4, 21));
524    }
525
526    #[test]
527    fn nyse_holidays_2026() {
528        let c = Calendar::UsNyse;
529        for holiday in [
530            d(2026, 1, 1),   // New Year
531            d(2026, 1, 19),  // MLK (3rd Monday)
532            d(2026, 2, 16),  // Washington
533            d(2026, 4, 3),   // Good Friday
534            d(2026, 5, 25),  // Memorial Day
535            d(2026, 6, 19),  // Juneteenth (Friday)
536            d(2026, 7, 3),   // Independence Day observed (July 4 is Saturday)
537            d(2026, 9, 7),   // Labor Day
538            d(2026, 11, 26), // Thanksgiving
539            d(2026, 12, 25), // Christmas (Friday)
540        ] {
541            assert!(!c.is_business_day(holiday), "{holiday} must be a holiday");
542        }
543        // regular trading days around them
544        for business in [d(2026, 1, 2), d(2026, 4, 6), d(2026, 7, 6), d(2026, 11, 27)] {
545            assert!(c.is_business_day(business), "{business} must be a business day");
546        }
547    }
548
549    #[test]
550    fn nyse_sunday_new_year_observed_on_monday() {
551        // Jan 1 2023 was a Sunday -> observed Monday Jan 2
552        assert!(!Calendar::UsNyse.is_business_day(d(2023, 1, 2)));
553        // Jan 1 2022 was a Saturday -> NOT observed (NYSE convention)
554        assert!(Calendar::UsNyse.is_business_day(d(2021, 12, 31)));
555    }
556
557    #[test]
558    fn target_holidays() {
559        let c = Calendar::Target;
560        for holiday in [
561            d(2026, 1, 1),
562            d(2026, 4, 3),  // Good Friday
563            d(2026, 4, 6),  // Easter Monday
564            d(2026, 5, 1),  // Labour Day
565            d(2026, 12, 25),
566            // Dec 26 2026 is a Saturday: weekend, not counted as holiday
567            d(2025, 12, 26),
568        ] {
569            assert!(!c.is_business_day(holiday), "{holiday} must be a holiday");
570        }
571        assert!(c.is_business_day(d(2026, 5, 25)), "no TARGET holiday on UK spring bank");
572    }
573
574    #[test]
575    fn uk_holidays_2026() {
576        let c = Calendar::UkSettlement;
577        for holiday in [
578            d(2026, 1, 1),
579            d(2026, 4, 3),   // Good Friday
580            d(2026, 4, 6),   // Easter Monday
581            d(2026, 5, 4),   // early-May bank holiday
582            d(2026, 5, 25),  // spring bank holiday
583            d(2026, 8, 31),  // summer bank holiday
584            d(2026, 12, 25),
585            d(2026, 12, 28), // Boxing Day observed (26th is a Saturday)
586        ] {
587            assert!(!c.is_business_day(holiday), "{holiday} must be a holiday");
588        }
589        // Christmas 2021: Sat 25th, Sun 26th -> observed Mon 27, Tue 28
590        assert!(!c.is_business_day(d(2021, 12, 27)));
591        assert!(!c.is_business_day(d(2021, 12, 28)));
592        assert!(c.is_business_day(d(2021, 12, 29)));
593    }
594
595    #[test]
596    fn adjust_conventions() {
597        use BusinessDayConvention::*;
598        let c = Calendar::WeekendsOnly;
599        let saturday = d(2026, 5, 30);
600        assert_eq!(c.adjust(saturday, Unadjusted), saturday);
601        assert_eq!(c.adjust(saturday, Following), d(2026, 6, 1));
602        assert_eq!(c.adjust(saturday, Preceding), d(2026, 5, 29));
603        // month-end rollover: Sunday May 31 -> Following crosses into June,
604        // ModifiedFollowing rolls back to Friday May 29
605        let sunday_eom = d(2026, 5, 31);
606        assert_eq!(c.adjust(sunday_eom, Following), d(2026, 6, 1));
607        assert_eq!(c.adjust(sunday_eom, ModifiedFollowing), d(2026, 5, 29));
608        // month-start mirror: ModifiedPreceding on Sunday Nov 1 rolls forward
609        let sunday_som = d(2026, 11, 1);
610        assert_eq!(c.adjust(sunday_som, Preceding), d(2026, 10, 30));
611        assert_eq!(c.adjust(sunday_som, ModifiedPreceding), d(2026, 11, 2));
612    }
613
614    #[test]
615    fn business_day_arithmetic_and_settlement_lag() {
616        let c = Calendar::UsNyse;
617        // T+2 from Wed Apr 1 2026 over Good Friday (Apr 3): Thu, then Mon
618        assert_eq!(c.add_business_days(d(2026, 4, 1), 2), d(2026, 4, 6));
619        // negative movement
620        assert_eq!(c.add_business_days(d(2026, 4, 6), -1), d(2026, 4, 2));
621        // count over the same stretch
622        assert_eq!(c.business_days_between(d(2026, 4, 1), d(2026, 4, 6)), 2);
623        assert_eq!(c.business_days_between(d(2026, 4, 6), d(2026, 4, 1)), -2);
624    }
625
626    #[test]
627    fn advance_periods_clamp_month_ends() {
628        let c = Calendar::WeekendsOnly;
629        // Jan 31 + 1M clamps to Feb 28 (2026 is not a leap year), a Saturday
630        // in 2026 -> Following moves to Mar 2
631        assert_eq!(
632            c.advance(d(2026, 1, 31), Period::Months(1), BusinessDayConvention::Following),
633            d(2026, 3, 2)
634        );
635        assert_eq!(
636            c.advance(d(2026, 1, 31), Period::Months(1), BusinessDayConvention::ModifiedFollowing),
637            d(2026, 2, 27)
638        );
639        assert_eq!(
640            c.advance(d(2026, 3, 15), Period::Years(1), BusinessDayConvention::Following),
641            d(2027, 3, 15)
642        );
643    }
644
645    #[test]
646    fn custom_calendar_takes_explicit_holidays() {
647        let holidays: BTreeSet<NaiveDate> = [d(2026, 3, 17)].into();
648        let c = Calendar::Custom { holidays };
649        assert!(!c.is_business_day(d(2026, 3, 17)));
650        assert!(c.is_business_day(d(2026, 3, 18)));
651    }
652
653    #[test]
654    fn quarterly_backward_schedule_with_front_stub() {
655        // 10 months of quarterly observations, backward: stub at the front
656        let s = Schedule::generate(
657            d(2026, 1, 15),
658            d(2026, 11, 16),
659            3,
660            &Calendar::WeekendsOnly,
661            BusinessDayConvention::Following,
662            DateGeneration::Backward,
663        )
664        .unwrap();
665        assert_eq!(
666            s.dates,
667            vec![d(2026, 2, 16), d(2026, 5, 18), d(2026, 8, 17), d(2026, 11, 16)]
668        );
669        // (Feb 16 anchor = Nov 16 - 9M; Feb 16 2026 is a Monday. May 16 is
670        // a Saturday -> May 18; Aug 16 is a Sunday -> Aug 17.)
671    }
672
673    #[test]
674    fn forward_schedule_puts_stub_at_the_back() {
675        let s = Schedule::generate(
676            d(2026, 1, 15),
677            d(2026, 11, 16),
678            3,
679            &Calendar::WeekendsOnly,
680            BusinessDayConvention::Following,
681            DateGeneration::Forward,
682        )
683        .unwrap();
684        assert_eq!(
685            s.dates,
686            vec![d(2026, 4, 15), d(2026, 7, 15), d(2026, 10, 15), d(2026, 11, 16)]
687        );
688    }
689
690    #[test]
691    fn schedule_dates_avoid_holidays() {
692        // monthly observations across Good Friday 2026 (Apr 3) on NYSE
693        let s = Schedule::generate(
694            d(2026, 1, 5),
695            d(2026, 6, 3),
696            1,
697            &Calendar::UsNyse,
698            BusinessDayConvention::Following,
699            DateGeneration::Backward,
700        )
701        .unwrap();
702        for date in &s.dates {
703            assert!(
704                Calendar::UsNyse.is_business_day(*date),
705                "{date} is not a business day"
706            );
707        }
708        // Apr 3 anchor (Jun 3 - 2M) is Good Friday -> moved to Apr 6
709        assert!(s.dates.contains(&d(2026, 4, 6)));
710    }
711
712    #[test]
713    fn schedule_rejects_bad_inputs() {
714        let r = Schedule::generate(
715            d(2026, 5, 1),
716            d(2026, 1, 1),
717            3,
718            &Calendar::WeekendsOnly,
719            BusinessDayConvention::Following,
720            DateGeneration::Backward,
721        );
722        assert!(r.is_err());
723        let r = Schedule::generate(
724            d(2026, 1, 1),
725            d(2026, 5, 1),
726            0,
727            &Calendar::WeekendsOnly,
728            BusinessDayConvention::Following,
729            DateGeneration::Backward,
730        );
731        assert!(r.is_err());
732    }
733}