Skip to main content

yield_curves/
date.rs

1//! Calendar dates, weekdays, and date periods — Phase 0 foundations.
2//!
3//! Real-world curve construction needs dates before anything else: day-count
4//! year fractions, holiday calendars, and coupon schedules all build on a
5//! [`Date`] primitive. This module provides one with **zero dependencies**,
6//! preserving the library's hard zero-dep constraint (the `time`/`chrono`
7//! crates would each pull a dependency tree for a surface we barely use).
8//!
9//! # Representation
10//!
11//! A [`Date`] is a proleptic Gregorian calendar date stored as a single
12//! `i32` *serial number* — the count of days since the Unix epoch
13//! (1970-01-01). Serial storage makes comparison, ordering, and day
14//! arithmetic trivial and exact, and makes [`Date`] `Copy`.
15//!
16//! Conversions between `(year, month, day)` and the serial number use Howard
17//! Hinnant's `days_from_civil` / `civil_from_days` algorithms, which are exact
18//! over the full `i32` serial range (roughly ±5.8 million days, ≈ ±16 000
19//! years around 1970).
20//!
21//! # Example
22//!
23//! ```
24//! use yield_curves::date::{Date, Period, Weekday};
25//!
26//! let settle = Date::new(2025, 1, 31).unwrap();
27//! assert_eq!(settle.weekday(), Weekday::Friday);
28//!
29//! // Month arithmetic clamps to end-of-month: Jan 31 + 1 month = Feb 28.
30//! let next = settle + Period::months(1);
31//! assert_eq!(next, Date::new(2025, 2, 28).unwrap());
32//!
33//! // Day counts come straight off the serial difference.
34//! assert_eq!(next.serial() - settle.serial(), 28);
35//! ```
36
37use std::fmt;
38use std::ops::{Add, Sub};
39
40/// Errors from date construction and arithmetic.
41#[derive(Debug, Clone, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum DateError {
44    /// `(year, month, day)` is not a valid Gregorian calendar date.
45    InvalidDate { year: i32, month: u32, day: u32 },
46    /// A serial number or arithmetic result fell outside the supported range.
47    OutOfRange(String),
48}
49
50impl fmt::Display for DateError {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::InvalidDate { year, month, day } => {
54                write!(f, "invalid date: {year:04}-{month:02}-{day:02}")
55            }
56            Self::OutOfRange(msg) => write!(f, "date out of range: {msg}"),
57        }
58    }
59}
60
61impl std::error::Error for DateError {}
62
63/// Day of the week, ISO order (Monday = 1 … Sunday = 7).
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub enum Weekday {
66    Monday,
67    Tuesday,
68    Wednesday,
69    Thursday,
70    Friday,
71    Saturday,
72    Sunday,
73}
74
75impl Weekday {
76    /// ISO weekday number, Monday = 1 … Sunday = 7.
77    #[must_use]
78    pub fn number(self) -> u32 {
79        match self {
80            Self::Monday => 1,
81            Self::Tuesday => 2,
82            Self::Wednesday => 3,
83            Self::Thursday => 4,
84            Self::Friday => 5,
85            Self::Saturday => 6,
86            Self::Sunday => 7,
87        }
88    }
89
90    /// True for Saturday and Sunday. Holiday calendars layer real non-business
91    /// days on top of this; the weekend itself is calendar-independent.
92    #[must_use]
93    pub fn is_weekend(self) -> bool {
94        matches!(self, Self::Saturday | Self::Sunday)
95    }
96}
97
98/// Unit of a [`Period`].
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100pub enum Unit {
101    Days,
102    Weeks,
103    Months,
104    Years,
105}
106
107/// A signed span of calendar time, e.g. `3 months` or `-2 weeks`.
108///
109/// `Days`/`Weeks` add a fixed number of days. `Months`/`Years` are *calendar*
110/// arithmetic: the day-of-month is preserved where possible and clamped to the
111/// last day of the target month otherwise (so Jan 31 + 1 month = Feb 28, and
112/// Feb 29 + 1 year = Feb 28 in a non-leap year).
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
114pub struct Period {
115    pub num: i32,
116    pub unit: Unit,
117}
118
119impl Period {
120    /// `n` days.
121    #[must_use]
122    pub fn days(n: i32) -> Self {
123        Self {
124            num: n,
125            unit: Unit::Days,
126        }
127    }
128
129    /// `n` weeks (7 days each).
130    #[must_use]
131    pub fn weeks(n: i32) -> Self {
132        Self {
133            num: n,
134            unit: Unit::Weeks,
135        }
136    }
137
138    /// `n` calendar months.
139    #[must_use]
140    pub fn months(n: i32) -> Self {
141        Self {
142            num: n,
143            unit: Unit::Months,
144        }
145    }
146
147    /// `n` calendar years.
148    #[must_use]
149    pub fn years(n: i32) -> Self {
150        Self {
151            num: n,
152            unit: Unit::Years,
153        }
154    }
155}
156
157impl fmt::Display for Period {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        let suffix = match self.unit {
160            Unit::Days => 'D',
161            Unit::Weeks => 'W',
162            Unit::Months => 'M',
163            Unit::Years => 'Y',
164        };
165        write!(f, "{}{}", self.num, suffix)
166    }
167}
168
169/// A proleptic Gregorian calendar date stored as days since 1970-01-01.
170///
171/// Ordering and equality are by serial number, so they coincide with calendar
172/// order. The type is `Copy` and cheap to pass by value.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
174pub struct Date {
175    serial: i32,
176}
177
178impl Date {
179    /// Constructs a date from a calendar `(year, month, day)`.
180    ///
181    /// `month` is 1–12, `day` is 1–`days_in_month`. Returns
182    /// [`DateError::InvalidDate`] for any out-of-range or nonexistent date
183    /// (e.g. month 13, day 0, or February 30).
184    ///
185    /// # Errors
186    ///
187    /// Returns [`DateError::InvalidDate`] if the triple is not a real date.
188    pub fn new(year: i32, month: u32, day: u32) -> Result<Self, DateError> {
189        if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
190            return Err(DateError::InvalidDate { year, month, day });
191        }
192        Ok(Self {
193            serial: days_from_civil(year, month, day),
194        })
195    }
196
197    /// Constructs a date directly from its serial number (days since
198    /// 1970-01-01). Always valid; provided for round-tripping and arithmetic.
199    #[must_use]
200    pub fn from_serial(serial: i32) -> Self {
201        Self { serial }
202    }
203
204    /// The serial number: days since 1970-01-01 (negative before the epoch).
205    ///
206    /// Day-count year fractions for actual/N conventions are
207    /// `(b.serial() - a.serial())` divided by the convention's basis.
208    #[must_use]
209    pub fn serial(self) -> i32 {
210        self.serial
211    }
212
213    /// Calendar year.
214    #[must_use]
215    pub fn year(self) -> i32 {
216        civil_from_days(self.serial).0
217    }
218
219    /// Calendar month, 1–12.
220    #[must_use]
221    pub fn month(self) -> u32 {
222        civil_from_days(self.serial).1
223    }
224
225    /// Day of month, 1–31.
226    #[must_use]
227    pub fn day(self) -> u32 {
228        civil_from_days(self.serial).2
229    }
230
231    /// `(year, month, day)` in one call (one serial conversion instead of three).
232    #[must_use]
233    pub fn ymd(self) -> (i32, u32, u32) {
234        civil_from_days(self.serial)
235    }
236
237    /// Day of the week.
238    #[must_use]
239    pub fn weekday(self) -> Weekday {
240        // 1970-01-01 (serial 0) was a Thursday. Shift so Monday maps to 0.
241        match (self.serial + 3).rem_euclid(7) {
242            0 => Weekday::Monday,
243            1 => Weekday::Tuesday,
244            2 => Weekday::Wednesday,
245            3 => Weekday::Thursday,
246            4 => Weekday::Friday,
247            5 => Weekday::Saturday,
248            _ => Weekday::Sunday,
249        }
250    }
251
252    /// True if the date falls on a Saturday or Sunday. Holiday calendars (a
253    /// later phase) extend this with named holidays per market.
254    #[must_use]
255    pub fn is_weekend(self) -> bool {
256        self.weekday().is_weekend()
257    }
258
259    /// True if the date's year is a Gregorian leap year.
260    #[must_use]
261    pub fn is_leap_year(self) -> bool {
262        is_leap(self.year())
263    }
264
265    /// Returns this date advanced by `n` days (negative moves backward).
266    #[must_use]
267    pub fn add_days(self, n: i32) -> Self {
268        Self {
269            serial: self.serial + n,
270        }
271    }
272
273    /// Signed number of days from `self` to `other` (`other - self`).
274    #[must_use]
275    pub fn days_until(self, other: Self) -> i32 {
276        other.serial - self.serial
277    }
278
279    /// The last day of this date's month, preserving year and month.
280    #[must_use]
281    pub fn end_of_month(self) -> Self {
282        let (y, m, _) = self.ymd();
283        Self {
284            serial: days_from_civil(y, m, days_in_month(y, m)),
285        }
286    }
287
288    /// True if this date is the last day of its month.
289    #[must_use]
290    pub fn is_end_of_month(self) -> bool {
291        let (y, m, d) = self.ymd();
292        d == days_in_month(y, m)
293    }
294
295    /// Returns this date advanced by `period` (negative periods move backward).
296    ///
297    /// Days/weeks add a fixed day count. Months/years use calendar arithmetic
298    /// with end-of-month clamping (see [`Period`]).
299    #[must_use]
300    pub fn add_period(self, period: Period) -> Self {
301        match period.unit {
302            Unit::Days => self.add_days(period.num),
303            Unit::Weeks => self.add_days(period.num * 7),
304            Unit::Months => self.add_months(period.num),
305            Unit::Years => self.add_months(period.num * 12),
306        }
307    }
308
309    /// Adds `n` calendar months with end-of-month clamping.
310    fn add_months(self, n: i32) -> Self {
311        let (y, m, d) = self.ymd();
312        // Zero-based month index from year 0, shifted by n.
313        let total = (i64::from(y) * 12 + i64::from(m) - 1) + i64::from(n);
314        let new_year = total.div_euclid(12) as i32;
315        let new_month = (total.rem_euclid(12) + 1) as u32;
316        let new_day = d.min(days_in_month(new_year, new_month));
317        Self {
318            serial: days_from_civil(new_year, new_month, new_day),
319        }
320    }
321}
322
323impl Add<Period> for Date {
324    type Output = Date;
325    fn add(self, period: Period) -> Date {
326        self.add_period(period)
327    }
328}
329
330impl Sub<Period> for Date {
331    type Output = Date;
332    fn sub(self, period: Period) -> Date {
333        self.add_period(Period {
334            num: -period.num,
335            unit: period.unit,
336        })
337    }
338}
339
340impl Sub<Date> for Date {
341    /// Difference in days (`self - rhs`).
342    type Output = i32;
343    fn sub(self, rhs: Date) -> i32 {
344        self.serial - rhs.serial
345    }
346}
347
348impl fmt::Display for Date {
349    /// ISO 8601 `YYYY-MM-DD`.
350    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351        let (y, m, d) = self.ymd();
352        write!(f, "{y:04}-{m:02}-{d:02}")
353    }
354}
355
356/// True if `year` is a Gregorian leap year.
357#[must_use]
358pub fn is_leap(year: i32) -> bool {
359    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
360}
361
362/// Number of days in `(year, month)`. `month` must be 1–12; out-of-range
363/// months return 0.
364#[must_use]
365pub fn days_in_month(year: i32, month: u32) -> u32 {
366    match month {
367        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
368        4 | 6 | 9 | 11 => 30,
369        2 if is_leap(year) => 29,
370        2 => 28,
371        _ => 0,
372    }
373}
374
375/// Days since 1970-01-01 for a valid civil date (Howard Hinnant's algorithm).
376///
377/// Correct for any date in the proleptic Gregorian calendar. The caller must
378/// pass a valid `(year, month, day)`; [`Date::new`] validates before calling.
379fn days_from_civil(year: i32, month: u32, day: u32) -> i32 {
380    let y = i64::from(year) - i64::from(month <= 2);
381    let era = if y >= 0 { y } else { y - 399 } / 400;
382    let yoe = y - era * 400; // [0, 399]
383    let m = i64::from(month);
384    let mp = if m > 2 { m - 3 } else { m + 9 }; // March = 0 … February = 11
385    let doy = (153 * mp + 2) / 5 + i64::from(day) - 1; // [0, 365]
386    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
387    (era * 146097 + doe - 719468) as i32
388}
389
390/// Civil `(year, month, day)` from days since 1970-01-01 (inverse of
391/// [`days_from_civil`], Howard Hinnant's algorithm).
392fn civil_from_days(serial: i32) -> (i32, u32, u32) {
393    let z = i64::from(serial) + 719468;
394    let era = if z >= 0 { z } else { z - 146096 } / 146097;
395    let doe = z - era * 146097; // [0, 146096]
396    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
397    let y = yoe + era * 400;
398    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
399    let mp = (5 * doy + 2) / 153; // [0, 11]
400    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
401    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
402    ((y + i64::from(m <= 2)) as i32, m as u32, d as u32)
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn epoch_serial_is_zero() {
411        assert_eq!(Date::new(1970, 1, 1).unwrap().serial(), 0);
412        assert_eq!(Date::new(1970, 1, 2).unwrap().serial(), 1);
413        assert_eq!(Date::new(1969, 12, 31).unwrap().serial(), -1);
414    }
415
416    #[test]
417    fn ymd_roundtrips_over_wide_range() {
418        // Walk every day across two centuries and check the serial round-trips
419        // through civil_from_days back to the same (y, m, d).
420        let start = Date::new(1900, 1, 1).unwrap().serial();
421        let end = Date::new(2100, 12, 31).unwrap().serial();
422        for s in start..=end {
423            let (y, m, d) = civil_from_days(s);
424            assert_eq!(
425                days_from_civil(y, m, d),
426                s,
427                "roundtrip failed at serial {s}"
428            );
429        }
430    }
431
432    #[test]
433    fn leap_year_rules() {
434        assert!(is_leap(2000)); // divisible by 400
435        assert!(!is_leap(1900)); // divisible by 100, not 400
436        assert!(is_leap(2024));
437        assert!(!is_leap(2023));
438        assert!(Date::new(2024, 2, 29).unwrap().is_leap_year());
439    }
440
441    #[test]
442    fn days_in_month_handles_february() {
443        assert_eq!(days_in_month(2024, 2), 29);
444        assert_eq!(days_in_month(2023, 2), 28);
445        assert_eq!(days_in_month(2024, 4), 30);
446        assert_eq!(days_in_month(2024, 12), 31);
447        assert_eq!(days_in_month(2024, 13), 0);
448    }
449
450    #[test]
451    fn weekday_known_anchors() {
452        assert_eq!(Date::new(1970, 1, 1).unwrap().weekday(), Weekday::Thursday);
453        assert_eq!(Date::new(2000, 1, 1).unwrap().weekday(), Weekday::Saturday);
454        assert_eq!(Date::new(2024, 2, 29).unwrap().weekday(), Weekday::Thursday);
455        assert_eq!(Date::new(2025, 6, 5).unwrap().weekday(), Weekday::Thursday);
456    }
457
458    #[test]
459    fn weekend_detection() {
460        assert!(Date::new(2000, 1, 1).unwrap().is_weekend()); // Saturday
461        assert!(Date::new(2000, 1, 2).unwrap().is_weekend()); // Sunday
462        assert!(!Date::new(2000, 1, 3).unwrap().is_weekend()); // Monday
463    }
464
465    #[test]
466    fn rejects_invalid_dates() {
467        assert!(Date::new(2023, 2, 29).is_err()); // not a leap year
468        assert!(Date::new(2024, 0, 1).is_err()); // month 0
469        assert!(Date::new(2024, 13, 1).is_err()); // month 13
470        assert!(Date::new(2024, 1, 0).is_err()); // day 0
471        assert!(Date::new(2024, 4, 31).is_err()); // April has 30
472        assert!(Date::new(2024, 2, 29).is_ok()); // leap-year Feb 29 is fine
473    }
474
475    #[test]
476    fn day_arithmetic_and_difference() {
477        let a = Date::new(2024, 1, 1).unwrap();
478        let b = a.add_days(31);
479        assert_eq!(b, Date::new(2024, 2, 1).unwrap());
480        assert_eq!(a.days_until(b), 31);
481        assert_eq!(b - a, 31);
482        assert_eq!(a.add_days(-1), Date::new(2023, 12, 31).unwrap());
483    }
484
485    #[test]
486    fn add_months_clamps_end_of_month() {
487        let jan31 = Date::new(2021, 1, 31).unwrap();
488        assert_eq!(jan31 + Period::months(1), Date::new(2021, 2, 28).unwrap());
489
490        let jan31_leap = Date::new(2020, 1, 31).unwrap();
491        assert_eq!(
492            jan31_leap + Period::months(1),
493            Date::new(2020, 2, 29).unwrap()
494        );
495
496        // Crossing a year boundary.
497        assert_eq!(
498            Date::new(2024, 11, 30).unwrap() + Period::months(3),
499            Date::new(2025, 2, 28).unwrap()
500        );
501    }
502
503    #[test]
504    fn add_years_handles_leap_day() {
505        let leap = Date::new(2020, 2, 29).unwrap();
506        assert_eq!(leap + Period::years(1), Date::new(2021, 2, 28).unwrap());
507        assert_eq!(leap + Period::years(4), Date::new(2024, 2, 29).unwrap());
508    }
509
510    #[test]
511    fn subtract_period_moves_backward() {
512        let d = Date::new(2025, 3, 31).unwrap();
513        assert_eq!(d - Period::months(1), Date::new(2025, 2, 28).unwrap());
514        assert_eq!(d - Period::days(1), Date::new(2025, 3, 30).unwrap());
515        assert_eq!(d - Period::weeks(1), Date::new(2025, 3, 24).unwrap());
516    }
517
518    #[test]
519    fn add_period_weeks_and_days() {
520        let d = Date::new(2025, 1, 1).unwrap();
521        assert_eq!(d + Period::weeks(2), Date::new(2025, 1, 15).unwrap());
522        assert_eq!(d + Period::days(10), Date::new(2025, 1, 11).unwrap());
523    }
524
525    #[test]
526    fn end_of_month_helpers() {
527        let mid = Date::new(2024, 2, 15).unwrap();
528        assert_eq!(mid.end_of_month(), Date::new(2024, 2, 29).unwrap());
529        assert!(!mid.is_end_of_month());
530        assert!(Date::new(2024, 2, 29).unwrap().is_end_of_month());
531        assert!(Date::new(2025, 4, 30).unwrap().is_end_of_month());
532    }
533
534    #[test]
535    fn ordering_matches_calendar() {
536        let a = Date::new(2024, 1, 1).unwrap();
537        let b = Date::new(2024, 6, 1).unwrap();
538        let c = Date::new(2025, 1, 1).unwrap();
539        assert!(a < b);
540        assert!(b < c);
541        let mut v = vec![c, a, b];
542        v.sort();
543        assert_eq!(v, vec![a, b, c]);
544    }
545
546    #[test]
547    fn display_is_iso() {
548        assert_eq!(Date::new(2025, 6, 5).unwrap().to_string(), "2025-06-05");
549        assert_eq!(Date::new(999, 1, 9).unwrap().to_string(), "0999-01-09");
550        assert_eq!(Period::months(3).to_string(), "3M");
551        assert_eq!(Period::days(-5).to_string(), "-5D");
552    }
553
554    #[test]
555    fn serial_roundtrip_via_from_serial() {
556        let d = Date::new(2030, 7, 4).unwrap();
557        assert_eq!(Date::from_serial(d.serial()), d);
558    }
559}