Skip to main content

yield_curves/
schedule.rs

1//! Schedule generation — coupon/pillar date sequences. Phase 0 finisher.
2//!
3//! A [`Schedule`] is the ordered set of dates that partition a deal's life into
4//! accrual periods: coupon dates for a bond, fixing/payment dates for a swap
5//! leg, pillar dates for a bootstrap. It composes everything earlier in Phase
6//! 0 — [`Date`], [`Period`], a [`Calendar`], and a [`BusinessDayConvention`].
7//!
8//! # Generation
9//!
10//! Dates are generated **from an anchor** (`termination` for backward rules,
11//! `effective` for forward) as `anchor ± i·tenor`, never iteratively, so they
12//! do not drift when month arithmetic clamps an end-of-month day. The raw grid
13//! is then adjusted onto business days and consecutive duplicates are removed.
14//!
15//! - [`DateGeneration::Backward`] — regular dates measured back from
16//!   termination; an uneven first period becomes a front stub.
17//! - [`DateGeneration::Forward`] — measured forward from effective; an uneven
18//!   final period becomes a back stub.
19//! - [`DateGeneration::Zero`] — `[effective, termination]` only (tenor ignored).
20//! - [`DateGeneration::ThirdWednesday`] — each date snapped to the third
21//!   Wednesday of its month (IMM/futures dates).
22//!
23//! # Stubs
24//!
25//! When the tenor does not divide the interval evenly, [`StubConvention`]
26//! controls the odd period: `ShortFront`/`LongFront` for backward generation,
27//! `ShortBack`/`LongBack` for forward.
28//!
29//! # Example
30//!
31//! ```
32//! use yield_curves::date::{Date, Period};
33//! use yield_curves::calendar::{Brazil, BusinessDayConvention};
34//! use yield_curves::schedule::Schedule;
35//!
36//! let sched = Schedule::builder(
37//!     Date::new(2024, 1, 15).unwrap(),
38//!     Date::new(2025, 1, 15).unwrap(),
39//!     Period::months(6),
40//! )
41//! .calendar(Box::new(Brazil))
42//! .convention(BusinessDayConvention::ModifiedFollowing)
43//! .build()
44//! .unwrap();
45//!
46//! assert_eq!(sched.len(), 3); // 2024-01-15, 2024-07-15, 2025-01-15
47//! assert_eq!(sched.effective_date(), Date::new(2024, 1, 15).unwrap());
48//! ```
49
50use std::fmt;
51use std::ops::Index;
52
53use crate::calendar::{BusinessDayConvention, Calendar, WeekendsOnly};
54use crate::date::{Date, Period, Unit};
55
56/// How the regular date grid is generated and where a stub may fall.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58#[non_exhaustive]
59pub enum DateGeneration {
60    /// Regular dates measured backward from the termination date.
61    Backward,
62    /// Regular dates measured forward from the effective date.
63    Forward,
64    /// No intermediate dates: just effective and termination.
65    Zero,
66    /// Like [`Backward`](Self::Backward) but each date is snapped to the third
67    /// Wednesday of its month (IMM dates).
68    ThirdWednesday,
69}
70
71/// Placement and length of the irregular period when the tenor does not divide
72/// the interval evenly.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74#[non_exhaustive]
75pub enum StubConvention {
76    /// Short irregular first period (backward generation).
77    ShortFront,
78    /// Long irregular first period (backward generation).
79    LongFront,
80    /// Short irregular final period (forward generation).
81    ShortBack,
82    /// Long irregular final period (forward generation).
83    LongBack,
84}
85
86/// Errors from schedule construction.
87#[derive(Debug, Clone, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum ScheduleError {
90    /// `termination` is not strictly after `effective`.
91    EmptyRange { effective: Date, termination: Date },
92    /// The tenor's magnitude is not positive.
93    NonPositiveTenor(i32),
94    /// The stub convention's direction does not match the generation rule
95    /// (e.g. a back stub with backward generation).
96    StubDirectionMismatch {
97        rule: DateGeneration,
98        stub: StubConvention,
99    },
100}
101
102impl fmt::Display for ScheduleError {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            Self::EmptyRange {
106                effective,
107                termination,
108            } => write!(
109                f,
110                "termination {termination} must be after effective {effective}"
111            ),
112            Self::NonPositiveTenor(n) => write!(f, "tenor must be positive, got {n}"),
113            Self::StubDirectionMismatch { rule, stub } => {
114                write!(f, "stub {stub:?} is incompatible with rule {rule:?}")
115            }
116        }
117    }
118}
119
120impl std::error::Error for ScheduleError {}
121
122/// The third Wednesday of `(year, month)` — the standard IMM/futures roll date.
123#[must_use]
124pub fn third_wednesday(year: i32, month: u32) -> Date {
125    let first = Date::new(year, month, 1).expect("first of month is valid");
126    // Days from the 1st to the first Wednesday (ISO Wednesday = 3), then +2 weeks.
127    let offset = (3 + 7 - first.weekday().number()) % 7;
128    first.add_days(offset as i32 + 14)
129}
130
131/// A generated sequence of dates partitioning `[effective, termination]`.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Schedule {
134    dates: Vec<Date>,
135}
136
137impl Schedule {
138    /// Starts a [`ScheduleBuilder`] for the interval `[effective, termination]`
139    /// with the regular period `tenor`.
140    #[must_use]
141    pub fn builder(effective: Date, termination: Date, tenor: Period) -> ScheduleBuilder {
142        ScheduleBuilder::new(effective, termination, tenor)
143    }
144
145    /// The generated dates in ascending order.
146    #[must_use]
147    pub fn dates(&self) -> &[Date] {
148        &self.dates
149    }
150
151    /// Number of dates (one more than the number of periods).
152    #[must_use]
153    pub fn len(&self) -> usize {
154        self.dates.len()
155    }
156
157    /// Always false — a valid schedule has at least two dates.
158    #[must_use]
159    pub fn is_empty(&self) -> bool {
160        self.dates.is_empty()
161    }
162
163    /// The first (effective) date.
164    #[must_use]
165    pub fn effective_date(&self) -> Date {
166        self.dates[0]
167    }
168
169    /// The last (termination) date.
170    #[must_use]
171    pub fn termination_date(&self) -> Date {
172        self.dates[self.dates.len() - 1]
173    }
174
175    /// Iterator over the dates.
176    pub fn iter(&self) -> std::slice::Iter<'_, Date> {
177        self.dates.iter()
178    }
179}
180
181impl Index<usize> for Schedule {
182    type Output = Date;
183    fn index(&self, i: usize) -> &Date {
184        &self.dates[i]
185    }
186}
187
188impl<'a> IntoIterator for &'a Schedule {
189    type Item = &'a Date;
190    type IntoIter = std::slice::Iter<'a, Date>;
191    fn into_iter(self) -> Self::IntoIter {
192        self.dates.iter()
193    }
194}
195
196/// Builder for a [`Schedule`]. Defaults: [`WeekendsOnly`] calendar,
197/// `ModifiedFollowing` for both conventions, `Backward` generation, no
198/// end-of-month rolling, and the natural short stub for the rule.
199pub struct ScheduleBuilder {
200    effective: Date,
201    termination: Date,
202    tenor: Period,
203    calendar: Box<dyn Calendar>,
204    convention: BusinessDayConvention,
205    termination_convention: BusinessDayConvention,
206    end_of_month: bool,
207    rule: DateGeneration,
208    stub: Option<StubConvention>,
209}
210
211impl ScheduleBuilder {
212    fn new(effective: Date, termination: Date, tenor: Period) -> Self {
213        Self {
214            effective,
215            termination,
216            tenor,
217            calendar: Box::new(WeekendsOnly),
218            convention: BusinessDayConvention::ModifiedFollowing,
219            termination_convention: BusinessDayConvention::ModifiedFollowing,
220            end_of_month: false,
221            rule: DateGeneration::Backward,
222            stub: None,
223        }
224    }
225
226    /// Sets the holiday calendar used for adjustment.
227    #[must_use]
228    pub fn calendar(mut self, calendar: Box<dyn Calendar>) -> Self {
229        self.calendar = calendar;
230        self
231    }
232
233    /// Sets the business-day convention for all dates except termination.
234    #[must_use]
235    pub fn convention(mut self, convention: BusinessDayConvention) -> Self {
236        self.convention = convention;
237        self
238    }
239
240    /// Sets the business-day convention applied to the termination date.
241    #[must_use]
242    pub fn termination_convention(mut self, convention: BusinessDayConvention) -> Self {
243        self.termination_convention = convention;
244        self
245    }
246
247    /// Enables end-of-month rolling: when the anchor is the last day of its
248    /// month, every generated month/year date is rolled to its month end.
249    #[must_use]
250    pub fn end_of_month(mut self, eom: bool) -> Self {
251        self.end_of_month = eom;
252        self
253    }
254
255    /// Sets the date-generation rule.
256    #[must_use]
257    pub fn rule(mut self, rule: DateGeneration) -> Self {
258        self.rule = rule;
259        self
260    }
261
262    /// Forces a stub convention (otherwise the rule's natural short stub).
263    #[must_use]
264    pub fn stub(mut self, stub: StubConvention) -> Self {
265        self.stub = Some(stub);
266        self
267    }
268
269    /// Generates the schedule.
270    ///
271    /// # Errors
272    ///
273    /// - [`ScheduleError::EmptyRange`] if `termination <= effective`.
274    /// - [`ScheduleError::NonPositiveTenor`] if the tenor magnitude is `<= 0`
275    ///   (except under [`DateGeneration::Zero`], which ignores the tenor).
276    /// - [`ScheduleError::StubDirectionMismatch`] if the stub direction
277    ///   contradicts the rule.
278    pub fn build(self) -> Result<Schedule, ScheduleError> {
279        if self.termination <= self.effective {
280            return Err(ScheduleError::EmptyRange {
281                effective: self.effective,
282                termination: self.termination,
283            });
284        }
285
286        // Zero rule: a single period, tenor irrelevant.
287        if self.rule == DateGeneration::Zero {
288            return Ok(self.adjust_and_finish(vec![self.effective, self.termination]));
289        }
290
291        if self.tenor.num <= 0 {
292            return Err(ScheduleError::NonPositiveTenor(self.tenor.num));
293        }
294
295        let forward = self.rule == DateGeneration::Forward;
296        let stub = self.resolve_stub(forward)?;
297
298        let mut unadjusted = if forward {
299            self.generate_forward(stub)
300        } else {
301            self.generate_backward(stub)
302        };
303
304        if self.rule == DateGeneration::ThirdWednesday {
305            for date in &mut unadjusted {
306                *date = third_wednesday(date.year(), date.month());
307            }
308        }
309
310        Ok(self.adjust_and_finish(unadjusted))
311    }
312
313    /// Picks/validates the stub for the chosen direction.
314    fn resolve_stub(&self, forward: bool) -> Result<StubConvention, ScheduleError> {
315        match self.stub {
316            None => Ok(if forward {
317                StubConvention::ShortBack
318            } else {
319                StubConvention::ShortFront
320            }),
321            Some(s) => {
322                let ok = matches!(
323                    (forward, s),
324                    (true, StubConvention::ShortBack | StubConvention::LongBack)
325                        | (
326                            false,
327                            StubConvention::ShortFront | StubConvention::LongFront
328                        )
329                );
330                if ok {
331                    Ok(s)
332                } else {
333                    Err(ScheduleError::StubDirectionMismatch {
334                        rule: self.rule,
335                        stub: s,
336                    })
337                }
338            }
339        }
340    }
341
342    /// `anchor` shifted by `mult` tenors, with optional end-of-month rolling.
343    fn seed(&self, anchor: Date, mult: i32) -> Date {
344        let shifted = anchor.add_period(Period {
345            num: self.tenor.num * mult,
346            unit: self.tenor.unit,
347        });
348        if self.end_of_month
349            && matches!(self.tenor.unit, Unit::Months | Unit::Years)
350            && anchor.is_end_of_month()
351        {
352            shifted.end_of_month()
353        } else {
354            shifted
355        }
356    }
357
358    /// Backward grid: termination, termination−tenor, … down toward effective.
359    fn generate_backward(&self, stub: StubConvention) -> Vec<Date> {
360        let mut tmp = Vec::new();
361        let mut i = 0;
362        loop {
363            let d = self.seed(self.termination, -i);
364            if d < self.effective {
365                break;
366            }
367            tmp.push(d);
368            if d == self.effective {
369                break;
370            }
371            i += 1;
372        }
373        // tmp is descending; its last element is the smallest regular date >= effective.
374        let exact = tmp.last() == Some(&self.effective);
375        if !exact {
376            if stub == StubConvention::LongFront && tmp.len() >= 2 {
377                tmp.pop(); // merge the first regular period into the stub
378            }
379            tmp.push(self.effective);
380        }
381        tmp.reverse();
382        tmp
383    }
384
385    /// Forward grid: effective, effective+tenor, … up toward termination.
386    fn generate_forward(&self, stub: StubConvention) -> Vec<Date> {
387        let mut tmp = Vec::new();
388        let mut i = 0;
389        loop {
390            let d = self.seed(self.effective, i);
391            if d > self.termination {
392                break;
393            }
394            tmp.push(d);
395            if d == self.termination {
396                break;
397            }
398            i += 1;
399        }
400        // tmp is ascending; its last element is the largest regular date <= termination.
401        let exact = tmp.last() == Some(&self.termination);
402        if !exact {
403            if stub == StubConvention::LongBack && tmp.len() >= 2 {
404                tmp.pop(); // merge the last regular period into the stub
405            }
406            tmp.push(self.termination);
407        }
408        tmp
409    }
410
411    /// Adjusts the raw grid onto business days and removes adjacent duplicates.
412    fn adjust_and_finish(&self, unadjusted: Vec<Date>) -> Schedule {
413        let n = unadjusted.len();
414        let mut dates: Vec<Date> = unadjusted
415            .into_iter()
416            .enumerate()
417            .map(|(idx, d)| {
418                let conv = if idx == n - 1 {
419                    self.termination_convention
420                } else {
421                    self.convention
422                };
423                self.calendar.adjust(d, conv)
424            })
425            .collect();
426        dates.dedup();
427        Schedule { dates }
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use crate::calendar::Brazil;
435
436    fn d(y: i32, m: u32, day: u32) -> Date {
437        Date::new(y, m, day).unwrap()
438    }
439
440    fn unadjusted_builder(eff: Date, term: Date, tenor: Period) -> ScheduleBuilder {
441        Schedule::builder(eff, term, tenor)
442            .convention(BusinessDayConvention::Unadjusted)
443            .termination_convention(BusinessDayConvention::Unadjusted)
444    }
445
446    #[test]
447    fn third_wednesday_known() {
448        // March 2025: Wednesdays are 5, 12, 19, 26 → third is the 19th.
449        assert_eq!(third_wednesday(2025, 3), d(2025, 3, 19));
450        // December 2025: Wednesdays 3, 10, 17, 24, 31 → third is the 17th.
451        assert_eq!(third_wednesday(2025, 12), d(2025, 12, 17));
452    }
453
454    #[test]
455    fn backward_even_periods_no_stub() {
456        let s = unadjusted_builder(d(2024, 1, 15), d(2025, 1, 15), Period::months(6))
457            .build()
458            .unwrap();
459        assert_eq!(s.dates(), &[d(2024, 1, 15), d(2024, 7, 15), d(2025, 1, 15)]);
460        assert_eq!(s.len(), 3);
461        assert_eq!(s.effective_date(), d(2024, 1, 15));
462        assert_eq!(s.termination_date(), d(2025, 1, 15));
463    }
464
465    #[test]
466    fn backward_short_front_stub() {
467        // 2024-02-10 → 2026-01-15, 6M backward.
468        let s = unadjusted_builder(d(2024, 2, 10), d(2026, 1, 15), Period::months(6))
469            .build()
470            .unwrap();
471        assert_eq!(
472            s.dates(),
473            &[
474                d(2024, 2, 10), // short front stub
475                d(2024, 7, 15),
476                d(2025, 1, 15),
477                d(2025, 7, 15),
478                d(2026, 1, 15),
479            ]
480        );
481    }
482
483    #[test]
484    fn backward_long_front_stub() {
485        let s = unadjusted_builder(d(2024, 2, 10), d(2026, 1, 15), Period::months(6))
486            .stub(StubConvention::LongFront)
487            .build()
488            .unwrap();
489        // First regular date (2024-07-15) merged into a long initial period.
490        assert_eq!(
491            s.dates(),
492            &[
493                d(2024, 2, 10),
494                d(2025, 1, 15),
495                d(2025, 7, 15),
496                d(2026, 1, 15),
497            ]
498        );
499    }
500
501    #[test]
502    fn forward_short_back_stub() {
503        // 2024-01-15 → 2025-04-10, 6M forward.
504        let s = unadjusted_builder(d(2024, 1, 15), d(2025, 4, 10), Period::months(6))
505            .rule(DateGeneration::Forward)
506            .build()
507            .unwrap();
508        assert_eq!(
509            s.dates(),
510            &[
511                d(2024, 1, 15),
512                d(2024, 7, 15),
513                d(2025, 1, 15),
514                d(2025, 4, 10), // short back stub
515            ]
516        );
517    }
518
519    #[test]
520    fn forward_long_back_stub() {
521        let s = unadjusted_builder(d(2024, 1, 15), d(2025, 4, 10), Period::months(6))
522            .rule(DateGeneration::Forward)
523            .stub(StubConvention::LongBack)
524            .build()
525            .unwrap();
526        // Last regular date (2025-01-15) merged into a long final period.
527        assert_eq!(s.dates(), &[d(2024, 1, 15), d(2024, 7, 15), d(2025, 4, 10)]);
528    }
529
530    #[test]
531    fn zero_rule_is_endpoints_only() {
532        let s = unadjusted_builder(d(2024, 1, 15), d(2034, 1, 15), Period::months(6))
533            .rule(DateGeneration::Zero)
534            .build()
535            .unwrap();
536        assert_eq!(s.dates(), &[d(2024, 1, 15), d(2034, 1, 15)]);
537    }
538
539    #[test]
540    fn end_of_month_rolling() {
541        // Anchor 2024-07-31 is month-end; monthly backward dates roll to EOM.
542        let s = unadjusted_builder(d(2024, 1, 31), d(2024, 7, 31), Period::months(1))
543            .end_of_month(true)
544            .build()
545            .unwrap();
546        assert_eq!(
547            s.dates(),
548            &[
549                d(2024, 1, 31),
550                d(2024, 2, 29), // leap-year February end
551                d(2024, 3, 31),
552                d(2024, 4, 30),
553                d(2024, 5, 31),
554                d(2024, 6, 30),
555                d(2024, 7, 31),
556            ]
557        );
558    }
559
560    #[test]
561    fn third_wednesday_rule_snaps_dates() {
562        // Quarterly IMM dates measured back from 2025-12-17 (3rd Wed Dec).
563        let s = unadjusted_builder(d(2025, 3, 19), d(2025, 12, 17), Period::months(3))
564            .rule(DateGeneration::ThirdWednesday)
565            .build()
566            .unwrap();
567        assert_eq!(
568            s.dates(),
569            &[
570                d(2025, 3, 19),
571                d(2025, 6, 18),
572                d(2025, 9, 17),
573                d(2025, 12, 17),
574            ]
575        );
576    }
577
578    #[test]
579    fn adjustment_moves_dates_to_business_days() {
580        // 2024-01-15 → 2025-01-15 with Brazil + Following.
581        // 2024-07-15 is a Monday and a business day; pick a case that adjusts:
582        // generate quarterly so a date lands on a holiday/weekend.
583        let s = Schedule::builder(d(2024, 1, 13), d(2024, 7, 13), Period::months(3))
584            .calendar(Box::new(Brazil))
585            .convention(BusinessDayConvention::Following)
586            .build()
587            .unwrap();
588        for &date in s.dates() {
589            assert!(Brazil.is_business_day(date), "{date} should be adjusted");
590        }
591    }
592
593    #[test]
594    fn rejects_empty_range() {
595        let err = unadjusted_builder(d(2025, 1, 15), d(2025, 1, 15), Period::months(6))
596            .build()
597            .unwrap_err();
598        assert!(matches!(err, ScheduleError::EmptyRange { .. }));
599    }
600
601    #[test]
602    fn rejects_non_positive_tenor() {
603        let err = unadjusted_builder(d(2024, 1, 15), d(2025, 1, 15), Period::months(0))
604            .build()
605            .unwrap_err();
606        assert!(matches!(err, ScheduleError::NonPositiveTenor(0)));
607    }
608
609    #[test]
610    fn rejects_stub_direction_mismatch() {
611        let err = unadjusted_builder(d(2024, 1, 15), d(2025, 1, 15), Period::months(6))
612            .rule(DateGeneration::Backward)
613            .stub(StubConvention::ShortBack)
614            .build()
615            .unwrap_err();
616        assert!(matches!(err, ScheduleError::StubDirectionMismatch { .. }));
617    }
618
619    #[test]
620    fn iteration_and_indexing() {
621        let s = unadjusted_builder(d(2024, 1, 15), d(2025, 1, 15), Period::months(6))
622            .build()
623            .unwrap();
624        assert_eq!(s[0], d(2024, 1, 15));
625        let collected: Vec<Date> = s.iter().copied().collect();
626        assert_eq!(collected, s.dates().to_vec());
627        assert_eq!((&s).into_iter().count(), 3);
628    }
629}