Skip to main content

yield_curves/
calendar.rs

1//! Holiday calendars, business-day adjustment, and BUS/252 — Phase 0.
2//!
3//! Curve construction needs to know which days are *business days*: coupon
4//! dates roll off non-business days, and the Brazilian BUS/252 day count
5//! divides business days by 252. This module provides a [`Calendar`] trait with
6//! the adjustment machinery as default methods, concrete market calendars, a
7//! [`JoinCalendar`] for multi-currency curves, and the BUS/252 year fraction.
8//!
9//! # Calendars
10//!
11//! - [`Brazil`] — ANBIMA national financial calendar (the basis for BUS/252 DI
12//!   and NTN-B curves). Fixed national holidays plus Easter-derived Carnival,
13//!   Good Friday, and Corpus Christi.
14//! - [`Target2`] — Eurosystem TARGET2 settlement calendar.
15//! - [`WeekendsOnly`] — Saturdays and Sundays only; a dependency-free baseline.
16//!
17//! `Brazil` follows the ANBIMA national list, **not** B3 exchange-only
18//! closures (e.g. Dec 24 / Dec 31), because curve work uses the ANBIMA basis.
19//!
20//! # Business-day conventions
21//!
22//! [`BusinessDayConvention`] adjusts a date that lands on a non-business day:
23//! `Following`, `ModifiedFollowing`, `Preceding`, `ModifiedPreceding`, and
24//! `Unadjusted`. The modified variants avoid crossing a month boundary.
25//!
26//! # Example
27//!
28//! ```
29//! use yield_curves::date::Date;
30//! use yield_curves::calendar::{Brazil, Calendar, BusinessDayConvention};
31//!
32//! let cal = Brazil;
33//! // 2025-04-21 is Tiradentes (a national holiday).
34//! assert!(!cal.is_business_day(Date::new(2025, 4, 21).unwrap()));
35//! // Roll a Saturday forward to the next business day.
36//! let sat = Date::new(2025, 5, 31).unwrap();
37//! let adj = cal.adjust(sat, BusinessDayConvention::Following);
38//! assert!(cal.is_business_day(adj));
39//! ```
40
41use crate::date::Date;
42
43/// How to move a date that falls on a non-business day onto a business day.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45#[non_exhaustive]
46pub enum BusinessDayConvention {
47    /// Leave the date unchanged.
48    Unadjusted,
49    /// Roll forward to the next business day.
50    Following,
51    /// Roll forward, unless that crosses into the next month — then roll back.
52    ModifiedFollowing,
53    /// Roll backward to the previous business day.
54    Preceding,
55    /// Roll backward, unless that crosses into the previous month — then forward.
56    ModifiedPreceding,
57}
58
59/// A holiday calendar: which dates are business days, plus date arithmetic that
60/// respects them.
61///
62/// Implementors only need [`name`](Calendar::name) and
63/// [`is_business_day`](Calendar::is_business_day); the rest are provided. The
64/// trait is object-safe, so calendars compose via [`JoinCalendar`].
65pub trait Calendar {
66    /// Stable identifier, e.g. `"Brazil"`, `"TARGET2"`.
67    fn name(&self) -> &'static str;
68
69    /// True if `date` is a trading/settlement business day for this market
70    /// (neither a weekend nor a holiday).
71    fn is_business_day(&self, date: Date) -> bool;
72
73    /// True if `date` is a non-weekend holiday. Weekends are not holidays.
74    fn is_holiday(&self, date: Date) -> bool {
75        !date.is_weekend() && !self.is_business_day(date)
76    }
77
78    /// Adjusts `date` onto a business day per `conv`. A date that is already a
79    /// business day is returned unchanged.
80    fn adjust(&self, date: Date, conv: BusinessDayConvention) -> Date {
81        match conv {
82            BusinessDayConvention::Unadjusted => date,
83            BusinessDayConvention::Following => self.roll(date, 1),
84            BusinessDayConvention::Preceding => self.roll(date, -1),
85            BusinessDayConvention::ModifiedFollowing => {
86                let rolled = self.roll(date, 1);
87                if rolled.month() != date.month() {
88                    self.roll(date, -1)
89                } else {
90                    rolled
91                }
92            }
93            BusinessDayConvention::ModifiedPreceding => {
94                let rolled = self.roll(date, -1);
95                if rolled.month() != date.month() {
96                    self.roll(date, 1)
97                } else {
98                    rolled
99                }
100            }
101        }
102    }
103
104    /// Advances `date` by `n` business days (negative `n` moves backward).
105    /// `n == 0` returns `date` unchanged, even if it is not a business day.
106    fn advance(&self, date: Date, n: i32) -> Date {
107        if n == 0 {
108            return date;
109        }
110        let step = if n > 0 { 1 } else { -1 };
111        let mut remaining = n.abs();
112        let mut d = date;
113        while remaining > 0 {
114            d = d.add_days(step);
115            if self.is_business_day(d) {
116                remaining -= 1;
117            }
118        }
119        d
120    }
121
122    /// Number of business days in the half-open interval `[start, end)` —
123    /// includes `start`, excludes `end` (the QuantLib default). Returns the
124    /// negative count when `end < start`, and `0` when the dates are equal.
125    fn business_days_between(&self, start: Date, end: Date) -> i32 {
126        if start == end {
127            return 0;
128        }
129        if end < start {
130            return -self.business_days_between(end, start);
131        }
132        let mut count = 0;
133        let mut d = start;
134        while d < end {
135            if self.is_business_day(d) {
136                count += 1;
137            }
138            d = d.add_days(1);
139        }
140        count
141    }
142
143    /// BUS/252 year fraction: business days in `[start, end)` divided by 252.
144    ///
145    /// This is the Brazilian fixed-income convention. It lives on the calendar
146    /// (not [`crate::daycount::DayCount`]) because it needs the holiday set.
147    fn year_fraction_252(&self, start: Date, end: Date) -> f64 {
148        f64::from(self.business_days_between(start, end)) / 252.0
149    }
150
151    /// Internal: roll `date` in `step` direction until it is a business day.
152    /// Returns `date` unchanged if it is already a business day. Not intended
153    /// for direct use — call [`adjust`](Calendar::adjust) instead.
154    #[doc(hidden)]
155    fn roll(&self, date: Date, step: i32) -> Date {
156        let mut d = date;
157        while !self.is_business_day(d) {
158            d = d.add_days(step);
159        }
160        d
161    }
162}
163
164/// Gregorian Easter Sunday for `year` (Meeus/Jones/Butcher computus).
165///
166/// Carnival, Good Friday, Corpus Christi, and Easter Monday are all fixed
167/// offsets from this date.
168#[must_use]
169pub fn easter(year: i32) -> Date {
170    let a = year % 19;
171    let b = year / 100;
172    let c = year % 100;
173    let d = b / 4;
174    let e = b % 4;
175    let f = (b + 8) / 25;
176    let g = (b - f + 1) / 3;
177    let h = (19 * a + b - d - g + 15) % 30;
178    let i = c / 4;
179    let k = c % 4;
180    let l = (32 + 2 * e + 2 * i - h - k) % 7;
181    let m = (a + 11 * h + 22 * l) / 451;
182    let month = (h + l - 7 * m + 114) / 31; // 3 = March, 4 = April
183    let day = ((h + l - 7 * m + 114) % 31) + 1;
184    Date::new(year, month as u32, day as u32).expect("computus yields a valid date")
185}
186
187/// Saturdays and Sundays are non-business; no named holidays.
188#[derive(Debug, Clone, Copy, Default)]
189pub struct WeekendsOnly;
190
191impl Calendar for WeekendsOnly {
192    fn name(&self) -> &'static str {
193        "WeekendsOnly"
194    }
195    fn is_business_day(&self, date: Date) -> bool {
196        !date.is_weekend()
197    }
198}
199
200/// ANBIMA national financial calendar for Brazil — the BUS/252 basis.
201///
202/// Holidays: New Year (Jan 1), Tiradentes (Apr 21), Labour (May 1),
203/// Independence (Sep 7), Our Lady of Aparecida (Oct 12), All Souls (Nov 2),
204/// Republic (Nov 15), Black Awareness (Nov 20, national from 2024), Christmas
205/// (Dec 25); plus Carnival Monday/Tuesday, Good Friday, and Corpus Christi
206/// derived from [`easter`].
207#[derive(Debug, Clone, Copy, Default)]
208pub struct Brazil;
209
210impl Brazil {
211    fn is_named_holiday(date: Date) -> bool {
212        let (y, m, d) = date.ymd();
213        if matches!(
214            (m, d),
215            (1, 1) | (4, 21) | (5, 1) | (9, 7) | (10, 12) | (11, 2) | (11, 15) | (12, 25)
216        ) {
217            return true;
218        }
219        // Black Awareness Day became a national holiday in 2024 (Law 14.759/2023).
220        if m == 11 && d == 20 && y >= 2024 {
221            return true;
222        }
223        let easter = easter(y);
224        let s = date.serial();
225        s == easter.add_days(-48).serial() // Carnival Monday
226            || s == easter.add_days(-47).serial() // Carnival Tuesday
227            || s == easter.add_days(-2).serial() // Good Friday
228            || s == easter.add_days(60).serial() // Corpus Christi
229    }
230}
231
232impl Calendar for Brazil {
233    fn name(&self) -> &'static str {
234        "Brazil"
235    }
236    fn is_business_day(&self, date: Date) -> bool {
237        !date.is_weekend() && !Self::is_named_holiday(date)
238    }
239}
240
241/// Eurosystem TARGET2 settlement calendar.
242///
243/// Holidays: New Year (Jan 1), Good Friday, Easter Monday, Labour (May 1),
244/// Christmas (Dec 25), and Dec 26.
245#[derive(Debug, Clone, Copy, Default)]
246pub struct Target2;
247
248impl Target2 {
249    fn is_named_holiday(date: Date) -> bool {
250        let (_, m, d) = date.ymd();
251        if matches!((m, d), (1, 1) | (5, 1) | (12, 25) | (12, 26)) {
252            return true;
253        }
254        let easter = easter(date.year());
255        let s = date.serial();
256        s == easter.add_days(-2).serial() // Good Friday
257            || s == easter.add_days(1).serial() // Easter Monday
258    }
259}
260
261impl Calendar for Target2 {
262    fn name(&self) -> &'static str {
263        "TARGET2"
264    }
265    fn is_business_day(&self, date: Date) -> bool {
266        !date.is_weekend() && !Self::is_named_holiday(date)
267    }
268}
269
270/// How to combine the business days of several calendars.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
272pub enum JoinRule {
273    /// A date is a business day only if it is a business day in **every**
274    /// calendar (the union of holidays). Standard for multi-currency curves.
275    JoinHolidays,
276    /// A date is a business day if it is a business day in **any** calendar
277    /// (the intersection of holidays).
278    JoinBusinessDays,
279}
280
281/// Combines multiple calendars under a [`JoinRule`].
282///
283/// Used for cross-currency curves where a payment must avoid the holidays of
284/// both currencies (`JoinHolidays`).
285pub struct JoinCalendar {
286    calendars: Vec<Box<dyn Calendar>>,
287    rule: JoinRule,
288}
289
290impl JoinCalendar {
291    /// Builds a joint calendar from `calendars` under `rule`.
292    ///
293    /// At least one calendar should be supplied; an empty set degenerates
294    /// (`JoinHolidays` makes every day a business day, `JoinBusinessDays` makes
295    /// none).
296    #[must_use]
297    pub fn new(calendars: Vec<Box<dyn Calendar>>, rule: JoinRule) -> Self {
298        Self { calendars, rule }
299    }
300}
301
302impl Calendar for JoinCalendar {
303    fn name(&self) -> &'static str {
304        "Joint"
305    }
306    fn is_business_day(&self, date: Date) -> bool {
307        match self.rule {
308            JoinRule::JoinHolidays => self.calendars.iter().all(|c| c.is_business_day(date)),
309            JoinRule::JoinBusinessDays => self.calendars.iter().any(|c| c.is_business_day(date)),
310        }
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    fn d(y: i32, m: u32, day: u32) -> Date {
319        Date::new(y, m, day).unwrap()
320    }
321
322    #[test]
323    fn easter_known_values() {
324        assert_eq!(easter(2024), d(2024, 3, 31));
325        assert_eq!(easter(2025), d(2025, 4, 20));
326        assert_eq!(easter(2000), d(2000, 4, 23));
327        assert_eq!(easter(2027), d(2027, 3, 28));
328    }
329
330    #[test]
331    fn weekends_only_marks_weekends() {
332        let cal = WeekendsOnly;
333        assert!(!cal.is_business_day(d(2025, 1, 4))); // Saturday
334        assert!(!cal.is_business_day(d(2025, 1, 5))); // Sunday
335        assert!(cal.is_business_day(d(2025, 1, 6))); // Monday
336        assert!(cal.is_business_day(d(2025, 1, 1))); // New Year is a business day here
337    }
338
339    #[test]
340    fn brazil_fixed_holidays() {
341        let cal = Brazil;
342        assert!(!cal.is_business_day(d(2025, 1, 1))); // New Year (Wed)
343        assert!(!cal.is_business_day(d(2025, 4, 21))); // Tiradentes (Mon)
344        assert!(cal.is_holiday(d(2025, 4, 21)));
345        assert!(cal.is_business_day(d(2025, 1, 2))); // Thursday, ordinary day
346    }
347
348    #[test]
349    fn brazil_moving_holidays_2025() {
350        let cal = Brazil;
351        // Easter 2025 = Apr 20.
352        assert!(!cal.is_business_day(d(2025, 3, 3))); // Carnival Monday
353        assert!(!cal.is_business_day(d(2025, 3, 4))); // Carnival Tuesday
354        assert!(!cal.is_business_day(d(2025, 4, 18))); // Good Friday
355        assert!(!cal.is_business_day(d(2025, 6, 19))); // Corpus Christi
356    }
357
358    #[test]
359    fn brazil_black_awareness_from_2024() {
360        let cal = Brazil;
361        // 2023-11-20 is a Monday and an ordinary business day (pre-national).
362        assert!(cal.is_business_day(d(2023, 11, 20)));
363        // 2024-11-20 is a Wednesday and now a national holiday.
364        assert!(!cal.is_business_day(d(2024, 11, 20)));
365    }
366
367    #[test]
368    fn target2_holidays_2025() {
369        let cal = Target2;
370        assert!(!cal.is_business_day(d(2025, 1, 1)));
371        assert!(!cal.is_business_day(d(2025, 4, 18))); // Good Friday
372        assert!(!cal.is_business_day(d(2025, 4, 21))); // Easter Monday
373        assert!(!cal.is_business_day(d(2025, 5, 1)));
374        assert!(!cal.is_business_day(d(2025, 12, 26)));
375        assert!(cal.is_business_day(d(2025, 12, 24))); // ordinary in TARGET2
376    }
377
378    #[test]
379    fn following_rolls_forward() {
380        let cal = WeekendsOnly;
381        // 2025-01-04 is Saturday → Monday Jan 6.
382        let adj = cal.adjust(d(2025, 1, 4), BusinessDayConvention::Following);
383        assert_eq!(adj, d(2025, 1, 6));
384    }
385
386    #[test]
387    fn preceding_rolls_backward() {
388        let cal = WeekendsOnly;
389        // 2025-01-05 is Sunday → Friday Jan 3.
390        let adj = cal.adjust(d(2025, 1, 5), BusinessDayConvention::Preceding);
391        assert_eq!(adj, d(2025, 1, 3));
392    }
393
394    #[test]
395    fn modified_following_stays_in_month() {
396        let cal = WeekendsOnly;
397        // 2025-05-31 is Saturday. Following → Jun 2 (next month) →
398        // ModifiedFollowing rolls back to Fri May 30.
399        let saturday = d(2025, 5, 31);
400        assert_eq!(
401            cal.adjust(saturday, BusinessDayConvention::Following),
402            d(2025, 6, 2)
403        );
404        let mf = cal.adjust(saturday, BusinessDayConvention::ModifiedFollowing);
405        assert_eq!(mf, d(2025, 5, 30));
406        assert_eq!(mf.month(), 5);
407    }
408
409    #[test]
410    fn modified_preceding_stays_in_month() {
411        let cal = WeekendsOnly;
412        // 2025-06-01 is Sunday. Preceding → May 30 (prev month) →
413        // ModifiedPreceding rolls forward to Mon Jun 2.
414        let sunday = d(2025, 6, 1);
415        assert_eq!(
416            cal.adjust(sunday, BusinessDayConvention::Preceding),
417            d(2025, 5, 30)
418        );
419        let mp = cal.adjust(sunday, BusinessDayConvention::ModifiedPreceding);
420        assert_eq!(mp, d(2025, 6, 2));
421        assert_eq!(mp.month(), 6);
422    }
423
424    #[test]
425    fn unadjusted_is_identity() {
426        let cal = Brazil;
427        let holiday = d(2025, 1, 1);
428        assert_eq!(
429            cal.adjust(holiday, BusinessDayConvention::Unadjusted),
430            holiday
431        );
432    }
433
434    #[test]
435    fn adjust_business_day_unchanged() {
436        let cal = WeekendsOnly;
437        let wed = d(2025, 1, 8);
438        assert_eq!(cal.adjust(wed, BusinessDayConvention::Following), wed);
439        assert_eq!(cal.adjust(wed, BusinessDayConvention::Preceding), wed);
440    }
441
442    #[test]
443    fn advance_business_days() {
444        let cal = WeekendsOnly;
445        // Friday Jan 3 2025 + 1 business day = Monday Jan 6.
446        assert_eq!(cal.advance(d(2025, 1, 3), 1), d(2025, 1, 6));
447        // Monday Jan 6 - 1 business day = Friday Jan 3.
448        assert_eq!(cal.advance(d(2025, 1, 6), -1), d(2025, 1, 3));
449        // Zero is identity even on a weekend.
450        assert_eq!(cal.advance(d(2025, 1, 4), 0), d(2025, 1, 4));
451    }
452
453    #[test]
454    fn business_days_between_half_open() {
455        let cal = WeekendsOnly;
456        // [Mon Jan 6, Fri Jan 10) = Mon,Tue,Wed,Thu = 4.
457        assert_eq!(cal.business_days_between(d(2025, 1, 6), d(2025, 1, 10)), 4);
458        // Equal dates = 0; reversed = negative.
459        assert_eq!(cal.business_days_between(d(2025, 1, 6), d(2025, 1, 6)), 0);
460        assert_eq!(cal.business_days_between(d(2025, 1, 10), d(2025, 1, 6)), -4);
461    }
462
463    #[test]
464    fn business_days_between_differs_by_calendar() {
465        // [2025-01-01, 2025-01-08): Jan 1 Wed,2,3 Fri, 4 Sat,5 Sun,6 Mon,7 Tue.
466        let start = d(2025, 1, 1);
467        let end = d(2025, 1, 8);
468        // WeekendsOnly counts 1,2,3,6,7 = 5.
469        assert_eq!(WeekendsOnly.business_days_between(start, end), 5);
470        // Brazil drops Jan 1 (holiday) → 4.
471        assert_eq!(Brazil.business_days_between(start, end), 4);
472    }
473
474    #[test]
475    fn bus252_year_fraction() {
476        let cal = WeekendsOnly;
477        // 5 business days / 252.
478        let yf = cal.year_fraction_252(d(2025, 1, 1), d(2025, 1, 8));
479        assert!((yf - 5.0 / 252.0).abs() < 1e-12);
480    }
481
482    #[test]
483    fn join_holidays_is_intersection_of_business_days() {
484        let joint = JoinCalendar::new(
485            vec![Box::new(Brazil), Box::new(Target2)],
486            JoinRule::JoinHolidays,
487        );
488        // Corpus Christi 2025-06-19 (Thu): Brazil holiday, TARGET2 business.
489        // JoinHolidays → not a business day.
490        assert!(!joint.is_business_day(d(2025, 6, 19)));
491        // Dec 26 2025 (Fri): TARGET2 holiday, Brazil business → not business.
492        assert!(!joint.is_business_day(d(2025, 12, 26)));
493        // An ordinary weekday in both is a business day.
494        assert!(joint.is_business_day(d(2025, 1, 2)));
495    }
496
497    #[test]
498    fn join_business_days_is_union_of_business_days() {
499        let joint = JoinCalendar::new(
500            vec![Box::new(Brazil), Box::new(Target2)],
501            JoinRule::JoinBusinessDays,
502        );
503        // Corpus Christi: business in TARGET2 → business under JoinBusinessDays.
504        assert!(joint.is_business_day(d(2025, 6, 19)));
505        // Dec 26: business in Brazil → business.
506        assert!(joint.is_business_day(d(2025, 12, 26)));
507        // New Year is a holiday in both → not a business day.
508        assert!(!joint.is_business_day(d(2025, 1, 1)));
509        // Weekend remains non-business under either rule.
510        assert!(!joint.is_business_day(d(2025, 1, 4)));
511    }
512}